存隊(duì)列堵死引發(fā)的性能事故復(fù)盤(pán))
事情是從一次大促前壓測(cè)開(kāi)始的。群里突然有人甩了張截圖說(shuō)訂單支付回調(diào)接口的RT從平時(shí)的50毫秒一路飆到了快兩秒后臺(tái)還有一堆訂單狀態(tài)卡在“支付中”不更新用戶(hù)開(kāi)始陸續(xù)收到重復(fù)的短信提醒。我當(dāng)時(shí)第一反應(yīng)是數(shù)據(jù)庫(kù)又抖了結(jié)果查了一圈發(fā)現(xiàn)問(wèn)題源頭居然是一個(gè)我們自己寫(xiě)的MessageQueue——更準(zhǔn)確點(diǎn)說(shuō)是我老大當(dāng)初“順手”寫(xiě)的一段隊(duì)列消費(fèi)代碼。這個(gè)標(biāo)題里的“Bug”其實(shí)不是那種讓你編譯都過(guò)不去的錯(cuò)誤而是一段看著沒(méi)什么毛病的代碼在流量稍微上來(lái)一點(diǎn)之后把整條異步鏈路活活拖垮了。這個(gè)復(fù)盤(pán)過(guò)程很有價(jià)值我把它完整記錄下來(lái)給所有在做業(yè)務(wù)異步化、內(nèi)存隊(duì)列、削峰填谷的團(tuán)隊(duì)一個(gè)參考。1. 先還原現(xiàn)場(chǎng)老大的“手藝”和半線(xiàn)上事故1.1 業(yè)務(wù)背景為什么需要一個(gè)隊(duì)列先說(shuō)業(yè)務(wù)背景。我們的訂單模塊在用戶(hù)支付成功之后要做一連串的“售后動(dòng)作”更新訂單狀態(tài)、發(fā)短信通知、加積分、推送消息給運(yùn)營(yíng)后臺(tái)、再通知倉(cāng)儲(chǔ)系統(tǒng)開(kāi)始備貨。最早這套邏輯是同步寫(xiě)在支付回調(diào)接口里的一個(gè)請(qǐng)求進(jìn)來(lái)挨個(gè)調(diào)用這些服務(wù)全部做完之后才返回結(jié)果給客戶(hù)端。同步方案在低峰期沒(méi)有大問(wèn)題但有兩個(gè)隱患一是接口耗時(shí)被下游系統(tǒng)拖累短信服務(wù)一抖動(dòng)支付回調(diào)就跟著超時(shí)二是支付成功瞬間的流量通常有尖峰比如整點(diǎn)秒殺、促銷(xiāo)活動(dòng)開(kāi)啟的幾分鐘內(nèi)回調(diào)請(qǐng)求會(huì)密集進(jìn)來(lái)如果每個(gè)請(qǐng)求都要走一遍外部IO數(shù)據(jù)庫(kù)和短信接口都可能被打爆。所以就有了這個(gè)異步改造支付回調(diào)只負(fù)責(zé)把“訂單支付成功”這個(gè)事件丟進(jìn)隊(duì)列立刻返回后臺(tái)再用消費(fèi)者線(xiàn)程慢慢處理。這正是MessageQueue在這個(gè)場(chǎng)景里最核心的價(jià)值——解耦和削峰。當(dāng)時(shí)的實(shí)現(xiàn)并不復(fù)雜也沒(méi)有引入RocketMQ或者Kafka這些重量級(jí)組件老大直接在應(yīng)用內(nèi)存里用ArrayBlockingQueue手寫(xiě)了一個(gè)輕量隊(duì)列。理論上只要消費(fèi)者處理速度夠快這套方案完全能支撐現(xiàn)有業(yè)務(wù)量。1.2 老大寫(xiě)的這段代碼到底長(zhǎng)啥樣我后來(lái)翻到最初的實(shí)現(xiàn)大概長(zhǎng)這樣public class OrderNotifyQueue { private static final int CAPACITY 1000; private static final BlockingQueueNotifyTask QUEUE new ArrayBlockingQueue(CAPACITY); private static final ExecutorService PRODUCER_POOL Executors.newFixedThreadPool(20); private static final ExecutorService CONSUMER Executors.newSingleThreadExecutor(); static { CONSUMER.execute(new NotifyWorker()); } public static void submit(NotifyTask task) { PRODUCER_POOL.execute(() - { try { QUEUE.put(task); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } static class NotifyWorker implements Runnable { Override public void run() { while (true) { try { NotifyTask task QUEUE.take(); process(task); } catch (Exception e) { // 失敗就放回隊(duì)頭等下輪再處理 try { QUEUE.put(task); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } } private void process(NotifyTask task) { Order order orderMapper.selectById(task.getOrderId()); // 第一次IO查訂單 if (order ! null) { orderMapper.updateStatus(order.getId()); // 第二次IO更新?tīng)顟B(tài) smsClient.send(order.getMobile(), 您的訂單已支付成功); // 第三次IO外部短信 } } } }說(shuō)實(shí)話(huà)第一眼掃過(guò)去這段代碼并不算“丑”用了有界隊(duì)列防止無(wú)腦堆積用put()保證入隊(duì)安全消費(fèi)端單線(xiàn)程也不會(huì)出現(xiàn)并發(fā)修改問(wèn)題失敗重試也“照顧”到了。但它的問(wèn)題恰恰就藏在這些“看似合理”的細(xì)節(jié)里而且這些問(wèn)題在小流量下根本暴露不出來(lái)。1.3 問(wèn)題為什么沒(méi)在代碼評(píng)審時(shí)被發(fā)現(xiàn)這不是一句“評(píng)審不仔細(xì)”就能解釋的。我自己后來(lái)復(fù)盤(pán)發(fā)現(xiàn)這類(lèi)代碼在CR階段經(jīng)常被放過(guò)的原因有三個(gè)一是評(píng)審的重點(diǎn)放在“功能正確性”上。大家會(huì)盯著try-catch寫(xiě)沒(méi)寫(xiě)、空指針會(huì)不會(huì)出現(xiàn)、事務(wù)有沒(méi)有加卻很難靜態(tài)地看出一個(gè)“吞吐量不達(dá)標(biāo)”的問(wèn)題——消費(fèi)者單條處理、逐條打庫(kù)這些屬于性能設(shè)計(jì)范疇不是代碼審查能一眼識(shí)破的。二是沒(méi)有壓測(cè)環(huán)節(jié)。我們的測(cè)試環(huán)境數(shù)據(jù)量小隊(duì)列永遠(yuǎn)吃不飽生產(chǎn)者一扔消費(fèi)者馬上就消化了CPU、線(xiàn)程、隊(duì)列深度這些指標(biāo)全部正常導(dǎo)致問(wèn)題被完全掩蓋。等到大促壓測(cè)流量上來(lái)才徹底現(xiàn)出原形。三是對(duì)“隊(duì)列模型”缺少統(tǒng)一的規(guī)范。團(tuán)隊(duì)里沒(méi)有明確說(shuō)過(guò)“消息必須批量消費(fèi)”“重試必須退避”“入隊(duì)必須具備超時(shí)機(jī)制”老大按著他以前寫(xiě)同步接口的思路來(lái)寫(xiě)異步隊(duì)列自然就踩進(jìn)了這幾個(gè)典型陷阱。2. 排查過(guò)程從CPU飆高到揪出三宗罪2.1 線(xiàn)上現(xiàn)象接口超時(shí)和訂單狀態(tài)卡住壓測(cè)暴露出來(lái)的現(xiàn)象非常直接。支付回調(diào)接口的可接受響應(yīng)時(shí)間是1秒壓測(cè)開(kāi)始后P99直接飆到2秒以上同時(shí)后臺(tái)監(jiān)控發(fā)現(xiàn)訂單狀態(tài)更新出現(xiàn)大面積積壓積壓數(shù)量一度到了幾萬(wàn)條短信發(fā)送網(wǎng)關(guān)那邊則報(bào)了一堆限流。整個(gè)系統(tǒng)并沒(méi)有宕機(jī)但到處都在“排隊(duì)”一副快要被拖垮的樣子。這種“哪里都在排隊(duì)”的現(xiàn)象其實(shí)是個(gè)很好的排查起點(diǎn)。明明只是異步處理慢怎么連支付回調(diào)接口這種“只負(fù)責(zé)丟消息”的入口都變慢了這一問(wèn)就能順著生產(chǎn)鏈路摸到隊(duì)列本身。2.2 一步步定位top、jstack、Arthas排查第一步先看機(jī)器負(fù)載。top -Hp找出CPU占用高的線(xiàn)程發(fā)現(xiàn)有一組線(xiàn)程狀態(tài)很奇怪大量線(xiàn)程處于BLOCKED狀態(tài)堆棧全部卡在java.util.concurrent.ArrayBlockingQueue.put。這說(shuō)明什么說(shuō)明有一堆生產(chǎn)者在同一個(gè)隊(duì)列的入隊(duì)操作上互相競(jìng)爭(zhēng)、排隊(duì)等待。第二步用jstack抓線(xiàn)程棧重點(diǎn)看消息隊(duì)列相關(guān)的線(xiàn)程和業(yè)務(wù)回調(diào)線(xiàn)程。很快就能看到幾十個(gè)http-nio-exec-*線(xiàn)程都在put方法上等待鎖而真正干活的消費(fèi)者線(xiàn)程只有一個(gè)它正深度卡在orderMapper.selectById之后的等待里——準(zhǔn)確說(shuō)是在等短信服務(wù)響應(yīng)。單消費(fèi)者線(xiàn)程本身處理一條消息就要經(jīng)歷三次串行IO平均耗時(shí)80到100毫秒算下來(lái)吞吐量大概也就每秒12條。第三步我用Arthas做細(xì)化定位。thread -n 3再看線(xiàn)程CPU排行。接著用trace命令跟蹤NotifyWorker.process方法發(fā)現(xiàn)耗時(shí)大部分落在兩個(gè)地方一次訂單查詢(xún)SQL平均15毫秒、一次短信發(fā)送外部HTTP調(diào)用平均50毫秒。單條消息就要消耗掉70到80毫秒的處理時(shí)間而生產(chǎn)速率在峰值時(shí)一秒有三四百條這個(gè)隊(duì)列不堵才奇怪。2.3 壓測(cè)復(fù)現(xiàn)與根因確認(rèn)光看線(xiàn)程棧還不夠必須壓測(cè)復(fù)現(xiàn)。我們?cè)趬簻y(cè)環(huán)境重新搭了一套一模一樣的代碼用腳本模擬支付回調(diào)按峰值速率每秒400個(gè)請(qǐng)求往隊(duì)列里灌。10分鐘之后隊(duì)列長(zhǎng)度直接頂?shù)?000的容量上限隨后所有put請(qǐng)求開(kāi)始阻塞回調(diào)接口RT應(yīng)聲上漲。這一步把因果鏈坐實(shí)了生產(chǎn)速率大于消費(fèi)速率隊(duì)列開(kāi)始堆積堆積到容量上限后put無(wú)限期等待于是生產(chǎn)者線(xiàn)程——也就是支付回調(diào)的線(xiàn)程——全部被堵在隊(duì)列上回調(diào)線(xiàn)程被堵住接口RT自然飆升。整個(gè)過(guò)程層層傳導(dǎo)跟多米諾骨牌一樣。2.4 三宗罪隊(duì)列堵死的核心原因根因確認(rèn)之后我把問(wèn)題歸結(jié)為三宗罪第一宗罪入隊(duì)用put()無(wú)限阻塞。put()的語(yǔ)義是“隊(duì)列滿(mǎn)了一直等”這在低流量下沒(méi)什么但在峰值時(shí)段隊(duì)列一旦滿(mǎn)了所有回調(diào)線(xiàn)程都會(huì)無(wú)限期掛在入隊(duì)操作上。最要命的是這種等待沒(méi)有超時(shí)、沒(méi)有降級(jí)、沒(méi)有熔斷外部流量還在不斷增加線(xiàn)程池所有線(xiàn)程全部被占滿(mǎn)于是接口徹底失去響應(yīng)能力。第二宗罪消費(fèi)者單條處理全鏈路串行IO。每消費(fèi)一條消息就要查一次庫(kù)、更新一次狀態(tài)、發(fā)一次短信。這三個(gè)操作全是IO型操作單條耗時(shí)就接近百毫秒。一個(gè)單線(xiàn)程消費(fèi)者一天最多也就處理一百萬(wàn)條消息聽(tīng)著不少但頂不住秒殺瞬間的尖峰流量。隊(duì)列消費(fèi)端的設(shè)計(jì)完全不符合“批量”這個(gè)最基本的優(yōu)化思路。第三宗罪失敗重試直接放回隊(duì)頭。代碼里一旦process拋出異常就把任務(wù)重新放回隊(duì)列頭部。這個(gè)操作有兩個(gè)問(wèn)題第一如果某條消息持續(xù)失敗它會(huì)堵在隊(duì)首一直占著生產(chǎn)者的名額第二put操作本身也可能阻塞失敗的線(xiàn)程會(huì)把自己卡在重試入隊(duì)上。而且恢復(fù)正常順序會(huì)被打亂用戶(hù)收到的短信順序可能錯(cuò)亂業(yè)務(wù)上非常尷尬。2.5 一個(gè)隱藏的“背鍋位”單線(xiàn)程消費(fèi)者除了上面三條還有一個(gè)容易被忽略的設(shè)計(jì)問(wèn)題消費(fèi)線(xiàn)程只有一個(gè)。單線(xiàn)程消費(fèi)天然無(wú)法利用多核CPU遇到磁盤(pán)IO或者外部接口等待時(shí)CPU只能空轉(zhuǎn)。很多團(tuán)隊(duì)看到“單線(xiàn)程處理不存在并發(fā)問(wèn)題”就覺(jué)得安全卻忘了消費(fèi)能力一樣是硬指標(biāo)。增加合理的消費(fèi)線(xiàn)程數(shù)本來(lái)就是隊(duì)列設(shè)計(jì)的一部分。3. 優(yōu)化方案每一處改動(dòng)背后的理由3.1 把“按條消費(fèi)”改成“攢批消費(fèi)”第一個(gè)改動(dòng)也是最核心的一個(gè)消費(fèi)者不再一條一條處理而是“攢一批、處理一批”。這里用到了BlockingQueue的drainTo方法配合超時(shí)輪詢(xún)static class BatchNotifyWorker implements Runnable { private static final int MAX_BATCH_SIZE 500; private static final long POLL_TIMEOUT_MS 200; Override public void run() { ListNotifyTask batch new ArrayList(MAX_BATCH_SIZE); while (!Thread.currentThread().isInterrupted()) { batch.clear(); // 先嘗試拿到第一條最多等200ms NotifyTask first QUEUE.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); if (first null) { continue; // 隊(duì)列空閑避免空轉(zhuǎn) } batch.add(first); // 把當(dāng)前隊(duì)列里能拿的都拿出來(lái) QUEUE.drainTo(batch, MAX_BATCH_SIZE - 1); processBatch(batch); } } private void processBatch(ListNotifyTask tasks) { ListInteger orderIds tasks.stream().map(NotifyTask::getOrderId).collect(toList()); ListOrder orders orderMapper.selectByIds(orderIds); // 一次批量查詢(xún) orderMapper.batchUpdateStatus(orders); // 一次批量更新 smsClient.batchSend(orders.stream() .map(o - new SmsRequest(o.getMobile(), 您的訂單已支付成功)) .collect(toList())); // 一次合并發(fā)送 } }核心思路是把“三次串行IO”壓縮成“三次批量IO”。原來(lái)100條消息要查100次庫(kù)、更新100次庫(kù)、發(fā)100次短信現(xiàn)在變成1次批量查詢(xún)、1次批量更新、1次合并短信發(fā)送。數(shù)據(jù)庫(kù)批量操作的耗時(shí)并不是按條數(shù)線(xiàn)性增長(zhǎng)的100條的批量更新和1條的單條更新相比耗時(shí)可能只多了一倍不到但單位吞吐量翻了近百倍。這里有兩點(diǎn)要注意drainTo最大取499條加上前面那一條正好湊滿(mǎn)500而第一次poll設(shè)置200毫秒超時(shí)是為了在隊(duì)列空閑時(shí)不頻繁空轉(zhuǎn)同時(shí)保證只要隊(duì)列里有消息最多等200毫秒就能積攢出一批。3.2 入隊(duì)策略put換offer把無(wú)限阻塞改成有界等待生產(chǎn)端改動(dòng)也很關(guān)鍵put()換成offer()加超時(shí)超時(shí)之后走降級(jí)邏輯public static boolean submit(NotifyTask task) { // 隊(duì)列滿(mǎn)時(shí)最多等200ms再不行就降級(jí) return QUEUE.offer(task, 200, TimeUnit.MILLISECONDS); } // 使用處 boolean accepted OrderNotifyQueue.submit(task); if (!accepted) { // 降級(jí)策略寫(xiě)入本地文件或DB待重發(fā)也可以直接走同步處理 fallbackSave(task); }為什么不是把隊(duì)列直接改成無(wú)界無(wú)界隊(duì)列看起來(lái)“永遠(yuǎn)不會(huì)拒絕消息”但代價(jià)是內(nèi)存被無(wú)限占用最終觸發(fā)FGC甚至OOM。線(xiàn)上的資源是有限的削峰的本質(zhì)是“暫時(shí)存不下了就先擋住”而不是“有多少都硬接”。有界隊(duì)列加超時(shí)本質(zhì)上是一種背壓機(jī)制——當(dāng)下游處理不過(guò)來(lái)了上游也要感覺(jué)到壓力然后想辦法降級(jí)或者限流而不是把整個(gè)系統(tǒng)拖死。超時(shí)時(shí)間選200毫秒是和支付回調(diào)的可用性指標(biāo)對(duì)齊的回調(diào)本身還有一系列其他邏輯入隊(duì)最多占200毫秒再往上去就觸發(fā)降級(jí)保證接口不會(huì)被隊(duì)列拖到超時(shí)。3.3 重試隊(duì)列獨(dú)立出來(lái)用延遲隊(duì)列做退避重試邏輯的優(yōu)化核心原則是“失敗的消息不能回主隊(duì)列頭必須退避而且不能擠壓新消息”。我改成了獨(dú)立的延遲隊(duì)列private static final DelayQueueDelayItemNotifyTask RETRY_QUEUE new DelayQueue(); private static final long[] RETRY_DELAYS_MS {5_000, 30_000, 60_000, 300_000}; public static void retryLater(NotifyTask task, int retryCount) { long delay RETRY_DELAYS_MS[Math.min(retryCount, RETRY_DELAYS_MS.length - 1)]; RETRY_QUEUE.put(new DelayItem(task, System.currentTimeMillis() delay)); }消費(fèi)線(xiàn)程在處理完主隊(duì)列任務(wù)后會(huì)檢查延遲隊(duì)列是否有到期任務(wù)有就取出來(lái)重新放回主隊(duì)列。這樣既保證了失敗任務(wù)會(huì)重試又不會(huì)讓它們卡在主隊(duì)列里餓死后來(lái)的正常消息。分級(jí)退避從5秒到300秒逐級(jí)拉長(zhǎng)避免某條消息持續(xù)失敗的時(shí)候瘋狂重試打爆下游。為什么不用ScheduledExecutorService因?yàn)檠舆t隊(duì)列天然支持“按到期時(shí)間排序取出”而定時(shí)線(xiàn)程池更偏向“周期性任務(wù)”處理那種“到點(diǎn)觸發(fā)一次、不再管了”的邏輯還行要做“延遲之后重新入隊(duì)”這種動(dòng)態(tài)流轉(zhuǎn)DelayQueue更順手。3.4 消費(fèi)者線(xiàn)程數(shù)怎么算消費(fèi)線(xiàn)程從1個(gè)改成多個(gè)但線(xiàn)程數(shù)不能拍腦袋。我用的估算公式是這個(gè)對(duì)于IO密集型任務(wù)線(xiàn)程數(shù) ≈ CPU核數(shù) × (1 平均等待時(shí)間 / 平均計(jì)算時(shí)間)我們壓測(cè)環(huán)境是8核批量處理中數(shù)據(jù)庫(kù)批量IO加外部短信調(diào)用的等待時(shí)間大約占75%本地CPU計(jì)算和JSON序列化大約占25%。算下來(lái)等待時(shí)間和計(jì)算時(shí)間的比值大約是3。于是線(xiàn)程數(shù) ≈ 8 × (1 3) 32但這是理論上限實(shí)際并沒(méi)有直接配32——線(xiàn)程太多會(huì)導(dǎo)致數(shù)據(jù)庫(kù)連接池和短信網(wǎng)關(guān)連接數(shù)不夠用反而加劇競(jìng)爭(zhēng)。折中一下我配了4個(gè)消費(fèi)線(xiàn)程每個(gè)線(xiàn)程一次處理500條4個(gè)線(xiàn)程疊加起來(lái)峰值吞吐量已經(jīng)能達(dá)到每秒幾百條完全覆蓋當(dāng)前生產(chǎn)速率。多線(xiàn)程時(shí)代的“安全”不是單線(xiàn)程而是“可控?cái)?shù)量的多線(xiàn)程加上批量消費(fèi)”。3.5 更進(jìn)一步要不要直接上Disruptor或者專(zhuān)業(yè)MQ優(yōu)化完自家內(nèi)存隊(duì)列之后團(tuán)隊(duì)里也有人問(wèn)都這么費(fèi)勁了為什么不直接換Kafka或者RocketMQ這個(gè)問(wèn)題我認(rèn)真想了一下。結(jié)論是業(yè)務(wù)場(chǎng)景和成本決定架構(gòu)選型。我們這里只是一個(gè)訂單模塊的內(nèi)部異步通知數(shù)據(jù)量級(jí)遠(yuǎn)沒(méi)有到需要分布式消息隊(duì)列的水平引入專(zhuān)業(yè)MQ意味著要部署B(yǎng)roker集群、維護(hù)Topic、處理分區(qū)和消費(fèi)者組關(guān)系運(yùn)維成本和復(fù)雜度一下就上來(lái)了。Disruptor的核心優(yōu)勢(shì)是無(wú)鎖環(huán)形隊(duì)列適合超高吞吐的場(chǎng)景但對(duì)批量處理、重試退避這類(lèi)業(yè)務(wù)邏輯沒(méi)有直接幫助反而因?yàn)锳PI抽象更底層團(tuán)隊(duì)學(xué)習(xí)成本更高。所以最終保留了自己優(yōu)化的內(nèi)存隊(duì)列但加了一條規(guī)則如果未來(lái)積壓量持續(xù)超過(guò)內(nèi)存隊(duì)列上限的50%或者出現(xiàn)多機(jī)房部署需求就切換專(zhuān)業(yè)MQ。4. 壓測(cè)效果與上線(xiàn)驗(yàn)證4.1 壓測(cè)方案與測(cè)試腳本優(yōu)化完成后不能直接上生產(chǎn)必須先壓測(cè)。我們復(fù)用了之前那套腳本模擬支付回調(diào)接口每秒產(chǎn)生400個(gè)訂單通知任務(wù)持續(xù)壓測(cè)30分鐘。同時(shí)把生產(chǎn)者和消費(fèi)者的關(guān)鍵指標(biāo)隊(duì)列深度、消費(fèi)耗時(shí)、重試次數(shù)都打印到日志再配合監(jiān)控系統(tǒng)看趨勢(shì)。壓測(cè)腳本我寫(xiě)得很簡(jiǎn)單核心就是通過(guò)一個(gè)循環(huán)接口往隊(duì)列里塞數(shù)據(jù)再統(tǒng)計(jì)每秒成功入隊(duì)的數(shù)量和回調(diào)響應(yīng)時(shí)間。這個(gè)腳本的價(jià)值不在于代碼多精妙而在于它能夠真實(shí)逼近線(xiàn)上峰值速率讓問(wèn)題在“發(fā)布之前”暴露出來(lái)。4.2 優(yōu)化前后的核心指標(biāo)對(duì)比壓測(cè)結(jié)束后我把數(shù)據(jù)整理成了表格結(jié)論非常直觀(guān)指標(biāo)優(yōu)化前優(yōu)化后說(shuō)明消費(fèi)者吞吐量約12條/秒約600條/秒批量消費(fèi)帶來(lái)的數(shù)量級(jí)提升支付回調(diào)接口P99耗時(shí)2秒180毫秒入隊(duì)不再阻塞回調(diào)線(xiàn)程隊(duì)列積壓數(shù)打滿(mǎn)1000持續(xù)觸頂峰值不超過(guò)200消費(fèi)能力覆蓋生產(chǎn)速率系統(tǒng)CPU占用90%以上約35%線(xiàn)程等待減少、批量IO減少空轉(zhuǎn)短信發(fā)送調(diào)用次數(shù)每單1次HTTP合并批量發(fā)送下游限流問(wèn)題基本消失失敗消息重試回隊(duì)頭干擾正常消息延遲隊(duì)列分級(jí)退避不再影響正常消費(fèi)順序最顯著的變化是吞吐量從12條/秒提升到600條/秒整整50倍。這個(gè)結(jié)果并不夸張因?yàn)閮?yōu)化的核心不是“讓單條消息處理得更快”而是“一次處理一批消息”——單條處理耗時(shí)80毫秒批量500條處理耗時(shí)也就200毫秒相當(dāng)于單條均攤時(shí)間從80毫秒降到了0.4毫秒。4.3 灰度發(fā)布與監(jiān)控落地指標(biāo)好看也不能一股腦全量上。我們分了三個(gè)步驟灰度先切10%流量觀(guān)察半天重點(diǎn)看監(jiān)控面板上的隊(duì)列深度、消費(fèi)延遲、重試次數(shù)三個(gè)指標(biāo)有沒(méi)有異常。半天沒(méi)問(wèn)題再放到50%再觀(guān)察半天最后才全量。全量之后持續(xù)盯了72小時(shí)確認(rèn)短信發(fā)送量正常、訂單狀態(tài)更新無(wú)積壓、回調(diào)接口RT穩(wěn)定這次優(yōu)化才算真正閉環(huán)。監(jiān)控是比壓測(cè)更重要的東西。壓測(cè)只能驗(yàn)證“在那個(gè)時(shí)間點(diǎn)沒(méi)問(wèn)題”線(xiàn)上流量像潮水一樣隨時(shí)變化沒(méi)有監(jiān)控就只能靠用戶(hù)投訴來(lái)發(fā)現(xiàn)問(wèn)題。我后來(lái)專(zhuān)門(mén)給隊(duì)列加了三塊看板隊(duì)列實(shí)時(shí)深度、單條消息從入隊(duì)到消費(fèi)完成的端到端延遲、失敗重試的次數(shù)和級(jí)別。有了這三個(gè)指標(biāo)以后隊(duì)列再出問(wèn)題打開(kāi)監(jiān)控就能定位到是生產(chǎn)太快還是消費(fèi)太慢或者是重試風(fēng)暴。4.4 上線(xiàn)后的一點(diǎn)觀(guān)察上線(xiàn)后正好趕上一次小規(guī)模促銷(xiāo)業(yè)務(wù)流量比平時(shí)翻了3倍多系統(tǒng)穩(wěn)如老狗。對(duì)比之前壓測(cè)就崩的狀態(tài)差異非常明顯。銷(xiāo)售那邊還跑來(lái)問(wèn)“最近短信怎么發(fā)得這么快”我們只能笑笑說(shuō)“改了個(gè)Bug”。5. 常見(jiàn)問(wèn)題與排坑經(jīng)驗(yàn)速查5.1 內(nèi)存隊(duì)列參數(shù)速查表這次踩坑之后我把內(nèi)存隊(duì)列的參數(shù)整理成一張表發(fā)給團(tuán)隊(duì)所有人參考。如果你們也在自己寫(xiě)內(nèi)存隊(duì)列可以直接抄參數(shù)推薦值理由隊(duì)列容量按峰值積壓量的2到3倍設(shè)置太小容易觸發(fā)背壓太大可能內(nèi)存浪費(fèi)單批次大小200到500太大時(shí)單批處理時(shí)間過(guò)長(zhǎng)太小體現(xiàn)不出批量?jī)?yōu)勢(shì)入隊(duì)超時(shí)100到300毫秒與接口可容忍延遲對(duì)齊超時(shí)即降級(jí)消費(fèi)線(xiàn)程數(shù)CPU核數(shù)×(1等待/計(jì)算時(shí)間)再折半防止把DB連接池和下游連接數(shù)打滿(mǎn)失敗重試延遲5秒/30秒/60秒/300秒分級(jí)逐級(jí)退避避免重試風(fēng)暴批量poll超時(shí)100到200毫秒平衡積攢時(shí)間和空轉(zhuǎn)消耗5.2 消息丟失、重復(fù)、亂序怎么取舍消息隊(duì)列的本質(zhì)是異步和削峰它不可能同時(shí)保證“不丟失、不重復(fù)、不亂序”這是分布式系統(tǒng)的基本約束。你必須根據(jù)業(yè)務(wù)的容忍度做取舍。比如我們訂單狀態(tài)更新這個(gè)場(chǎng)景重復(fù)通知用戶(hù)是難以接受的所以消費(fèi)者端必須做冪等更新訂單狀態(tài)前先判斷當(dāng)前狀態(tài)已經(jīng)是“已支付”的就不再重復(fù)發(fā)短信短信發(fā)送接口也做了業(yè)務(wù)冪等鍵同一個(gè)訂單號(hào)在短時(shí)間內(nèi)不會(huì)重復(fù)發(fā)送。消息丟失的兜底則是靠降級(jí)落庫(kù)如果隊(duì)列滿(mǎn)了實(shí)在入不了隊(duì)就把任務(wù)寫(xiě)進(jìn)本地待辦表由一個(gè)定時(shí)任務(wù)每5分鐘掃一次重新提交進(jìn)隊(duì)列。這樣一來(lái)極端情況下最多延遲幾分鐘但不會(huì)丟。亂序問(wèn)題在我們場(chǎng)景里影響不大因?yàn)橛唵沃Ц冻晒νㄖ举|(zhì)上不依賴(lài)嚴(yán)格順序。但如果你的業(yè)務(wù)是“先改狀態(tài)再發(fā)短信”這種強(qiáng)順序鏈路那就需要在消息體里帶一個(gè)業(yè)務(wù)序列號(hào)消費(fèi)端按序列號(hào)做排序或者丟棄過(guò)期消息而不是單純依賴(lài)隊(duì)列的有序性。5.3 這次踩坑后總結(jié)的幾條團(tuán)隊(duì)規(guī)約這次事故給團(tuán)隊(duì)帶來(lái)的最大收益不是代碼優(yōu)化本身而是幾個(gè)流程性的改變第一所有涉及隊(duì)列、線(xiàn)程池、異步處理的代碼CR時(shí)必須有壓測(cè)記錄或者明確的性能指標(biāo)預(yù)估不能只聊邏輯正確性。第二使用內(nèi)存隊(duì)列必須遵守“有界超時(shí)降級(jí)”三件套不允許在生產(chǎn)代碼里出現(xiàn)裸的put()無(wú)限阻塞。第三重試邏輯必須和正常消息分離要么用延遲隊(duì)列要么用專(zhuān)門(mén)的待處理表禁止把失敗任務(wù)放回隊(duì)列頭部。第四條是我個(gè)人加上的——任何異步鏈路上線(xiàn)前必須有隊(duì)列深度的監(jiān)控告警和消費(fèi)延遲的監(jiān)控告警。沒(méi)有監(jiān)控就是睜著眼睛把系統(tǒng)交給運(yùn)氣。5.4 關(guān)于“Bug”這件事的一點(diǎn)感想說(shuō)到“Bug”網(wǎng)上總能看到類(lèi)似“codex磁盤(pán)bug”或者“winsxs bug”這類(lèi)聽(tīng)著就讓人頭大的問(wèn)題但說(shuō)實(shí)話(huà)這些離我們?nèi)粘i_(kāi)發(fā)太遠(yuǎn)了。真正讓我們寢食難安的往往是老大寫(xiě)的這段“當(dāng)時(shí)看起來(lái)沒(méi)問(wèn)題”的隊(duì)列代碼它不報(bào)錯(cuò)、不崩潰只是在你最需要它扛住流量的時(shí)候安靜地堵在那里把所有入口都堵死。排這種Bug最難的從來(lái)不是修復(fù)而是找到那個(gè)讓所有線(xiàn)索串起來(lái)的核心因果鏈——隊(duì)列滿(mǎn)了生產(chǎn)者的線(xiàn)程全被卡在入隊(duì)上回調(diào)接口才變慢背后是消費(fèi)端單條處理吞吐不夠。我個(gè)人在實(shí)際排查和優(yōu)化過(guò)程中最大的體會(huì)是遇到性能相關(guān)的線(xiàn)上問(wèn)題永遠(yuǎn)不要急著改代碼。先把線(xiàn)程棧抓下來(lái)把壓測(cè)復(fù)現(xiàn)跑出來(lái)把數(shù)據(jù)擺在桌面上再動(dòng)手。因?yàn)橹挥袛?shù)據(jù)能告訴你真正該優(yōu)化的是什么——而不是你“感覺(jué)”該優(yōu)化的是什么。這個(gè)習(xí)慣在代碼評(píng)審里同樣適用看到一段“能用”的代碼多問(wèn)一句“流量翻十倍它還扛得住嗎”很多線(xiàn)上事故就根本不會(huì)發(fā)生。最后再分享一個(gè)實(shí)用的小技巧排查完類(lèi)似問(wèn)題之后把當(dāng)時(shí)的線(xiàn)程dump、壓測(cè)腳本和優(yōu)化對(duì)比數(shù)據(jù)留在一個(gè)專(zhuān)門(mén)的問(wèn)題追蹤文檔里。下次再遇到隊(duì)列或者線(xiàn)程池相關(guān)的性能問(wèn)題先翻這個(gè)文檔大概率能省下半天排查時(shí)間。