記憶體洩漏是指程式分配了記憶體、持有引用、卻永遠不釋放——隨時間推移,程序的駐留記憶體(RSS)只升不降。在生產環境中定位洩漏,約束遠比開發環境苛刻:不能重啟服務、不能掛 GDB 或 valgrind、不能改碼重新部署。

本文給出一套在這些約束下仍然可行的定位方法:先區分真洩漏與”假洩漏”——穩定的大記憶體足跡、分配器不歸還作業系統的空閒池,都不是洩漏;再按四類真實洩漏模式對號入座,從垃圾回收語言裡的無界快取,到 C/C++ 程式碼中的所有權錯誤;最後用自頂向下的分層歸因,把一條持續攀升的 RSS 曲線一路定位到具體的物件名和原始碼行號——就像一個金融 Perl 服務中把記憶體推高到數 GB、修復後穩定在 60MB 的洩漏快取一樣。

症狀:只升不降的記憶體曲線

記憶體洩漏在監控面板上有一套典型的三聯特徵:

  1. RSS 曲線持續攀升,流量平穩也在漲——程序吃的記憶體越來越多,與請求量無關。
  2. 重啟是唯一的"止痛藥"——重啟後記憶體驟降,隨後再次爬升,監控圖呈現週期性的鋸齒狀曲線:記憶體爬到接近 100% 後被迫重啟,然後再次開始爬升。
  3. toppmap 只能看到總量——它們告訴你"這個程序佔了 1GB",但無法歸因到具體的物件、模組或程式碼行。

定位記憶體洩漏的關鍵不在 top 的數字裡,而在物件級的引用路徑分析中:找到誰在持有記憶體、透過甚麼路徑持有、為甚麼不釋放。

記憶體洩漏還是記憶體佔用高?

並非所有"記憶體高"都是洩漏。區分的關鍵判據是:穩定的大記憶體足跡 vs 無界增長

一個 PHP 程序的記憶體大頭若是一個近 40MB 的 HTML 字串,這不一定是洩漏——它可能只是ProdController.php 第 40 行透過 file_get_contents 一次性把整個網頁讀進了記憶體,形成了一個大的記憶體足跡。這類問題的解法是最佳化記憶體使用模式(比如改用流式響應),而不是修洩漏。

如果你的程序記憶體很高但不再增長,那是記憶體足跡問題,不是洩漏。以下四篇教程各自展示瞭如何在不改碼、不重啟的前提下,定位執行中程序裡最大的記憶體物件:

而如果記憶體持續增長、永不停歇——那就是洩漏,繼續往下看。

四類洩漏模式的真實根因鏈

記憶體洩漏的機制可以歸納為四大類。每類我們給出通用機理和一條或多條真實的根因鏈——從物件引用路徑一路追蹤到原始碼級別的落點。

1. 垃圾回收語言中的無界快取

機制:垃圾回收器(GC)只回收沒有引用的物件。如果一個快取結構持有物件的引用且從不淘汰,那麼在 GC 的視角里這些物件永遠是"活"的——記憶體永遠不會被回收。

真實根因鏈

在一個 Python gunicorn 程序中,OpenResty XRay 的 GC 物件火焰圖把約 570MB 的記憶體追蹤到了 order_service.service.order.prev_processor 模組中的 order_name_cache 字典handle() 函式為每筆訂單寫入一條新記錄,但沒有任何程式碼刪除舊記錄——典型的無界增長。

在一個金融行業的 Perl 服務中,記憶體在數天內膨脹到數 GB。一張 Perl GC 物件記憶體分佈火焰圖立即暴露了問題:一個快取資料結構在一個完全意想不到的位置持續累積記憶體。修復後,記憶體從啟動時的約 100MB 降到約 60MB,長期執行穩定在 60MB+,降幅超過 95%。

