从一台内存吃紧的机器说起
上个月有台跑了三年的 2G 内存小机器,监控天天告警内存使用率 92%,但 free -m 一看 available 还有 700M,top 里也找不出哪个进程特别大。后来在 /proc/meminfo 里看到 SReclaimable 常年 300M 不降,加上一堆 slab 对象,才确定不是「内存泄漏」,而是内核缓存和内核对象把内存占满了。这件事说明一个道理:说「内存泄漏」之前,得先搞清楚你看到的那块内存到底是谁的。
这篇文章按排查顺序讲清楚四件事:怎么判断到底是不是泄漏、PHP 和 Python 这类脚本语言的泄漏长什么样、怎么在不重启服务的前提下把增长过程录下来、以及漏在哪儿之后怎么安全地重启止血。全部是能直接粘上去跑的命令,不讨论理论模型。
第一步:先分清「缓存」和「泄漏」
Linux 的内存几乎不会有真正的「空闲浪费」。你以为的占用高,通常属于下面几种情况之一,它们都不是泄漏:
buff/cache高:页缓存,可回收。判断标准是free -m里的 available,不是 used。SReclaimable/ slab 高:dentry 和 inode 缓存跟着文件数量走。一台每天处理几百万请求的机器,slab 里几万个 dentry 是正常的。- PHP-FPM / Gunicorn 的 worker 常驻内存:单个 worker 50~80M 很正常,10 个就是 800M。这是设计如此,不是漏。
- MySQL 的 innodb_buffer_pool:配置多少就吃多少,跟「泄漏」毫无关系。
真正的泄漏有一个非常硬的特征:某个进程的 RSS 只增不减,且重启后回到低位,然后重新单调上升。所以要做的第一件事是留下基线,而不是急着抓快照。先把当前关键时刻的每个进程 RSS 存下来:
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20
date >> /var/log/memwatch.log
ps -eo pid,rss,etime,comm --sort=-rss | head -10 >> /var/log/memwatch.log连续观察两三天,同一类进程的 RSS 是不是在稳定往上走。同时看两个内核指标,它们能直接判掉大部分误报:
grep -E 'MemTotal|MemAvailable|SReclaimable|SUnreclaim|Slab' /proc/meminfo
cat /proc/slabinfo | sort -k3 -nr | head -10SUnreclaim 如果持续上涨到几百 M,那多半是内核对象泄漏(常见于某些文件系统模块或驱动),这种问题不重启内核解决不了,但至少要先把模块版本记下来。如果 SUnreclaim 平稳而某个用户态进程 RSS 单调上涨,那就是这个进程的问题。
第二步:把增长过程录下来,而不是抓一张快照
很多人排查泄漏的方式是「出问题的时候 cat 一下 smaps」,只得到一个瞬间,看不出趋势。正确的做法是定时抓取并比较。最简单的是让 cron 每五分钟把 RSS 记一行:
#!/bin/bash
# /usr/local/bin/rss_track.sh
OUT=/var/log/rss_track.csv
[ -f "$OUT" ] || echo "ts,pid,rss_kb,etime" > "$OUT"
for p in $(pgrep -f 'php-fpm: pool' ); do
printf '%s,%s,%s,%s\n' "$(date +%F_%T)" "$p" \
"$(awk '/VmRSS/{print $2}' /proc/$p/status 2>/dev/null)" \
"$(ps -o etime= -p $p | tr -d ' ')" >> "$OUT"
done
awk -F, 'NR>1{s[$2]+=$3;c[$2]++} END{for(k in s) printf "%s avg=%d KB\n",k,s[k]/c[k]}' "$OUT"只记一个 PID 是不够的:PHP-FPM 的 worker 会被 pm.max_requests 回收,pid 会变。所以跟踪脚本要按池名聚合,或者干脆记账「所有同名前缀进程的 RSS 之和」。这一列和才是真正决定机器会不会 OOM 的数字。
如果要看得更细,看一个进程内部哪一段涨了,用 smaps 两次采样做减法,比看总量有效得多:
grep -E '^[0-9a-f]+-|Rss:|Anonymous:' /proc/12345/smaps_rollup
cat /proc/12345/status | grep -E 'VmRSS|VmData|VmStk|Threads'
ls /proc/12345/task | wc -l这里有三个数要分开看:VmData 涨而 VmRSS 稳,可能是申请了没碰的内存(malloc 未触碰页不一定计入 RSS);Threads 涨说明是线程堆栈或线程池泄漏;两者都不涨但 RSS 涨,往往是 mmap 文件映射或者内存碎片。这三条对应的修法完全不同,混为一谈就会走弯路。
第三步:PHP-FPM 场景的具体做法
脚本语言(PHP/Python/Ruby)的泄漏形态跟 C 程序不一样。PHP 每个请求结束后会把大部分内存还给 allocator,但不会还给操作系统,所以 worker 的 RSS 只会在高水位上停住,不会掉下来。你以为的「泄漏」很多时候只是一次大请求把水位抬高之后没下去。
确定它是不是真漏,用最大请求数做实验最干脆:把 pm.max_requests 从 0 改成 500,观察一两天 RSS 曲线。如果改成 500 之后曲线变成锯齿状、整体不再单调上升,那就没有泄漏,只需要保留这个配置;如果锯齿的波谷也在逐渐抬高,就是真漏,继续往下查。
; /etc/php/8.1/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.max_requests = 500
; 关键:请求结束后必须释放 worker 里拉的连接
; 否则连接句柄会随 worker 一起累积PHP 侧的常见泄漏源就那么几类,按排查顺序:
- 长生命周期的容器里塞了大数组:比如把整张表
fetchAll()后放进一个全局单例,下一次请求继续往上加。换fetch()逐行处理,或定期重建容器。 - 静态变量缓存没有上限:自己写的
static $cache = []秒杀级缓存,请求多了必然涨。换 APCu 并设apc.ttl。 - 循环里拼字符串:
$html .= $row循环几十万次会产生大量中间副本。用数组implode。 - 资源句柄没关:fopen/curl_init/PDO 连接在循环里创建却不释放,句柄泄漏在
lsof -p PID | wc -l上会非常明显。
用 lsof -p PID | wc -l 和 ls /proc/PID/fd | wc -l 做时间序列,如果 fd 数与 RSS 同步上涨,几乎可以锁定是句柄或连接泄漏,跟内存分配本身无关。
第四步:Python 服务的泄漏怎么定位
Python 服务的泄漏大多有两类:一是自己写的模块级全局字典缓存没有淘汰策略,二是 C 扩展(pandas、numpy、lxml、数据库驱动)在 Python 侧释放了但底层没真正还给系统。前者用 tracemalloc 能直接看到,后者只能看 RSS。
import tracemalloc, linecache
tracemalloc.start(25)
# ... 跑一段时间业务 ...
snap1 = tracemalloc.take_snapshot()
# 过 10 分钟再取一次
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, 'traceback')[:10]:
print(stat)
for line in stat.traceback.format():
print(' ', line)tracemalloc 只能看到 Python 堆,看不到 C 扩展里 malloc 的部分。所以要同时对比 RSS:如果 tracemalloc 的 Python 堆总量平稳,而 RSS 持续涨,结论就是 C 扩展或碎片,此时看下一步的 allocator 调参。
glibc malloc 在多线程、多 arena 场景下会产生严重的 RSS 虚高。设一个环境变量往往就能把增长曲线压平:
# 在 systemd unit 里加
Environment=MALLOC_ARENA_MAX=2
Environment=MALLOC_TRIM_THRESHOLD_=131072这不是修复泄漏,但能把「进程只涨不还」的现象消除掉一大半。真的长期跑的服务,用 jemalloc 或者 malloc_trim(0) 定时调用一次也很常见。注意 malloc_trim 要自己写个小扩展或者用 ctypes 调,别指望它解决逻辑上的泄漏,它只是把已经空闲的 arena 还给系统。
第五步:OOM 现场怎么留下证据
大多数泄漏最后是被 OOM Killer 结束的。默认 OOM 之后除了 dmesg 里一行字,什么都没留下,下次还是查不到。所以在出问题之前就要把现场收集脚本挂到 systemd-oomd 或内核事件上:
# /etc/systemd/system/memdiag.service
[Unit]
Description=dump memory info when oom
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'date >>/var/log/oomdiag.log; free -m >>/var/log/oomdiag.log; ps -eo pid,rss,comm --sort=-rss | head -20 >>/var/log/oomdiag.log; cat /proc/pressure/memory >>/var/log/oomdiag.log'另外几个日常就该开着的:systemd-coredump 保留崩溃进程的核心转储(coredumpctl list 查看),kernel.core_pattern 确认不是被写到了 /dev/null。/proc/pressure/memory 里的 full 和 some 两个数能告诉你机器是「真的不够」还是「瞬时峰值被误杀」,后者调 vm.min_free_kbytes 或给服务加 MemoryMax 就能避免波及整机。
# 限制单个服务的内存上限,让它自己挂掉而不是拖死整机
systemctl set-property php8.1-fpm.service MemoryMax=1G MemoryHigh=800M
systemctl show php8.1-fpm.service -p MemoryCurrent用 cgroup 限额代替「盯 top」是这个阶段性价比最高的改动:泄漏还在,但影响范围被控制住,你有时间慢慢查。
第六步:止血与重启的注意事项
查不清根因但业务不能停时,定期重启 worker 池是最实际的做法,但粗暴重启有三个坑:reload 不释放已经泄漏的内存(旧 worker 等请求处理完才退出,堆积的请求可能让它永远退不掉);一次全重启会掉连接;重启后基线没记,下次又得从零开始。
# 先摘流再重启,避免用户看到 502
# 1. Nginx 侧对 upstream 做 down 标记或直接 reload 去掉该节点
# 2. 等 30 秒让存量请求跑完
# 3. 平滑重启
systemctl reload php8.1-fpm
# 4. 记录重启后的基线 RSS,写进监控对比
Nginx 自己的 worker 也可能涨,但 Nginx 的 worker_shutdown_timeout 与 reuseport 组合能实现真正无感的热重启。PHP-FPM 的 pm.max_requests 本质就是「计划内重启」,把这件事配置化,比人工定时 crontab 重启干净得多。
最后提醒一点:如果泄漏发生在数据库连接池或消息队列消费者里,重启会引发重连风暴,务必加 random 睡眠错峰,并确认消费者是幂等的。否则止血压力的结果可能是数据重复。
把这套流程固化成月度检查
泄漏排查的成本高,往往是因为平时没有基线。花二十分钟做三件事,可以把「出事后从零开始查」变成「出事后看曲线就知道哪一段涨了」:
- 把
rss_track.sh挂到 cron,每天一个 csv,保留三十天; - 监控里加两条告警:单个 cgroup 的
MemoryCurrent环比上涨超过 30% 且持续两小时;/proc/pressure/memory的 full 大于零超过一分钟; - 每次重启或发布后,把当次的基线 RSS 写进变更记录,方便下次比对。
这三件事做完,绝大部分「内存泄漏」在被发现时就已经知道自己长什么样了。真正的技术难点不在工具,而在于分辨「缓存占用」和「泄漏」的那一步——判错方向,后面所有的 profiler 都会把你带到沟里。