线上OOM,内存像坐火箭
某周五晚11点,告警群炸了:xxx服务10台机器陆续OOM,php-fpm进程全部被kill,用户请求502。重启后撑不过2小时又挂。我ssh上去top -p 12345一看,RES从80MB开始,每分钟涨30MB,1小时后冲到1.2GB,然后被系统oom_killer干掉。
这不是普通的性能问题,是内存泄漏。而且不是瞬间爆发的,是慢慢涨,典型的内存只分配不释放。
第一步:用strace看系统调用,确认不是内核漏
先排除系统层面的问题。用strace跟踪php-fpm进程的内存分配相关系统调用:
# 跟踪brk和mmap,这两个是PHP内存分配的主要系统调用
strace -p 12345 -e trace=brk,mmap -o /tmp/strace.log 2>&1 &
sleep 30
kill %1
cat /tmp/strace.log | awk '{print $1}' | sort | uniq -c | sort -rn
输出显示brk调用的次数在30秒内从1000次增加到5000次,每次返回的地址越来越小(堆向低地址增长?不,brk是推高堆顶)。这说明PHP在不断地向系统申请内存,但从未释放(brk不回收)。文件里没看到munmap调用,确定是PHP内部没有把内存归还给操作系统。
第二步:valgrind上场,抓泄漏点
valgrind是C/C++的内存调试神器,也能用于检测PHP的PHP扩展层泄漏。但直接跑php-fpm太重,我用一个简单脚本模拟请求:
# 下载编译valgrind 3.22.0,确保带PHP符号
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all \
--trace-children=yes \
php -r '
// 模拟请求处理
include "/path/to/your/api.php";
// 强制GC
gc_collect_cycles();
' 2>&1 | tee /tmp/valgrind.log
注意:valgrind跑PHP脚本时性能下降几十倍,只能在小脚本上跑。然后查看报告:
grep "definitely lost" /tmp/valgrind.log
# 输出:==12345== definitely lost: 1,024 bytes in 1 blocks
grep -A2 "definitely lost" /tmp/valgrind.log
只泄漏了1KB?不对,实际服务泄漏了数百MB。valgrind只检测到C层面(比如PHP扩展或gc直接泄漏)的泄漏,而PHP自身的内存管理使用zend_mm_heap预分配大块内存,即使zval没有释放,它们也会被PHP的gc回收,valgrind不会报告为definitely lost,而是still reachable。所以对于纯PHP泄漏,valgrind基本没用。
第三步:上xhprof火焰图,锁定热点
换工具。xhprof可以采样函数调用和内存分配情况(需要安装xhprof扩展并开启memory统计)。测试环境搭建:
# PHP 8.2,安装xhprof扩展
pecl install xhprof-2.3.10
# 配置php.ini
echo "extension=xhprof.so" >> /etc/php/8.2/cli/php.ini
echo "xhprof.output_dir=/tmp/xhprof" >> /etc/php/8.2/cli/php.ini
在业务入口加入采样:
// 请求开始前
xhprof_enable(XHPROF_FLAGS_CPU + XHPROF_FLAGS_MEMORY);
register_shutdown_function(function() {
$data = xhprof_disable();
// 存到文件,后续用xhprof UI查看火焰图
$run_id = uniqid();
file_put_contents("/tmp/xhprof/{$run_id}.xhprof", serialize($data));
});
然后用ab压测:
ab -n 1000 -c 10 http://testserver/api/leak
压完后用xhprof自带的UI(需要web服务)查看火焰图,或者直接用命令行解析:
# 安装xhprof-utils
git clone https://github.com/phacility/xhprof.git
cd xhprof/extension/
php run.php /tmp/xhprof /tmp/xhprof_result.html
# 打开html看曲线图
火焰图显示processOrder函数中创建了一个array,且内存使用量在每次调用后都在增长,如图:
我们发现了疑似问题:
// 泄漏代码 (简化)
function processOrder($orderData) {
$order = new Order($orderData);
// 将订单对象缓存到静态数组中
static $cache = [];
$cache[spl_object_id($order)] = $order;
// 处理完不清理
// return $order;
}
这里static $cache将每个请求的$order对象永久缓存下来,即使请求结束也不释放。由于是静态变量,在PHP进程的生命周期内一直存在,导致内存只增不减。
修复代码
function processOrder($orderData) {
$order = new Order($orderData);
// 如果需要缓存,使用生命周期结束自动销毁的方式
// 例如放到本地缓存但设置过期时间,或者干脆不用静态
// 这里直接用局部变量处理
$result = $order->handle();
return $result;
// $order 会在函数结束时被自动销毁
}
更彻底的检查:在测试环境用memory_get_usage(true)在每次请求前后打印内存变化:
register_shutdown_function(function() {
$peak = memory_get_peak_usage(true);
$current = memory_get_usage(true);
error_log("Memory: peak={$peak}, current={$current}");
});
效果数据
| 指标 | 修复前 | 修复后 |
|---|---|---|
| php-fpm单个进程稳态内存 | 80MB起步,2小时升至1.2GB | 75~85MB,稳定 |
| OOM频率 | 每2小时一次 | 0次(连续运行7天) |
| 平均响应时间(ab -c 100 -n 10000) | 985ms(高峰期GC引起卡顿) | 312ms |
| 每秒请求数 | 101 | 320 |
压测环境:PHP 8.2.10,php-fpm pm=dynamic max_children=50,服务器8核16GB。
避坑指南
- valgrind对PHP泄漏无效:PHP使用自己的内存分配器(zend_mm_heap),valgrind默认只检测C层面的直接泄漏(如扩展漏了malloc)。PHP的gc能回收大部分zval,只有循环引用或静态变量才需要检查。
- 用xhprof时一定要开启XHPROF_FLAGS_MEMORY,否则只记录CPU。另外xhprof的采样频率不要设太高,否则请求本身内存暴涨,遮掩问题。 - 静态变量和全局变量是头号杀手:
static $cache在PHP-FPM常驻进程下永不释放。如果一定要缓存,使用swoole或redis代替。 - 小心循环引用:比如对象A引用对象B,对象B引用对象A,即使没有静态变量,PHP的gc默认是开启的(php.ini
zend.enable_gc=On),但gc只在zval计数器减到0或循环一定阈值后才触发。如果频繁创建短生命周期对象,gc可能来不及回收,导致内存瞬时暴涨。建议主动调用gc_collect_cycles()或调低gc_root_buffer_size。 - 模拟请求要注意冷启动:测试泄漏时,用ab先预热几百次,再观察增长率。否则第一次请求加载框架也产生内存,容易误判。
- STDOUT/错误日志也可能泄漏:如果代码中把大数组
echo出来又没有清buffer,php-fpm的output buffer可能累积。用ob_clean()或关闭无关输出。
总结
从观察到定位,三步走:
- 用
top、strace确认是用户态泄漏还是系统态泄漏。 - 用
xhprof+ 火焰图找热点函数。 - 结合代码review静态变量、循环引用。
记住:PHP的内存泄漏99%是静态变量和循环引用,valgrind是误入歧途。直接上xhprof最管用。