Python内存泄漏排查利器caveman:标记式内存采样实战指南

1. 从一段痛苦的内存排查经历说起

做后端开发的朋友大概率都遇到过这么一件事:服务上线跑了两天,内存曲线就像慢性的高血压,每天涨一点,三四天后直接触发 OOM,容器被 kill,然后重启,然后又慢慢涨上去。排查的时候你打开监控面板,看到那条稳稳向上的折线,心里只有一个念头——又来了。

我之前负责一个内部 API 服务,用的 Python 3.8 + Flask,跑在 K8s 容器里。现象极其典型:内存占用从启动时的 300MB 一路爬到 1.2GB,直到触发 limit。没有任何并发尖峰,也没有明显报错,就是匀速增长。我先用tracemalloc开起来看了几轮,拿到的是某个 dict 占用大量内存的结论,但那个 dict 是框架内部的__dict__,根本看不出业务代码在哪里往里面塞了东西。换objgraph画引用图,图大得能当抽象画挂墙上,依然找不到根因。最后是在代码里逐段加日志、反复对比新老版本,折腾了将近一周才定位到是一个定时任务里往一个类属性上追加数据,只增不减,等于活活把内存吃成了个无底洞。

这段经历让我开始重视一件小事:内存排查工具的好坏,很大程度上取决于你能多快把“内存增长”和“业务路径”对应起来。而真正在这件事上帮了我大忙的,是一个名字很不起眼的开源工具——caveman。它不会像 profiler 那样给你一本天文数字般的统计报表,它做的事情简单到有点“原始”:在内存这片黑暗的大陆上,帮你插上一根根有名字的路标。跑完一趟以后,你再回来数每个路标之间长了多少内存,问题在哪一段路径上基本一眼就看得出来。

这篇帖子就是想把caveman这套思路和用法完整地聊一聊,包括它背后的原理、我在实际项目里的踩坑经历,以及适合它的场景边界。如果你正在被 Python 服务的缓慢内存增长搞到睡不着,这篇文章应该能让你少走不少弯路。

1.1 为什么传统排查手段不够用

在聊 caveman 之前,先说说我为什么把常见的排查工具挨个用了一圈之后,仍然觉得差口气。

tracemalloc是 Python 自带的分配追踪模块,它能告诉你某一行代码分配了多少内存,甚至可以打印出分配点的调用栈。听起来很不错,但实际用起来有几个别扭的地方。第一,它追踪的是“整段运行过程”的分配,输出结果往往是一堆按总量排序的调用栈,排在最前面的是那些高频小对象,而不是真正导致增长的大头。第二,线上服务开启 tracemalloc 本身会带来百分之二三十的性能开销,压测环境还好,生产环境想常驻基本不现实。第三,数据量太大,一眼看过去头脑发麻,根本没法快速回答“这个请求进来之后,到底是哪条链路在囤内存”。

objgraph擅长画对象引用关系,但它是“点状”的,只能在某个时刻抓一张引用关系图。对于定位“内存为什么在增长”这种动态问题,它的视角是静态的,帮不上大忙。memory_profiler可以按行分析内存占用,也是静态剖析的思路,适合分析某一个函数内部的内存峰值,不适合盯住一个长期运行服务的内存漂移。

所以你会发现,传统工具擅长回答“哪一行分配了内存”、“此刻引用关系是什么”,但不太擅长回答“服务运行过程中,哪个业务阶段产生了持续累积的额外内存”。而持续累积,恰恰是内存泄漏最常见的形态。

1.2 caveman 到底是个什么东西

首次听到 caveman 这个名字的时候,我以为是个对“不优雅代码”的戏称,后来才知道它是一位开发者为了解决自己项目里查了半天查不到内存泄漏的问题,写出来的一个小工具,随后开源到了 PyPI 上。

它的用法非常直观:你在代码里需要观测的业务节点上,打一个mark()标记。比如你怀疑是某条数据批处理任务在积累内存,就在这批任务开始之前打一个标记,在任务结束之后再打一个标记。程序运行一段时间后,caveman 会把所有标记点之间的内存快照差异整理出来,生成报告,告诉你每一个业务阶段到底多长了多少内存,并且把分配内存最多的调用栈也一并列出来。

