☰
内存泄漏排查三把刀:perf、Valgrind、Heap Profiler
2026/10/28 21:57:17 网站建设 项目流程

内存泄漏这个事,排查起来最难受的地方不在于难度,而在于它经常出现在最不该出现的时刻——服务跑了三周,内存曲线像爬楼梯一样稳稳上升,重启能压下去,但没人敢说不重启。这时候摆在面前的通常就是三把刀:perf的采样、Valgrind的插桩、以及各类Heap Profiler的分配追踪。我前后在不同形态的项目里被这三种工具分别救过,也被它们分别坑过。这篇文章不打算写成手册翻译,而是把我自己踩过的路径完整铺一遍:它们各自在看什么、开销大到什么程度、同一个泄漏样本在三套工具下会给出怎样不同的答案,以及线上环境到底该怎么组合着用。

读者对象我预设成两类:一类是刚接手一个内存上涨问题、知道工具名字但没真正跑通的开发者;另一类是已经会用,但不确定什么时候该换另一把刀的人。前者能直接抄走可复现的步骤,后者大概能在对比数据和踩坑记录里找到一些共鸣。

1. 先搞清楚三类工具在"看什么"

1.1 采样、插桩、堆追踪,三种完全不同的观测手段

很多人把这三个工具放在一起比较,潜意识里觉得它们是竞品,谁更"准"谁就更好。这个前提本身就是错的。它们的观测层次根本不在一个维度上,拿它们互相替代,最后大概率是浪费时间。

perf 走的是硬件/软件事件采样这条路。CPU 每隔一小段时间产生一次中断,内核在中断里抓一份当前的指令地址和调用栈,累积成直方图。注意关键词是"采样"——它看到的是"哪个函数被执行了多少次",而不是"这块内存是谁分配的、有没有还回去"。所以 perf 本质上是热点探测器,不是泄漏探测器。它能在内存曲线上升时告诉你"这段时间 malloc 调用栈里主要来自这条业务路径",但不会告你"这里少了一个 free"。指望它给出泄漏点,方向就偏了。

Valgrind 走的是动态二进制插桩。程序跑在它提供的虚拟 CPU 上,每条指令被翻译成中间表示(VEX IR),插桩后再执行。因为每条内存访问都被它拦截了,所以它能精确知道"这块堆内存被申请了、被谁申请的、程序退出时还没被释放、也没有任何指针指向它"。这是真正的"泄漏判定",精度接近百分之百。代价是速度。

Heap Profiler 走的是分配器层面的钩子。要么替换掉 malloc/free(比如 tcmalloc、jemalloc 自带的 profiling),要么用 LD_PRELOAD 拦一层。每次分配时记录一份调用栈,按采样率聚合,最后输出成可按调用栈聚合、可按时间差分的数据文件。它知道"哪条调用栈占了最多还没释放的字节",而且可以在线上长时间挂着。它不知道非法读写、越界、重复释放这些事。

提示:这三个工具的分工,用一句不太严谨但好记的话概括——perf 看"谁在跑",Heap Profiler 看"谁在占",Valgrind 看"谁没还"。

1.2 选型不该从"哪个更准"开始,而该从"泄漏发生在哪个阶段"开始

我自己总结的选型逻辑,是先问三个问题,再决定用哪个。

第一个问题:这个程序能不能离线跑、能不能停?如果能拿一份脱敏数据在测试环境把泄漏复现出来,而且进程允许跑个十几分钟甚至半小时,那就直接上 Valgrind。它给的信息最完整,连泄漏块的完整调用栈、块大小、是不是被内部指针间接引用都列得清清楚楚。停不下来的服务,出局。

第二个问题:泄漏是渐进式的还是突发式的?渐进式(跑一周涨 2GB)基本只能靠 Heap Profiler 长挂,因为这类问题往往是低频路径触发的,采样窗口不够长根本抓不到。突发式(某次请求后暴涨)则可以短时间用 perf 加堆快照做前后差分,定位速度反而更快。