雲盾(YUNDUN)的生產環境中,OpenResty worker 程序的記憶體遠超預期。OpenResty XRay 的 LuaJIT GC 物件引用關係火焰圖發現 ngx.ctx.game_conf.tcp 引用了 66 個 table、佔用超過 1MB 記憶體,程序中有數百萬個 table 物件。從部署分析工具到生產驗證修復,全流程僅半天。修復後總記憶體降低 60%+,其中 Stream LuaJIT 部分降低 80%+。

2. 高層物件釘住底層原生記憶體

機制:語言層面的一個小物件可以持有底層 C/C++ 結構的引用。這些 C 結構的記憶體記在 Glibc 分配器的賬上,而語言層的 GC 看到的只是一個幾十位元組的引用——反差可以非常極端。

真實根因鏈

在一個 OpenResty 應用中,worker 程序的記憶體持續線性增長。OpenResty XRay記憶體分析報告顯示 Glibc 分配器佔約 93% 的記憶體,而 LuaJIT 分配器僅佔約 2.4%。看起來洩漏在 C 層——但真正的根因在 Lua 層:一個名為 _LOADED.dynamic_cert.cert_cachelua-resty-lrucache 物件快取了透過 ssl.parse_pem_certssl.parse_pem_priv_key 解析的 SSL 證書,而 LRU 快取容量設定過大導致幾乎不淘汰任何條目。每解析一個新域名的證書,其底層 OpenSSL 結構就永久駐留在 Glibc arena 中。

對於 Lua 層面的記憶體洩漏,OpenResty XRaylj-gco-ref 分析器可以直接展示 GC 物件的完整引用路徑GC roots => registry => ._LOADED => <模組名> => <table 名>——從 GC 根一路追蹤到洩漏的具體 table,無需改碼、無需重啟。

3. C/C++ 程式碼中的所有權錯誤

機制:分配方和釋放方對"這塊記憶體歸誰管"的認知不一致——分配方認為下游會釋放,下游認為上游或其他模組會釋放,結果誰也沒釋放。

真實根因鏈

在一個 Nginx C++ 模組記憶體洩漏案例中,OpenResty XRay記憶體洩漏火焰圖直接定位到了 ngx_dubbo_hessian2_encode_payload_map 函式。深入原始碼發現,ngx_dubbo_util.cpp 第 96 行透過 new 分配了一個 std::basic_string 物件。這個物件被封裝後傳給下游模組處理,但由於其特殊的建立方式,它被誤標為"非本模組管理"——下游的自動回收機制接到的訊號是"這塊記憶體由外部負責",於是靜默跳過了它。然而實際上並沒有任何其他方負責釋放它。每一個新請求都會分配一個這樣的記憶體塊,卻永遠不會被釋放。

4. 伺服器記憶體池洩漏

機制:Nginx 等高效能伺服器採用記憶體池(memory pool)架構管理記憶體分配。池化分配器讓單筆分配對外部工具不可見——valgrind 看到的是池的整塊分配,完全無法理解池內部的生命週期。當池本身的生命週期管理出錯時,記憶體就會洩漏。

真實根因鏈

在一個基於 Nginx 的高併發 API 閘道器中,單個 worker 程序記憶體從數百 MB 持續攀升至超過 1GBOpenResty XRay 首先在系統層面確認記憶體主要由 Glibc 分配器持有,然後進一步鑽入 Nginx 記憶體池層——發現記憶體消耗集中在 Tengine dynamic upstream 模組建立的記憶體池中。再進一步分析 Glibc 的記憶體塊大小分佈,在 256k–512k 範圍內發現了 2240 個記憶體塊——數量顯著偏離正常執行時的特徵,這些塊被長期持有而未釋放。最終透過 C 級別的記憶體洩漏火焰圖,把分配行為對映回完整的 C 函式呼叫鏈,形成了一條從記憶體分配點到生命週期終點的可驗證根因鏈。

看起來在漲,其實不是洩漏

並非所有記憶體增長都是洩漏。以下兩種常見的"假洩漏"模式,通用的記憶體除錯指南很少提及。