换句话说,它把纯粹的“内存统计”和“业务路径”绑在了一起。传统工具是给你一张完整的地图,你自己研究哪里可疑;caveman 是你自己在地图上先划出几个关心的区域,它帮你精确测量每个区域的增量。这个思路在真实排查场景里极其有效,因为它天然贴合人的排查方式——你不是真的想搞清楚进程里每一个字节的去向,你只是想知道“你怀疑的那几段逻辑,内存有没有异常增长”。

安装只需要一条命令:pip install caveman。它支持 Python 3.6 及以上版本,不挑操作系统,Linux 和 macOS 下都跑得很稳,Windows 我没有重点测过,但只要 Python 环境能装上 tracemalloc,通常问题不大。

2. 核心原理:标记式内存采样是怎么工作的

想用好一个工具,最好先知道它在背后替你做了什么。如果你只想把它当成黑盒来用,那照着第三章的步骤操作也可以,但遇到问题的时候你会比较难受。所以我专门用一章篇幅,把 caveman 的几个核心机制拆开讲讲,全是干货。

2.1 从三个机制讲清楚

caveman 之所以能做到“标记区间分析”,靠的是三个机制配合:分配追踪、快照差异比对、源码注册表。

分配追踪这层,caveman 直接走了 CPython 的tracemalloc接口。tracemalloc会在 Python 分配内存对象时记录当时的调用栈,以“文件名 + 行号”的形式保存下来。caveman 没有自己另起炉灶去 hook 底层的内存分配器,而是借力于这个现成的机制,省去了兼容各种 Python 版本的大量工作。

快照差异比对是 caveman 的核心。每次你调用mark()时,caveman 都会拍一张当时的“已追踪内存快照”,就像黑匣子里的数据记录仪,存下当前所有分配对象的数量和大小,以及对应调用栈。等程序跑完,caveman 再把两个相邻标记点的快照拿出来做 diff,得出增量。这里有一个容易理解偏差的细节:它做的不是“瞬时内存水位”的对比,而是“每个分配站点的累计增量”的对比。比如说你在标记 A 和标记 B 之间,有一个函数反复创建了一万个临时字典,每个字典用完都被回收了,那 A 和 B 的瞬时内存水位可能没有明显变化,但累计分配量是巨大的。caveman 会如实记录这个累计增量,而这一点恰恰是“缓慢增长型泄漏”最需要的数据。

源码注册表则解决了一个很现实的问题:生产环境的代码是非源码部署,文件路径可能会漂移。caveman 会把运行时的文件路径和安装时的源码路径做一个映射,保证最终报告里展示的调用栈能对应到你能看懂的源码位置,而不是一串抽象路径。

我用一个生活化的例子来帮你理解:假设你怀疑水管某一段漏水,你不想把整面墙砸开,就在水管沿途的几个关键节点各装了一个高精度流量计。第一个流量计和第二个流量计之间的读数差异,就是这一段水管漏掉的水量。caveman 的mark()就是你装在业务代码里的流量计,它不关心其他水管的情况,只管“这一段”。

2.2 标记文件与数据流

说完机制,再看数据流的形态。程序运行过程中,caveman 会把每次mark()时机的快照数据暂存在进程内,并在合适的时机写入一个标记文件,默认是当前工作目录下的.caveman文件。这个文件的格式是序列化的,里面包含了每个标记点的命名、时间戳、以及该时刻的分配快照摘要。等你分析完之后就可以把它删掉,不是必须保留的东西。

有一个使用层面的关键点:标记文件是在程序退出时生成的,所以如果你在跑一个常驻服务,比如用 gunicorn 启动的 Flask 应用,你需要保证程序能正常接受到退出信号,让 caveman 有机会把缓冲数据刷盘。我早期踩过一个坑,直接kill -9杀掉进程,标记文件没生成,白白浪费了一次完整的压测。后面我在自己的排查流程里养成了一个习惯:在需要采集数据的运行周期结束后,先发送 SIGINT 或调用应用的优雅退出接口,等两秒,确认.caveman文件真的出现了,再继续后续操作。

