ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP常驻进程内存泄漏排查与修复实战指南

PHP常驻进程内存泄漏排查与修复实战指南 先讲一个真实案例。上个月帮朋友看一个常驻队列消费者进程RSS从80MB开始每处理一万条消息内存涨一截跑到凌晨稳定在2GB左右然后被云平台的哨兵直接杀掉。这个问题的标签就是PHP内存泄漏检测。短生命周期脚本基本不需要担心内存问题但一旦进入常驻进程泄漏就是实打实的故障。这篇文章适合正在写CLI脚本、队列消费者、Workerman/Swoole常驻服务的PHP开发者也适合被PHP-FPM单worker内存持续上涨折磨的运维同学。我会把从“发现内存上涨”到“定位到具体函数”再到“修复验证”的完整链路拆开讲涉及内存观测、Valgrind/Xdebug/XHProf工具选型、gc_status判定以及日常监控固化。所有步骤都来自真实排障经历可以直接照着落地。1. 先搞清楚PHP里的“内存泄漏”有多常见1.1 短生命周期脚本和常驻进程是两个世界传统Web请求下PHP脚本的宿命是“跑完就死”。一次请求执行结束Zend引擎释放当前请求的所有上下文内存归还给进程内的内存池再由PHP-FPM复用。所以大多数业务代码即使写得再烂也不会在Web请求里造成持续上涨的内存泄漏——顶多是单次请求峰值过高或者某些扩展没有释放C层内存。真正的分水岭是常驻进程。CLI脚本里的while死循环、Queue Worker、Swoole/Workerman的Worker进程它们不会在每次任务结束后自动释放全部内存。此时任何“在循环里不断往全局容器里塞数据”的写法都会变成一条肉眼可见的上涨曲线。PHP-FPM的worker虽然没有进入while循环但只要有跨请求存活的静态属性、单例对象里的缓存容器内存同样会在一段时间内稳步爬升。所以我要先说结论排查PHP内存泄漏第一步不是找工具而是先确认你处理的是不是常驻场景。如果是短脚本重点应该看单次峰值和峰值分配位置如果是常驻进程才需要走完整套“趋势观察→GC判定→调用链定位→修复验证”的流程。1.2 三种常见却容易被误判的“内存问题”实战中“内存涨了”不代表一定泄漏。至少有三类情况容易混淆。第一类是内存碎片化。PHP维护了Zend内存池会向系统申请大块内存再用自己的分配器切分。如果脚本创建了大量小对象、临时数组即使都释放了内存池也不一定把整块内存还给操作系统。表现是memory_get_usage(false)没怎么涨但memory_get_usage(true)和进程RSS涨得很快。这不是严格意义的泄漏是内存池膨胀。第二类是单次业务数据量巨大导致的正常峰值。消费一条大消息就要分配几MB内存处理完释放下一条又分配。这个过程里内存可能是反复波动而不是单调递增。如果只盯某几个采样点容易误判成泄漏。正确做法是连续采样看趋势是围绕某条线波动还是始终向上。第三类才是真正的泄漏某些对象或数组被长期引用无法释放内存随循环次数单调增加。这类问题需要做GC判定和引用链分析。这三类问题在初期的表现可能都一样——进程内存越来越大但如果不分清类型工具选型会直接跑偏。Valgrind对碎片化问题不敏感Xdebug对C扩展层泄漏基本看不到这些边界下一章会详细讲。2. 检测之前先做一件事把内存趋势画成一条线2.1 代码打点用memory_get_usage和/proc拿到两层指标不要一上来就挂profiling工具先给目标进程装上“血压计”。最基础的工具就是memory_get_usage()但要注意它的两个参数含义完全不同。memory_get_usage(false)返回的是Zend内存管理器实际分配给PHP对象的内存字节数精确但只反映PHP层memory_get_usage(true)返回的是内存管理器向系统申请的内存池总量能反映“池子”膨胀的情况而Linux下真正的进程物理内存占用要从/proc/self/status读VmRSS。我常用的打点函数长这样?php function getMemRSS(): int { $status file(/proc/self/status, FILE_IGNORE_NEW_LINES); foreach ($status as $line) { if (str_starts_with($line, VmRSS:)) { return (int) filter_var($line, FILTER_SANITIZE_NUMBER_INT) * 1024; } } return 0; } function logMemory(string $tag): void { $file /tmp/mem_trend.log; $line sprintf( [%s] tag%s usage%.2fMB real%.2fMB rss%.2fMB\n, date(H:i:s), $tag, memory_get_usage(false) / 1048576, memory_get_usage(true) / 1048576, getMemRSS() / 1048576 ); file_put_contents($file, $line, FILE_APPEND); }在循环的入口、出口、业务关键节点各打一次点。跑足够长的时间建议至少覆盖一个完整业务高峰周期得到一组时间序列数据。只用这几个指标就能区分出前面说的三类型问题。2.2 shell侧定时采集VMRSS样本有些场景不方便改业务代码比如PHP-FPM的worker或者线上临时排查不方便重新发布代码。这时候就在操作系统层采集。最简单的方案是读取/proc/pid/status中的VmRSSPID$(pgrep -f consumer.php) if [ -z $PID ]; then echo process not found exit 1 fi while true; do RSS$(awk /VmRSS:/{print $2} /proc/$PID/status) echo $(date %s) $RSS /tmp/rss_trend.log sleep 30 done采集间隔不要太小30秒到1分钟足够太密集反而会被业务波动干扰。跑半小时到一小时把数据丢进Excel、Gnuplot或者随便什么绘图工具先看曲线形态。我见过很多人跳过这一步直接抓profile结果被海量函数调用信息淹没根本不知道从哪看起——趋势图能让你在动手定位前就明确“问题是否存在、上涨速率是多少、触发条件是什么”。2.3 从增长曲线反推泄漏层PHP内部 vs ZendMM vs C层趋势图拿到后三个指标之间的差异会直接给你定位线索。下面的对照表是我的判断习惯表现可能原因下一步usage(false)稳定real(true)持续上涨Zend内存池膨胀碎片化或池未归还周期性调用gc_mem_caches()观察是否能回落usage(false)单调上涨且每轮涨幅接近PHP层对象/数组被持续引用真实泄漏进入GC判定和调用链分析usage(false)稳定real(true)稳定但RSS持续上涨C扩展层分配未释放如curl、pdo、libxml、第三方so排查扩展Valgrind massif实测三个指标全部上涨但重启后回落常驻逻辑中缓存容器累积重点查静态属性、单例、闭包捕获这张表解决的是“该往哪个方向查”。绝大多数纯PHP代码造成的内存问题都会在usage(false)上露出马脚如果usage(false)一直平稳那基本可以判定嫌疑在扩展层这时才轮到Valgrind出场。3. 检测工具的适用边界Valgrind/Xdebug/XHProf到底该用哪个3.1 Valgrind massif查C扩展层和PHP内核层的好手Valgrind是C/C系选手的方案用在PHP上有点“降维打击”但对付扩展层泄漏很好用。massif工具会记录程序运行过程中的堆内存变化包括每个分配点对应的调用栈。使用时需要让PHP绕过自己的Zend内存池不然大量内存会被ZendMM吞掉Valgrind看不清真实分配行为USE_ZEND_ALLOC0 valgrind --toolmassif --massif-out-file/tmp/php.massif php /tmp/test_worker.php ms_print /tmp/php.massif | head -n 80USE_ZEND_ALLOC0是关键。它强制PHP底层直接走mallocValgrind才能看到每个分配点的具体信息。代价是运行速度明显下降所以不能拿它跑完整的生产流量只跑一段能复现问题的脚本就行。massif的输出里会列出占据堆内存最高的函数栈如果问题出在curl扩展、PDO驱动、或者某个第三方.so这里会直接暴露。但如果问题出在PHP用户态的数组或对象引用上massif基本帮不上忙因为它记录的是系统malloc层面而PHP层对象往往已经被内存池管理了。3.2 Tideways/XHProf按调用链展开“谁在分配内存”要说定位“哪个函数分配了大量内存”XHProf风格的profiler是最顺手的。PHP 7之后官方XHProf已经不太维护我目前用的是Tideways的xhprof扩展。开启内存统计后它能记录每个函数的独占内存和包含内存直接看报告里的Memory列就行。?php tideways_xhprof_enable(TIDEWAYS_XHPROF_FLAGS_MEMORY | TIDEWAYS_XHPROF_FLAGS_NO_BUILTINS); // 执行需要分析的业务逻辑例如消费1000条消息 $result $consumer-handleBatch($messages); $data tideways_xhprof_disable(); file_put_contents(/tmp/xhprof.dump, serialize($data));跑完把dump文件导入xhprof_html按“Incl. Mem Usage”排序。这里有一个经验不要单看一次运行的结果要对比“跑100条”和“跑10000条”两份报告找出那个内存增量随数据量线性增长的函数。如果某个函数在两次运行中的包含内存都排第一但它不是导致泄漏的元凶你还需要看它的增量。真正会泄漏的函数通常不是峰值最高而是“跑的越多、占用越大”的那个。3.3 Xdebug trace与meminfo低成本的粗筛Xdebug也可以做内存相关诊断。trace模式下每次函数调用都会记录内存增量能够看到某段代码执行后内存涨了多少适合小范围验证。启动方式是在CLI下直接传参php -dxdebug.modetrace -dxdebug.start_with_requestyes -dxdebug.output_dir/tmp script.php生成的trace文件非常大对几万次循环来说会膨胀到几十MB以上所以只建议把问题范围缩到某个模块后再用。另一个低门槛工具是meminfo扩展它能在CLI脚本结束时导出整个内存快照包括每个变量被谁引用、深浅拷贝情况。排查“某个对象为什么一直无法释放”时这个扩展能直接把引用链摊开比人肉瞪眼找引用要快得多。3.4 PHP自带的gc_status()垃圾回收器状态快照PHP 7.3之后有了gc_status()这是常驻进程排障时我最先调用的函数。它返回垃圾回收器的实时状态包括runsGC已运行次数、collected已回收的垃圾对象数、roots根缓冲区中的待处理数量。用法很简单在循环中周期性打印即可?php declare(ticks1); $i 0; while (true) { // 业务处理 $i; if ($i % 1000 0) { $gc gc_status(); $mem memory_get_usage(false); printf( i%d runs%d collected%d roots%d usage%.2fMB\n, $i, $gc[runs], $gc[collected], $gc[roots], $mem / 1048576 ); } }这个函数的价值在于帮你判断问题到底属于“垃圾回收延迟”还是“对象被持续引用”。如果roots一直维持在高位说明有大量循环引用正在等待GC处理如果调gc_collect_cycles()后内存立刻降下来说明问题属于回收延迟如果调用后内存纹丝不动说明那些对象仍然被某个全局引用持有GC根本谈不上“回收”。这两种情况的处理方式完全不同前者可以通过周期性触发GC缓解后者必须找到持有引用的容器。4. 实测一个常驻队列脚本从趋势异常到根因修复的完整链路4.1 还原现场每处理一万条消息内存上涨约60MB回到开头那个队列消费者。代码结构不复杂RabbitMQ消费者在while循环里拿消息丢给MessageProcessor处理处理完ack。跑起来的现象是进程启动时RSS 80MB处理约2万条消息后升到200MB6小时后到了1.2GB最后被哨兵杀掉。趋势图差不多是一条斜率稳定的直线没有平台期这基本可以断定是累积型泄漏不是波动。我在消费循环里打了点输出如下i0 usage18.44MB real26.11MB rss80.53MB i10000 usage32.91MB real38.77MB rss126.30MB i20000 usage47.82MB real52.09MB rss183.44MB i30000 usage62.75MB real66.83MB rss241.20MB每处理一万条usage(false)稳定上涨约15MBrss上涨约58MB。这说明PHP层确实有大量对象累积不是扩展层问题也不是内存池膨胀。4.2 排查链路二分试验、GC判定、profiling定位有了趋势数据下一步是缩小范围。MessageProcessor::handle()里有三块逻辑写日志、更新缓存、做业务计算。采用二分法分别注释掉每一块然后各跑1万条消息观察内存增量。日志模块注释掉后内存增量几乎降为零——嫌疑锁定在日志模块。接着做GC判定。在循环末尾调用gc_collect_cycles()后内存没有明显回落说明这批对象仍然被有效引用不是循环引用垃圾。再看gc_status()里的roots不高runs也只是缓慢增加。到这里基本能判断有一批对象被某个长生命周期容器持续引用每处理一条消息都在容器里塞东西塞进去就再也没拿出来。最后用Tideways xhprof跑了一批消息按Incl. Mem排序排第一的是Logger::write()。点进去看调用栈发现Logger内部维护了一个静态数组$context每次写日志都会把当前消息相关的上下文压进去而且越写越大。到这里根因已经浮出水面。4.3 最终根因静态数组、闭包引用与单例缓存的叠加继续往下挖真正的根因比“静态数组”还要深一点。代码里做了三件事单看每一件都不算严重叠在一起就是泄漏。第一Logger是单例并且用静态数组保存每次消息的上下文快照。第二MessageProcessor里给每个消息注册了一个闭包监听器闭包捕获了消息对象和Logger实例。第三所有这些闭包被塞进一个静态的事件容器里事件容器永不清理。每来一条消息就多往静态数组里推一个带上下文的闭包闭包又外带着消息对象消息对象再引用一批临时对象。整个引用链像一个越滚越大的雪球。GC能回收的只是没有外部引用的循环垃圾但这个容器里的对象个个都还有引用所以gc_collect_cycles()拿它毫无办法。修复思路也明确了砍掉跨循环生长的静态容器让消息相关对象的生命周期跟着循环走。具体动作有三个。静态上下文数组改成局部变量日志上下文在使用后立即释放?php public function log(string $level, string $message, array $context []): void { $entry [ level $level, message $message, context $context, time microtime(true), ]; // 写入日志后$entry 局部变量随函数返回自然释放 $this-writer-write($entry); }事件监听器改用对象作为键注册并在消息处理完毕后显式移除。如果你用的是PHP 8以上直接上WeakMap让条目随键对象自动消失?php final class MessageProcessor { private WeakMap $listeners; private WeakMap $localCache; public function __construct() { $this-listeners new WeakMap(); $this-localCache new WeakMap(); } public function handle(object $msg): void { $guard $this-localCache[$msg] ?? null; if ($guard null) { $guard new RuntimeGuard(); $this-localCache[$msg] $guard; } $listener function () use ($msg): void { // 处理逻辑 }; $this-listeners[$msg] $listener; // 业务处理完成后$msg 离开作用域 // WeakMap 中的记录随 $msg 对象销毁自动清理 } }如果你的项目还在PHP 7.4可以用WeakReference做类似的事但操作起来比WeakMap繁琐建议有条件的直接升到PHP 8。4.4 修复后的趋势验证10万条消息内存稳定在260MB修复后再次打点同样的消息样本跑10万条。结果是从第1万条到第10万条内存曲线变成了一条基本水平的直线usage稳定在22MB左右rss稳定在260MB附近不再上涨。投放生产后观察24小时RSS保持平稳没有出现重启任务。这次修复最关键的教训是GC全程都在工作但它只负责回收“无引用的循环结构”根本动不了一个被静态容器持续引用的对象。所以排查常驻进程内存问题时不要一上来就调GC先用gc_status()和一次显式gc_collect_cycles()做对照把问题定性为“垃圾未回收”还是“引用未释放”方向对了后面的工具才能发挥价值。5. 把检测固化到日常监控、CI和重启边界5.1 线上巡检让Worker自己上报内存画像定位修复一次不算完常驻服务应该把内存检测变成日常能力。我通常会在核心Worker里加一个轻量定时器每处理N条消息后把memory_get_usage(false)、memory_get_usage(true)、gc_status()中的collected和roots打到Metrics服务里。可视化之后趋势线一旦转头向上就能提前预警。如果只是PHP-FPM场景不想动业务代码也可以用node_exporter这类进程级采集器监控process_resident_memory_bytes。但要记住一个前提只看RSS绝对值意义不大重点是看它是否在一段时间内单调上涨。业务高峰、连接数变化都会导致RSS正常波动所以要设定“连续N个采样点持续上升且超过阈值”的告警规则而不是单点阈值告警。5.2 CI阶段的内存断言防回归内存泄漏最麻烦的地方在于代码合并时完全看不出来跑几条单元测试也不会暴露。我建议在CI里加一个“长循环内存回归用例”把泄漏场景写进测试?php final class MemoryLeakRegressionTest extends \PHPUnit\Framework\TestCase { public function testMessageProcessorDoesNotGrowMemory(): void { $processor new MessageProcessor(); $message new QueueMessage([id 1, payload str_repeat(a, 4096)]); $start memory_get_usage(false); for ($i 0; $i 50000; $i) { $processor-handle($message); } $end memory_get_usage(false); $this-assertLessThan(2 * 1024 * 1024, $end - $start); } }这里有一个细节必须在同一个进程内循环执行目标方法才能暴露跨调用累积的问题。普通单元测试每个用例跑完就释放根本测不出来。另外样本消息要固定PHP版本和扩展版本要固定不然基线漂移会导致误报。5.3 定期重启是兜底方案不是治疗手段最后说说重启。很多团队对付常驻进程内存上涨的手段是supervisor的autorestarttrue或者systemd的Restartalways内存超了就让进程退出重启。PHP-FPM那边也有pm.max_requests500这种配置让worker处理500个请求后自动重建。这些手段都属于运维兜底能恢复服务能压制故障但不能根除泄漏。换句话说重启只是把“在泄漏的进程”换成了“还没泄漏的进程”泄漏本身还在代码里。它会不断消耗你的系统资源、频繁触发重连、丢失内存中的中间状态而且会掩盖真正的回归。正确的做法是让重启机制只作为最后一公里防线同时把内存趋势监控跑起来在趋势线拐头时及时介入用本章前面说的GC判定和调用链分析把根因挖出来。我自己做这套方案最大的体感是工具并不神秘真正难的是顺序。先画趋势、再判GC、后上profile一步步收窄范围效率远高于拿一个工具从头扫到尾。排查内存泄漏本身就是个执行流程走顺了剩下的就是时间问题。
返回列表