生产环境内存泄漏定位:从 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. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:
翻译
我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!



















