您好,登錄后才能下訂單哦!
在一個處理用戶點擊廣告的高并發服務上找到了問題。看到服務打印的日記后我完全蒙了,全是jedis讀超時,Read time out!一直用的是亞馬遜的Redis服務,很難想象Jedis會讀超時。
看了服務的負載均衡統計,發現并發增長了一倍,從每分鐘3到4萬的請求數,增長到8.6萬,很顯然,是并發翻倍導致的服務雪崩。
服務的部署:
處理廣告點擊的服務:2臺2核8g的實例,每臺部署一個節點(服務)。下文統稱服務A
規則匹配服務(Rpc遠程調用服務提供者):2個節點,2臺2核4g實例。下文統稱服務B
還有其它的服務提供者,但不是影響本次服務雪崩的兇手,這里就不列舉了。
從日記可以看出的問題:
一是遠程rpc調用大量超時,我配置的dubbo參數是,每個接口的超時時間都是3秒。服務提供者接口的實現都是緩存級別的操作,3秒的超時理論上除了網絡問題,調用不應該會超過這個值。在服務消費端,我配置每個接口與服務端保持10個長連接,避免共享一個長連接導致應用層數據包排隊發送和處理接收。
二是剛說的Jedis讀操作超時,Jedis我配置每個服務節點200個最小連接數的連接池,這是根據netty工作線程數配置的,即讀寫操作就算200個線程并發執行,也能為每個線程分配一個連接。這是我設置Jedis連接池連接數的依據。
三是文件句柄數達到上線。SocketChannel套接字會占用一個文件句柄,有多少個客戶端連接就占用多少個文件句柄。我在服務的啟動腳本上為每個進程配置102400的最大文件打開數,理論上目前不會達到這個值。服務A底層用的是基于Netty實現的http服務引擎,沒有限制最大連接數。
所以,解決服務雪崩問題就是要圍繞這三個問題出發。
第一次是懷疑redis服務扛不住這么大的并發請求。估算廣告的一次點擊需要執行20次get操作從redis獲取數據,那么每分鐘8w并發,就需要執行160w次get請求,而redis除了本文提到的服務A和服務B用到外,還有其它兩個并發量高的服務在用,保守估計,redis每分鐘需要承受300w的讀寫請求。轉為每秒就是5w的請求,與理論值redis每秒可以處理超過 10萬次讀寫操作已經過半。
由于歷史原因,redis使用的還是2.x版本的,用的一主一從,jedis配置連接池是讀寫分離的連接池,也就是寫請求打到主節點,讀請求打到從節點,每秒接近5w讀請求只有一個redis從節點處理,非常的吃力。所以我們將redis升級到4.x版本,并由主從集群改為分布式集群,兩主無從。別問兩主無從是怎么做到的,我也不懂,只有亞馬遜清楚。
Redis升級后,理論上,兩個主節點,分槽位后請求會平攤到兩個節點上,性能會好很多。但好景不長,服務重新上線一個小時不到,并發又突增到了六七萬每分鐘,這次是大量的RPC遠程調用超時,已經沒有jedis的讀超時Read time out了,相比之前好了點,至少不用再給Redis加節點。
這次的事故是并發量超過臨界值,超過redis的實際最大qps(跟存儲的數據結構和數量有關),雖然升級后沒有Read time out! 但Jedis的Get讀操作還是很耗時,這才是罪魁禍首。Redis的命令耗時與Jedis的讀操作Read time out不同。
redis執行一條命令的過程是:
接收客戶端請求
進入隊列等待執行
執行命令
由于redis執行命令是單線程的,所以命令到達服務端后不是立即執行,而是進入隊列等待。redis慢查詢日記記錄slowlog get的是執行命令的耗時,對應步驟3,執行命令耗時是根據key去找到數據所在的內存地址這段時間的耗時,所以這對于key-value字符串類型的命令而言,并不會因為value的大小而導致命令耗時長。
為驗證這個觀點,我進行了簡單的測試。
分別寫入四個key,每個key對應的value長度都不等,一個比一個長。再來看下兩組查詢日記。先通過CONFIG SET slowlog-log-slower-than 0命令,讓每條命令都記錄耗時。
key_4的value長度比key_3的長兩倍,但get耗時比key_3少,而key_1的value長度比key_2短,但耗時比key_2長。
第二組數據也是一樣的,跟value的值大小無關。所以可以排除項目中因value長度過長導致的slowlog記錄到慢查詢問題。慢操作應該是set、hset、hmset、hget、hgetall等命令耗時比較長導致。
而Jedis的Read time out則是包括1、2、3、4步驟,從命令的發出到接收完成Redis服務端的響應結果,超時原因有兩大原因:
redis的并發量增加,導致命令等待隊列過長,等待時間長
所以將Redis從一主一從改為兩主之后,導致Jedis的Read time out的原因一有所緩解,分攤了部分壓力。但是原因2還是存在,耗時依然是問題。
Jedis的get耗時長導致服務B接口執行耗時超過設置的3s。由于dubbo消費端超時放棄請求,但是請求已經發出,就算消費端取消,提供者無法感知服務端超時放棄了,還是要執行完一次調用的業務邏輯,就像說出去的話收不回來一樣。
由于dubbo有重試機制,默認會重試兩次,所以并發8w對于服務b而言,就變成了并發24w。最后導致業務線程池一直被占用狀態,RPC遠程調用又多出了一個異常,就是遠程服務線程池已滿,直接響應失敗。
問題最終還是要回到Redis上,就是key對應的value太大,傳輸耗時,最終業務代碼拿到value后將value分割成數組,判斷請求參數是否在數組中,非常耗時,就會導致服務B接口耗時超過3s,從而拖垮整個服務。
模擬服務B接口做的事情,業務代碼(1)。
/**
* @author wujiuye
* @version 1.0 on 2019/10/20 {描述:}
*/
public class Match {
static class Task implements Runnable {
private String value;
public Task(String value) {
this.value = value;
}
@Override
public void run() {
for (; ; ) {
// 模擬jedis get耗時
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// =====> 實際業務代碼
long start = System.currentTimeMillis();
List<String> ids = Arrays.stream(value.split(",")).collect(Collectors.toList());
boolean exist = ids.contains("4029000");
// ====> 輸出結果,耗時171ms .
System.out.println("exist:" + exist + ",time:" + (System.currentTimeMillis() - start));
}
}
}
;
public static void main(String[] args) {
// ====> 模擬業務場景,從緩存中獲取到的字符串
StringBuilder value = new StringBuilder();
for (int i = 4000000; i <= 4029000; i++) {
value.append(String.valueOf(i)).append(",");
}
String strValue = value.toString();
System.out.println(strValue.length());
for (int i = 0; i < 200; i++) {
new Thread(new Task(strValue)).start();
}
}
}
這段代碼很簡單,就是模擬高并發,把200個業務線程全部耗盡的場景下,一個簡單的判斷元素是否存在的業務邏輯執行需要多長時間。把這段代碼跑一遍,你會發現很多執行耗時超過1500ms,再加上Jedis讀取到數據的耗時,直接導致接口執行耗時超過3000ms。
這段代碼不僅耗時,還很耗內存,沒錯,就是這個Bug了。改進就是將id拼接成字符串的存儲方式改為hash存儲,直接hget方式判斷一個元素是否存在,不需要將這么大的數據讀取到本地,即避免了網絡傳輸消耗,也優化了接口的執行速度。
由于并發量的增長,導致redis讀并發上升,Jedis的get耗時長,加上業務代碼的缺陷,導致服務B接口耗時長,從而導致服務A遠程RPC調用超時,導致dubbo超時重試,導致服務B并發乘3,再導致服務B業務線程池全是工作狀態以及Redis并發又增加,導致服務A調用異常。正是這種連環效應導致服務雪崩。
最后優化分三步
一是優化數據的redis緩存的結構,剛也提到,由大量id拼接成字符串的key-value改成hash結構緩存,請求判斷某個id是否在緩存中用hget,除了能降低redis的大value傳輸耗時,也能將判斷一個元素是否存在的時間復雜度從O(n)變為O(1),接口耗時降低,消除RPC遠程調用超時。
二是業務邏輯優化,降低Redis并發。將服務B由一個服務拆分成兩個服務。這里就不多說了。
三是Dubbo調優,將Dubbo的重試次數改為0,失敗直接放棄當前的廣告點擊請求。為避免突發性的并發量上升,導致服務雪崩,為服務提供者加入熔斷器,估算服務所能承受的最大QPS,當服務達到臨界值時,放棄處理遠程RPC調用。
(我用的是Sentinel,官方文檔傳送門:
https://github.com/alibaba/Sentinel/wiki/%E6%8E%A7%E5%88%B6%E5%8F%B0)
所以,緩存并不是簡單的Get,Set就行了,Redis提供這么多的數據結構的支持要用好,結合業務邏輯優化緩存結構。避免高并發接口讀取的緩存value過長,導致數據傳輸耗時。同時,Redis的特性也要清楚,分布式集群相比單一主從集群的優點。反省img。
經過兩次的項目重構,項目已經是分布式微服務架構,同時業務的合理劃分讓各個服務之間完美解耦,每個服務內部的實現合理利用設計模式,完成業務的高內聚低耦合,這是一次非常大的改進,但還是有還多歷史遺留的問題不能很好的解決。同時,分布式也帶來了很多問題,總之,有利必有弊。
有時候就需要這樣,被項目推著往前走。在未發生該事故之前,我花一個月時間也沒想出困擾我的兩大難題,是這次的事故,讓我從一個短暫的夜晚找出答案,一個通宵讓我想通很多問題。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。