服务器入坑指北:内存泄漏的观察与处置顺序
内存一点点涨,重启能缓一阵,过几天又涨回去;一看就说是"泄漏",其实可能是缓存;也不知道什么时候该重启、什么时候该先取证。
在你下"泄漏"这个结论之前,先记住这条前提:单次读数说明不了任何事——"在涨"必须由两次读数之间的差来证明;而**"涨的是哪一类"决定它是不是泄漏**。
这篇给三段可直接粘贴的内容:取两次算速率 → 分流与算到顶 → 处置顺序(第三段含写操作,顺序不能反)。取值用占位符,字段名、命令名与配置键照写。
用速率,不要用单次读数
增长速率 = Δ常驻内存 / Δ时间——两次读数相减,再除以两次取值的间隔秒数。
- 分子取常驻量(
VmRSS那一行,或用只出数值的形态); - 分母取两次时间戳之差(秒)。
取两次算速率块
date +%s # 第一次:记下时间戳(秒)
cat /proc/<PID>/status # 第一次取样:整份状态,判据行是 VmRSS(常驻)与 VmSize(虚拟)
ps -o pid,rss,vsz,cmd -p <PID> # 一眼对比:判据列是 rss(常驻,KB)与 vsz(虚拟)
grep -E 'VmRSS|VmSize' /proc/<PID>/status # 第一次取样:判据是 VmRSS 那一行(虚拟量级只作对照)
ps -o rss= -p <PID> # 只输出 RSS 数值(KB),便于两次相减
date +%s # 第二次:间隔后记时间戳
grep -E 'VmRSS|VmSize' /proc/<PID>/status # 第二次取样:同一行再读一次
判据:持续为正且不回落,才算"在涨";单次读数高或者低,都不构成结论——没有分母,就没有速率。
算一遍(数值为演示值):第一次读到 512 MiB、第二次读到 768 MiB,两次取值相隔 3600 秒 ⇒ 增长速率 = (768 - 512) / 3600 ≈ 0.071(MiB 每秒),也就是约 256 MiB 每小时。
同一组数接着算第二步——还有多久到顶:
到顶时间 = (上限 − 当前) / 速率。
接着算:上限 2048 MiB、当前 768 MiB、速率按 256 MiB 每小时 ⇒ 到顶时间 = (2048 - 768) / 256 = 1280 / 256 = 5(小时)。
这两步的意义:它把"要不要现在处理"从感觉变成算式——到顶时间远大于你能接受的处理窗口,就可以先观察;否则先留现场再处置。("涨多少才算泄漏"的门槛按你自己的口径定,这里演示的只是怎么算。)
先分流:能回收的,与回不来的
本篇最值钱的一条:内存统计里页缓存与匿名内存是两类——
file(页缓存)通常可在内存压力下被回收:看到"涨"了,但压力上来它会退;anon(匿名内存)涨上去往往回不来:需要交换或被杀才能释放。
cat /sys/fs/cgroup/<路径>/memory.stat # 分流判据:file 对 anon(版本相关,以现读为准)
cat /sys/fs/cgroup/<路径>/memory.current # 组级当前占用(版本相关)
cat /sys/fs/cgroup/<路径>/memory.max # 上限:算"到顶时间"的分母侧减数(版本相关)
systemctl show <单元> -p MemoryMax # 上限的服务级写法,输出 MemoryMax=<值>
free -h # 全局对照:判据是 available 列(把可回收算进去后的可用量)
判据:先看这两类的构成变化,再决定要不要处置——anon 持续增长=偏向真泄漏;只有 file 增长=页缓存,不是泄漏。不分流就下"泄漏"结论,是这类问题最常见的误判。
共享页还要再看一层:cat /proc/<PID>/smaps_rollup 里的 Rss 含共享、Pss 把共享页按份额摊到本进程——两者差得多,说明占用里有相当一部分是共享的(该入口版本相关,以现读为准)。
内核侧也要排除一下:slabtop 看内核对象占用是否也在涨(它由 procps-ng 提供,未装则先装);段级分布用 pmap -x <PID>(判据列是各段的 RSS 与 Dirty,同样由 procps-ng 提供);宿主级视角用 systemd-cgtop(需在运行 systemd 的宿主上;容器内通常没有)。
处置顺序:先留现场,最后才重启
顺序是固定的,因为重启会把现场清掉:
- 先拿只读证据:段级分布(
pmap -x <PID>)、两类构成(memory.stat的file与anon)、上限与当前值——这些一旦重启就都没了; - 再算两式:速率与到顶时间(上面那两步);
- 再看"被杀过没有":这是独立于"在涨"的另一件事;
- 最后才重启或扩容(写操作,标回滚点、先记原值)。
cat /sys/fs/cgroup/<路径>/memory.events # 判据键=oom_kill 是否非零/递增(版本相关)
valgrind --leak-check=full <程序> # 处置顺序的最后一步:在可复现的测试进程上定位泄漏点
判据:
oom_kill递增说明该层级确实杀过进程——它与"是否在涨"是两件事,两者都要看;- 定位工具是最后一步:
valgrind --leak-check=full <程序>需先装 valgrind,而且要在可复现的测试进程上跑——在生产进程上做跟踪类观测会影响被测对象; - 另一个容易混的错误码:分配失败由调用返回
ENOMEM(不是内核杀进程);内核杀进程才体现在oom_kill上——两者不要互相代入。
取舍:扩容、定位,还是先重启止损
- 扩容(改
memory.max或MemoryMax=):争取时间;代价是把问题推迟,并没有解决; - 定位(在测试进程上用
valgrind):找到根因;代价是观测代价与时间; - 先重启止损:业务先恢复;代价是丢现场——除非业务已经不可用,否则不优先。
判据:三条路的代价都要写出来;"先重启"只在业务不可用时才优先。上限的持久化落点是MemoryMax=——重启后仍生效。
现象到动作的判定表
| 现象 | 结论 | 动作 |
|---|---|---|
| 内存一点点涨 | 需要用速率判定 | 取两次读数、算 Δ常驻 / Δ时间 |
| 涨的是文件缓存 | 到压力会退,不是泄漏 | 看 memory.stat 的 file;不必按泄漏处置 |
| 匿名内存回不来 | 偏向真泄漏 | 看 anon 的持续增长,按处置顺序走 |
| 重启缓一阵又涨回去 | 根因没被处理 | 先留现场再重启,并把两式算出来 |
| 到顶时间远大于处理窗口 | 可以继续观察 | 记下速率与到顶时间,设一个复查点 |
改完怎么验证:每项一条判据
- 速率由两次读数算出(不是单次读数);
- 两类构成能解释"为什么涨"(
file还是anon); - 到顶时间可复算;
- 留存了只读证据(重启之后也能说清当时的样子);
- 有回滚形态与原值记录。
这篇不解决什么
- 讲cgroup v2 内存上限与 OOM 取证的那篇管限制层——上限设多少、OOM 之后怎么归因(“什么时候被杀”);本篇管增长类问题的观察与处置顺序(“涨不涨、怎么处置”)。判定入口:是"触顶被杀"还是"缓慢增长未见顶"。
- 讲内存口径打架时信谁的那篇是读数口径之间的取舍;本篇是时间维度上的趋势——一个管"信哪个数"、一个管"这个数在往哪走"。
- 讲进程与线程数上限的那篇在"执行单元数量"层;本篇在"内存占用趋势"层,两篇无重叠。
踩坑清单
- 用单次读数下"泄漏"结论:没有速率。
- 不分流就看总量:分不清页缓存与匿名内存。
- 把文件缓存的涨当成泄漏:它到压力会退。
- 先重启把现场清掉:顺序反了,可诊断的问题变成不可诊断。
- 只算速率不算到顶时间:没法决定"现在动不动"。
- 在生产进程上直接上定位工具:会影响被测对象。
- 把"上限被撞"当成泄漏:那是限制层的问题,判据与入口都不同。
- 把
ENOMEM与oom_kill混为一谈:一个是调用返回的错误码,一个是内核杀进程的计数。
- 点赞
- 收藏
- 关注作者
评论(0)