从进程树到协议层:一次线上内存泄漏的逐层定位

举报
yd_246468676 发表于 2026/11/09 01:31:41 2026/11/09
【摘要】 一、先看进程生命周期

一、先看进程生命周期

线上告警进来时是凌晨两点十七分,某采集服务单实例常驻内存从 1.2 GB 缓慢爬到 3.8 GB,四小时涨了 2.6 GB,节点被 OOM killer 干掉一次,restart 后曲线几乎重合。这类"涨得匀、崩得慢"的形态,第一反应不是去翻业务代码,而是先把进程生命周期拉平看。用得惯的命令是 ps 配合 etimes 和 RSS 的原始输出:

ps -eo pid,ppid,lstart,etimes,rss,vsz,cmd --sort=-rss | head -20

第一眼就能定性。如果 RSS 涨而 pid 不变、etimes 连续增长,说明是进程内泄漏,重启即重置;如果 pid 一直在换、ppid 是一个每分钟调度一次的守护进程,那就是另一种故事:进程被反复拉起,每个实例在退场前没把资源交回去,泄漏点在父进程或退出路径上,堆分析会白看。这次属于前者,pid 4127 从启动起稳如老狗,父子关系单薄,没有任何 fork 子进程的痕迹。接着确认生命周期边界:

cat /proc/4127/status | grep -E 'VmRSS|VmPeak|Threads'

VmRSS 与 RSS 一致,Threads 恒定 47,排除了线程无限创建这条常见歧路,RSS 增长与线程栈无关。再看 glibc 层面的碎片,用 pmap 比对匿名映射段:

pmap -x 4127 | tail -5

输出里 total 的 KB 是 3961244,多出来的匿名映射在 1.5 GB 上下散成数十块 64 MB 的 chunk,恰好是 jemalloc 的 arena 粒度,数字对得干净,说明分配器没把页还给系统,但真正被引用的对象有多少还要另算。进程生命周期这一层能给出的结论只有三条:泄漏发生在长期驻留的单进程内、与线程增长无关、分配器把水位抬高了。再往下就得抓堆,把存活对象和引用链翻出来,否则所有判断都是在猜。

二、抓堆与对象引用链

堆上做一次全量快照,先让 allocator 把地址空间吐干净。jemalloc 下用 MALLOC_CONF 控制 dump,进程若已稳定运行一周,直接 gcore 会阻塞上百毫秒,选凌晨低峰。gcore -o /tmp/leak 4127 跑完在 /tmp 下落地 leak.4127,4.2 GB,耗时 38 秒,与 top 里的 RSS 基本对齐。

echo 'prof.dump:true,prof.active:true,lg_prof_sample:19' > /tmp/malloc.conf

MALLOC_CONF=/tmp/malloc.conf gcore -o /tmp/leak 4127

用 gdb 挂上去扫 jemalloc 的 arena chunk,看哪类 size class 占用最多:

gdb -p 4127 -batch -ex 'call je_malloc_stats_print(0,0,"J")'

输出里 large 区间 1 MB 以上的对象占了 2.8 GB,集中在 1.2 MB 与 256 KB 两个 bin。接着抓引用链,用 gdb 的 python 脚本对每个 arena 遍历 bin,打印 chunk 的 slab 起始地址,再配合 ptype 定位对象头。发现一处 256 KB 的 buffer 被一个单例连接池的成员变量强引用,池里的 fd 已关闭但对象没摘除,句柄挂在 epoll 的注册表上。引用链是 static conn_pool -> slab array -> 该 buffer,链路简单到一眼能认出来,说明不是循环引用,而是释放路径压根没走到。这一步把范围从整个堆压到连接池这一小块,往下就只剩协议层为什么留着这些对象。

三、协议层连接未释放

堆里的对象既然靠单例连接池强引用,那协议层要做的事就只剩一件:弄清楚这些 fd 为什么在关闭后没从池里摘掉。先在进程里看连接的实际状态:

ss -tanp | grep 4127 | awk '{print $1}' | sort | uniq -c

输出里 CLOSE_WAIT 有 1732 条,ESTAB 只有 9 条,比例严重失衡,说明服务端已经发了 FIN,本地却没人调用 close。回到 gdb 里对连接池的成员逐个 inspect:

gdb -p 4127 -batch -ex 'p conn_pool->conns.size()' -ex 'p conn_pool->epoll_fd'

size() 返回 1741,和 CLOSE_WAIT 数基本对上,epoll_fd 是 17,说明注册表还挂着这些句柄。把 libevent 版本打出来:

gdb -p 4127 -batch -ex 'p event_get_version()'

结果是 2.1.12-stable。这个版本里 event_del 后若再调 event_free,内部会先检查 EVLIST_INSERTED 标志;我们代码里的回调路径是先 free 后 del,顺序反了,event_free 时句柄还挂在 epoll 上,内核不会回收,于是 fd 进了 CLOSE_WAIT 且池里对象留着。修法是把连接池的回收改成统一走 remove_conn,先 event_del 再 event_free 再 erase,顺手把 conn_pool->conns 从裸指针改成 shared_ptr。改完压测 30 分钟,CLOSE_WAIT 稳定在个位数,256 KB 的 bin 占用从 2.1 GB 掉到 40 MB 左右。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。