入職多年,面對生產環境,儘管都是小心翼翼,慎之又慎,還是難免捅出簍子。輕則滿頭大汗,面紅耳赤。重則系統停擺,損失資金。每一個生產事故的背後,都是寶貴的經驗和教訓,都是項目成員的血淚史。為了更好地防範和遏制今後的各類事故,特開此專題,長期更新和記錄大大小小的各類事故。有些是親身經歷,有些是經人耳傳口授,但無一例外都是真實案例。
注意:為了避免不必要的麻煩和商密問題,文中提到的特定名稱都將是化名、代稱。
0x00 大綱
- 0x00 大綱
- 0x01 案例一
- 0x02 案例二
- 0x03 案例三
- 0x04 案例四
- 0x05 案例五
- 0x06 案例六
- 0x07 案例七
- 0x08 案例八
0x01 案例一
事故時間:2018年6月13日
故障類型:java.lang.OutOfMemoryError: Java heap space
事故經過:某考務管理系統,前期收集考生報名信息時允許上傳ZIP附件提交相關材料,後台服務會解析壓縮包並從中獲取相關文件。
系統運行後不久,考務羣就陸續有人反饋報名網站打不開,無法訪問等等。讓運維重啓系統後,又恢復正常,跑了一段時間以後,又有人説無法訪問。仔細檢查故障時的日誌,發現故障時間點都是發生在有人上傳ZIP文件的時候。
從服務器上提取了一部分樣本,發現壓縮文件裏面包含若干個TXT文件,TXT文件中是重複的字符,類似AAA...該TXT文件原始數據巨大且單調重複,導致壓縮後的ZIP卻非常小,真是個天才!直覺告訴我們這是被惡意攻擊了,遂暫時關閉了文件上傳接口,改為通過表單錄入信息報名。
事後覆盤當時的代碼,發現處理ZIP文件時沒有釋放到磁盤臨時文件,都是在內存中直接解壓並讀取解壓後的文本數據,這就給了攻擊者可乘之機。但是後來專門去研究了下這方面的安全漏洞,發現這是一種ZIP炸彈(ZIP of Death or ZIP Bomb),即使是釋放到磁盤,也有可能造成磁盤資源耗盡。除了構造簡單重複內容,還能通過遞歸嵌套,目錄穿越等構造惡意的ZIP並釋放巨量數據,有興趣的朋友可以去自行查閲。
解決方案:禁止上傳嵌套壓縮包,只允許上傳單級壓縮文件;檢查文件大小;檢查文件路徑。
0x02 案例二
事故時間:2021年6月30日
故障類型:java.lang.OutOfMemoryError: Metaspace
事故經過:某報文處理服務,需要同時處理多種渠道的XML報文,使用了 JAXB (Java Architecture for XML Binding) 和 XSD (XML Schema Definition) 進行報文編/解組和格式檢查。
隨着業務越來越繁重,某次上線後,生產服務頻繁出現java.lang.OutOfMemoryError: Metaspace內存異常。最後經查是因為應用啓動時,一次性加載了全量的XSD和Document對象,大量的加載類填滿了Metaspace。
應用JVM參數-XX:MaxMetaspaceSize、-XX:MetaspaceSize均設置為256MB,當時的加載代碼如下:
SchemaFactory schemaFactory
= SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
return schemaFactory.newSchema(schemaSources);
一次性初始化了所有的XSD源。老規矩,先救命再治病:
- 臨時解決方案:評估最大Metaspace並擴容
- 最終解決方案:服務拆分,分塊加載
0x03 案例三
事故時間:2022年3月17日
故障類型:java.lang.StackOverflowError
事故經過:某營銷管理系統對接第三方接口上送的數據,並進行解析處理,觸發對應的業務流程。其中一個業務處理是給編號為0-N的直連機構推送通知,按照接口約定,其中N由第三方接口指定,且最大值不會超過255。
管理後台採用了類似這樣的代碼進行處理:
public static void process(int corpNum) {
try {
System.out.println("發送通知給企業,當前編號: " + corpNum);
sendSms(corpNum);
} catch (RuntimeException e) {
System.err.println("發送通知給企業失敗,當前編號: " + corpNum);
}
if (corpNum != 0) {
process(corpNum - 1);
}
}
上線之後系統一直運行良好,直到有一天,第三方接口上送數據時傳了個-1,嚯!系統直接崩了,打電話過去對方説是配置有誤,導致參數填寫錯誤。這邊喜提java.lang.StackOverflowError。
其實測試之初應該可以避免的,但是負責該業務的開發過於信任第三方上送的數據,沒有考慮到意外的參數範圍,狠狠的交了一筆學費。
解決方案:增加嚴格的參數校驗,同時修改尾遞歸寫法為循環發送。
0x04 案例四
事故時間:2022年4月15日
故障類型:java.lang.OutOfMemoryError: unable to create new native thread
事故經過:某接口服務配置了無界線程池作為業務線程池。該接口業務非常簡單,收集各個上游服務的度量指標 (Metrics) ,簡單記錄日誌並寫入數據庫,輕量、高頻、無長時間阻塞,一切都那麼完美。
然而,某天突然運維報告服務不可用,查詢日誌發現服務已經涼了有段時間,死因是java.lang.OutOfMemoryError: unable to create new native thread。還好留下了堆棧,一通分析,發現是有段時間應用日誌所在磁盤空間寫滿,導致線程得不到釋放,高頻調用之下,最終無法創建新線程,導致服務被壓垮。
那麼為什麼寫日誌會阻塞線程呢?當時應用使用的是logback日誌實現,查看其配置,使用的是AsyncAppender異步記錄器:
<appender name="file.async" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丟失日誌 -->
<discardingThreshold>0</discardingThreshold>
<appender-ref ref="file.log"/>
</appender>
這裏的配置正是壓死駱駝的最後一根稻草。日誌首先被寫入BlockingQueue內存隊列,再由工作線程異步寫入磁盤。如果磁盤寫滿導致下游FileAppender無法正常工作,而AsyncAppender的隊列又被填滿,就會導致對Logger的調用發生阻塞。
官方文檔裏對於discardingThreshold是這樣描述的:
In light of the discussion above and in order to reduce blocking, by default, when less than 20% of the queue capacity remains, AsyncAppender will drop events of level TRACE, DEBUG and INFO keeping only events of level WARN and ERROR. This strategy ensures non-blocking handling of logging events (hence excellent performance) at the cost loosing events of level TRACE, DEBUG and INFO when the queue has less than 20% capacity. Event loss can be prevented by setting the discardingThreshold property to 0 (zero).
設置為0,雖然可以防丟,但也讓logback沒有退路可言。
解決方案:為接口配置有界線程池,並調整discardingThreshold為合理數值。
0x05 案例五
事故時間:2022年5月25日
故障類型:java.lang.OutOfMemoryError: Java heap space
事故經過:某後台管理系統,由於存在敏感數據,需要在本地安裝安全控件來輔助訪問,該系統在首頁上提供了多個版本的控件安裝包下載。
上線之初系統運行都挺正常,但是某天突然有用户反饋系統無法訪問,瀏覽器提示502網關錯誤。查閲發現服務已掛,應用日誌提示java.lang.OutOfMemoryError: Java heap space,使用MAT(Memory Analyzer Tool)工具分析dump文件,發現存在大量的byte[]內存佔用。
結合應用日誌,發現服務異常之時正在調用某個文件下載方法,該方法使用FileInputStream讀取文件到內存中,並使用byte[]數組存儲文件內容, subsequent to將該byte[]數組寫入到Response的輸出流完成下載,關鍵代碼如下:
public static byte[] readFileContent(File file) {
long fileLength = file.length();
byte[] fileContent = new byte[(int) fileLength];
try (FileInputStream in = new FileInputStream(file)) {
in.read(fileContent);
return fileContent;
} catch (Exception e) {
logger.error(e.getMessage(), e);
return null;
}
}
短短几行代碼卻讓人虎軀一震,沒有判斷文件的大小就直接完整讀取,危險!而且沒有使用緩衝流的方式進行讀寫。事實證明問題恰恰就是出在這裏,某個版本的控件由於打包時體積偏大(約200多MB),導致多個用户同時下載時,堆區內存一下子就被控件文件數據填滿,進而發生OOM異常。
解決方案:將控件安裝包文件掛載到FTP上並提供外鏈,不經過應用服務器下載。
0x06 案例六
事故時間:2023年3月10日
故障類型:java.lang.OutOfMemoryError: Java heap space
事故經過:A公司開發人員在開發某開放接口時,需要調用C公司的一個基礎數據接口服務。然而,從14時許開始,A公司的接口調用就開始出現異常,返回錯誤碼500,錯誤信息為java.lang.OutOfMemoryError: Java heap space。
C公司開發人員向A公司開發人員反映某開放接口從14時許開始無法訪問和使用。該系統為某基礎數據接口服務,基於HTTP協議進行通信。
按照慣例,首先排查網絡是否異常,經運維人員檢查,證明網絡連通性沒有問題。A公司開發組於14時30分通知運維人員重啓應用服務,期間短暫恢復正常。但是,很快,十分鐘後,電話再次響起,告知服務又出現異常,無法訪問。
在日誌中搜索,找到了若干處內存溢出錯誤java.lang.OutOfMemoryError: Java heap space,但是令人費解的是每次出現OOM錯誤的位置居然都不一樣。最後發現是應用啓動腳本中,-Xmn參數設置成與-Xmx參數一樣的大小,導致堆區大小失衡,進而引發內存異常。
該問題的排查過程在生產事故-記一次特殊的OOM排查一文中有詳細的分析過程,這裏就不再贅述了。
0x07 案例七
事故時間:2024年4月28日
故障類型:java.lang.OutOfMemoryError: Java heap space
事故經過:某報表分析系統,其業務大體上為導入各種CSV/XLS/XLSX文件進行解析,校驗並計算各項統計數據,對於異常的數據可以在首頁上監控告警並提示。
有天運營的妹子突然找過來説她登錄不了系統了,剛開始聽到的我認為只是簡單的瀏覽器問題,可以秀一波操作了,結果到了工位上一看,發現登錄頁面驗證碼出不來了。做過前後端分離項目的朋友都知道,這種情況下,後端服務非死即傷。強裝鎮定,安撫一下妹子,説我得去查查日誌看看咋回事。
遠程到服務器,發現後端應用確實已經灰飛煙滅,查看GC日誌,發現有若干java.lang.OutOfMemoryError: Java heap space錯誤。找到那段時間的應用日誌,最終問題定位到了某個SQL語句上,該SQL是個單表查詢語句,但是返回的記錄行數竟然有10w+。
追查源頭,發現就是首頁上的監控告警。前端定時器每隔20秒調用一次後端服務掃描該表的記錄,篩選出狀態異常的數據並返回,但是沒有做分頁限制,導致某個業務人員上傳了一個超大的Excel表,但是有個關鍵數據項填寫錯誤,該批數據10w+行記錄全部被系統標記為異常,當有多個運營人員登錄系統並進入首頁後,就會反覆觸發該查詢語句,進而導致內存溢出。
解決方案:限制首頁監控查詢行數,同時優化監控邏輯,建立查詢緩存,防止短時間內重複掃描業務表。
0x08 案例八
事故時間:2024年12月5日
故障類型:java.lang.OutOfMemoryError: GC Overhead limit exceeded
事故經過:某查詢接口服務,上線後基本穩定運行,三個月後有用户反映查詢緩慢。
遂查之,發現GC日誌中頻繁出現java.lang.OutOfMemoryError: GC Overhead limit exceeded報告。第一時間做了堆棧快照,發現內存中有大量的List容器未釋放,MAT分析Incoming references指向了ThreadLocalMap,基本可以定位到是ThreadLocal中的數據沒有及時清理,無法被GC回收,導致的內存泄露,最終頻繁Full GC也無法回收足夠空間。
解決方案:嚴格遵循使用後釋放的原則,及時移除ThreadLocal中的數據引用。