内存泄漏和内存不足,是两种完全不同的病
很多人一看到服务器内存吃紧,第一反应是加内存、或者调 swappiness、或者上 OOM Killer 的 oom_score_adj。但如果你的进程是"内存泄漏",这些操作全是治标——加多少内存,它就能吃多少,跑上十天半月一样把机器拖垮。
先把两个概念分清楚。内存不足(OOM)是瞬时峰值超过物理内存+swap,进程被内核直接杀掉,dmesg 里能看到 Out of memory: Killed process。内存泄漏则是进程的常驻内存随时间单调爬升,永不回落,最终要么被 OOM Killer 收走,要么拖慢整机。前者是"量"的问题,后者是"趋势"的问题。
判断方法很简单:连续采样 RSS,画出曲线。如果曲线是锯齿状(涨了又落,落在相近的基线),那是正常的缓存行为;如果是"阶梯式只涨不跌",泄漏八九不离十。真正的排障难点不在于判断,而在于精确定位到底是哪个进程、哪段代码在漏。这篇就把这条链路走完。
第一步:确认到底谁在涨,别靠 free 一眼定论
free -h 看到的是整机视角,buff/cache 占大头是正常的,Linux 会主动用空闲内存做文件缓存,需要时自动回收。真正要盯的是每个进程的 RSS。用 smem 能看到更真实的 PSS(按比例分摊共享内存),但先别装工具,可以用最基础的 top 采样:
# 每 30 秒记录一次,只挑内存最高的 15 个进程
while true; do
echo "===== $(date '+%F %T') ====="
ps -eo pid,ppid,rss,vsz,comm --sort=-rss | head -16
sleep 30
done | tee /root/memwatch.log跑上一两个小时,然后把日志里的某个 pid 的 RSS 列拿出来看趋势。更省事的做法是用 pmap 看单个进程的内存映射明细,重点看哪一段 [anon] 在不断变大——[anon] 是堆和线程栈,泄漏通常发生在这里;如果是 [heap] 段持续增长,那基本是 CHA 语言(C/C++)的 malloc 没配对 free:
pmap -x 12345 | tail -1 # 看 total RSS 概览
pmap -x 12345 | sort -k4 -n | tail -20 # 看最大的映射段如果是 Java/PHP/Go 这类带运行时或 GC 的进程,RSS 涨不一定是泄漏——GC 只是"借"了内存还没还,属于正常。这种情况要看语言层面的指标,而不是直接判死刑。
第二步:分类型定位——三类泄漏,三套工具
类型一:C/C++ 原生泄漏,用 Valgrind 或 AddressSanitizer。这是最经典也最难的一类。开发阶段用 ASan 编译跑,线上不方便的话可以用 Valgrind 附加到已有进程,但它会显著拖慢程序,只能在低峰期短时间跑:
# 在开发/测试环境编译时打开 ASan
gcc -fsanitize=address -g -o app app.c
./app
# 退出时自动打印泄漏报告,含分配点的调用栈
# 线上短时探测(会拖慢进程,慎用)
valgrind --leak-check=full --show-leak-kinds=definite \
--log-file=/tmp/vg.log --trace-children=no ./app更温和的线上方案是用 LD_PRELOAD 挂一个只做统计的分配器钩子,或者直接上 heaptrack,它对进程入侵性小、能画出分配调用栈的火焰图。
类型二:脚本/运行时层的内存爬升,用语言自带工具。PHP 可以用 memory_get_usage(true) 在长驻脚本(比如 workerman/swoole 的常驻进程)里定期打点;如果是 foreach 引用了大数组忘了 unset,那是 PHP 最常见的"伪泄漏"。Python 用 tracemalloc 抽样,配合 objgraph 看类型增长:
import tracemalloc, time
tracemalloc.start(10) # 保留 10 层调用栈
snap1 = tracemalloc.take_snapshot()
time.sleep(300) # 让业务跑一会儿
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, 'lineno')[:10]:
print(stat)Node.js 就更直接,启动时加 --inspect,或者用 process.memoryUsage() 定时打点,浏览器 devtools 里抓 heap snapshot 对比两个时间点的对象增长。
类型三:内核态泄漏(内核对象/连接/句柄没释放)。这类最隐蔽,因为进程 RSS 看着正常,整机内存却在涨。用 slabtop 看内核 slab 缓存的增长,用 ss -s 看 socket 总数:
slabtop -s c -o | head -20 # 按对象数排序内核缓存
ss -s # socket 统计,看 TIME-WAIT / orphan 是否爆表
cat /proc/slabinfo | awk 'NR>2 {print $1, $3, $4} ' | sort -k3 -n | tail我曾经遇到过一个案例:一台机器 RSS 都很正常,但 slabtop 里 dentry 和 inode 缓存各涨到几个 G。根因是某个脚本每分钟创建又删除成千上万个临时文件,虽然文件删了,但 dentry 缓存要等内存压力才回收。解法是给那个目录加挂载参数或者改用 tmpfs,而不是去 kill 进程。
第三步:文件描述符与句柄泄漏,和内存泄漏是孪生兄弟
lsof 报 Too many open files、或者 ls /proc/PID/fd | wc -l 只增不减,往往和内存泄漏同源——句柄没关,附带的内存也就没释放。每条 TCP 连接、每个打开的文件都占一个 fd。查某个进程当前打开了多少:
ls /proc/12345/fd | wc -l
ls -l /proc/12345/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head
# 看是不是大量同类 fd(比如全是 socket 或全是某个日志文件)再核对一下该进程的软硬限制,确认是不是撞到了 ulimit -n 上限:
cat /proc/12345/limits | grep -i 'open files'
ulimit -n # 当前 shell 软限制
systemctl show myapp | grep -i LimitNOFILE如果是 systemd 托管的服务,/etc/security/limits.conf 对它不生效——systemd 服务必须在 unit 文件里写 LimitNOFILE=65535,这是极高频的踩坑点。改完 systemctl daemon-reload 再重启服务。
第四步:用监控把"事后排障"变成"事前预警"
靠人肉 top 采样终究是滞后的。真正专业的做法是采集历史指标并设阈值告警。不装 Prometheus 全套的话,用 ps+阈值+轻量脚本也能兜住底线:
#!/bin/bash
# /root/scripts/memleak-alert.sh —— 每 5 分钟跑一次
THRESHOLD_MB=800 # 单进程 RSS 超过这个值就告警
ALERT_LOG=/var/log/memleak-alert.log
ps -eo pid,rss,comm --sort=-rss --no-headers | while read pid rss comm; do
mb=$((rss/1024))
if [ "$mb" -gt "$THRESHOLD_MB" ]; then
echo "$(date '+%F %T') HIGH RSS pid=$pid comm=$comm rss=${mb}MB" >> "$ALERT_LOG"
# 可以在这里接邮件/webhook 通知
fi
done更聪明一点:把每次采样的 RSS 存进一个环形文件,用斜率判断——如果 5 个小时内某进程 RSS 增长了超过 200MB 且没有回落,就认为触发了泄漏趋势,比单纯看绝对阈值更早发现。监控的意义在于把"机器半夜挂掉"变成"下午就收到一条告警,还有时间处理"。
第五步:用 /proc 和 cgroup 把容器里的泄漏看清
容器化之后,内存排查会比裸机多一层迷雾。你在容器里执行 free -h,看到的往往是宿主机的内存,不代表容器能用的额度;而 OOM Killer 杀容器时,日志可能不在容器里而在宿主机内核日志。要看清容器的真实内存,必须读 cgroup 文件。cgroup v2 下:
cat /sys/fs/cgroup/memory.current # 当前用量(字节)
cat /sys/fs/cgroup/memory.max # 内存上限,看到 max 表示不限
cat /sys/fs/cgroup/memory.stat # 明细:anon/file/slab 各占多少其中 anon 增长是进程堆内存泄漏的信号,file 增长通常只是页缓存、可以回收,slab 增长则要怀疑内核对象。如果 cgroup v1,路径换成 /sys/fs/cgroup/memory/memory.usage_in_bytes 等。很多人误判容器泄漏,就是因为把 file 页缓存当成了泄漏——它其实随时可回收,不是病。
还可以对比容器内外看同一个进程的 RSS:容器内看到的是宿主视角的绝对 RSS,但如果容器有 memory limit,cgroup 会先于内核 OOM Killer 触发容器级 kill。查宿主机的 dmesg | grep -i 'oom\|killed' 才能看到"是谁杀了谁、触发时用量是多少"。这条链路走通,容器里的泄漏就藏不住了。
第六步:一次真实复盘——PHP 常驻脚本的伪泄漏
讲个我踩过的坑。一台跑常驻队列消费脚本的机器,内存每小时爬 50MB,三天后被 OOM Killer 干掉。按流程抓快照、比分配点,最后发现根因不是"没释放",而是在一个大循环里做 $logger->addInfo($data),把每次消费的报文都塞进了一个全局的数组里等着"最后统一落盘",结果这个数组永不落盘。这属于典型的逻辑泄漏:内存分配和释放的代码都对,是业务逻辑把它一直引用着。
这类问题的教训是:泄漏排查不能只看"有没有 free",还要看"有没有东西一直持有引用"。抓 heap snapshot 时,重点看增长最快的对象是不是被某个长生命周期的全局变量、单例、缓存数组引用着——objgraph.show_backrefs() 或 Java 的 dominator tree 就是干这个的。修复方式通常是给缓存加 TTL 或容量上限(LRU),而不是去改分配代码。
一个可复用的排查决策树
把上面所有内容浓缩成一张顺序表,下次遇到内存问题照着走:
第一问:整机内存涨还是单进程涨?整机涨看 slabtop 和 ss -s(内核态);单进程涨看 ps/pmap(用户态)。
第二问:趋势是锯齿还是单边?锯齿正常,单边不回落就是泄漏。连续采样至少几小时再下结论。
第三问:是 C/原生还是带 GC 的运行时?原生用 Valgrind/ASan/heaptrack;带 GC 的用语言自带 profiler,别被 GC 未归还的内存吓到。
第四问:是分配没释放,还是被逻辑引用着?前者看分配调用栈,后者看对象引用链,两者修法完全不同。
第五问:是容器还是裸机?容器一定要读 cgroup,别信容器里的 free。
走完这五问,绝大多数内存问题都能定位。剩下的,就是耐心地画曲线、读日志、看调用栈——排障没有捷径,但方法对了,路就不绕。
排查纪律:先证据,后改代码
最后给几条肺腑之言。第一,不要在生产上直接改代码试图修泄漏,先复现、先定位、先留存证据(快照、调用栈、分配点),否则你一重启进程,现场全没了。第二,区分"泄漏"和"高占用",一个吃 2G 内存但稳定的进程未必有病,一个吃 200M 但每小时涨 10M 的进程才危险。第三,容器里排查记得看 cgroup 限制,cat /sys/fs/cgroup/memory.max 才是容器的真实内存天花板,free 看的是宿主机,两个视角经常打架。
内存排查是个慢功夫,但只要抓住"采样→画趋势→分类→定位→验证"这条主线,就没有查不清的泄漏。真正难的是耐心,不是工具。