一、凌晨两点,线上服务突然挂了那天晚上我正睡得迷迷糊糊,手机连续震了好几下,群里炸了锅:线上接口大面积超时,监控面板上一片飘红。我爬起来一看,PHP-FPM 的进程数飙到了上限,每个 worker 的内存占用都异常偏高,日志里刷满了Allowed memory size of 134217728 bytes exhausted。得,内存泄漏,跑不掉了。说实话,这种问题最磨人。不是一下子崩,而是慢慢把内存吃光,等到某个时刻集中爆发。今天就把我这次排查的完整过程写下来,希望能帮遇到类似问题的朋友少走点弯路。二、先复现,再谈定位排查内存泄漏,第一步永远是复现。我先把出问题的接口单独拉出来压测,用ab连续打了几百个请求,然后盯着 PHP-FPM 的进程内存看。果然,单个 worker 的内存从 30MB 一路涨到 100MB 多,而且请求结束后并没有降回去。这就基本坐实了:某个请求周期里,有对象一直没被释放。为了更直观地观察,我写了个简单的监控脚本,把每个请求前后的内存差打出来:<?php // 在入口文件开头记录基线 $baseline = memory_get_usage(); // ... 业务逻辑 ... // 在请求结束时对比 error_log('内存增量: ' . (memory_get_usage() - $baseline) . ' bytes'); ?>跑了几轮之后,日志里能明显看到某些接口的内存增量异常大,而且每次请求都在涨。这就把排查范围缩小到了那几个接口上。三、顺着调用链找元凶锁定接口之后,我开始逐段检查代码。翻到一段处理批量导入的逻辑时,发现了一个很典型的坑:循环里不断往数组里塞数据,但数组本身没有清空,导致内存越积越多。类似这样:<?php // 危险写法:循环里无限累积 $allRows = []; foreach ($fileData as $row) { $processed = processRow($row); // 处理完的数据 $allRows[] = $processed; // 全部攒在内存里 } // 直到循环结束才一次性处理,内存早就爆了 ?>这种写法在数据量小的时候看不出问题,一旦文件里有几万行,内存就直线上升。修复思路很简单:边处理边释放,不要把所有中间结果都攒在内存里。改成流式处理,每处理完一行就写出去,然后及时清掉引用:<?php // 安全写法:边读边处理,及时释放 $handle = fopen($filePath, 'r'); while (($row = fgetcsv($handle)) !== false) { $processed = processRow($row); writeToDb($processed); // 立即落库 unset($processed, $row); // 主动释放引用 } fclose($handle); ?>改完之后再压测,内存增量明显平稳了,不再一路飙升。四、还有两个容易忽略的坑除了循环累积,还有两个地方也容易埋雷。一个是静态变量和单例。如果某个类用静态属性缓存了数据,又没做清理,那这些数据会一直活到进程结束。另一个是外部服务调用没超时,比如某个接口请求第三方 API,对方一直不返回,连接和缓冲区就一直占着内存。排查的时候可以用memory_get_peak_usage()看峰值,再配合gc_collect_cycles()手动触发一次垃圾回收,对比回收前后的内存差,能帮你判断是不是循环引用导致的泄漏:<?php $before = memory_get_usage(); gc_collect_cycles(); $after = memory_get_usage(); error_log('GC 回收内存: ' . ($before - $after) . ' bytes'); ?>如果回收前后差得很多,说明代码里存在循环引用,需要检查对象之间的相互持有关系。五、总结:内存问题要防在日常这次故障从发现到解决,前后折腾了大半夜。回头想想,其实很多内存泄漏都是编码习惯问题:循环里不注意释放、静态变量乱缓存、外部调用不设超时。与其等线上报警再熬夜排查,不如在写代码的时候就多留个心眼。建议大家在测试环境就养成看内存曲线的习惯,把memory_get_usage()打点加到关键接口里,早发现早处理。如果你也遇到过类似的内存泄漏问题,欢迎在评论区聊聊你的排查思路,咱们一起交流交流。
一、凌晨两点,线上服务突然挂了那天晚上我正睡得迷迷糊糊,手机连续震了好几下,群里炸了锅:线上接口大面积超时,监控面板上一片飘红。我爬起来一看,PHP-FPM 的进程数飙到了上限,每个 worker 的内存占用都异常偏高,日志里刷满了Allowed memory size of 134217728 bytes exhausted。得,内存泄漏,跑不掉了。说实话,这种问题最磨人。不是一下子崩,而是慢慢把内存吃光,等到某个时刻集中爆发。今天就把我这次排查的完整过程写下来,希望能帮遇到类似问题的朋友少走点弯路。二、先复现,再谈定位排查内存泄漏,第一步永远是复现。我先把出问题的接口单独拉出来压测,用ab连续打了几百个请求,然后盯着 PHP-FPM 的进程内存看。果然,单个 worker 的内存从 30MB 一路涨到 100MB 多,而且请求结束后并没有降回去。这就基本坐实了:某个请求周期里,有对象一直没被释放。为了更直观地观察,我写了个简单的监控脚本,把每个请求前后的内存差打出来:<?php // 在入口文件开头记录基线 $baseline = memory_get_usage(); // ... 业务逻辑 ... // 在请求结束时对比 error_log('内存增量: ' . (memory_get_usage() - $baseline) . ' bytes'); ?>跑了几轮之后,日志里能明显看到某些接口的内存增量异常大,而且每次请求都在涨。这就把排查范围缩小到了那几个接口上。三、顺着调用链找元凶锁定接口之后,我开始逐段检查代码。翻到一段处理批量导入的逻辑时,发现了一个很典型的坑:循环里不断往数组里塞数据,但数组本身没有清空,导致内存越积越多。类似这样:<?php // 危险写法:循环里无限累积 $allRows = []; foreach ($fileData as $row) { $processed = processRow($row); // 处理完的数据 $allRows[] = $processed; // 全部攒在内存里 } // 直到循环结束才一次性处理,内存早就爆了 ?>这种写法在数据量小的时候看不出问题,一旦文件里有几万行,内存就直线上升。修复思路很简单:边处理边释放,不要把所有中间结果都攒在内存里。改成流式处理,每处理完一行就写出去,然后及时清掉引用:<?php // 安全写法:边读边处理,及时释放 $handle = fopen($filePath, 'r'); while (($row = fgetcsv($handle)) !== false) { $processed = processRow($row); writeToDb($processed); // 立即落库 unset($processed, $row); // 主动释放引用 } fclose($handle); ?>改完之后再压测,内存增量明显平稳了,不再一路飙升。四、还有两个容易忽略的坑除了循环累积,还有两个地方也容易埋雷。一个是静态变量和单例。如果某个类用静态属性缓存了数据,又没做清理,那这些数据会一直活到进程结束。另一个是外部服务调用没超时,比如某个接口请求第三方 API,对方一直不返回,连接和缓冲区就一直占着内存。排查的时候可以用memory_get_peak_usage()看峰值,再配合gc_collect_cycles()手动触发一次垃圾回收,对比回收前后的内存差,能帮你判断是不是循环引用导致的泄漏:<?php $before = memory_get_usage(); gc_collect_cycles(); $after = memory_get_usage(); error_log('GC 回收内存: ' . ($before - $after) . ' bytes'); ?>如果回收前后差得很多,说明代码里存在循环引用,需要检查对象之间的相互持有关系。五、总结:内存问题要防在日常这次故障从发现到解决,前后折腾了大半夜。回头想想,其实很多内存泄漏都是编码习惯问题:循环里不注意释放、静态变量乱缓存、外部调用不设超时。与其等线上报警再熬夜排查,不如在写代码的时候就多留个心眼。建议大家在测试环境就养成看内存曲线的习惯,把memory_get_usage()打点加到关键接口里,早发现早处理。如果你也遇到过类似的内存泄漏问题,欢迎在评论区聊聊你的排查思路,咱们一起交流交流。