内存泄漏排查实战:从观察到定位
发布日期: 2026/07/31 阅读总量: 1

线上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.2GB75~85MB,稳定
OOM频率每2小时一次0次(连续运行7天)
平均响应时间(ab -c 100 -n 10000)985ms(高峰期GC引起卡顿)312ms
每秒请求数101320

压测环境: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常驻进程下永不释放。如果一定要缓存,使用swooleredis代替。
  • 小心循环引用:比如对象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()或关闭无关输出。

总结

从观察到定位,三步走:

  1. topstrace确认是用户态泄漏还是系统态泄漏。
  2. xhprof + 火焰图找热点函数。
  3. 结合代码review静态变量、循环引用。

记住:PHP的内存泄漏99%是静态变量和循环引用,valgrind是误入歧途。直接上xhprof最管用。