分析阶段,caveman 通过命令行工具读取这个文件,把相邻标记点的数据做 diff,按增量从大到小排序,输出一份报告。报告一般有两块核心内容:一块是“各标记区间内存增量排名”,让你知道哪一段业务逻辑涨得最凶;另一块是“Top 分配调用栈”,让你能从排名靠前的增量区间下钻,看到具体是哪些代码行的对象在堆积。这两块内容配合起来,基本就是一场完整的内存体检报告。

3. 实操实战:给线上服务做一次完整的内存体检

理论部分聊够了,下面进入真正的实操环节。我以自己某个真实项目为例,带你完整跑一遍内存体检,包括安装、埋点、运行和报告解读。这套流程我现在几乎是闭着眼睛就能走完,你也完全可以照着来。

3.1 安装与环境准备

第一步没什么悬念,直接在虚拟环境里安装:

pip install caveman

如果你的项目用了poetry或pipenv,记得把 caveman 加进开发依赖,因为线上环境不会常驻运行它,但排查时又需要它可用,所以放到 dev 依赖组更合适。

安装完成后,可以通过命令行验证一下:

caveman --help

能正常打印帮助信息,说明安装没问题。这里提醒一下,caveman 在运行时会开启tracemalloc,这会给服务带来一定性能损耗,实测大概是百分之二十左右,取决于你的分配频率。压测环境可以接受,生产环境建议选业务低峰期短时开启,不要长期挂着。

3.2 在代码里埋点

埋点这门手艺看起来简单,但实际埋得好不好,直接决定排查效率。我的经验是:在业务边界埋点,而不是在函数内部埋头乱插。什么叫业务边界?就是一个完整的业务阶段的入口和出口。

以我当时的服务为例,它最主要的功能是拉取第三方数据、清洗、入库、再同步到缓存。我就在这四个阶段各打一对标记:

from caveman import mark def sync_pipeline(): # 阶段一:拉取数据 mark("fetch:start") data = fetch_third_party_data() mark("fetch:done") # 阶段二:清洗 mark("clean:start") cleaned = clean_data(data) mark("clean:done") # 阶段三:入库 mark("write_db:start") write_to_db(cleaned) mark("write_db:done") # 阶段四:同步缓存 mark("sync_cache:start") sync_to_cache(cleaned) mark("sync_cache:done")

打完标记,caveman 会把这些点记录成“路标”。跑完一遍流程后,它就能告诉你从fetch:start到fetch:done这一段累计分配了多少内存,而且在程序退出后保留分配快照,方便你再往下钻。

埋点的时候有几个原则:

  • 标记点要配对,有 start 就有 done。如果你只埋了入口没埋出口,那这个区间的数据就缺失了。
  • 标记名字要有语义,不要用mark_1、mark_2这种,不然分析报告出来你还得回忆哪个是哪个。用模块名:业务动作这种命名方式最清晰。
  • 异常路径也要覆盖。如果某个阶段可能抛异常,而你没有在异常分支里打标记,那么失败场景下的内存增长就会漏掉。我的习惯是在finally块或异常处理里再加一个mark("phase:failed"),保证每个路径都有数据。

3.3 运行与数据采集

埋点完成之后,就是跑起来采集数据了。caveman 有两种用法,一种是直接包裹你的脚本启动:

caveman python your_script.py

另一种是手动运行你的服务,然后通过命令行参数指定标记输出路径。我更喜欢后者,因为服务进程由自己管理,方便控制启停时机。

caveman --out /tmp/leak.caveman python your_service.py

注意这里说的是在进程内部完成数据收集,进程退出时写文件。如果你的服务是被 gunicorn 或 uwsgi 启动的多进程模式,建议先切换成单进程模式来跑这个实验,否则多个 worker 同时写同一个输出文件,数据会互相覆盖。多进程场景的解法我在第四章聊。

采集时长就看你的内存曲线了。我那次排查,是让服务在压测环境跑了一天一夜,把一整天的业务高峰都覆盖到了,然后优雅退出,拿到.caveman文件。如果你只是复现一个特定场景,那跑个几轮业务循环其实也够了。

