Netty 堆外內存洩露排查盛宴

背景

最近在做一個基於 websocket 的長連中間件,服務端使用實現了 socket.io 協議(基於websocket協議,提供長輪詢降級能力) 的 netty-socketio 框架,該框架為 netty 實現,鑑於本人對 netty 比較熟,並且對比同樣實現了 socket.io 協議的其他框架,這個框架的口碑要更好一些,因此選擇這個框架作為底層核心。

任何開源框架都避免不了 bug 的存在,我們在使用這個開源框架的時候,就遇到一個堆外內存洩露的 bug,鑑於對 netty 比較熟,於是接下來便想挑戰一下,找出那隻臭蟲(bug),接下來便是現象和排查過程,想看結論的同學可以直接拉到最後總結。

現象

某天早上突然收到告警,nginx 服務端大量5xx

Netty 堆外內存洩露排查盛宴

我們使用 nginx 作為服務端 websocket 的七層負載,5xx爆發通常表明服務端不可用。由於目前 nginx 告警沒有細分具體哪臺機器不可用,接下來,到 cat(點評美團統一監控平臺)去檢查一下整個集群的各項指標,發現如下兩個異常

Netty 堆外內存洩露排查盛宴

某臺機器在同一時間點爆發 gc,同一時間,jvm 線程阻塞

Netty 堆外內存洩露排查盛宴

接下來,便開始漫長的 堆外內存洩露排查之旅行。

排查過程

階段1: 懷疑是log4j2

線程被大量阻塞,首先想到的是定位哪些線程被阻塞,最後查出來是 log4j2 狂打日誌導致 netty 的 nio 線程阻塞(由於沒有及時保留現場,所以截圖缺失),nio 線程阻塞之後,我們的服務器無法處理客戶端的請求,對nginx來說是5xx。

接下來,查看 log4j2 的配置文件

Netty 堆外內存洩露排查盛宴

發現打印到控制檯的這個 appender 忘記註釋掉了,所以我初步猜測是因為這個項目打印的日誌過多,而 log4j2 打印到控制檯是同步阻塞打印的,接下來,把線上所有機器的這行註釋掉,以為大功告成,沒想到,過不了幾天,5xx告警又來敲門了,看來,這個問題沒那麼簡單。

階段2:可疑日誌浮現

接下來,只能硬著頭皮去查日誌,查看故障發生點前後的日誌,發現了一處可疑的地方

Netty 堆外內存洩露排查盛宴

在極短的時間內,狂打 failed to allocate 64(bytes) of direct memory(...)日誌(瞬間十幾個日誌文件,每個日誌文件幾百M),日誌裡拋出一個 netty 自己封裝的OutOfDirectMemoryError,說白了,就是堆外內存不夠用了,netty 一直在喊冤。

堆外內存洩露,我去,聽到這個名詞就有點沮喪,因為這個問題的排查就像 c 語言內存洩露一樣難以排查,首先想到的是,在 OOM 爆發之前,查看有無異常,然後查遍了 cat 上與機器相關的所有指標,查遍了 OOM 日誌之前的所有日誌,均未發現任何異常!這個時候心裡開始罵了……

階段3:定位OOM源

但是沒辦法,只能看著這堆討厭的 OOM 日誌發著呆,妄圖答案能夠蹦到眼前。一籌莫展之際,突然一道光在眼前一閃而過,在 OOM 下方的幾行日誌變得耀眼起來(為啥之前就沒想認真查看日誌?估計是被堆外內存洩露這幾個詞嚇怕了吧==),這幾行字是 ....PlatformDepedeng.incrementMemory()...。我去,原來,堆外內存是否夠用,是 netty 這邊自己統計的,那是不是可以找到統計代碼,找到統計代碼之後我們就可以看到 netty 裡面的對外內存統計邏輯了?於是,接下來翻翻代碼,找到這段邏輯,在 PlatformDepedent 這個類裡面

Netty 堆外內存洩露排查盛宴

這個地方,是一個對已使用堆外內存計數的操作,計數器為 DIRECT_MEMORY_COUNTER,如果發現已使用內存大於堆外內存的上限(用戶自行指定),就拋出一個自定義 OOM Error,異常裡面的文本內容正是我們在日誌裡面看到的。

接下來,驗證一下是否這個函數是在堆外內存分配的時候被調用

Netty 堆外內存洩露排查盛宴

果然,在 netty 每次分配堆外內存之前,都會計數,想到這,思路開始慢慢清晰起來,心情也開始變好。

階段4:反射進行堆外內存監控

既然 cat 上關於堆外內存的監控沒有任何異常(應該是沒有統計準確,一直維持在 1M),而這邊我們又確認堆外內存已快超過上限,並且已經知道 netty 底層是使用哪個字段來統計的,那麼接下來要做的第一件事情,就是反射拿到這個字段,然後我們自己統計 netty 使用堆外內存的情況。

Netty 堆外內存洩露排查盛宴