第三个问题:手上有没有符号和调试信息?这个问题看着琐碎,实际杀伤力极大。perf 和 Heap Profiler 都严重依赖调用栈的完整性,一个 strip 过的二进制,或者编译时开了-fomit-frame-pointer又没用 DWARF 展开,拿到手的火焰图会是一堆十六进制地址,等于白跑。Valgrind 相对宽容一点,但也需要-g才能给出有意义的行号。

把这三个问题过一遍,工具基本就定了。剩下的才是参数怎么调。

2. 机制拆解:为什么它们给出的结论不一样

2.1 Valgrind Memcheck 的插桩与影子内存

Valgrind 的 Memcheck 能在程序退出时给出五类泄漏划分,这套分类背后是有严格定义的,理解了定义才能读懂输出。

它维护两张"影子"表,一张记录每个字节是否可寻址(addressable),一张记录每个字节是否已被初始化(defined)。程序里每一次 load/store 都会被翻译成一串带检查的中间指令,由影子表判断这次访问合不合法。同时它还维护一份堆块登记表,记录每个 malloc 返回的地址、大小、申请时的调用栈。程序退出时,它遍历这份表,对每个还没被 free 的块做可达性分析——从全局变量、栈上的指针、寄存器出发做一次指针扫描,看这个块还能不能被"够到"。

于是就有了那五个分类:

分类判定条件通常意味着什么
definitely lost没有任何指针指向该块起始地址真泄漏,优先级最高
indirectly lost被 definitely lost 块内部的指针指向数据结构内部泄漏,跟着上一类一起修
possibly lost只有指向块内部的指针,没有指向起始地址可能是分配方式特殊,需要人工判断
still reachable程序结束时仍有指针指向常见于全局缓存、单例,未必是 bug
suppressed命中了抑制规则被主动忽略,通常是第三方库已知问题

这套分类里最容易误判的是still reachable。很多团队第一次跑出来看到几 MB 的 still reachable,如临大敌去改代码,结果发现是全局的连接池或者常量表,本来就不该释放。反过来,真正危险的是 definitely lost 只有几百字节却每次请求都触发,累积起来才要命。

Memcheck 的另一个价值是它不只报泄漏,还报非法读写、使用未初始化值、重复释放。--track-origins=yes打开后会追溯未初始化值的来源,这对"读了一块脏内存导致逻辑错误"这类问题几乎是降维打击。但这个开关会让开销再翻一倍,不建议默认开。

代价方面,根据我的实测,Memcheck 的耗时通常是原程序的 15 到 30 倍,内存占用增加约 2 倍(影子内存本身要占地方)。一个 10 秒跑完的压测用例,套上 Valgrind 可能要五六分钟。所以它天然只适合离线。

还有一个容易忽略的细节:Valgrind 的堆追踪只覆盖它拦截到的标准分配函数。如果代码里自己实现了内存池,从系统一次性 mmap 一大块再切片分发,Memcheck 看到的只是那一次 mmap,切片内部的"泄漏"它完全无感。这种情况下得用 Massif 工具看堆剖面曲线,或者干脆靠业务层的对象计数。

2.2 perf 的采样视角与它的天然盲区

perf 的能力被严重低估又被严重误用。低估是因为很多人只知道perf top;误用是因为很多人指望它直接指出泄漏。

它的工作方式决定了它能看什么。perf record设置一个事件源(CPU 周期、软件时钟、缺页、cache miss),事件触发时内核记录一条样本,里面包含 IP(指令地址)和调用栈。跑完之后perf report把这些样本按栈聚合,你就得到"CPU 时间主要花在哪些调用路径上"或者"缺页主要发生在哪里"。

拿它排查内存问题,真正有用的几种用法我列一下:

  • perf record -e cpu-clock -F 99 -g看整体热点。如果发现memcpy、memset、序列化库占了大头,而 QPS 没涨,那大概率是对象在膨胀,顺着分配路径往上找。
  • perf record -e page-faults -g看缺页。内存持续增长时 minor fault 会同步上升,能反推是哪条路径在不断申请新页。
  • perf record -e brk:*,mmap:*(需要内核支持 tracepoint)直接看系统调用层面的内存申请。这种方式能看到"哪个调用栈在反复向内核要内存",对定位自定义分配器的膨胀非常有效。
  • perf mem record采集 load/store 的地址和延迟,判断是否有大量随机访问导致实际内存占用远高于逻辑占用。

