服务跑几天就越来越慢:内存泄漏的定位顺序与三个常见来源

2026-07-29

数据截至 2026-07。文中涉及的运行时行为、cgroup 路径与命令参数会随版本变化,具体以你所用运行时和操作系统的官方最新说明为准。

多数人把”服务跑几天就变慢”归因成流量涨了或者机器不够,然后加机器、调线程池、换更大的实例——但真正的原因往往是进程里有一堆东西该释放没释放,加机器只是把崩溃时间从第三天推到第六天。 判断标准很直接:如果重启之后立刻恢复如初,然后又用大致相同的节奏慢慢劣化,那就不是容量问题,是进程内的状态在累积。容量问题不会被重启治好。

这篇讲的是你自己写的、跑在服务器上的长驻进程。如果你想找的是 AI 生成的代码为什么整体性能一代不如一代、该怎么衡量,看 AI 辅助开发中的性能退化排查;如果你的问题是本地编辑器和插件把开发机内存吃光,那是另一条线,看 IDE 或 CLI 内存暴涨到卡死。本篇不碰编辑器,只碰你部署出去的那个进程。

一、先分清三种不同的”内存涨了”

在动手抓堆之前,你得先确定手上是哪一种。三种表现看起来都是内存曲线往上走,处置方式完全不同。

真泄漏:内存单调上升,GC 之后回不去,波谷线(每次回收后的最低点)也在稳步抬高。这是有引用链吊着不放,对象永远进不了回收范围。

缓存膨胀:内存上升但会在某个水位附近横盘,或者在压力大的时候有明显回落。这类东西技术上是”有意保留”的,问题出在没有上限、没有过期,或者键的基数比你以为的大得多。它不是 bug 意义上的泄漏,但后果一样。

正常抖动:内存在一个区间里来回跑,波谷线平稳。很多运行时会预留堆空间不还给操作系统,你从外面看 RSS 一直是高的,其实里面早就空了。这种情况你去调代码纯属浪费时间。

区分方法只有一个可靠动作:看波谷,不看波峰。拿一段至少覆盖两个业务周期的时间序列出来,把每次回收后的最低点连成线。这条线平的就不是泄漏,这条线往上走的才是。

最省事的观察命令是盯 RSS:

while true; do
  printf '%s ' "$(date +%Y-%m-%dT%H:%M:%S)"
  ps -o pid=,rss=,etime= -p "$PID"
  sleep 300
done >> /tmp/mem-trace.log

第一列的时间戳别省,后面你要拿这条曲线跟发布记录、流量曲线、告警时间对齐,没有绝对时间就对不上。ps 输出里的 etime 是进程已运行时长,重启过一次它就归零,正好可以用来切分不同的生命周期段。RSS 的单位在多数 Linux 发行版上是 KiB,画图前先确认一遍,别把纵轴标错量级。

跑一整天,然后画出来。别用 top 看两眼就下结论,内存问题的时间尺度是小时和天,不是秒。容器里还要注意 RSS 和 cgroup 统计口径不一样,被 OOM 杀掉的判定用的是后者。cgroup v2 读容器内的 /sys/fs/cgroup/memory.current,v1 读 /sys/fs/cgroup/memory/memory.usage_in_bytes,先 mount | grep cgroup 确认当前节点是哪一套,别照抄路径。

二、判别表:现象直接对成因

这张表是我实际排查时的入口。先在左列找到你看到的现象,再按第三列去验证,验证通过了再执行第四列,不要跳步。