堆外內存統計字段是 DIRECT_MEMORY_COUNTER,我們可以通過反射拿到這個字段,然後定期check這個值,就可以監控 netty 堆外內存的增長情況。

Netty 堆外內存洩露排查盛宴

我們通過反射拿到這個字段,然後每隔一秒打印,我為什麼要這樣做?

因為,通過我們前面的分析,在爆發大量 OOM 現象之前,沒有任何可疑的現象,那麼只有兩種情況,一種是突然某個瞬間分配了大量的堆外內存導致OOM,一種是堆外內存緩慢增長,到達某個點之後,最後一根稻草將機器壓垮。這段代碼加上去之後,打包上線。

階段5:到底是緩慢增長還是瞬間飆升?

代碼上線之後,初始內存為 16384k(16M),這是因為線上我們使用了池化堆外內存,默認一個 chunk 為16M,不必過於糾結。

Netty 堆外內存洩露排查盛宴

沒過一會,內存就開始緩慢飆升,並且沒有釋放的跡象,20幾分鐘之後,內存如下

Netty 堆外內存洩露排查盛宴

到了這裡,猜測可能是前面提到的第二種情況,也就是內存緩慢增長造成的 OOM,由於內存實在增長太慢,於是調整機器負載權重為其他機器的兩倍,但是仍然是以幾K級別在增長,這天剛好是週五,索性就過他個一個週末再開看。

過完一個愉快的週末之後,到公司第一時間便是連上跳板機,登錄線上機器,開始 tail -f 繼續查看日誌,輸完命令之後,懷著期待的心情重重的敲下了回車鍵

Netty 堆外內存洩露排查盛宴

果然不出所料,內存一直在緩慢增長,一個週末的時間,堆外內存已經飆到快一個 G 了,這個時候,我竟然想到了一句成語:只要功夫深,鐵杵磨成針!雖然堆外內存幾個K幾個K的在增長,但是隻要一直持續下去,總有把內存打爆的時候(線上堆外內存上限設置的是2G)。

到了這裡,我又開始自問自答了:內存為啥會緩慢增長,伴隨著什麼而增長?因為我們的應用是面向用戶端的websocket,那麼,會不會是每一次有用戶進來,交互完之後,然後離開,內存都會增長一些,然後不釋放呢?帶著這個疑問,我開始線下模擬。

階段6:線下模擬

本地起好服務,把監控堆外內存的單位改為以B為單位(因為本地流量較小,打算一次一個客戶端連接),另外,本地也使用非池化內存(內存數字較小,容易看出問題),這樣,服務端啟動之後,控制檯打印信息如下

Netty 堆外內存洩露排查盛宴

在沒有客戶端接入的時候,堆外內存一直是0,在意料之中。接下來,懷著著無比激動的心情,打開瀏覽器,然後輸入網址,開始我們的模擬之旅。

我們的模擬流程是:新建一個客戶端鏈接->斷開鏈接->再新建一個客戶端鏈接->再斷開鏈接

Netty 堆外內存洩露排查盛宴

如上圖所示,一次 connect 和 disconnect 為一次連接的建立與關閉,上圖綠色框框的日誌分別是兩次連接的生命週期。我們可以看到,內存每次都是在連接被關閉的的時候暴漲 256B 然後不釋放,到了這裡,問題進一步縮小,肯定是連接被關閉的時候,觸發了框架的一個bug,這個bug在觸發之前分配了 256B 的內存,然後bug觸發,內存沒有釋放。問題縮小之後,接下來開始擼源碼捉蟲!

階段7:線下排查

接下來,我將本地服務重啟,開始完整的線下排查過程。將目光定位到 netty-socketio 這個框架的 disconnect 事件(客戶端websocket連接關閉的時候回調用到這裡),基本上可以確定是在 disconnect 事件前後申請的內存沒有釋放

Netty 堆外內存洩露排查盛宴

這裡,在使用 idea debug的時候,要選擇只掛起當前線程,這樣我們在單步跟蹤的時候,控制檯仍然可以看到堆外內存統計線程在打印日誌。

客戶端連接上之後然後關閉,斷點進入到 onDisconnect 回調,我特意在此多停留了一會,發現控制檯內存並沒有飆升(7B這個內存暫時沒有去分析,只需要知道,客戶端連接斷開之後,我們斷點hold住,內存還未開始漲),接下來,神奇的一幕出現了,我將斷點放開,讓程序跑完

Netty 堆外內存洩露排查盛宴

debug 鬆掉之後,內存立馬飆升了!!這個時候我已經知道,這隻臭蟲飛不了多遠了。在 debug 的時候,掛起的是當前線程,那麼肯定是當前線程某個地方申請了堆外內存,然後沒有釋放,接下來,快馬加鞭,深入源碼。

每一次單步調試,我都會觀察控制檯的內存飆升的情況,很快,我們來到了這個地方

Netty 堆外內存洩露排查盛宴