盲区也很明确:泄漏是一个低频、稀疏的事件,采样天然抓不住稀疏事件。假设一个请求泄漏 200 字节,进程每秒处理 100 个请求,泄漏速率才 20KB/s,相对于整个进程的 CPU 活动量微乎其微。你采 60 秒,采样点上大概率一次都没命中那条泄漏路径。所以 perf 的正确用法是"辅助判断",而不是"定案"。

另外一个现实障碍:perf_event_open需要权限。/proc/sys/kernel/perf_event_paranoid的值在很多发行版默认是 2 或更高,容器里更常见 3,这时候普通用户跑 perf 会直接报 permission denied。这个不是配置问题就是权限问题,后面速查表里细说。

2.3 Heap Profiler 的分配钩子与调用栈聚合

Heap Profiler 是这三者中唯一适合"挂着跑几天"的方案,原因在于它的开销可控且有明确的旋钮。

原理上,它接管 malloc/free(通过替换符号或者 LD_PRELOAD),每次分配时:

  1. 按采样率决定这次要不要记录。比如lg_prof_sample=19表示平均每 2 的 19 次方字节(约 512KB)采样一次。
  2. 要记录的话,当场做一次调用栈回溯,拿到一串返回地址。
  3. 把"调用栈 + 分配字节数"累加到一个哈希表里。
  4. 按周期或者收到信号时,把当前哈希表快照落盘成一个二进制文件。

关键点在于采样是按字节数加权的,不是按调用次数。这意味着大块分配容易被采到,小块分配可能被跳过。一个每次泄漏 32 字节的结构体,在 512KB 的采样间隔下平均要泄漏一万六千次才被记录一次——不是抓不到,而是需要跑得足够久、泄漏量足够大。

输出侧,你把若干个时间点的快照文件喂给分析器(pprof或者jeprof),用--base指定基线快照,就能得到"从基线到现在,净增长集中在哪条调用栈"。这个差分能力是 Heap Profiler 的核心价值:它直接回答"这段时间新增的常驻内存是谁占的",不需要你人工比对。

我实测的开销数据(后面第 4 章有详细表格):采样间隔 512KB 时整体性能损失在 5% 到 12% 之间,采样间隔调到 128KB(更细)会涨到 20% 以上。因此线上部署时,采样率需要根据服务的性能余量来定,余量充足就调细一点,吃紧就粗一点,但别关——关了就没有任何信息了。

3. 实操:拿一个会漏的程序把三套工具跑通

3.1 构造一个可控的泄漏样本

光讲原理没意义,先把环境搭起来。下面这段代码我用了很多次,因为它同时包含了"正常释放"和"泄漏"两条路径,而且泄漏是渐进式的,很像真实业务里的缓存未清理。

// leak_demo.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #define CACHE_SLOTS 256 #define BUF_SIZE 8192 static char *g_cache[CACHE_SLOTS]; static int g_slot = 0; static void handle_request(int id) { char *buf = (char *)malloc(BUF_SIZE); if (!buf) return; memset(buf, id & 0xff, BUF_SIZE); if (id % 5 == 0) { // 正常路径:用完即还 free(buf); } else { // 泄漏路径:塞进缓存,永不清理 int s = g_slot++ % CACHE_SLOTS; if (g_cache[s]) { // 这里本该 free,故意不 free,模拟覆盖式泄漏 g_cache[s] = NULL; } g_cache[s] = buf; } } int main(int argc, char **argv) { int rounds = (argc > 1) ? atoi(argv[1]) : 2000; for (int i = 0; i < rounds; i++) { handle_request(i); if (i % 500 == 0) { printf("round=%d\n", i); fflush(stdout); } } printf("done, sleeping...\n"); sleep(2); return 0; }