现象大概率成因怎么验证处置动作
内存单调涨且始终不横盘,重启后归零并重新以相同斜率上涨监听器/回调/定时器注册后没注销周期性打印监听器注册数或事件目标数量,看是否随请求数线性增长找到注册点,配对写卸载;把注册收敛到生命周期钩子里
内存涨到某个水位横盘,但水位远高于预期缓存无上限或键基数失控打印缓存条目数和键的样本,看键里有没有拼进用户 ID、时间戳、请求参数换成有容量上限和过期策略的实现,并给键做归一化
内存缓涨且伴随连接数、文件描述符同步增长连接/客户端实例没复用或没关闭ls /proc/$PID/fd | wc -l 定时采样,或看服务端侧的连接数改成单例客户端 + 连接池;补 finally/defer 关闭
响应时间已经在劣化,但 RSS 看着还平,GC 时间占比升高存活对象在涨,只是被预留的堆空间掩盖了采集 GC 日志,看每次回收后的堆内存活量、单次停顿时长和回收频率的趋势先按上面三类找存活对象来源,调 GC 参数是最后手段
只有某几台实例涨,其他正常流量倾斜或特定租户/参数触发的路径对比这几台的访问日志,找独占的接口或参数组合定位那条路径单独复现,不要全量排查
内存曲线在发版后突变,之前一直平稳本次变更引入直接比对发布区间的提交,git log --oneline 划范围回滚优先,回滚后再慢慢查
RSS 高但业务正常、GC 后堆内很空运行时未归还内存给系统看堆内使用量而非进程 RSS通常不用处理,必要时配置归还策略

用这张表有两处要提醒。

第一处:前两行的现象并不是互斥的,“大概率成因”那一列是给你排优先级用的,不是靠曲线形状就能定案。一个完全没有容量上限的缓存,画出来同样是单调涨、同样不横盘、同样重启后以相同斜率重来,跟监听器泄漏在曲线上分不出。所以第三列的验证动作是必做的,不是可选的:监听器看注册数与注销数的差值,缓存看条目数与键的样本。两个都涨就两个都改,一个涨一个平就只改涨的那个。真正能靠形状先分开的,是”横盘在一个高水位”这个特征——它说明确实存在某种上限(容量上限、连接池上限、或者干脆就是运行时的堆上限),只是这个上限定得太宽。

第二处:第四行说的”采集 GC 日志”在不同运行时是不同的东西。JVM 有成熟的 GC 日志开关,Node 需要额外启动参数才会把回收细节打出来,Go 走的是运行时自带的统计入口,Python 以引用计数为主、循环回收只是补充,看的指标也不一样。你要拿到的信息本身是统一的——每次回收之后还活着多少、回收频率有没有变密、单次停顿有没有变长——但拿这三个数的方式请按你的运行时查官方最新说明,别照抄别的语言的参数名。

表里最后一行经常被误判。有人看到 RSS 居高不下就开始大改代码,改完发现曲线没变,因为堆内本来就是空的。

三、三个来源的确证方法

监听器与回调

这是最常见的一类,也是最容易被 AI 补全代码引入的一类。模式是:在每次请求、每次连接建立、每个组件初始化的时候注册一个事件监听、一个信号处理、一个定时任务,但只写了注册没写注销。宿主对象长期存活,注册进去的闭包就跟着长期存活,闭包又捕获了请求上下文里的大对象。

确证方法是查数量而不是查内存。找到你运行时里能拿到监听器数量的接口,或者干脆自己在注册和注销的地方各加一个计数器,每分钟打印一次差值。如果注册数随请求量单调增长而注销数不动,不用再看堆快照了,结论已经出来了。

排查这类问题时有个高效技巧:用 git log -p 只看注册相关关键字的历史改动,比通读代码快得多。

git log -p -S "addEventListener" --since="3 months ago" -- src/

处置动作是把注册和注销做成成对的、由同一个作用域负责的操作。语言层面各有各的写法,共同点是:谁注册谁负责卸载,别把卸载交给”另一个模块”。

缓存

缓存的问题很少是”缓存本身错了”,而是三件事之一:没有容量上限、没有过期时间、键的基数远超预期。第三条最隐蔽。你以为键是接口名,实际上有人往里面拼了完整的请求参数或者时间戳,于是每个请求都是新键,这个”缓存”变成了只写不读的垃圾堆。