3.4 从报告里揪出真正的问题

拿到标记文件后,执行分析命令:

caveman --analyze /tmp/leak.caveman

输出会分成几个区块。第一个区块是每个标记区间的增量排名,最直观的表格大致长这样:

标记区间新分配对象数存活对象增长字节增长
clean:start -> clean:done1,238,455120,33485,021,432
fetch:start -> fetch:done3,402,1128,2125,021,204
write_db:start -> write_db:done204,9821,8241,203,442
sync_cache:start -> sync_cache:done89,211412350,214

一眼就能看出,清洗阶段的数据清洗函数是绝对的“内存黑洞”——单次运行存活对象增长了 12 万个,字节涨了 85MB。再往下钻,报告会列出内存增量排在前列的调用栈,我那次抓到的元凶是一个正则替换函数里,反复对一个超长字符串做检查,某个中间结果被保存在了闭包里,导致每次清洗完都不释放。

注意一个容易误解的点:“新分配对象数”很高并不代表泄漏,它可能只是正常的高频临时对象;“存活对象增长”才是核心指标,它更接近我们常说的“内存增长”。看报告的时候重点盯着存活增长和字节增长这两列。

4. 常见误用场景与避坑指南

工具本身不难用,难的是别被自己的操作坑到。这一章我把几个常见的误区和实战中踩过的问题整理出来,节省你直面撞墙的时间。

4.1 只标记不隔离:标记数量与粒度

有些人埋点很随意,一个函数里打了十几个标记,结果跑完报告一看,增量全都集中在某一个区间,反而难以定位到具体逻辑,因为分得太细了,代码上下文反而被切碎了。

我的建议是,先粗后细,两轮排查。第一轮只在大的业务阶段埋点,比如“读取配置”“加载数据”“执行任务”“清理回收”,跑完一轮拿到粗粒度报告;确定了哪个阶段是问题区间之后,第二轮到那个阶段里面再细埋几个标记点,进一步缩小范围。一次就想把标记精细到行,往往徒劳无功。

另外,标记本身的代价虽然不高,但它会触发快照记录,如果标记点数量太多,比如成千上万个,分析文件会变得很大,速度也会下降到难以接受。

4.2 与多线程、异步任务的冲突

如果你在 threaded 或者 asyncio 任务里埋点,要注意一个细节:caveman 的快照按线程/协程隔离,报告里会区分不同执行流的标记。如果多个 worker 同时跑同一个业务函数,他们的标记名称会一样,但数据会叠加。此时报告里某个区间的内存增长看起来会异常巨大,其实是多个并发任务共同的结果,不是单个任务真的吃掉了这么多内存。

我的处理办法是:排查阶段尽量把并发数调到 1,先保证数据纯粹。确认不是并发导致的假阳性之后,再恢复并发做压力验证。这个方法在真实环境中帮我避免了好几次误判。

另外,如果你的服务里有大量的 C 扩展模块,比如 numpy、pandas 这类底层内存管理绕过了 Python 分配器的模块,caveman 是追踪不到 C 层分配的内存的。遇到这种情况,不要困死在 Python 侧排查,还得借助系统级的工具,比如 RSS 监控和malloc相关的分析。

4.3 与内存池、缓存库的互相掩盖

再有一个高频误区:把“正常预期内的缓存增长”误判成内存泄漏。我见过有人兴冲冲地拿着报告说某个缓存字典在持续增长,结果一查,人家就是某种 LRU cache,设了容量上限,只是还没到上限而已。

区分的方法很简单:在报告里如果增量集中在某个缓存对象上,先去看这个缓存的上限和淘汰策略是否正常工作。比如用cachetools或者functools.lru_cache,都是有容量控制的,那它的增长就是合规行为。反过来,如果你发现某个全局列表或 dict 在无上限地涨,恭喜你,大概率找到真凶了。

还有一个策略是结合gc.collect()来判断:在某个标记点之前手动执行一次gc.collect(),再去取快照。如果收集完 GC 之后增长量大幅下降,说明至少有一部分增量是可回收的循环引用或垃圾对象,这本身也是一个排查线索。