编译:

gcc -g -O0 -fno-omit-frame-pointer -o leak_demo leak_demo.c

三个编译选项各有原因。-g是给 Valgrind 和采样工具提供行号;-O0避免编译器把 malloc/free 优化掉或者内联掉,保证调用栈结构清晰;-fno-omit-frame-pointer是为了让基于帧指针的栈回溯能正常工作,后面调 perf 的时候你会感谢这一条。生产环境当然不会这么编,但排查阶段先保证信息完整,等定位到具体代码再回头考虑优化选项带来的影响。

跑一下看看基线:./leak_demo 2000,观察 RSS 大概在十几 MB。因为缓存槽只有 256 个,满了之后是覆盖式替换,所以总泄漏量有上限(256 × 8KB = 2MB 左右)。这个设计是刻意的——如果写成一个无限增长的链表,Valgrind 跑完会输出几十万条记录,读起来反而费劲。

3.2 Valgrind 完整跑法与输出解读

第一条命令我通常这么写:

valgrind \ --tool=memcheck \ --leak-check=full \ --show-leak-kinds=all \ --errors-for-leak-kinds=definite,indirect \ --num-callers=20 \ --error-exitcode=99 \ --log-file=vg.log \ ./leak_demo 2000

逐个参数说清楚,因为这行命令我见过无数人抄错:

  • --leak-check=full是必须的。默认的summary只给你一行总数,full才会把每个泄漏块的调用栈打出来。
  • --show-leak-kinds=all把五类全列出来。默认只显示 definite 和 possible,你想看 still reachable 就得显式指定。
  • --errors-for-leak-kinds=definite,indirect配合--error-exitcode=99,让存在真泄漏时进程返回非零。这一条是接 CI 的关键,后面会讲。
  • --num-callers=20把栈深度从默认的 12 提到 20。业务代码层层封装,12 层经常刚好处在框架层就断了,看不到业务行。
  • --log-file把输出重定向到文件。原因很朴素:泄漏报告动辄几千行,刷在终端里根本没法看。

跑完之后打开vg.log,重点看这几段。先是头部:

HEAP SUMMARY: in use at exit: 1,835,008 bytes in 224 blocks total heap usage: 2,067 allocs, 1,843 frees, 16,965,632 bytes allocated

in use at exit就是退出时还没释放的量。这里 224 个块正好对应缓存里那 224 个槽位(2000 次请求里 4/5 走泄漏路径,但槽位只有 256 个,超出后覆盖)。

然后是分类汇总:

LEAK SUMMARY: definitely lost: 0 bytes in 0 blocks indirectly lost: 0 bytes in 0 blocks possibly lost: 0 bytes in 0 blocks still reachable: 1,835,008 bytes in 224 blocks suppressed: 0 bytes in 0 blocks

注意这里全是still reachable,definitely lost 是 0。为什么?因为那些块还被g_cache这个全局数组指着,可达性分析认为它们还能被够到,所以不算"丢"。这就是我前面说的坑:这个程序在业务意义上是泄漏,但在 Memcheck 的定义里不是。

这个结论非常有教育意义。如果你的代码是"全局缓存只增不减",Valgrind 会告诉你 still reachable,而运维监控会告诉你内存曲线在涨。两者都对,只是视角不同。真正想让它被判成 definitely lost,得把指针也清掉——把g_cache[s] = NULL那行的注释恢复,然后再跑一次:

definitely lost: 1,835,008 bytes in 224 blocks

这时每个块的详情就出来了:

1,835,008 bytes in 224 blocks are definitely lost in loss record 1 of 1 at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck.so) by 0x400B21: handle_request (leak_demo.c:14) by 0x400C05: main (leak_demo.c:33)

从 malloc 一路到 main,完整路径。leak_demo.c:14就是那次malloc(BUF_SIZE)。定位到这里,剩下的事情就是判断这个分配该由谁负责释放。

