震驚!線上四臺機器同一時間全部 OOM,到底發生了什麼?
案發現場
昨天晚上突然簡訊收到 APM (即 Application Performance Management 的簡稱),我們內部自己搭建了這樣一套系統來對應用的效能、可靠性進行線上的監控和預警的一種機制)大量告警
畫外音: 監控是一種非常重要的發現問題的手段,沒有的話一定要及時建立哦
緊接著運維打來電話告知線上部署的四臺機器全部 OOM (out of memory, 記憶體不足),服務全部不可用,趕緊檢視問題!
問題排查
首先運維先重啟了機器,保證線上服務可用,然後再仔細地看了下線上的日誌,確實是因為 OOM 導致服務不可用
第一時間想到 dump 當時的記憶體狀態,但由於為了讓線上儘快恢復服務,運維重啟了機器,導致無法 dump 出事發時的記憶體。所以我又看了下我們 APM 中對 JVM 的監控圖表
畫外音: 一種方式不行,嘗試另外的角度切入!再次強調,監控非常重要!完善的監控能還原當時的事發現場,方便定位問題。
不看不知道,一看嚇一跳,從 16:00 開始應用中建立的執行緒居然每時每刻都在上升,一直到 3w 左右,重啟後(藍色箭頭),執行緒也一直在不斷增長),正常情況下的執行緒數是多少呢,600!問題找到了,應該是在下午 16:00 左右發了一段有問題的程式碼,導致執行緒一直在建立,且建立的執行緒一直未消亡!檢視釋出記錄,發現釋出記錄只有這麼一段可疑的程式碼 diff:在 HttpClient 初始化的時候額外加了一個 evictExpiredConnections 配置
問題定位了,應該是就是這個配置導致的!(執行緒上升的時間點和釋出時間點完全吻合!),於是先把這個新加的配置給幹掉上線,上線之後執行緒數果然恢復正常了。那 evictExpiredConnections 做了什麼導致執行緒數每時每刻在上升呢?這個配置又是為了解決什麼問題而加上的呢?於是找到了相關同事來了解加這個配置的前因後果
還原事發經過
最近線上出現不少 NoHttpResponseException 的異常,那是什麼導致了這個異常呢?
在說這個問題之前我們得先了解一下 http 的 keep-alive 機制。
先看下正常的一個 TCP 連線的生命週期
可以看到每個 TCP 連線都要經過三次握手建立連線後才能傳送資料,要經過四次揮手才能斷開連線,如果每個 TCP 連線在 server 返回 response 後都立馬斷開,則發起多個 HTTP 請求就要多次建立斷開 TCP, 這在 Http 請求很多的情況下無疑是很耗效能的, 如果在 server 返回 response 不立即斷開 TCP 連結,而是複用這條連結進行下一次的 Http 請求,則無形中省略了很多建立 / 斷開 TCP 的開銷,效能上無疑會有很大提升。
如下圖示,左圖是不復用 TCP 發起多個 HTTP 請求的情況,右圖是複用 TCP 的情況,可以看到發起三次 HTTP 請求,複用 TCP 的話可以省去兩次建立 / 斷開 TCP 的開銷,理論上發起 一個應用只要啟一個 TCP 連線即可,其他 HTTP 請求都可以複用這個 TCP 連線,這樣 n 次 HTTP 請求可以省去 n-1 次建立 / 斷開 TCP 的開銷。這對效能的提升無疑是有巨大的幫助。
回過頭來看 keep-alive (又稱持久連線,連線複用)做的就是複用連線, 保證連線持久有效。
畫中音: Http 1.1 之後 keep-alive 才預設支援並開啟,不過目前大部分網站都用了 http 1.1 了,也就是說大部分都預設支援連結複用了
天下沒有免費的午餐 ,雖然 keep-alive 省去了很多不必要的握手/揮手操作,但由於連線長期保活,如果一直沒有 http 請求的話,這條連線也就長期閒著了,會佔用系統資源,有時反而會比複用連線帶來更大的效能消耗。 所以我們一般會為 keep-alive 設定一個 timeout, 這樣如果連線在設定的 timeout 時間內一直處於空閒狀態(未發生任何資料傳輸),經過 timeout 時間後,連線就會釋放,就能節省系統開銷。
看起來給 keep-alive 加 timeout 是完美了,但是又引入了新的問題(一波已平,一波又起!),考慮如下情況:
如果服務端關閉連線,傳送 FIN 包(注:在設定的 timeout 時間內服務端如果一直未收到客戶端的請求,服務端會主動發起帶 Fin 標誌的請求以斷開連線釋放資源),在這個 FIN 包傳送但是還未到達客戶端期間,客戶端如果繼續複用這個 TCP 連線傳送 HTTP 請求報文的話,服務端會因為在四次揮手期間不接收報文而傳送 RST 報文給客戶端,客戶端收到 RST 報文就會提示異常 (即 NoHttpResponseException)
我們再用流程圖仔細梳理一下上述這種產生 NoHttpResponseException 的原因,這樣能看得更明白一些
費了這麼大的功夫,我們終於知道了產生 ** NoHttpResponseException** 的原因,那該怎麼解決呢,有兩種策略
- 重試,收到異常後,重試一兩次,由於重試後客戶端會用有效的連線去請求,所以可以避免這種情況,不過一次要注意重試次數,避免引起雪崩!
- 設定一個定時執行緒,定時清理上述的閒置連線,可以將這個定時時間設定為 keep alive timeout 時間的一半以保證超時前回收。
evictExpiredConnections 就是用的上述第二種策略,來看下官方用法使用說明
Makes this instance of HttpClient proactively evict idle connections from the
connection pool using a background thread.
呼叫這個方法只會產生一個定時執行緒,那為啥應用中執行緒會一直增加呢,因為我們對每一個請求都建立了一個 HttpClient! 這樣由於每一個 HttpClient 例項都會呼叫 evictExpiredConnections ,導致有多少請求都會建立多少個 定時執行緒!
還有一個問題,為啥線上四臺機器幾乎同一時間點全掛呢?
因為由於負載均衡,這四臺機器的權重是一樣的,硬體配置也一樣,收到的請求其實也可以認為是差不多的,這樣這四臺機器由於建立 HttpClient 而生成的後臺執行緒也在同一時間達到最高點,然後同時 OOM。
解決問題
所以針對以上提到的問題,我們首先把 HttpClient 改成了單例,這樣保證服務啟動後只會有一個定時清理執行緒,另外我們也讓運維針對應用的執行緒數做了監控,如果超過某個閾值直接告警,這樣能在應用 OOM 前及時發現處理。
畫外音:再次強調,監控相當重要,能把問題扼殺在搖籃裡!
總結
本文通過線上四臺機器同時 OOM 的現象,來詳細剖析產定位了產生問題的原因,可以看到我們在應用某個庫時首先要對這個庫要有充分的了了解(上述 HttpClient 的建立不用單例顯然是個問題),其次必要的網路知識還是需要的,所以要成為一個合格的程式設計師,不關對語言本身有所瞭解,還要對網路,資料庫等也要有所涉獵,這些對排查問題以及效能調優等會有非常大的幫助,再次,完善的監控非常重要,通過觸發某個閾值提前告警,可以將問題扼殺在搖籃裡!