Netty堆外内存泄露排查盛宴
導讀
Netty 是一個異步事件驅動的網絡通信層框架,用于快速開發高可用高性能的服務端網絡框架與客戶端程序,它極大地簡化了 TCP 和 UDP 套接字服務器等網絡編程。
Netty 底層基于 JDK 的 NIO,我們為什么不直接基于 JDK 的 NIO 或者其他NIO框架:
背景
最近在做一個基于 Websocket 的長連中間件,服務端使用實現了 Socket.IO 協議(基于WebSocket協議,提供長輪詢降級能力) 的 netty-socketio 框架,該框架為 Netty 實現,鑒于本人對 Netty 比較熟,并且對比同樣實現了 Socket.IO 協議的其他框架,Netty 的口碑都要更好一些,因此選擇這個框架作為底層核心。
誠然,任何開源框架都避免不了 Bug 的存在,我們在使用這個開源框架時,就遇到一個堆外內存泄露的 Bug。美團的價值觀一直都是“追求卓越”,所以我們就想挑戰一下,找到那只臭蟲(Bug),而本文就是遇到的問題以及排查的過程。當然,想看結論的同學可以直接跳到最后,閱讀總結即可。
問題
某天早上,我們突然收到告警,Nginx 服務端出現大量5xx。
我們使用 Nginx 作為服務端 WebSocket 的七層負載,5xx的爆發通常表明服務端不可用。由于目前 Nginx 告警沒有細分具體哪臺機器不可用,接下來,我們就到 CAT(美團點評統一監控平臺,目前已經開源)去檢查一下整個集群的各項指標,就發現如下兩個異常:
某臺機器在同一時間點爆發 GC(垃圾回收),而且在同一時間,JVM 線程阻塞。
接下來,我們就就開始了漫長的堆外內存泄露“排查之旅”。
排查過程
階段1: 懷疑是log4j2
因為線程被大量阻塞,我們首先想到的是定位哪些線程被阻塞,最后查出來是 Log4j2 狂打日志導致 Netty 的 NIO 線程阻塞(由于沒有及時保留現場,所以截圖缺失)。NIO 線程阻塞之后,因我們的服務器無法處理客戶端的請求,所以對Nginx來說就是5xx。
接下來,我們查看了 Log4j2 的配置文件。
我們發現打印到控制臺的這個 appender 忘記注釋掉了,所以初步猜測:因為這個項目打印的日志過多,而 Log4j2 打印到控制臺是同步阻塞打印的,所以就導致了這個問題。那么接下來,我們把線上所有機器的這行注釋掉,本以為會“大功告成”,但沒想到僅僅過了幾天,5xx告警又來“敲門”。看來,這個問題并沒我們最初想象的那么簡單。
階段2:可疑日志浮現
接下來,我們只能硬著頭皮去查日志,特別是故障發生點前后的日志,于是又發現了一處可疑的地方:
可以看到:在極短的時間內,狂打 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 這個類里面。
這個地方,是一個對已使用堆外內存計數的操作,計數器為 DIRECT_MEMORY_COUNTER,如果發現已使用內存大于堆外內存的上限(用戶自行指定),就拋出一個自定義 OOM Error,異常里面的文本內容正是我們在日志里面看到的。
接下來,就驗證一下這個方法是否是在堆外內存分配的時候被調用。
果然,在 Netty 每次分配堆外內存之前,都會計數。想到這,思路就開始慢慢清晰,而心情也開始從“秋風瑟瑟”變成“春光明媚”。
階段4:反射進行堆外內存監控
CAT 上關于堆外內存的監控沒有任何異常(應該是沒有統計準確,一直維持在 1M),而這邊我們又確認堆外內存已快超過上限,并且已經知道 Netty 底層是使用的哪個字段來統計。那么接下來要做的第一件事情,就是反射拿到這個字段,然后我們自己統計 Netty 使用堆外內存的情況。
堆外內存統計字段是 DIRECT_MEMORY_COUNTER,我們可以通過反射拿到這個字段,然后定期 Check 這個值,就可以監控 Netty 堆外內存的增長情況。
于是我們通過反射拿到這個字段,然后每隔一秒打印,為什么要這樣做?
因為,通過我們前面的分析,在爆發大量 OOM 現象之前,沒有任何可疑的現象。那么只有兩種情況,一種是突然某個瞬間分配了大量的堆外內存導致OOM;一種是堆外內存緩慢增長,到達某個點之后,最后一根稻草將機器壓垮。在這段代碼加上去之后,我們打包上線。
階段5:到底是緩慢增長還是瞬間飆升?
代碼上線之后,初始內存為 16384k(16M),這是因為線上我們使用了池化堆外內存,默認一個 chunk 為16M,這里不必過于糾結。
但是沒過一會,內存就開始緩慢飆升,并且沒有釋放的跡象,二十幾分鐘之后,內存使用情況如下:
走到這里,我們猜測可能是前面提到的第二種情況,也就是內存緩慢增長造成的 OOM,由于內存實在增長太慢,于是調整機器負載權重為其他機器的兩倍,但是仍然是以數K級別在持續增長。那天剛好是周五,索性就過一個周末再開看。
周末之后,我們到公司第一時間就連上了跳板機,登錄線上機器,開始 tail -f 繼續查看日志。在輸完命令之后,懷著期待的心情重重的敲下了回車鍵:
果然不出所料,內存一直在緩慢增長,一個周末的時間,堆外內存已經飆到快一個 G 了。這個時候,我竟然想到了一句成語:“只要功夫深,鐵杵磨成針”。雖然堆外內存以幾個K的速度在緩慢增長,但是只要一直持續下去,總有把內存打爆的時候(線上堆外內存上限設置的是2G)。
此時,我們開始自問自答環節:內存為啥會緩慢增長,伴隨著什么而增長?因為我們的應用是面向用戶端的WebSocket,那么,會不會是每一次有用戶進來,交互完之后離開,內存都會增長一些,然后不釋放呢?帶著這個疑問,我們開始了線下模擬過程。
階段6:線下模擬
本地起好服務,把監控堆外內存的單位改為以B為單位(因為本地流量較小,打算一次一個客戶端連接),另外,本地也使用非池化內存(內存數字較小,容易看出問題),在服務端啟動之后,控制臺打印信息如下
在沒有客戶端接入的時候,堆外內存一直是0,在意料之中。接下來,懷著著無比激動的心情,打開瀏覽器,然后輸入網址,開始我們的模擬之旅。
我們的模擬流程是:新建一個客戶端鏈接->斷開鏈接->再新建一個客戶端鏈接->再斷開鏈接。
如上圖所示,一次 Connect 和 Disconnect 為一次連接的建立與關閉,上圖綠色框框的日志分別是兩次連接的生命周期。我們可以看到,內存每次都是在連接被關閉的的時候暴漲 256B,然后也不釋放。走到這里,問題進一步縮小,肯定是連接被關閉的時候,觸發了框架的一個Bug,而且這個Bug在觸發之前分配了 256B 的內存,隨著Bug被觸發,內存也沒有釋放。問題縮小之后,接下來開始“擼源碼”,捉蟲!
階段7:線下排查
接下來,我們將本地服務重啟,開始完整的線下排查過程。同時將目光定位到 netty-socketio 這個框架的 Disconnect 事件(客戶端WebSocket連接關閉時會調用到這里),基本上可以確定,在 Disconnect 事件前后申請的內存并沒有釋放。
在使用 idea debug 時,要選擇只掛起當前線程,這樣我們在單步跟蹤的時候,控制臺仍然可以看到堆外內存統計線程在打印日志。
在客戶端連接上之后然后關閉,斷點進入到 onDisconnect 回調,我們特意在此多停留了一會,發現控制臺內存并沒有飆升(7B這個內存暫時沒有去分析,只需要知道,客戶端連接斷開之后,我們斷點hold住,內存還未開始漲)。接下來,神奇的一幕出現了,我們將斷點放開,讓程序跑完:
Debug 松掉之后,內存立馬飆升了!!此時,我們已經知道,這只“臭蟲”飛不了多遠了。在 Debug 時,掛起的是當前線程,那么肯定是當前線程某個地方申請了堆外內存,然后沒有釋放,繼續“快馬加鞭“,深入源碼。
其實,每一次單步調試,我們都會觀察控制臺的內存飆升的情況。很快,我們來到了這個地方:
在這一行沒執行之前,控制臺的內存依然是 263B。然后,當執行完該行之后,立刻從 263B漲到519B(漲了256B)。
于是,Bug 范圍進一步縮小。我們將本次程序跑完,釋然后客戶端再來一次連接,斷點打在 client.send() 這行, 然后關閉客戶端連接,之后直接進入到這個方法,隨后的過程有點長,因為與 Netty 的時間傳播機制有關,這里就省略了。最后,我們跟蹤到了如下代碼,handleWebsocket:
在這個地方,我們看到一處非常可疑的地方,在上圖的斷點上一行,調用 encoder 分配了一段內存,調用完之后,我們的控制臺立馬就彪了 256B。所以,我們懷疑肯定是這里申請的內存沒有釋放,它這里接下來調用 encoder.encodePacket() 方法,猜想是把數據包的內容以二進制的方式寫到這段 256B的內存。接下來,我們追蹤到這段 encode 代碼,單步執行之后,就定位到這行代碼:
這段代碼是把 packet 里面一個字段的值轉換為一個 char。然而,當我們使用 idea 預執行的時候,卻拋出類一個憤怒的 NPE!!也就是說,框架申請到一段內存之后,在 encoder 的時候,自己 GG 了,還給自己挖了個NPE的深坑,最后導致內存無法釋放(最外層有堆外內存釋放邏輯,現在無法執行到了)。而且越攢越多,直到被“最后一根稻草”壓垮,堆外內存就這樣爆了。這里的源碼,有興趣的讀者可以自己去分析一下,限于篇幅原因,這里就不再展開敘述了。
階段8:Bug解決
既然 Bug 已經找到,接下來就要解決問題了。這里只需要解決這個NPE異常,就可以 Fix 掉。我們的目標就是,讓這個 subType 字段不為空。于是我們先通過 idea 的線程調用棧,定位到這個 packet 是在哪個地方定義的:
我們找到 idea 的 debugger 面板,眼睛盯著 packet 這個對象不放,然后上線移動光標,便光速定位到。原來,定義 packet 對象這個地方在我們前面的代碼其實已經出現過,我們查看了一下 subType 這個字段,果然是 null。接下來,解決 Bug 就很容易了。
我們給這個字段賦值即可,由于這里是連接關閉事件,所以我們給他指定了一個名為 DISCONNECT 的字段(可以改天深入去研究 Socket.IO 的協議),反正這個 Bug 是在連接關閉的時候觸發的,就粗暴一點了 !
解決這個 Bug 的過程是:將這個框架的源碼下載到本地,然后加上這一行,最后重新 Build一下,pom 里改了一下名字,推送到我們公司的倉庫。這樣,項目就可以直接進行使用了。
改完 Bug 之后,習慣性地去 GitHub上找到引發這段 Bug 的 Commit:
好奇的是,為啥這位 dzn commiter 會寫出這么一段如此明顯的 Bug,而且時間就在今年3月30號,項目啟動的前夕!
階段9:線下驗證
一切準備就緒之后,我們就來進行本地驗證,在服務起來之后,我們瘋狂地建立連接,瘋狂地斷開連接,并觀察堆外內存的情況:
Bingo!不管我們如何斷開連接,堆外內存不漲了。至此,Bug 基本 Fix,當然最后一步,我們把代碼推到線上驗證。
階段10:線上驗證
這次線上驗證,我們避免了比較土的打日志方法,我們把堆外內存的這個指標“噴射”到 CAT 上,然后再來觀察一段時間的堆外內存的情況:
過完一段時間,堆外內存已經穩定不漲了。此刻,我們的“捉蟲之旅”到此結束。最后,我們還為大家做一個小小的總結,希望對您有所幫助。
總結
最后,祝愿大家都能找到自己的“心儀已久” Bug!
作者簡介
- 閃電俠,2014年加入美團點評,主要負責美團點評移動端統一長連工作,歡迎同行進行技術交流。
招聘
目前我們團隊負責美團點評長連基礎設施的建設,支持美團酒旅、外賣、到店、打車、金融等幾乎公司所有業務的快速發展。加入我們,你可以親身體驗到千萬級在線連接、日吞吐百億請求的場景,你會直面互聯網高并發、高可用的挑戰,有機會接觸到 Netty 在長連領域的各個場景。我們誠邀有激情、有想法、有經驗、有能力的同學,和我們一起并肩奮斗!歡迎感興趣的同學投遞簡歷至 chao.yu#dianping.com 咨詢。
參考文獻
總結
以上是生活随笔為你收集整理的Netty堆外内存泄露排查盛宴的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spring Boot中使用MongoD
- 下一篇: 美团点评联盟广告场景化定向排序机制