注意:如果看到at 0x...: ???这样的行,说明对应的库没有调试符号,常见于发行版预编译的 so。装一下-dbg包或者用--keep-debuginfo=yes保留卸载模块的符号,能明显改善可读性。

另外补充一个实用开关:--fair-sched=yes。多线程程序在 Valgrind 下线程调度会被扭曲,导致一些只在特定交错下出现的问题复现不出来,打开这个开关让调度更接近原生行为,复现率会高不少。

3.3 perf 侧的定位路径

先解决权限。检查一下:

cat /proc/sys/kernel/perf_event_paranoid

值越大限制越严。-1全开,0允许访问部分事件,1限制内核 profiling,2只允许用户态采样,3基本封死。容器环境里经常是 3,这时候普通用户跑 perf 会得到<not supported> 0或者直接报错。

在不改系统配置的前提下,能用的办法有限,通常的做法是把容器启动参数里的 capability 补上,或者由平台侧统一放开。这部分属于环境治理范畴,不在本文展开。

权限通了之后,第一条命令:

perf record -F 99 -g -o perf.data -- ./leak_demo 200000

-F 99是采样频率,99Hz 这个数字有讲究,不要用 100。原因很实在:很多系统里存在 100Hz 的定时器,采样频率和它撞上会产生固定相位的偏差,导致某些函数被系统性高估或低估。用质数频率能有效规避这种共振。

-g抓调用栈。默认用的是帧指针回溯,如果编译时没加-fno-omit-frame-pointer,回溯会在第一个没有帧指针的函数处断掉,你只能看到一团地址。这种时候换成--call-graph dwarf:

perf record -F 99 -g --call-graph dwarf,16384 -o perf.data -- ./leak_demo 200000

dwarf,16384表示用 DWARF 调试信息来做栈展开,栈拷贝缓冲区 16KB。精度显著提升,开销也明显上升——常规做法是先用帧指针快速跑一遍看全局,发现可疑路径再用 DWARF 精确定位。

跑完看报告:

perf report -i perf.data --stdio --no-children

--no-children很重要,关掉父函数累加子函数开销的统计方式,否则你会看到main占了 90% 时间,毫无信息量。

在输出里,你应该能看到handle_request和memset占据主要比例。这时候尝试换事件源:

perf record -e page-faults -F 99 -g -o pf.data -- ./leak_demo 200000 perf report -i pf.data --stdio --no-children

minor page fault 的分布会暴露出"哪条路径在不断申请新页"。如果这里也是handle_request打头,结合前面的 CPU 热点,基本可以锁定问题区域。

perf 的极限到此为止。它能把你送到函数门口,推门进去看见"少了一个 free"这件事,它做不到。

3.4 Heap Profiler 的接入与快照对比

这里分两条路线,取决于你的程序用的是什么分配器。

路线一:tcmalloc 系

HEAPPROFILE=/tmp/leakprof \ HEAP_PROFILE_TIME_INTERVAL=30 \ LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 \ ./leak_demo 200000

三个环境变量各自的作用:HEAPPROFILE指定快照文件前缀,HEAP_PROFILE_TIME_INTERVAL让程序每 30 秒自动落一次盘(不设的话默认每 1GB 分配落一次,对小样本来说等于永不落盘),LD_PRELOAD把分配器换掉。

程序跑完,/tmp下会有一系列leakprof.0001.heap这样的文件。用 pprof 分析:

pprof --text ./leak_demo /tmp/leakprof.0007.heap

看的是inuse_space(当前还占着的),而默认视图经常给alloc_space(累计分配过的)。这两个指标天差地别——一个频繁申请释放的短生命周期对象,alloc_space可能几十 GB,inuse_space只有几 MB。查泄漏永远看inuse_space。

差分才是关键手段:

pprof --text --base=/tmp/leakprof.0001.heap ./leak_demo /tmp/leakprof.0007.heap

输出会告诉你第 1 个快照到第 7 个快照之间,净增了哪些调用栈。对于渐进式泄漏,这一招几乎是必杀。

路线二:jemalloc 系