LuaJIT 分配器的 free pool 機制:LuaJIT 的記憶體分配器有一個空閒記憶體池(free pool),它只在存在連續空閒段時才把記憶體歸還作業系統。這是設計如此,不是洩漏。在雲盾的案例中,切走流量後 in-use 記憶體確實下降了,但 RSS 並沒有顯著降低——趨勢圖清晰地顯示:流量切走時 in-use 逐漸減少、free 逐漸增加;流量切回時 free 被重新利用。如果需要把這些已釋放的頁面真正歸還給作業系統(例如在 Kubernetes 記憶體限制下),請參閱我們關於在分配器層面修復 LuaJIT RSS 膨脹的文章。

直譯器自身的基礎開銷:即使你的業務邏輯很輕量,直譯器載入的模組本身就可能佔據大量記憶體。在一個約 85MB 的 Django 程序中,僅 .modules(已載入的 Python 模組登錄檔)就佔了 38.27MB——1521 個模組合併在一起。其中 openpyxl.utils.cell 單個模組佔 2.6MB,標準庫的 linecache 佔 648KB。這不是洩漏,是啟動時的固定足跡。

判據收束:區分真洩漏和假洩漏的關鍵,是看增長來自 in-use 記憶體還是 free/cached 記憶體。如果 in-use 持續增長且不隨負載下降——那是洩漏;如果 in-use 穩定而 free 不歸還——那是分配器行為,不是洩漏。

如何定位洩漏:自頂向下的分層歸因

定位記憶體洩漏不是猜謎。有效的方法論是自頂向下、逐層收斂——從系統級總量到分配器層、再到具體的物件或程式碼行。

先按分配器拆賬

第一步不是急著找物件,而是先搞清楚記憶體在誰的賬上:系統分配器(Glibc)、語言分配器(LuaJIT/Python/Perl GC),還是應用級記憶體池(Nginx pool)?

這一步的判斷力至關重要。在前述 LRU 快取洩漏案例中,如果據 Glibc 佔比認定洩漏在 C 層並一頭扎進 C 程式碼審查,就會完全走偏——正確做法是繼續檢查語言層物件是否透過 C 擴充套件間接持有這些記憶體。同樣,在Nginx 記憶體池案例中,記憶體塊大小分佈的異常直接把排查範圍從"Glibc 總量高"收斂到了"特定大小的塊在異常堆積"。

從 GC 物件引用路徑到原始碼行號

對於垃圾回收語言,GC 物件火焰圖展示的是引用路徑——從 GC 根到每個活躍物件的完整路徑,寬度代表記憶體佔用。沿著最寬的路徑讀下去,就能找到持有最多記憶體的物件。

找到物件名後,下一步是在原始碼中定位它。在 PHP 案例中,引用路徑指向 productPage 屬性,在原始碼目錄中 grep 這個名字,直接定位到 ProdController.php 第 40 行。在 Python 案例中,引用路徑指向 order_name_cache,複製模組名、利用其中的點號作為 grep 萬用字元搜尋原始碼檔案,同樣可以快速落地到具體的原始檔和程式碼行。

對於 Java 場景,同樣可以在不做堆轉儲(heap dump)、不重啟服務的前提下診斷生產環境中的 Java 記憶體洩漏——分析執行中 JVM 的 GC 物件引用鏈,找到仍然被 GC Root 持有但本應被釋放的物件。

傳統工具為甚麼在生產環境裡不夠用

每種傳統工具在生產環境中都有一條硬約束——不是"不好用",而是"用不了":

OpenResty XRay 的 Guided Analysis 和 Insights 功能提供了一條不同的路徑:直接分析執行中的未修改程序,無需重啟、無需改碼、無需附加偵錯程式。它透過動態追蹤技術同時生成系統級(Glibc/記憶體池)和語言級(Perl/Python/PHP/Lua/Java/C/C++)的記憶體分析,並自動推匯出最顯著的引用路徑和根因建議。

常見問題

如何在不重啟服務的前提下定位生產環境中的記憶體洩漏?

