内存泄漏是指程序分配了内存、持有引用、却永远不释放——随时间推移,进程的驻留内存(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. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!