内存一点点涨上去,然后服务器失联
有一台跑了三年的 2 核 4G 小机器,负责一个日 PV 两万左右的博客。它的表现很有规律:重启后内存占用 1.2G,一周后 2.4G,两周后 3.5G,然后某个夜里 OOM killer 出手,把 mysqld 干掉,站点报 502,第二天早上我才发现。
这类问题的麻烦之处在于:它慢、它不报错、它看起来像是「正常增长」。free -m 显示 available 还有几百兆,你以为是缓存,其实是泄漏。top 里按内存排序,排前面的每个进程看起来都「不算特别大」,没有一个占满 4G。
这篇讲怎么把这种「慢性内存泄漏」揪出来,从确认它到底是泄漏还是缓存开始,到 valgrind 定位到具体代码行。全部是 Linux 上现成的工具,不需要装 APM,也不需要重启业务来复现。
第一步:分清泄漏、缓存和高水位
内存涨了不等于泄漏。Linux 上至少有三种「看起来在涨」的正常行为,先把它们排除掉,否则后面全是白费功夫。
page cache不是泄漏
free -m 的 buff/cache 那一列,是内核把空闲内存拿去做文件缓存。这部分随时可回收,压力大时自动让出。判断标准是看 available 列,它才是「还能给新进程用多少」。如果 available 一直稳定在 1.5G 以上,那 cache 涨到 3G 也不用管。
free -m
# total used free shared buff/cache available
# Mem: 3951 1203 180 12 2567 2480上面这份输出是健康的:used 1.2G,cache 2.5G,available 2.4G。如果换成 used 3.5G、available 300M,才是真问题。
进程的RSS不等于独占内存
多进程服务(PHP-FPM、Nginx worker、MySQL)有大量共享内存:opcache 的共享段、APCu 的 shm、mmap 的库文件。你把这些进程的 RSS 加起来会得到远超物理内存的数字,因为共享页被重复计算了。真正该看的是 PSS(Proportional Set Size),共享页按共享进程数均摊。
# 看某个服务的真实内存占用(PSS 求和)
sudo smem -P 'php-fpm' -c "pid user pss rss name" | tail -n +2
# 没有 smem 时的替代:把 PSS 从 smaps 里加起来
for p in $(pgrep -u www-data php-fpm); do
awk '/^Pss:/ {s+=$2} END {print FILENAME, s" kB"}' /proc/$p/smaps 2>/dev/null
donesmem 如果没装,apt install smem 只有几百 K,值这个钱。我用它第一次看清了:FPM 的 30 个 worker 每个 RSS 90M,但 PSS 只有 32M——其中 58M 是共享的 opcache 和扩展。如果按 RSS 算,我会误以为 FPM 占了 2.7G,然后去砍 pm.max_children,完全搞错方向。
高水位不是泄漏
有些程序的行为是「涨上去就不下来」:它们申请了内存就不还给系统,即使逻辑上已经不用了。glibc 的 malloc arena 就是典型——每个线程一个 arena,释放后不一定归还。这种叫高水位,特点是涨到某个值后就平稳,不再继续爬。
判断标准很清晰:泄漏是一条持续上升的斜线,高水位是一条到顶就平的线。取七天数据画一下就知道,不需要任何工具,一天记一个数就够。
第二步:用四个指标锁定是哪个进程
确认是在涨之后,要定位到进程。四个数按顺序看:
/proc/PID/status里的VmRSS:物理内存占用,涨了就是真的占着。/proc/PID/status里的VmSize:虚拟地址空间,只涨 RSS 不涨,问题较小;两者一起涨才是持续的 malloc。ls /proc/PID/fd | wc -l:句柄数。内存涨的同时句柄也涨,多半是资源没关,顺着句柄能直接找到对象。/proc/PID/smaps_rollup:PSS 汇总,核对你之前的判断。
# 一行看全部嫌疑进程的关键三数
for p in $(pgrep -f 'mysql|php-fpm|nginx|redis'); do
printf "%-8s %-18s %8s %8s %6s\n" "$p" \
"$(cat /proc/$p/comm)" \
"$(awk '/VmRSS/{print $2}' /proc/$p/status)" \
"$(awk '/VmSize/{print $2}' /proc/$p/status)" \
"$(ls /proc/$p/fd 2>/dev/null | wc -l)"
done这个脚本每十分钟跑一次写进日志,跑一天就有趋势了。关键是要有「正常运行」的基线:比如 PHP-FPM worker 稳定在 35~45M,如果某个 worker 三天后变成 400M,几乎不用再查了。
第三步:区分「进程内泄漏」和「进程外泄漏」
定位到具体进程之后,还有一层要分。PHP 的泄漏有两种完全不同的形态:
形态一:单个worker越来越胖
几十个 FPM worker 里,只有一两个体积巨大。这说明是某段代码在特定请求上泄漏,其他 worker 正常。这时候看 pm.max_requests——如果设成 0(永不重启),泄漏 worker 会一直胖下去;设成 500~1000,泄漏被周期性洗掉,问题「消失」但没解决。
找一个胖 worker,用 gdb 或者 PHP 自带的 debug_zval_dump 很难定位,更实用的办法是给 FPM 打开慢日志和状态页,看那个 worker 在处理什么请求:
# 打开 FPM status,观察每个 pool 的进程
curl -s http://127.0.0.1/fpm-status?full | grep -E 'pid|requests|memory'
# 如果 worker 的 requests 数很大时内存也大,说明按请求累积
# 用 pm.max_requests 兜底,再回代码找问题形态二:所有worker一起涨
这种情况通常是共享内存:opcache 的 opcache.memory_consumption 被填满、APCu 的 shm 被写满。这不是泄漏,是「缓存满了」。判断方法是看 opcache_get_status() 的 used_memory 是否接近 memory_consumption,以及 wasted_memory 的占比。超过 5% 就说明碎片严重,需要 reload。
还有一种更隐蔽的:全局静态变量(global/static)在 FPM 常驻进程里只增不减。比如一个「访问足迹」数组,每个请求往里 append 一条,永不清理。这种在 worker 里表现为平滑上升,重启 FPM 立刻归零。判断方法是把 FPM 重启一次,如果内存立刻回到基线,然后就再慢慢涨,那就是进程内状态累积。
第四步:用valgrind把泄漏定位到代码行
如果前几步都确认了是「代码泄漏」,就需要 valgrind。这里有个前提要说清楚:valgrind 不能挂在一个已经跑了三周的线上进程上,它需要从头启动被检测的程序。所以做法是在测试机上用 valgrind 跑一个同样的请求,让它复现。
# 最常用的两个模式
# 1) 找内存泄漏(程序退出时打印未释放的块)
valgrind --leak-check=full \
--show-leak-kinds=definite,indirect \
--error-exitcode=1 \
--log-file=/tmp/vg.log \
./your_program --args
# 2) 挂在脚本函数上(PHP 需要 Zend 的调试符号)
USE_ZEND_ALLOC=0 valgrind --leak-check=full php test.phpUSE_ZEND_ALLOC=0 这一行是关键:PHP 默认用自己的内存池(Zend MM),valgrind 看到的是池子的整块申请,看不到内部碎片分配。关掉之后 PHP 直接用 malloc,valgrind 才能逐块追踪。代价是跑得慢十到二十倍,而且只能在开发环境做。
读报告时只看三段:
definitely lost:真的泄漏,程序再也拿不到这块内存。这是元凶。indirectly lost:被 definitely lost 的块引用着,跟着一起丢,通常和上面同源。still reachable:退出时还挂着,但程序本来就结束了,多为全局变量/单例,基本可以忽略。
报告里每个泄漏块都带调用栈。找最上面那一帧是你自己的代码的位置——栈里往往有 libc、libmysqlclient、libxml 之类的系统库,但真正的分配点通常在你的函数里。
对于 C/C++ 写的自研程序(比如我写过的日志采集器),valgrind 是唯一可靠的手段。对于 PHP,还有更轻的替代:
// 在可疑函数前后打点,看内存差值
$before = memory_get_usage(true);
$result = suspicious_function($data);
$after = memory_get_usage(true);
error_log(sprintf('suspicious_function delta=%d bytes', $after - $before));跑一千次请求,如果 delta 每次都为正且不回落,泄漏就锁定了。这个方法比 valgrind 粗,但不需要特殊环境,线上也能短暂用。
MySQL和Nginx的内存增长要单独看
服务端软件的内存模型和 PHP 不一样,不能套同一套判据。
MySQL:buffer pool是预留,不是泄漏
innodb_buffer_pool_size 配了 1G,MySQL 启动后 RSS 会逐渐涨到 1G 左右然后停住——这是正常的高水位。SHOW ENGINE INNODB STATUS 里有 buffer pool 的使用率,information_schema.innodb_buffer_pool_stats 也能查到。如果 RSS 明显超过 buffer pool + 连接开销的预期(比如配 1G 却占了 3G),要看这几个:
SHOW VARIABLES LIKE 'performance_schema';
SHOW VARIABLES LIKE 'tmp_table_size';
SHOW VARIABLES LIKE 'sort_buffer_size';
SHOW VARIABLES LIKE 'join_buffer_size';
-- 当前所有连接的缓冲分配(每连接都会分配,乘以连接数)
SELECT * FROM performance_schema.memory_summary_global_by_event_name
ORDER BY SUM_NUMBER_OF_BYTES_ALLOC DESC LIMIT 10;最常见的坑:sort_buffer_size 设成 8M,连接数 200,理论上限就是 1.6G。这类 per-connection 缓冲是按需分配的,但高峰时就是会真的吃掉这么多。把它们调小,比怀疑 MySQL 泄漏有效得多。
Nginx:worker数量乘以前端峰值
Nginx 每个 worker 的常驻内存很小(几 M),但处理大文件上传/下载、gzip 压缩、proxy_cache 时会临时分配。如果开了 gzip on 且 gzip_buffers 16 8k,每个并发压缩请求都要占一块。真正的排查步骤是看 worker 的 RSS 与连接数的关系:连接数下来之后 RSS 不回落,才可能是泄漏。
Nginx 从代码质量上说极少泄漏,更多是「配置导致的高水位」:client_body_buffer_size 太大、proxy_buffers 条数太多、上游返回大响应时缓冲区堆积。改配置比查代码划算。
第五步:兜底——让泄漏不至于变成事故
即使短期定位不到,也要有兜底,否则一次 OOM 就是半夜爬起来重启服务器。三条措施,从轻到重:
- 限制单进程内存:systemd unit 里加
MemoryMax=512M,超了只杀这一个进程,不影响整机。加上Restart=on-failure+RestartSec=10,服务自己起来。这比整机 OOM 好太多。 - 改内核的策略:
vm.overcommit_memory保持默认 0 即可,重点是配systemd-oomd或者给 mysqld 设OOMScoreAdjust=-500,让 OOM killer 优先杀可牺牲的进程(比如日志采集器),而不是数据库。 - 设一个「重启阈值」:如果某个 worker 的 RSS 超过基线的三倍且持续十分钟,就
systemctl reload。这是运维层面的妥协,但能让站点活到你有时间查代码。
# systemd drop-in
[Service]
MemoryMax=512M
MemoryHigh=400M
Restart=on-failure
RestartSec=10
OOMScoreAdjust=200MemoryHigh 是软限,超过后内核会施加压力让其主动回收;MemoryMax 是硬限,超过直接 OOM 掉这个 unit。两个一起用,效果最好。
一次完整的排查时间线复盘
回到开头那台机器。最后的真实原因是:一个野生的采集脚本用了 PHP 的 curl 扩展,处理图片时把每张图的原始二进制内容存进了一个全局数组做「去重判断」,这个数组在 FPM 常驻进程里跨请求存活。一天采集 800 张图,每张平均 200KB,一周就是 1.1G。
定位过程其实很短,全靠前面几步的顺藤摸瓜:
free -m确认 available 掉到 200M,是真实压力而不是缓存;smem发现 PHP-FPM 的 PSS 占了 2.8G,锁定 FPM;pgrep逐个看 worker,发现有六个 worker 的 RSS 在 300M 以上,其余 40M;- 看那几个 worker 的
pm.max_requests是 0(不重启),改了以后问题被掩盖; - 用
memory_get_usage打点定位到采集脚本里的那个全局数组,改成只存 md5,问题消失。
四条教训:第一,先分清缓存和泄漏,不然你会去改 sysctl;第二,结论一定要有基线对比,没有基线就没有「涨」这个判定;第三,在 FPM 里,任何全局/静态变量都要问一句「它什么时候清空」;第四,pm.max_requests 设 0 是很多慢性泄漏的温床,设一个有限值(500~1000)成本几乎为零,收益是——泄漏变成「性能抖动」而不是「整机宕机」。
最后提醒一句:不要一上手就 valgrind。它的成本高(慢二十倍、需要符号、需要复现),应该放在最后一步。前面四步加起来不到半小时,能解决八成的问题。