确证方法是采样看键,不是看数量。定时随机抽二三十个键打出来,人眼扫一遍就知道有没有混进不该有的东西。命中率也要一起看:命中率长期极低而条目数一直涨的缓存,本质上就是泄漏。

处置上,最小改动是给缓存换一个自带容量淘汰和过期的实现,同时把键归一化。如果业务上确实需要保留全量,那它就不该在进程内存里,该挪到外部存储。

连接与客户端

数据库连接、HTTP 客户端、消息队列消费者、各类 SDK 的客户端实例——这些东西的共同特征是内部持有连接、缓冲区和后台线程。每次调用新建一个而不复用,是很典型的写法失误,尤其在 AI 生成的示例代码里,因为示例通常是单次调用的场景,直接搬到高频路径上就变成了每次请求造一个。

确证方法是同时看进程的文件描述符数和内存曲线,两条线同步往上走基本可以定案:

ls /proc/"$PID"/fd | wc -l

这条只在有 procfs 的 Linux 上能跑;本机是 macOS 或者进不去容器的 procfs 时,改用 lsof -p "$PID" | wc -l。两者口径不同(后者把内存映射文件也算进去),所以只拿同一条命令的前后趋势做对比,别把两种口径的绝对值放在一起比。定时采样时把时间戳一起写进日志,否则事后对不上内存曲线的时间轴。

但反过来不成立:文件描述符数平稳,不等于可以排除这一类。 客户端实例被反复创建、却还没真正建连(懒连接)或者连接已经关了而实例本身仍被某个集合引用着,fd 就不会涨,内存照涨不误。所以这一格建议配一个第二指标——线程数:

grep '^Threads:' /proc/"$PID"/status

大部分 SDK 客户端在构造时就会拉起自己的后台线程(心跳、重连、批量刷新之类),实例数上去了线程数一般跟着上去,比 fd 更灵敏。同样只看趋势:线程数随运行时长单调爬升而业务并发没变,基本就是实例没复用。这两个指标都平、内存还在涨,就把这一类往后排,先回去查监听器和缓存。

处置动作是把客户端提升为进程级单例,配置连接池上限,并在异常路径上补关闭。超时配置也要一起补,因为悬挂的连接不但占资源还会拖住调用方,这块的判断依据可以参考 接口超时与连接中断的处置

一个通用的兜底动作

如果三类都排查过了还没结论,才轮到抓堆快照做对比。做法是在稳定期抓一次、劣化期抓一次,比较两次的对象数量差,按增量排序看前几名。单看一份快照几乎没用,因为你不知道哪些是正常存活的。

部分运行时自带内存追踪能力,可以做同样的对比。以 Python 为例:

import tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
# 让服务跑一段时间
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, 'lineno')[:15]:
    print(stat)

这个方法的好处是直接给你代码行号,坏处是有性能开销,别常开。

四、什么情况下别再折腾

排查内存问题最大的成本不是修不好,是修的过程中你一直让线上带病运行。给自己画三条线。

止损线:影响到用户就先重启。 如果内存已经逼近上限、请求开始超时或者被 OOM 杀掉,先做滚动重启把服务拉回健康区间,再继续查。有人觉得”重启就抓不到现场了”,所以硬扛着——正确做法是重启前先留下现场(堆快照、当时的指标快照、一段访问日志),留完就重启。保留现场和保留故障是两回事。

回滚线:发版后突变的,两小时内查不出就回滚。 内存曲线在某次发布后改变形态,说明变更范围里就有原因。这种情况下回滚的信息量比继续查更大:回滚后曲线恢复,等于确认了范围;回滚后曲线不恢复,说明另有原因,你也省下了在错误方向上的时间。发布节奏和回滚判断可以对着 Agent 日常运维 里那套流程走。