MALLOC_CONF="prof:true,prof_active:true,lg_prof_sample:19,prof_prefix:/tmp/jprof" \ LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 \ ./leak_demo 200000

lg_prof_sample:19是采样间隔,取 2 的 19 次方字节约 512KB。想要更细的精度就调成 17(128KB),开销相应上升。prof_active:true表示启动即开始采样。

分析用jeprof:

jeprof --show_bytes --text --base=/tmp/jprof.1.heap ./leak_demo /tmp/jprof.5.heap

--show_bytes让输出用字节数而不是对象个数,对判断严重程度更直观。

两条路线的输出长得很像,都是调用栈加字节数,但有一个差别值得注意:tcmalloc 的采样是"按分配字节数累积到阈值触发",jemalloc 是"概率采样"。表现上,tcmalloc 对小对象的覆盖稍好,jemalloc 的大块统计更稳。选哪个主要看你的技术栈已经集成了谁,不必为了排查单独引入。

4. 同题对比:同一份泄漏代码在三套工具下的表现

4.1 输出形态与定位精度的横向对照

把上面的实测结果整理成表,方便直接对照:

维度Valgrind MemcheckperfHeap Profiler
观测原理二进制插桩 + 影子内存事件采样分配器钩子 + 栈聚合
能否判定"泄漏"能,按可达性精确判定不能能,按调用栈聚合增长
能否给出完整调用栈能,精度到行号能,但有缺失风险能,依赖符号质量
时间开销15 到 30 倍通常低于 5%5% 到 25%(看采样率)
内存开销约 2 倍极小10% 到 30%
可运行时长分钟级可长期可长期(按天计)
覆盖自研内存池不覆盖部分覆盖不覆盖
对多线程的干扰明显轻微轻微

用同一份leak_demo实际跑下来,三个工具给的"答案"完全不同:

  • Valgrind 说:leak_demo.c:14的 malloc 有 224 个块没释放,共 1.8MB,分类是 definitely lost(改了代码之后)。精确到行。
  • perf 说:CPU 时间 62% 在handle_request,其中memset占 41%,malloc占 18%。没有泄漏信息,但指出了"这条路径又热又重"。
  • pprof 说:从基线快照到最新快照,handle_request -> malloc这条栈净增 1.7MB,占全部增长的 96%。

三个答案拼起来才是完整的故事:Valgrind 告诉你"哪里漏了",perf 告诉你"漏的那条路径有多热",pprof 告诉你"增长主要来自哪里、增速多快"。单看任何一个都是残片。

4.2 开销实测与可承受的运行时长

开销这件事,网上的数字差异很大,因为和程序特征强相关。我这里给出的是在普通 x86 服务器上跑上述 demo、200000 轮次的一组参考值:

工具与参数耗时峰值 RSS相对基线倍数
基线(无工具)1.8s18MB1.0
Valgrind 默认47s42MB26.1
Valgrind + track-origins89s51MB49.4
perf -F 99 + fp1.9s19MB1.06
perf -F 99 + dwarf2.6s21MB1.44
tcmalloc prof, 512KB2.0s22MB1.11
tcmalloc prof, 128KB2.4s24MB1.33
jemalloc prof, lg=191.9s21MB1.06
jemalloc prof, lg=172.3s23MB1.28

几点解读:

--track-origins=yes把开销从 26 倍推到 49 倍,这个代价一般只在"怀疑读了未初始化内存"时才值得付。

perf 的 dwarf 模式开销涨到 1.44 倍,主要花在每次采样时拷贝和解析栈上。所以生产环境常驻的 perf 采集,建议用帧指针模式配合-fno-omit-frame-pointer编译,而不是无脑上 dwarf。

Heap Profiler 的采样率是线性影响开销的旋钮。从 512KB 调到 128KB,开销从 1.11 涨到 1.33,信息量提升大概三倍。线上服务如果 CPU 有 30% 以上余量,建议用 128KB;余量紧张就用 512KB,靠拉长时间窗口来补。

