生產環境記憶體洩漏定位:從 RSS 飆升到原始碼行號的分層歸因
記憶體洩漏是指程式分配了記憶體、持有引用、卻永遠不釋放——隨時間推移,程序的駐留記憶體(RSS)只升不降。在生產環境中定位洩漏,約束遠比開發環境苛刻:不能重啟服務、不能掛 GDB 或 valgrind、不能改碼重新部署。
本文給出一套在這些約束下仍然可行的定位方法:先區分真洩漏與”假洩漏”——穩定的大記憶體足跡、分配器不歸還作業系統的空閒池,都不是洩漏;再按四類真實洩漏模式對號入座,從垃圾回收語言裡的無界快取,到 C/C++ 程式碼中的所有權錯誤;最後用自頂向下的分層歸因,把一條持續攀升的 RSS 曲線一路定位到具體的物件名和原始碼行號——就像一個金融 Perl 服務中把記憶體推高到數 GB、修復後穩定在 60MB 的洩漏快取一樣。
症狀:只升不降的記憶體曲線
記憶體洩漏在監控面板上有一套典型的三聯特徵:
- RSS 曲線持續攀升,流量平穩也在漲——程序吃的記憶體越來越多,與請求量無關。
- 重啟是唯一的"止痛藥"——重啟後記憶體驟降,隨後再次爬升,監控圖呈現週期性的鋸齒狀曲線:記憶體爬到接近 100% 後被迫重啟,然後再次開始爬升。
top和pmap只能看到總量——它們告訴你"這個程序佔了 1GB",但無法歸因到具體的物件、模組或程式碼行。
定位記憶體洩漏的關鍵不在 top 的數字裡,而在物件級的引用路徑分析中:找到誰在持有記憶體、透過甚麼路徑持有、為甚麼不釋放。
記憶體洩漏還是記憶體佔用高?
並非所有"記憶體高"都是洩漏。區分的關鍵判據是:穩定的大記憶體足跡 vs 無界增長。
一個 PHP 程序的記憶體大頭若是一個近 40MB 的 HTML 字串,這不一定是洩漏——它可能只是在 ProdController.php 第 40 行透過 file_get_contents 一次性把整個網頁讀進了記憶體,形成了一個大的記憶體足跡。這類問題的解法是最佳化記憶體使用模式(比如改用流式響應),而不是修洩漏。
如果你的程序記憶體很高但不再增長,那是記憶體足跡問題,不是洩漏。以下四篇教程各自展示瞭如何在不改碼、不重啟的前提下,定位執行中程序裡最大的記憶體物件:
- PHP:在執行中的 PHP 程序裡定位最大記憶體物件——追蹤到控制器中一個 40MB 的 HTML 字串
- Python:在執行中的 Python 程序裡定位最大記憶體物件——發現一個約 570MB 的模組級字典
- Django:分析 Django 應用的物件級記憶體分佈——直譯器自身的
.modules就佔了 38.27MB - Perl:在執行中的 Perl 程序裡定位最大記憶體物件——在一個 180MB 的 Perl 程序裡,定位到其符號表中的一個雜湊表
而如果記憶體持續增長、永不停歇——那就是洩漏,繼續往下看。
四類洩漏模式的真實根因鏈
記憶體洩漏的機制可以歸納為四大類。每類我們給出通用機理和一條或多條真實的根因鏈——從物件引用路徑一路追蹤到原始碼級別的落點。
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_cache的lua-resty-lrucache物件快取了透過ssl.parse_pem_cert和ssl.parse_pem_priv_key解析的 SSL 證書,而 LRU 快取容量設定過大導致幾乎不淘汰任何條目。每解析一個新域名的證書,其底層 OpenSSL 結構就永久駐留在 Glibc arena 中。
對於 Lua 層面的記憶體洩漏,OpenResty XRay 的
lj-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 持續攀升至超過 1GB。OpenResty 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 持有但本應被釋放的物件。
傳統工具為甚麼在生產環境裡不夠用
每種傳統工具在生產環境中都有一條硬約束——不是"不好用",而是"用不了":
- valgrind:需要在 valgrind 下重新啟動程序,生產環境不可行。更關鍵的是,它不理解 Nginx 記憶體池的生命週期——池化分配器把記憶體打包分配,valgrind 只能看到池級別的分配/釋放,對池內部的洩漏完全失明。
- GDB:在生產環境中掛 GDB 存在安全風險——它會暫停目標程序,對高併發服務來說意味著所有請求瞬間超時。
- 堆轉儲(heap dump):匯出一個數 GB 的堆快照本身就是一次 Stop-the-World 事件,對線上服務不可接受。
@profile裝飾器 / Memray:需要修改程式碼新增裝飾器或更換啟動方式——這意味著改碼、重新部署、重啟程序,在生產環境中都是高風險操作。top/pmap:只能看到 Glibc 分配器的總量,無法歸因到具體的記憶體物件。
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、LuaJIT、GDB、SystemTap、LLVM、Perl 等,並編寫過 60 多個開源軟體庫。
關注我們
如果您喜歡本文,歡迎關注我們 OpenResty Inc. 公司的部落格網站 。也歡迎掃碼關注我們的微信公眾號:
翻譯
我們提供了英文版原文和中譯版(本文)。我們也歡迎讀者提供其他語言的翻譯版本,只要是全文翻譯不帶省略,我們都將會考慮採用,非常感謝!



