4.4 报告异常时的排查技巧

有时候你会遇到程序正常退出了,但 caveman 就是不生成标记文件,或者生成的报告全部是 0 增量。多半是因为程序在某些分支里根本没走到埋点,比如提前 return 或者异常退出。排查这种问题,我的做法是先在关键位置加一条日志,打印mark()的调用成功与否,确保每个标记真的被执行了。

还要提一下和 docker 容器相关的一个细节:如果你在容器里跑的服务做内存排查,建议把宿主机的内存 limit 临时调大,或者给进程加一个 swap 限制。因为 cortar 内存检测期间的峰值占用通常会超过正常水位,如果容器在报告生成前就被 OOM kill 了,你连数据都拿不到。我吃过这个亏,那次压测一晚上,第二天起来发现容器重启了两次,.caveman文件根本没来得及写盘,等于白跑。后来我改成用--out指定一个挂在宿主机的持久化路径,同时调大了内存 limit,才算稳定跑完。

5. 为什么适合作为常备工具

走到这里,你应该已经明白 caveman 的核心价值了:它不是用来替代 profiler 的,而是用来在排查过程中快速缩小怀疑范围的。换句话说,它解决的是“问题大概在哪个业务段”的问题,而不是“每一行代码分配了多少”的问题。后者你需要结合其他工具做二次甚至三次深挖。

我把自己常备的几款内存工具放在一起做了个对比:

工具主要用途优势局限
caveman标记区间内存增量分析贴合业务路径、报告定位清晰、上手快需手动埋点、对 C 层内存不敏感
tracemalloc进程级分配追踪能精确到文件行与调用栈数据量大、性能开销高
objgraph快照引用分析可视化引用关系、适合查循环引用不回答增量问题、图难读
memory_profiler逐行内存剖析精细到函数行弱于持续运行场景
py-spy在线采样 CPU/内存无需埋点、生产友好主要面向 CPU,内存分析能力有限

这样的工具体系,既覆盖了“全局轮廓”,又覆盖了“业务路径定点”,再遇到内存类问题,我就不会像最早那样盲人摸象式地耗时一周了。

再补一个实际使用的经验:caveman 不只是排查内存泄漏能用。有一段时间我们在调优一个批处理任务,想知道每个阶段的内存峰值到底出现在哪里,同样用 caveman 把任务切成几个大阶段,跑完看每个阶段的峰值占用,很快就发现有个中间结果集不该一次性加载到内存,改成生成器之后,峰值直接砍半。这算是意外收获,但也说明一个工具把你和内存的真实行为拉近之后,你很容易发现原本没注意到的优化空间。

另外,caveman 也可以和 CI 结合起来做回归检查。比如在关键业务路径上固定埋点,在每次发布前跑一轮压测,检查标记区间的存活对象增长是否超出阈值。如果超出,就直接挂掉 CI。这样内存问题就不会再等到线上 OOM 之后才被发现,而是在发布前就被拦截。这个用法我在团队内部已经跑通,效果很好,强烈建议试试。具体做法也不复杂:写一个定位到报告关键字段的字符串解析脚本,把它接到压测流水线的门槛判断里即可。

按照我的个人体会,内存排查类的工具讲究的不是“分析能力最强”,而是“证据链能多快闭环”。caveman 用标记的方式,把“业务路径”和“内存增量”绑在一起,这种思路本身就比给一堆冷冰冰的统计数字更贴合人的排查直觉。你不需要懂它底层所有的实现细节,只需在代码里插几个标记,就能把一场难熬的内存排查缩短成一个下午的体力活。

最后分享一个实用小技巧:如果排查过程中发现某一对标记区间的增量始终为 0,而你又确实觉得这段逻辑可疑,可以试着在这个区间里循环执行多次,人为放大它的内存行为,比如把业务函数调上几百次,看看存活增量是否开始线性上升。这是一个简单但极其有效的放大手法,很多细微的累积型泄漏,就是靠这种“多次碾压”把它暴露出来的。