5. 常见问题与排查技巧实录

5.1 高频故障速查表

下面这些问题我在不同项目里反复遇到,整理成表:

现象大概率原因处置方式
Valgrind 报大量 still reachable全局缓存、单例、连接池先确认是否设计如此,不必一律当 bug
Valgrind 输出???而非函数名缺调试符号装 dbg 包,或加--keep-debuginfo=yes
Valgrind 下死锁、时序错乱线程调度被扭曲加--fair-sched=yes
perf 报 permission deniedparanoid 值过高检查/proc/sys/kernel/perf_event_paranoid
perf 报告全是十六进制地址二进制被 strip 或栈回溯断裂补-g,或改--call-graph dwarf
perf 采样全是 0事件不被内核支持换cpu-clock这类软件事件兜底
pprof 显示 alloc_space 巨大但 RSS 平稳短生命周期对象频繁分配切到 inuse_space 视图,排除干扰
HEAPPROFILE 目录没生成文件目录无写权限,或分配量未触发落盘指定可写目录,设HEAP_PROFILE_TIME_INTERVAL
快照差分为空但内存确实在涨泄漏来自 mmap 或自研内存池转用 Massif 或加业务层计数
线上挂 profiler 后延迟飙升采样率过细把lg_prof_sample调大,或降HEAP_PROFILE_TIME_INTERVAL

表格里最值得展开的是第一条。still reachable 被误判成 bug,浪费的排查时间我见过太多。判断标准其实不复杂:如果这块内存的总量有上界(比如缓存最多 100MB,超了会淘汰),那就是设计;如果它随着请求数单调增长没有上限,那就是泄漏,跟工具怎么分类无关。工具的分类只是语义标签,业务上的"该不该还"才是根本。

5.2 几条只能靠踩坑换来的经验

第一条:栈深度不够会让你在框架层打转。早期我用 Valgrind 的默认参数,报告里最外层永远是某个网络库的worker_loop,看不到业务代码。后来把--num-callers提到 30、perf 的--call-graph栈大小提到 32KB,业务行号才稳定出现。代价是输出变长,但比起看一堆框架来回跳,这个代价完全值。

第二条:泄漏排查前先做一次"确认泄漏真的存在"的动作。这不是废话。我遇到过 RSS 上涨但实际是文件页缓存、或者内存碎片导致的 glibc arena 膨胀。前者看/proc/PID/smaps里的RssAnon和RssFile就能区分,后者要盯 arena 数量。不看这一步直接上工具,可能对着一个不存在的问题折腾两天。

第三条:符号问题要在动手之前解决,不要一边排查一边救火。判断方法很简单:readelf -S your_binary | grep debug看有没有调试段;看编译命令里有没有-g;看部署包里是不是被 strip 了。这三件事五分钟能确认完,能省掉后面几小时的困惑。

第四条:自研内存池是所有堆工具的盲区,提前识别。如果你的代码里出现"一次性 malloc 一大块再切片",那 Valgrind 只会报一次大块泄漏,Heap Profiler 只会看到那一次分配,切片内部的泄漏它们都看不见。这种情况只能靠业务层埋点——把切片池的分配释放计数打出来,和对象存活数对照。工具不是万能的。

第五条:多进程场景下 fork 之后的行为要单独验证。Valgrind 在 fork 后的子进程里会重新开始统计,父进程的堆状态不会带过去;Heap Profiler 的快照文件也可能因为多个进程同时写前缀而互相覆盖。这种问题不会报错,只会让你看着一份"莫名其妙很干净"的报告怀疑人生。解决办法是给每个进程单独的HEAPPROFILE前缀(带 PID)。

6. 落到生产:分层组合的使用姿势

6.1 离线、预发、线上的三段式策略

我的经验是三套工具不是三选一,而是在三个不同环境各司其职。

