在一個處理用戶點擊廣告的高併發服務上找到了問題。看到服務打印的日記後我徹底蒙了,全是jedis讀超時,Read time out!一直用的是亞馬遜的Redis服務,很難想象Jedis會讀超時。git
看了服務的負載均衡統計,發現併發增加了一倍,從每分鐘3到4萬的請求數,增加到8.6萬,很顯然,是併發翻倍致使的服務雪崩。github
服務的部署:redis
處理廣告點擊的服務: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則是包括一、二、三、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。
通過兩次的項目重構,項目已是分佈式微服務架構,同時業務的合理劃分讓各個服務之間完美解耦,每一個服務內部的實現合理利用設計模式,完成業務的高內聚低耦合,這是一次很是大的改進,但仍是有還多歷史遺留的問題不能很好的解決。同時,分佈式也帶來了不少問題,總之,有利必有弊。
有時候就須要這樣,被項目推着往前走。在未發生該事故以前,我花一個月時間也沒想出困擾個人兩大難題,是此次的事故,讓我從一個短暫的夜晚找出答案,一個通宵讓我想通不少問題。