如何定位導致伺服器程式記憶體洩漏的模組

你可以很快找到發生洩漏的程序。打開 top、htop 或工作管理員,記下你的伺服器程式的行程 ID(PID)。持續觀察該 PID 的記憶體使用情況。真正的記憶體洩漏會表現為記憶體占用持續上升,而且再也不會回落到基線。一旦確認存在洩漏,就該讓分析工具接手了。它們能夠將分配行為追蹤到具體的模組、函式,甚至精確到分配位置。這一點在遠端伺服器上尤其重要,因為你無法總是手動附加除錯器。你不需要靠猜測判斷究竟是哪一個函式庫有問題。無論你是在自己的裝置上排查記憶體洩漏,還是在資料中心中定位問題,下面這套步驟都同樣適用。
記憶體洩漏檢測工具概覽
你選擇什麼工具,會直接影響排查路徑。不同系統需要不同的分析器。Linux 有適合 Linux 的方法,Windows 則需要另一套手段。
用於伺服器記憶體洩漏的通用分析器
在 Linux 上,Valgrind 和 Heaptrack 是最常用的兩大利器。Valgrind 會監視每一次記憶體存取,並報告某個記憶體區塊是在哪裡分配的。Heaptrack 則會記錄堆分配隨時間的變化。兩者都可以找到函式庫內部的記憶體洩漏,甚至能精確指出是函式庫中的哪一段程式碼一直持有分配的記憶體。不過要注意,它們執行時通常會帶來較大的效能負擔。你應該在盡可能真實的負載下進行測試,但每次分析工作階段盡量保持簡短。
另一條路徑是使用基於編譯器的 sanitizer。Address sanitizer 可以檢查你的 C 和 C++ 程式碼中的非法記憶體使用情況;配合資源洩漏檢測工具,還能發現哪些記憶體區塊沒有被釋放。這類分析器會為每一次分配列出對應的原始碼行號。這樣一來,原本模糊的「疑似洩漏」就能變成一個明確可修復的問題。執行分析器時,應讓程式處理正常業務流量。只有在真實條件下,洩漏才會如實暴露出來。
語言特定和平台特定的分析器
語言專用分析器可以把排查範圍縮得更小。Java 內建 VisualVM,可以依類別查看記憶體使用情況。Python 提供 tracemalloc,這個模組能夠追蹤每一行原始碼的記憶體分配。Go 則有 pprof,可用於分析堆隨時間的成長情況。如果你已經懷疑是某個特定模組出了問題,這些工具會尤其高效。每一種工具都能產生可以保存與後續比對的報告。
在 Windows 上,問題通常分成兩個區域。核心態的問題大多來自驅動程式,此時 Driver Verifier 很有幫助,它透過強制讓非法呼叫失敗來發現這類洩漏。使用者態問題則可以藉助 Application Verifier 來檢查應用程式模組和 DLL。對於 C++ 專案,Visual Studio 的偵錯 CRT 堆函式可以在偵錯過程中識別洩漏的記憶體區塊。你可以在壓力測試前後分別拍攝快照,比對差異後,罪魁禍首所屬的模組就會顯現。自動化安全工具和雲端監控工具也很有價值,它們能夠持續監控遠端伺服器,並在記憶體持續上漲時發出警示。這樣,即便是緩慢成長的洩漏,也能在真正引發故障之前被及時發現。
逐步監控與分析
識別 PID,並確認確實發生了記憶體洩漏
先從行程 ID 開始。在 Linux 上執行 top 或 htop,或者在 Windows 上打開工作管理員,記下你的伺服器程式的 PID。觀察一段時間,重點看它的記憶體占用是否持續攀升,並且始終維持在高位。
僅僅出現一次峰值,並不能說明什麼。快取本來就可能先成長、後回落。真正的記憶體洩漏,表現為記憶體使用量持續上升,並且永遠回不到初始基線。為了區分這兩者,你應重點追蹤以下指標:
- RSS(常駐集大小)—— 行程目前實際持有的記憶體。
- 垃圾回收後的存活記憶體集 —— 在一次 GC 週期之後仍然保留的記憶體。
- 活躍 goroutine 數量 —— 如果這個數量持續上升,可能意味著 goroutine 洩漏。
- 堆快照差異 —— 透過比較不同時刻的 profile,識別持續成長的分配。
如果記憶體持續成長,而垃圾回收始終無法回收,那麼這就是洩漏,而不是正常快取行為。有一次排查中發現:在程序啟動時,JVM 中仍被參照的記憶體占總記憶體的 97%;24 小時後,這個比例下降到了只有 80%,而且缺失部分還在持續擴大。最大的元凶是 java.util.zip.Inflater 和 java.util.zip.Deflater,兩者合計占了流失記憶體的 18.2%。這些原生函式會分配緩衝區,如果沒有呼叫 end(),這部分記憶體就不會被釋放。而 finalizer 只有在完整 GC 時才會執行,但輕負載叢集往往很少觸發完整 GC。
對於長時間執行的服務,最好先從持續監控著手。這樣可以在你附加分析器之前,就提前發現記憶體洩漏。如果你管理的是一台有明確記憶體限制的聯網伺服器,請將最大記憶體設定控制在宿主機可用記憶體之下。這個安全緩衝區可以防止洩漏直接引發服務中斷。
蒐集快照並比較各模組的成長情況
接下來要為程式加上分析手段。你需要的是按模組分組的分配資料,而不是一個籠統的總數。讓服務在真實負載下執行,並按固定時間間隔擷取堆快照。
不同語言的工具鏈有所差異。GHC 分析需要加上 -prof 旗標,然後透過 -i<secs> 設定取樣間隔,對存活堆進行取樣,並將結果寫入 .hp 檔案或事件日誌中。-hm 選項會依照產生資料的模組對取樣到的存活堆進行分組,而 hp2ps 會把結果轉譯成「存活堆隨時間變化」的圖表。某個模組對應的區域如果持續變寬上升,就說明這個模組一直在保留越來越多的記憶體。在 Node.js 中,可以使用 --inspect 搭配 Chrome DevTools,或者使用 heapdump 模組來擷取快照,哪怕是在正式環境中也可以。將不同時刻擷取的快照進行比較,重點觀察 Closure 物件以及 EventEmitter 實例中的陣列等物件。如果某一類物件的數量或大小在多個快照中持續增加,就說明記憶體正在不斷累積。
把這些快照並排比較。通常會有一個模組表現出穩定成長,而其他模組基本保持平穩。那個模組就是你的首要懷疑對象。如果你是在與遠端伺服器通訊,或者是在除錯一個正在與遠端伺服器通訊的裝置,那麼必須直接在遠端伺服器上蒐集快照。本地快照看不到真正發生洩漏的那個行程。接下來就去閱讀這個可疑模組的原始碼。重點查找「有分配、無釋放」的路徑,並檢查它呼叫的每一個函式庫。很多時候,真正的元凶其實隱藏在第三方函式庫內部。
隔離故障模組的技巧
確認存在記憶體洩漏之後,你手上得到的通常只是一個嫌疑清單,而不是最終判決。你需要透過可控實驗,把真正有問題的元件從無辜者中分離出來。最快的方法是成批停用程式中的部分元件。另一條路徑則是在每一條分配路徑上加入計數器。如果條件允許,最好兩種方法一起用,它們通常能很快得出一致結論。
停用模組並使用二分法
一次只關閉一個元件,會浪費很多時間。更高效的方法是使用二分法。先給所有可載入元件編號,並將它們分成兩半。停用第一半,重新啟動服務,執行同樣的負載測試,然後觀察堆快照。如果記憶體仍然繼續成長,說明洩漏在仍然啟用的那一半中;如果記憶體趨於平穩,則說明洩漏在被停用的那一半中。接著繼續對包含洩漏的那一半做二分。對於模組較多的程式,這種方法相比逐一排查,能在更少的實驗次數中快速定位到具體元件。
每一輪實驗都必須保持條件一致。使用相同的工作負載、相同的持續時間,以及相同的測量時點。遠端程序呼叫層在稀疏流量下的表現,可能與正常流量完全不同,因此測試請求一定要盡量貼近真實業務。只有在相同壓力下比較結果,你才真正看得出是否存在洩漏。
即使如此,停用法有時也會誤導你。有些元件共享程式碼,一個本身不洩漏的模組,可能只是觸發了鄰近模組中的洩漏。比如它自身負責分配,然後呼叫共享程式碼,而共享程式碼從不歸還這些記憶體;甚至連它自己的清理路徑也沒能正確釋放所擁有的資源。如果某個元件無法安全停用,那就直接閱讀該可疑模組的原始碼,追蹤每一個分配位置。尤其要留意錯誤分支和提前返回路徑。在許多真實案例中,清理函式恰恰就是在這些分支上沒有被呼叫。那個缺失的呼叫,往往就是洩漏根源。
增加日誌與模組級計數器
如果模組無法被停用,那就給它們加上監控。為每一個分配位置增加一個計數器:記憶體區塊建立時遞增,釋放時遞減。在每一批請求處理完成後,記錄這些計數器的值。若某個計數持續上升,就說明有物件或記憶體區塊一直被保留。
如果把這些計數器暴露到一個監控端點,效果會更好。在遠端伺服器上,你未必能迅速附加除錯器;但透過外部持續取樣這些計數器,同樣可以得到非常有力的證據。你會很清楚地看到,究竟是哪個元件在成長,哪個元件保持平穩。
不要只停留在元件邊界。應把每一個計數器對應到具體修改它的函式。如果某個計數器在每批請求之後都會上升,就在對應的分配函式上設中斷點。在 Linux 上可以用 gdb 來捕捉那個瞬間。回溯堆疊會顯示究竟是函式庫程式碼中的哪個函式執行了分配。你可能會發現,某個外部 i/o 函式庫會分配原生緩衝區,並要求你的應用程式碼負責釋放;如果從未呼叫與之匹配的釋放函式,記憶體就會不斷累積。一旦你識別出那個確切函式,就去查看它的文件。很多廠商都會提供一個必須顯式呼叫的清理常式。修復方式往往只是一行:在物件不再需要時呼叫它。
這種系統化的方法,能把猜測變成證據。二分法找到故障元件,計數器驗證成長趨勢,除錯器鎖定具體程式碼行。你將不必再靠想像去猜記憶體到底流失到哪裡去了。
透過堆分析進行確認與除錯
找到確切的分配位置
你已經把問題縮小到某個模組。現在需要找到那一行「分配了記憶體,卻從未釋放」的程式碼。堆分析能給你這個證據。不同執行階段的工作流程有所區別,但底層邏輯是相同的:蒐集、比較、追蹤。
對於 JVM 伺服器,可以使用 jcmd 或 jmap 產生堆轉儲,也可以讓 JVM 在發生 out-of-memory 錯誤時自動寫出堆轉儲。然後用 Eclipse Memory Analyzer 打開這個轉儲。先執行 Leak Suspects Report 做快速概覽,再切換到 Histogram 檢視,查找那些保留堆異常大或物件數量異常多的類別。右鍵點擊某個可疑類別,選擇 List Objects,然後選擇 with incoming references。Dominator Tree 會顯示究竟是哪些物件保留了該類別的實例。接著套用 Path to GC Roots,並排除 weak 或 soft references。這樣一條參照鏈最終會把你帶到分配源頭,比如某個示範類別中不斷成長的 ArrayList。
Node.js 的路徑與之類似。透過 Chrome DevTools 或 v8 模組產生 .heapsnapshot 檔案。把兩個快照載入到 Memory 面板中,並切換到 Comparison 檢視。依 Size Delta 或 Count Delta 對建構函式排序。某個建構函式如果保留大小持續增加,就會非常顯眼。再查看 Retainers 面板,沿著參照鏈回溯,例如從一個陣列一路追到全域變數 cachedRequests。這個變數就是洩漏源。
驗證修復並防止回歸
修復那個分配位置之後,還需要證明修復確實生效。使用相同的負載重新執行伺服器,並繼續觀察記憶體。理想情況下,記憶體會先上升,然後在垃圾回收後回落到基線。如果它依然持續成長,說明記憶體洩漏還存在於另一個你尚未追蹤到的路徑上。
同時要防止問題再次出現。為這個可疑模組新增一個回歸測試,在負載下執行,並斷言記憶體保持穩定。在正式環境中繼續保留持續監控。哪怕洩漏再次出現,也會先在趨勢線上顯現,而不是等到影響使用者時才被發現。還要順便留意諸如 heap-use-after-free 之類的相關缺陷,這類問題可以在同一次測試中透過 sanitizer 一併發現。閱讀你所呼叫的每一個函式庫的原始碼,並確認你已經呼叫了其清理常式。遺漏函式庫提供的釋放呼叫,是非常常見的根本原因。
現在,你已經可以從症狀一路快速追查到源頭。先識別對應的 PID,然後透過持續監控確認確實發生了記憶體洩漏。再利用分析工具比較各模組的快照。透過停用元件或使用二分法,隔離出故障模組。最後藉助堆分析,拿到確鑿證據,證明究竟是哪一個分配位置導致了問題。
對於長時間執行的伺服器服務而言,這種系統化、工具驅動的方法,永遠比直覺猜測更可靠。一個錯誤的直覺,可能會把你引向錯誤的函式庫,白白浪費大量時間。接下來,請把記憶體監控加入到日常健康檢查中。同時,將最大堆設定控制在宿主機可用記憶體之下,這樣即便發生洩漏,也不至於立刻引發當機。
常見問題
我需要監控多久,才能相信結果?
至少要跨越多個垃圾回收週期來觀察,而不是只看幾分鐘。真正的洩漏會在每次 GC 之後繼續成長;正常快取則會先升後降,最終回到基線。如果記憶體始終回不去基線,你就已經確認存在洩漏,可以開始附加分析器了。
哪種工具最適合我的伺服器?
應根據執行階段來匹配工具。Valgrind 和 Heaptrack 適用於 Linux 上的 C/C++ 程式;Java 用 VisualVM;Python 用 tracemalloc;Go 用 pprof。在 Windows 上,Driver Verifier 用於驅動問題,而 Application Verifier 與偵錯 CRT 則用於應用程式模組排查。
我能否安全地在正式環境中做分析?
可以,但要謹慎。Node.js 支援在線上伺服器上使用 --inspect 和 heapdump 模組。Valgrind 的效能負擔很大,因此每次分析時間應盡量短。並且一定要直接在遠端伺服器上擷取快照,因為本地快照看不到真正發生洩漏的那個行程。
如果停用某個模組反而把洩漏隱藏起來了,怎麼辦?
共享程式碼會讓問題變得複雜。某個模組可能先分配記憶體,然後呼叫一段從不釋放記憶體的共享程式碼。此時,你需要直接閱讀可疑模組的原始碼,追蹤每一個分配位置。重點檢查錯誤分支和提前返回路徑,因為很多洩漏正是由於這些路徑上遺漏了清理呼叫。
怎樣防止同樣的洩漏再次發生?
為該模組新增一個回歸測試,在負載下執行並斷言記憶體保持穩定。在正式環境中保留持續監控,這樣一旦洩漏重現,就會及早出現在趨勢圖上。並確認你已經呼叫了每個函式庫要求的清理常式,同時將最大堆設定控制在宿主機可用記憶體之下。