使用 OpenResty XRay 的 Guided Analysis 功能,選擇"High memory usage"問題型別,直接分析執行中的程序。系統自動生成 GC 物件記憶體分佈火焰圖和記憶體分配歸因報告,展示最大的記憶體物件及其完整引用路徑。全程不需要重啟服務、不需要修改程式碼、不需要附加偵錯程式——OpenResty XRay 以非侵入方式對執行中的程序進行動態追蹤分析。

怎麼判斷是記憶體洩漏還是記憶體佔用高?

關鍵判據是記憶體是否無界增長。如果一個程序的記憶體很高但穩定不變——比如一個 PHP 程序因為一次性讀入一整個網頁而佔用 40MB——那是大記憶體足跡,不是洩漏。如果記憶體持續攀升、永不停歇、與負載無關——那就是洩漏:某個程式碼路徑在不斷分配記憶體但從不釋放。

垃圾回收語言也會記憶體洩漏嗎?

會。垃圾回收器只回收沒有引用可達的物件。只要一個快取結構持有物件的引用且從不淘汰,GC 就認為這些物件是活的——記憶體永遠不會被回收。在真實案例中,一個 Python 模組級字典 order_name_cache 為每筆訂單寫入新記錄但從不刪除舊記錄,以及一個 Lua cert_cache LRU 快取容量過大導致永不淘汰已解析的 SSL 證書——都是垃圾回收語言中典型的洩漏模式。

為甚麼程序記憶體一直在漲,但其實沒有洩漏?

常見原因有兩個。一是分配器的空閒池機制——比如 LuaJIT 的記憶體分配器在物件釋放後不一定立即歸還記憶體給作業系統,RSS 看起來在漲,但 in-use 記憶體已經下降了,free pool 裡的記憶體會在新請求到來時被複用。二是直譯器自身的基礎開銷——比如一個 Django 程序僅載入 1521 個 Python 模組就佔了 38.27MB,這不是洩漏,是啟動時的固定足跡。區分的辦法是看 in-use 記憶體還是 free/cached 記憶體在增長。

關於 OpenResty XRay

OpenResty XRay 是一個動態追蹤產品,它可以自動分析執行中的應用,以解決效能問題、行為問題和安全漏洞,並提供可行的建議。在底層實現上,OpenResty XRay 由我們的 Y 語言驅動,可以在不同環境下支援多種不同的執行時,如 Stap+、eBPF+、GDB 和 ODB。

關於作者

章亦春是開源 OpenResty® 專案創始人兼 OpenResty Inc. 公司 CEO 和創始人。

章亦春(Github ID: agentzh),生於中國江蘇,現定居美國灣區。他是中國早期開源技術和文化的倡導者和領軍人物,曾供職於多家國際知名的高科技企業,如 Cloudflare、雅虎、阿里巴巴, 是"邊緣計算"、“動態追蹤"和"機器程式設計"的先驅,擁有超過 22 年的程式設計及 16 年的開源經驗。作為擁有超過 4000 萬全球域名使用者的開源專案的領導者。他基於其 OpenResty® 開源專案打造的高科技企業 OpenResty Inc. 位於美國矽谷中心。其主打的兩個產品 OpenResty XRay(利用動態追蹤技術的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最適合微服務和分散式流量的全能型閘道器軟體),廣受全球眾多上市及大型企業青睞。在 OpenResty 以外,章亦春為多個開源專案貢獻了累計超過百萬行程式碼,其中包括,Linux 核心、Nginx、LuaJITGDBSystemTapLLVM、Perl 等,並編寫過 60 多個開源軟體庫。

關注我們

如果您喜歡本文,歡迎關注我們 OpenResty Inc. 公司的部落格網站 。也歡迎掃碼關注我們的微信公眾號:

我們的微信公眾號

翻譯

我們提供了英文版原文和中譯版(本文)。我們也歡迎讀者提供其他語言的翻譯版本,只要是全文翻譯不帶省略,我們都將會考慮採用,非常感謝!