事故復(fù)盤:從并發(fā)超賣到系統(tǒng)雪崩的避坑指南)
1. 從一次夜間緊急工單說起做Java開發(fā)這幾年最怕的不是需求復(fù)雜也不是工期緊張而是半夜手機(jī)震動——線上報警了。有一次我印象特別深凌晨兩點(diǎn)多某商城系統(tǒng)的下單接口突然大面積超時緊接著庫存數(shù)據(jù)出現(xiàn)負(fù)數(shù)用戶瘋狂下單系統(tǒng)直接被打穿。事后復(fù)盤發(fā)現(xiàn)問題根源居然只是一個很不起眼的并發(fā)控制缺失庫存扣減用的是先查詢再更新的邏輯沒有加鎖也沒有做樂觀鎖控制多個請求同時讀到同一個庫存值各自扣減后寫回于是出現(xiàn)了嚴(yán)重超賣。那次事故直接導(dǎo)致故障時長近一小時損失難以估量。但也正是從那次開始我養(yǎng)成了一個習(xí)慣每遇到一起線上事故不管大小都會認(rèn)真做一次復(fù)盤把根因、修復(fù)過程、預(yù)防措施完整記錄下來。這篇文章就是基于我這些年積累的編碼生產(chǎn)事故總結(jié)復(fù)盤經(jīng)驗整理成一份可以反復(fù)參考的資料。如果你也是一名Java開發(fā)尤其在做電商、交易、支付這類高并發(fā)場景的系統(tǒng)這篇文章應(yīng)該能幫你少踩不少坑。我不會去講那些教科書上早就寫過的理論而是把真實(shí)遇到過的場景、當(dāng)時怎么排查、問題出在哪里、代碼層面怎么改、流程層面怎么堵一五一十說清楚。很多內(nèi)容是我個人踩坑后的沉淀不一定適合所有團(tuán)隊但至少代表了一條被驗證過的路。2. 編碼生產(chǎn)事故的主要形態(tài)與根因分析生產(chǎn)事故千奇百怪但把根子刨出來看Java后端領(lǐng)域的事故大多集中在幾個點(diǎn)上。我先把這幾年見過、經(jīng)歷過的典型類型梳理一遍再逐一展開講案例。事故類型典型現(xiàn)象根因走向并發(fā)控制缺失超賣、重復(fù)扣款、數(shù)據(jù)被覆蓋鎖粒度、事務(wù)隔離級別、冪等設(shè)計空指針與數(shù)據(jù)異常接口500、NullPointerException刷屏下游數(shù)據(jù)為空、參數(shù)校驗缺失事務(wù)邊界錯位部分成功部分失敗、數(shù)據(jù)不一致事務(wù)方法自調(diào)用、異常被吞異步與線程池問題消息積壓、線程池拒絕、狀態(tài)丟失異步鏈路未做補(bǔ)償、參數(shù)未傳全內(nèi)存與性能問題頻繁Full GC、OOM、接口RT飆升大對象、集合無界、慢SQL2.1 并發(fā)控制缺失從超賣事故講到鎖的選型先說我開頭提到的那個超賣案例。當(dāng)時的下單接口代碼大致長這樣public boolean createOrder(Long userId, Long skuId, Integer num) { // 1. 查庫存 Stock stock stockMapper.selectBySkuId(skuId); // 2. 判斷庫存是否足夠 if (stock.getAvailable() num) { return false; } // 3. 扣減庫存 stock.setAvailable(stock.getAvailable() - num); stockMapper.updateById(stock); // 4. 創(chuàng)建訂單 orderMapper.insert(Order.build(userId, skuId, num)); return true; }這段代碼在低并發(fā)下完全沒問題但一旦遇到秒殺或者大促問題立刻暴露。兩個線程同時執(zhí)行到第二步讀到的可用庫存都是10都認(rèn)為可以扣5分別更新成5寫回實(shí)際賣出了10件而庫存只剩5件系統(tǒng)還能繼續(xù)往下賣直到庫存變負(fù)數(shù)為止。這類問題的解法業(yè)內(nèi)已經(jīng)比較成熟了無非幾種樂觀鎖方式在更新SQL里加上庫存充足條件UPDATE stock SET available available - #{num} WHERE sku_id #{skuId} AND available #{num}悲觀鎖方式查詢時加上行鎖。分布式鎖方式適合跨服務(wù)的庫存扣減。我用得最多的是第一種因為實(shí)現(xiàn)簡單、性能也好。實(shí)際工作中要注意很多人以為加上synchronized就萬事大吉了但如果是多實(shí)例部署synchronized只能鎖住本機(jī)JVM內(nèi)的線程跨節(jié)點(diǎn)依然會有問題。這點(diǎn)新手特別容易踩。后來我還總結(jié)出一條經(jīng)驗**凡是涉及金額、庫存、狀態(tài)流轉(zhuǎn)的寫操作必須在設(shè)計階段就把并發(fā)模型畫出來而不是等上線出了事再去補(bǔ)鎖。**并發(fā)問題的修復(fù)成本永遠(yuǎn)比預(yù)防成本高一個數(shù)量級。2.2 空指針與數(shù)據(jù)異常那些被“看似不會為空”的數(shù)據(jù)坑慘的時刻如果說并發(fā)問題還分場景那空指針就是天天見的???。有一次我們的用戶信息接口突然在某個渠道報錯排查發(fā)現(xiàn)是上游服務(wù)返回的用戶擴(kuò)展字段里某個屬性為null而我們在代碼里直接調(diào)用了該屬性的方法。這種問題在Java里實(shí)在太多了尤其是從接口拿數(shù)據(jù)時對方的文檔寫著“非空”但實(shí)際上某些歷史數(shù)據(jù)就是不滿足約束。我當(dāng)時給團(tuán)隊立了一條規(guī)矩**除了基礎(chǔ)類型和明確由自己寫入且必填的字段其他所有外部數(shù)據(jù)都要按可能為null來處理。**這不是代碼潔癖是對系統(tǒng)邊界保持敬畏。處理空指針的方法很多我用得最多的組合是方法入口用Objects.requireNonNull或者提前校驗。傳輸對象用Optional做顯式處理不要無腦get()。集合操作前判斷空集合Collections.emptyList()兜底。參數(shù)校驗落地到注解上比如NotNull、NotBlank在Controller層就把問題擋住。數(shù)據(jù)異常類的事故就更隱蔽了。比如前一陣我們做數(shù)據(jù)訂正腳本把一批訂單的金額字段從字符串轉(zhuǎn)成了整數(shù)結(jié)果有個別訂單因為包含了小數(shù)點(diǎn)后兩位以外的特殊字符轉(zhuǎn)換直接拋異常腳本中斷。這種看起來不影響線上接口的事故往往會造成更大的數(shù)據(jù)污染。我現(xiàn)在的習(xí)慣是凡是批量更新、數(shù)據(jù)訂正、遷移腳本先加試跑模式只輸出結(jié)果不落庫核對無誤后再全量執(zhí)行而且在執(zhí)行前必須手動備份相關(guān)表。2.3 事務(wù)邊界錯位一個Transactional引發(fā)的連環(huán)事故事務(wù)是Java后端最常用的能力之一但也是最容易被誤用的。我遇到過一個特別典型的案例某個訂單回調(diào)服務(wù)里開發(fā)同學(xué)在方法上加了Transactional方法內(nèi)部先更新訂單狀態(tài)再調(diào)用遠(yuǎn)程接口通知倉庫發(fā)貨最后更新一張積分表。整個方法跑完大約需要3秒期間事務(wù)一直持有數(shù)據(jù)庫連接。問題出在遠(yuǎn)程調(diào)用如果超時事務(wù)會長時間掛起連接池很快被耗盡其他請求全部排隊等待最終導(dǎo)致服務(wù)雪崩。事務(wù)里做遠(yuǎn)程調(diào)用這件事我一直強(qiáng)調(diào)嚴(yán)格控制事務(wù)邊界絕不在事務(wù)內(nèi)做耗時操作。另一個坑是事務(wù)自調(diào)用。同一個類里A方法調(diào)用B方法B上有Transactional但Spring的AOP基于代理自調(diào)用不會觸發(fā)代理邏輯事務(wù)注解直接失效。很多團(tuán)隊因此專門用Autowired注入自身來繞開或者把被調(diào)方法拆到另一個Service里。后來我還在Code Review清單里加了一條自查項檢查所有事務(wù)注解是否真正生效。還有一類事務(wù)問題是異常被吞。方法上聲明了Transactional(rollbackFor Exception.class)但內(nèi)部catch了異常又沒往外拋事務(wù)照樣不回滾。數(shù)據(jù)已經(jīng)寫了一半接口返回成功下游卻看到的是半成品數(shù)據(jù)。現(xiàn)在我對團(tuán)隊的規(guī)范是事務(wù)方法內(nèi)部盡量不要自己catch異常要catch就處理后重新拋出至少要區(qū)分哪些異常必須回滾、哪些可以忽略。2.4 異步處理與線程池場景下的隱患異步化是提升接口響應(yīng)速度的利器但異步鏈路里的坑一點(diǎn)也不少。第一個坑是線程池參數(shù)拍腦袋定。有一次我們把一個異步推送任務(wù)放到了一個無界的線程池里高峰時任務(wù)排隊上百萬個內(nèi)存直接飆升到接近堆上限頻繁Full GC。后來改成有界隊列、自定義拒絕策略才穩(wěn)定下來。線程池的核心參數(shù)并沒有萬能公式但至少要根據(jù)任務(wù)類型估算。比如任務(wù)平均執(zhí)行時間是50ms期望吞吐是每秒200個任務(wù)那核心線程數(shù)至少要讓處理能力大于流入速度否則隊列只會越積越長。我們在生產(chǎn)環(huán)境的做法是核心線程數(shù)和最大線程數(shù)壓到合理范圍內(nèi)隊列用有界隊列拒絕策略選擇CallerRunsPolicy讓提交任務(wù)的線程自己跑這樣不會丟任務(wù)也起到了天然限流的作用。還有一個經(jīng)典問題異步線程里的上下文丟失。比如在普通請求線程里設(shè)置了一個追蹤ID丟到異步線程池執(zhí)行時追蹤ID就拿不到了排查問題的時候日志全對不上。我們后期統(tǒng)一通過TransmittableThreadLocal解決沒有用原生的ThreadLocal。異步場景另一個高頻事故是漏傳參數(shù)或者丟失主鍵。有一次我們把某個通知任務(wù)異步化只傳了訂單號過去異步線程里根據(jù)訂單號查詢時訂單狀態(tài)已經(jīng)變了導(dǎo)致通知內(nèi)容發(fā)錯。這個問題的本質(zhì)是異步執(zhí)行時的數(shù)據(jù)快照和提交時的數(shù)據(jù)快照可能完全不同依賴實(shí)時查詢的結(jié)果去做業(yè)務(wù)判斷要特別小心。2.5 內(nèi)存與性能方面的生產(chǎn)事故內(nèi)存和性能事故比較隱蔽往往不是一次性爆發(fā)的而是積少成多。我舉一個例子有個報表導(dǎo)出功能一次性從數(shù)據(jù)庫查了幾十萬條記錄放進(jìn)了List然后逐個處理每個對象還保留著一個很大的JSON字段。幾個用戶同時操作內(nèi)存立刻吃緊Full GC頻繁整個服務(wù)RT翻倍其他接口跟著一起遭殃。這個問題的本質(zhì)是大對象駐留老年代解決思路就兩個字分頁或者流式處理。數(shù)據(jù)庫端用游標(biāo)讀取應(yīng)用端處理一批就釋放一批千萬不能把全量數(shù)據(jù)一次性load到內(nèi)存里。性能類的生產(chǎn)事故里慢SQL也是大頭。最常見的情況是查詢條件里用了函數(shù)計算導(dǎo)致索引失效。比如WHERE DATE_FORMAT(create_time, %Y-%m-%d) 2024-01-01這種寫法一定全表掃描。正確的做法是算好時間范圍再傳參WHERE create_time ? AND create_time ?。我在復(fù)盤這些性能事故的時候越來越覺得**性能問題不是什么高深莫測的東西大多數(shù)時候就是數(shù)據(jù)的訪問模式錯了。**只要對數(shù)據(jù)量有感知對索引的使用有常識就能避免絕大部分問題。3. 事故復(fù)盤方法論與排查實(shí)操遇到生產(chǎn)事故最怕的是慌亂。我自己的排查流程已經(jīng)固定下來了不管什么事故都按這套思路走效率會高很多。第一步看監(jiān)控、看日志、定性。先把影響范圍搞清楚是全部用戶掛還是部分接口掛是持續(xù)掛還是間歇性掛。這一步?jīng)Q定了接下來的排查方向。第二步看最近變更。很多事故不是突然出現(xiàn)的而是某個發(fā)布或者配置改動觸發(fā)的。排查變更比從代碼海洋里撈針快得多。我們團(tuán)隊已經(jīng)形成習(xí)慣每次發(fā)布都必須有變更記錄否則出了事故連方向都找不到。第三步定位根因。這一步要用到日志、線程棧、堆轉(zhuǎn)儲、APM鏈路追蹤等手段。第四步驗證并修復(fù)。修復(fù)不能用“試試看”的心態(tài)必須能解釋為什么這么改是對的最好有壓測或者回放作為佐證。第五步沉淀復(fù)盤報告。這一步很多人忽略但恰恰是最重要的。3.1 第一時間定位問題的思路我舉個例子。有一次線上接口突然超時我第一反應(yīng)不是打開IDE看代碼而是先看監(jiān)控面板的P95和P99。發(fā)現(xiàn)P99飆升但P50正常?;究梢詳喽ú皇钦w負(fù)載問題而是少數(shù)慢請求或者某條特殊路徑存在問題。再往下看發(fā)現(xiàn)超時請求集中在某幾個商品SKU而這些SKU有一個共同特點(diǎn)積壓的庫存記錄特別多。再結(jié)合數(shù)據(jù)庫慢查詢?nèi)罩景l(fā)現(xiàn)對庫存表的分頁查詢走了全表掃描因為查詢條件里store_id沒有索引。整個過程大概花了二十分鐘如果從代碼開始看可能兩小時都未必找得到。這里我要強(qiáng)調(diào)一點(diǎn)**排查事故時要克制住“憑經(jīng)驗猜”的沖動。**我見過不少老開發(fā)一看超時就說“是不是GC了”上去先抓堆棧折騰半天發(fā)現(xiàn)根本不是。正確做法是用數(shù)據(jù)說話用監(jiān)控面板一步步縮小范圍。一線排查的常用命令我也會整理成一個速查表場景命令/操作作用查看JVM堆使用jstat -gcutil pid 1000觀察GC頻率、內(nèi)存余量查看線程狀態(tài)jstack pid thread.txt分析死鎖、線程阻塞抓取堆轉(zhuǎn)儲jmap -dump:formatb,fileheap.hprof pid分析OOM、大對象查詢慢SQL開啟慢日志并分析定位SQL性能問題鏈路追蹤根據(jù)traceId串聯(lián)日志定位跨服務(wù)耗時3.2 日志與監(jiān)控如何幫助我們復(fù)盤做事故復(fù)盤最怕的是沒有數(shù)據(jù)全靠記憶。所以日志記錄這件事真到了復(fù)盤時才會發(fā)現(xiàn)有多重要。我之前踩過一個坑生產(chǎn)環(huán)境某個對象的序列化日志被打得鋪天蓋地一天幾個GB真正需要追蹤的業(yè)務(wù)字段卻沒有一個。后來我主導(dǎo)了一次日志規(guī)范梳理才把日志從“能跑就行”提升到“可觀測”的水平。日志規(guī)范里核心就幾條關(guān)鍵業(yè)務(wù)操作必須記錄入?yún)?、出參、耗時、traceId。日志級別要有明確規(guī)范debug不進(jìn)生產(chǎn)info記錄業(yè)務(wù)關(guān)鍵節(jié)點(diǎn)error記錄異常和失敗路徑。禁止在循環(huán)里打日志尤其禁止在大集合的循環(huán)里打。敏感信息脫敏不能把手機(jī)號、身份證明文打進(jìn)日志。監(jiān)控方面我們現(xiàn)在做到了三層基礎(chǔ)層監(jiān)控CPU、內(nèi)存、磁盤、網(wǎng)絡(luò)應(yīng)用層監(jiān)控QPS、RT、錯誤率、線程池活躍度業(yè)務(wù)層監(jiān)控訂單量、支付成功率、庫存扣減次數(shù)這種和用戶感受直接相關(guān)的指標(biāo)。有了這三層事故的定位速度會快非常多。3.3 從現(xiàn)象到根因的推理過程復(fù)盤文檔里我最看重的一個環(huán)節(jié)就是從現(xiàn)象到根因的推理過程而不是最終結(jié)論。因為只有把推理鏈寫出來才能驗證當(dāng)時是不是有邏輯跳躍才能讓后人學(xué)習(xí)到完整的思路。舉一個之前遇到的事故?,F(xiàn)象是某天晚上整體訂單量無明顯增長但支付回調(diào)接口的錯誤率突然上升持續(xù)了大概十分鐘后自行恢復(fù)。如果只看結(jié)論可能就寫一句“上游服務(wù)抖動導(dǎo)致的偶發(fā)失敗”。但我們深入推理后發(fā)現(xiàn)錯誤率上升的時間點(diǎn)正好和某臺Tomcat節(jié)點(diǎn)被重啟的時間吻合——那臺節(jié)點(diǎn)重啟是因為健康檢查失敗被自動摘除了。健康檢查失敗的原因是JVM發(fā)生了一次長達(dá)20秒的Full GC而Full GC的誘因是前一天的灰度發(fā)布增加了對象緩存的大小。所以整條鏈路是灰度發(fā)布加緩存 → 內(nèi)存占用升高 → 觸發(fā)Full GC → 節(jié)點(diǎn)對健康檢查無響應(yīng) → 被摘除 → 流量重新分配到其他節(jié)點(diǎn) → 其他節(jié)點(diǎn)壓力短暫升高 → 支付回調(diào)偶發(fā)超時 → 客戶端重試反饋到錯誤率上。如果只停留在“上游抖動”這件事永遠(yuǎn)不可能真正解決。也正因為我們堅持把推理鏈挖到最底層才發(fā)現(xiàn)真正需要優(yōu)化的是對象緩存的過期策略和堆內(nèi)存參數(shù)。這種復(fù)盤方式讓我對系統(tǒng)運(yùn)行的理解上升了不止一個層次。4. 如何從制度與習(xí)慣上減少編碼生產(chǎn)事故事故復(fù)盤再多如果只是寫文檔那也只是一堆文字。真正有效的復(fù)盤一定要落在制度建設(shè)和習(xí)慣改變上。4.1 代碼評審與靜態(tài)檢查的落地很多人覺得代碼評審走形式但我認(rèn)為**評審是編碼生產(chǎn)事故的第一道防線。**問題在于很多團(tuán)隊的評審只關(guān)注“代碼能不能跑”“功能對不對”很少關(guān)注“會不會掛”。我建議評審時至少增加這幾個視角并發(fā)安全視角這段代碼有沒有可能被多個線程同時執(zhí)行覆蓋的共享資源是什么邊界視角傳入?yún)?shù)有沒有可能為null集合有沒有可能為空數(shù)據(jù)量有沒有可能超出預(yù)期依賴視角這個方法依賴的外部服務(wù)不可用時系統(tǒng)會怎樣生命周期視角臨時文件、線程、連接這些資源是否都會釋放靜態(tài)檢查工具也是必須的比如SonarQube、SpotBugs、Alibaba Java Coding Guidelines插件。但要記住工具只能抓明顯的壞味道抓不了設(shè)計層面的問題。真正有價值的還是人。后來我們團(tuán)隊要求所有代碼合并前必須有至少一個評審人認(rèn)真過一遍不是點(diǎn)個“通過”按鈕那種而是要真的理解改動邏輯。4.2 測試策略與灰度發(fā)布編碼事故里很多都是可以通過測試提前發(fā)現(xiàn)的。我們現(xiàn)在的分支覆蓋率要求可能不算高但核心鏈路的場景測試覆蓋得很全。什么叫核心鏈路就是用戶下單、支付回調(diào)、退款、庫存扣減、對賬這些每一個都要有正反向用例。測試環(huán)境的問題是數(shù)據(jù)量小、不會觸發(fā)并發(fā)和性能隱患所以單測過了不代表生產(chǎn)安全。我比較推崇的是把壓測常態(tài)化。團(tuán)隊每隔一個迭代做一次核心接口的壓測不需要極其精確但至少能知道當(dāng)前接口的瓶頸在哪里。很多線上超時事故其實(shí)在壓測階段就該暴露了?;叶劝l(fā)布更是救命稻草。我們現(xiàn)在的發(fā)布方式是先金絲雀節(jié)點(diǎn)觀察幾分鐘沒有異常再全量。而且灰度期間要重點(diǎn)盯業(yè)務(wù)指標(biāo)不只是CPU、內(nèi)存這些技術(shù)指標(biāo)。有一次新版本發(fā)布后接口技術(shù)上一切正常但訂單轉(zhuǎn)化率掉了1.5%灰度監(jiān)控立刻把它抓了出來一查發(fā)現(xiàn)是前端頁面的某個控件被錯誤地挪了位置。技術(shù)指標(biāo)正常不代表業(yè)務(wù)正常這條經(jīng)驗很重要。4.3 團(tuán)隊層面的復(fù)盤與知識沉淀團(tuán)隊例行的事故復(fù)盤會一定要開成“學(xué)習(xí)會”而不是“追責(zé)會”。一旦追責(zé)所有人都會選擇隱瞞和掩藏事故信息就會丟失。我們的原則很簡單**只談技術(shù)根因不談責(zé)任人。**所有代碼都是團(tuán)隊資產(chǎn)別人寫的爛代碼如果你沒有在評審時攔住那這個責(zé)任是共同的。復(fù)盤會的流程通常是這樣的事故當(dāng)事人講時間線和現(xiàn)象。大家一起剖析根因允許反駁和補(bǔ)充。得出行動計劃責(zé)任到人給截止時間。把復(fù)盤內(nèi)容歸檔到團(tuán)隊知識庫并且同步到新人學(xué)習(xí)資料里。我還特別建議做“事故案例庫”。每一起事故不論大小都記錄在一個共享文檔里標(biāo)注清楚標(biāo)題、現(xiàn)象、根因、影響、修復(fù)方案、預(yù)防措施。新人入職的時候不要先看規(guī)范文檔先看事故案例庫這比任何培訓(xùn)都有效。因為規(guī)范講的是“應(yīng)該怎么做”事故案例講的是“不這么做會有什么后果”后者才能真正讓人記住。5. 我個人的幾條心得與建議講完這么多最后再說幾條比較零散、但確實(shí)是我工作里反復(fù)驗證過的經(jīng)驗吧。第一**寫代碼前先想一想失敗的情況。**很多事故的根源是開發(fā)默認(rèn)“所有事都會成功”。但真實(shí)生產(chǎn)環(huán)境里下游服務(wù)會超時數(shù)據(jù)庫連接會斷緩存會失效消息會丟失。寫代碼的時候把異常路徑和正常路徑同等對待很多事故從一開始就不會存在。第二**變更比故障更值得關(guān)注。**生產(chǎn)事故絕大多數(shù)是由某個變更觸發(fā)的。所以做任何變更前都要想一想影響范圍列一個回滾方案。發(fā)布后盯著看一會兒再離開工位。不用多十五分鐘就好往往能攔住大部分問題。第三**不要迷信“測試環(huán)境能測出來”。**生產(chǎn)環(huán)境的數(shù)據(jù)量、并發(fā)量、網(wǎng)絡(luò)狀況測試環(huán)境永遠(yuǎn)模擬不出來。所以線上監(jiān)控和日志必須從一開始就做好那是你唯一能在生產(chǎn)環(huán)境看清系統(tǒng)的眼睛。第四**復(fù)盤報告不是寫給領(lǐng)導(dǎo)看的是寫給自己和下一個人看的。**哪怕沒有人要求你寫出了事故我也會自己寫一份哪怕只是幾百字的備忘錄下次遇到類似問題翻出來看一眼能省幾個小時。做Java開發(fā)越久我越覺得真正拉開差距的往往不是框架用得有多熟新特性背得有多牢而是面對復(fù)雜系統(tǒng)時那種“下意識地敬畏”敬畏數(shù)據(jù)敬畏并發(fā)敬畏每一個可能為空的外部依賴。一次生產(chǎn)事故是一次很好的學(xué)習(xí)機(jī)會關(guān)鍵看你能不能把機(jī)會變成經(jīng)驗把經(jīng)驗變成制度把制度變成習(xí)慣。希望這篇文章里記錄的那些坑和思路能幫你在碼代碼的路上少翻幾次車。