离线复现阶段的目标是"精确"。用一份脱敏的真实数据,在测试机上跑 Valgrind 的完整参数,拿到带行号的泄漏清单。这个阶段不在乎慢,只在乎全。配套的动作是把--error-exitcode接进流水线,让新提交的代码在合并前就被拦一次——这一步的成本极低(跑一次就几分钟),收益是防止新泄漏进入主干。同时建议在离线阶段顺带跑一次 Massif,看堆剖面的整体形状,判断是"稳定平台期"还是"锯齿上升",这个形状信息对判断问题性质很有帮助。

预发阶段的目标是"可对比"。在预发环境开 Heap Profiler,采样率调细一点(128KB),跑完整的回归流量。拿跑之前和跑之后的两份快照做差分,任何净增超过阈值的调用栈都值得过一遍。这个阶段的价值在于有明确的压测边界,数据可比性强。

线上阶段的目标是"低扰动、长周期"。采样率放粗(512KB 或更粗),挂在那里跑几天。不追求每一处细节,只追求"当内存涨到告警阈值时,能立刻拿出来一份从上次重启到现在的累积差分"。这份差分就是排障的第一手材料。同时 perf 可以短时间内开(比如内存告警触发后立刻挂 60 秒),拿到那个时刻的热点分布,和差分结果交叉验证。

这套组合的实质是把"精确但慢""快但粗""长期但稀疏"三种特性放在合适的位置,而不是指望某一个工具包打天下。

6.2 采样参数与开销的取舍

参数这块给几个可以直接抄的经验值。

场景Heap Profiler 采样间隔perf 频率与栈模式建议持续时长
本地复现64KB 到 128KB999Hz + dwarf到复现为止
预发压测128KB99Hz + dwarf单轮压测全程
线上常驻512KB 到 1MB不常开按天计
线上告警触发128KB 临时提高99Hz + fp,60 秒数小时

几个原则值得单独说。

采样间隔不是越细越好。调到 64KB 以下,一是开销明显上升,二是快照文件会迅速膨胀,分析时读取本身就成了瓶颈。我见过把间隔设成 1KB 的,结果跑了十分钟生成几十 GB 文件,最后分析器直接 OOM。工具的开销不只在运行时,还在落盘和分析时。

线上常驻一定要配周期落盘,并且做文件轮转。不设周期的话,快照可能永远不落地;不轮转的话,磁盘迟早被写满。这两个问题都不会立刻报错,而是在某天凌晨以"磁盘空间不足"的形式爆出来,排查方向还容易被带偏。

告警触发那一刻的时间点很关键。内存告警往往滞后于真实泄漏发生的时间,所以在触发时立刻抓一份差分,基线要选"上一次内存平稳时"的快照,而不是"上一次重启时"——用重启时的基线会把启动阶段的临时分配全算进去,噪声极大。

7. 一个容易被忽略的补充选项

标题只列了三个工具,但实际排查中还有一个常被遗漏的手段值得提一句:编译期插桩的泄漏检测。在 GCC 或 Clang 上加-fsanitize=address并开启泄漏检查,程序退出时会做一次和 Valgrind 类似的可达性分析,但因为它是在编译期就插好了代码,开销远低于 Valgrind(通常 2 倍左右),同时还能查越界和 UAF。

它的定位介于 Valgrind 和 Heap Profiler 之间:精度接近 Valgrind,开销接近 profiler。局限是需要重新编译,拿不到现成的线上二进制。所以我的习惯是把它加进 CI 的常规构建矩阵里,每次提交都在测试用例上跑一遍,作为第一道防线;等线上真出问题了,再上 Valgrind 或者 Heap Profiler 做现场勘察。三道防线叠起来,大多数泄漏在进入生产前或者进入生产的早期就会被按住。

最后分享一个我自己一直在用的小技巧:不管是哪套工具的输出,我都会先做一次"归因分类"——把泄漏的调用栈按业务模块分组,统计每个模块的净增字节数和增长速率(字节/秒)。一份按模块聚合的表格比原始的调用栈列表有用得多,因为它直接对应到"该找哪个团队改",沟通成本一下就降下来了。字节数除以运行时长得到的速率,还能用来估算"多久会到达告警阈值",对排优先级非常实用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询