换路线:改了三轮曲线斜率没变,说明方向错了。 典型表现是你反复优化某个你”觉得”有问题的模块,每次上线都盯着曲线,每次都失望。这时候停下来,回到第一步重新分因——很可能你一开始就把缓存膨胀当成了真泄漏,或者把运行时不归还内存当成了泄漏。

还有一种情况值得单独说:如果这个服务本来就快下线了,或者内存增长速度慢到重启周期完全覆盖得住,定期重启就是一个合法的工程决策。 用一个可靠的定时重启加健康检查,把人力投到更值钱的地方,比花两周找一个每月只涨一点点的泄漏更划算。前提是你得知道自己在做取舍,而不是假装问题不存在。

五、避坑清单

盯着波峰下结论。 会踩是因为监控面板默认展示的往往是最大值或者最近值,视觉上很吓人。避法是把回收后的最低点单独作为一条曲线画出来,判断只看这条线。

在业务低谷期做判断。 会踩是因为低谷期请求少,泄漏速度慢,曲线看着就平了,于是误判成”已修复”。避法是所有前后对比都放在可比的时间窗里,至少覆盖一个完整的业务周期。

一次改多个地方。 会踩是因为排查时会同时怀疑好几处,想着一起改一次上线省事。结果曲线变好了你也不知道是哪一处起的作用,下次遇到类似问题还是零经验。避法是一次只改一处,每次留够观察窗口。

把堆快照当成第一步。 会踩是因为抓堆听起来很专业,工具也现成。但抓堆本身有停顿开销,而且单份快照读不出增量,很多人抓完一份对着看半天什么也看不出来。避法是先做第一节的分因和第二节的表格判别,堆快照放在最后。

信任 AI 给出的”修复”而不验证机制。 会踩是因为这类问题的表述很容易让模型给出一个看起来合理的答案——加个 clear()、加个 weak 引用、加个上限。有些确实对,有些是把症状盖住了。避法是要求它先说清楚”是谁持有了这个对象、引用链是什么”,你能复述出这条链再动手改;复述不出来就说明这个修复你没验证过,只是接受了它。

日志开太狠反而制造新问题。 会踩是因为排查时想多留信息,就把日志级别调到最细,结果日志缓冲、结构化对象、异步写入队列本身开始占内存,你排查的对象被排查手段污染了。避法是只对可疑路径加采样日志,加完设个到期时间,别让临时开关变成常驻配置。日志量的控制思路见 日志太多怎么维护

忘了容器的内存口径。 会踩是因为你在进程里看到的堆使用量很正常,但被杀掉的判定用的是 cgroup 那一套,里面还算上了页缓存等内容。其中可回收的部分会先被回收掉,真正把你压过线的是回收不掉的那些,所以两个数字都要看。避法是排查时同时记录进程堆内使用量和 cgroup 的用量,别只信一个。

六、收束

内存问题的难点从来不在工具,在顺序。工具都是现成的,但如果你一上来就抓堆、就调 GC 参数、就加机器,大概率是在给自己制造工作量。正确的顺序是:先看波谷线确认是不是真泄漏,再用表格把现象对上成因,再按监听器、缓存、连接的次序逐一确证,最后才动堆快照。

给你一份可以直接照着走的自检清单:

  • 重启后是否立刻恢复?是则排除容量问题。
  • 波谷线是否单调上升?是则按真泄漏查引用链;否则转去查缓存膨胀和运行时不归还这两条线,别急着抓堆。
  • 曲线形态是否在某次发布后改变?是则先划提交范围,优先回滚。
  • 监听器/定时器的注册数与注销数是否配平?
  • 缓存有没有容量上限和过期?抽样看键里有没有混进高基数字段。
  • 文件描述符数或线程数是否与内存同步增长?是则查客户端复用;两者都平也不能直接排除,只是排在后面查。
  • 有没有留下现场就重启的预案?没有就先补上。
  • 每次改动是否只动一处,并留了足够长的观察窗口?

这套顺序不保证你一次找到根因,但能保证你不会在错误的方向上花掉一整周。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。