在這一行沒執行之前,控制檯的內存依然是 263B,然後,當執行完這一行之後,立馬從 263B漲到519B(漲了256B)

Netty 堆外內存洩露排查盛宴

於是,bug的範圍進一步縮小,我將本次程序跑完,釋然後客戶端再來一次連接,斷點打在 client.send() 這行, 然後關閉客戶端連接,之後直接進入到這個方法,隨後的過程有點長,因為與 netty 的時間傳播機制有關,這裡就省略了,最後,我跟到了如下代碼,handleWebsocket

Netty 堆外內存洩露排查盛宴

在這個地方,我看了一處非常可疑的地方,在上圖的斷點上一行,調用 encoder 分配了一段內存,調用完之後,我們的控制檯立馬就彪了 256B,所以,我懷疑肯定是這裡申請的內存沒有釋放,他這裡接下來調用 encoder.encodePacket() 方法,猜想是把數據包的內容以二進制的方式寫到這段 256B的內存,接下來,我跟到這段 encode 代碼,單步執行之後,定位到這行代碼

Netty 堆外內存洩露排查盛宴

這段代碼是把 packet 裡面一個字段的值轉換為一個 char,然而,當我使用 idea 預執行的時候,卻拋出類一個憤怒的 NPE!!也就是說,框架申請到一段內存之後,在encoder的時候,自己GG了,自己給自己挖了個NPE的深坑,最後導致內存無法釋放(最外層有堆外內存釋放邏輯,現在無法執行到了),然後越攢越多,越攢越多,直到最後一根稻草,堆外內存就這樣爆了,這裡的源碼有興趣的讀者可以自己去分析一下,限於篇幅原因,這裡就不再分析了。

階段8:bug解決

bug既然已經找到,接下來便要解決了,這裡只需要解決這個NPE異常,就可以fix掉,我們的目標就是,讓這個 subType 字段不為空,我們先通過 idea 的線程調用棧定位到這個 packet 是在哪個地方定義的

Netty 堆外內存洩露排查盛宴

我們找到 idea 的 debugger 面板,眼睛盯著 packet 這個對象不放,然後上線移動光標,便光速定位到,原來,定義 packet 對象這個地方在我們前面的代碼其實已經出現過,我們查看了一下 subType 這個字段,果然是null,接下來,解決bug很容易。

Netty 堆外內存洩露排查盛宴

我們給這個字段賦值即可,由於這裡是連接關閉事件,所以,我給他指定了一個名為 DISCONNECT 的字段(改日深入去研究socket.io的協議),反正這個bug是在連接關閉的時候觸發的,就粗暴一點了 !==。

解決這個bug的過程是:將這個框架的源碼下載到本地,然後加上這一行,最後,我重新build一下,pom 裡改改名字,推送到我們公司的倉庫,這樣,我項目就可以直接使用了。

改完bug之後,習慣性地去github上找到引發這段bug的commit

Netty 堆外內存洩露排查盛宴

好奇的是,為啥這位 dzn commiter 會寫出這麼一段如此明顯的bug,而且時間就在今年3月30號,項目啟動的前夕!階段9:線下驗證

一切就緒之後,首先,我們來進行本地驗證,服務起起來之後,我瘋狂地建立連接,瘋狂地斷開連接,觀察堆外內存的情況

Netty 堆外內存洩露排查盛宴

好傢伙,不管你如何斷開連接,堆外內存一直不漲了,至此,bug 基本fix,當然,最後一步,我們把代碼推到線上驗證。

階段10:線上驗證

這次線上驗證,我們避免了比較土的打日誌方法,我們把堆外內存的這個指標噴射到 cat上,然後再來觀察一段時間的堆外內存的情況

Netty 堆外內存洩露排查盛宴

發現過一段時間,堆外內存已經穩定不漲了,我們的捉蟲之旅到此結束!最後,我來給本地捉蟲之旅做一次總結

總結

1.遇到堆外內存洩露不要怕,仔細耐心分析,總能找到思路,要多看日誌,多分析。

2.如果使用了netty 堆外內存,那麼你可以自行監控堆外內存的使用情況,不需要藉助第三方工具,我這裡是使用的反射拿到的堆外內存的情況。

3.逐漸縮小範圍,直到bug被你找到。當你確認某個線程的執行帶來 bug 的時候,可單步執行,可二分執行,定位到某行代碼之後,跟到這段代碼,然後繼續單步執行或者二分的方式來定位最終出 bug 的代碼,這個方法屢試不爽,最後總能找到bug。

4.熟練掌握 idea 的調試,讓你的捉蟲速度快如閃電,這裡,最常見的調試方式是預執行表達式,以及通過線程調用棧,死盯某個對象,就能夠掌握這個對象的定義,賦值之類。

最後,祝願大家都能找到自己的 bug!給大家分享一點Java架構視頻資料,對Java架構有興趣的同僚們可以關注一下我,私信“架構資料”,即可得到獲取架構視頻資料的途徑。


分享到:


相關文章: