ARTICLE DETAIL

资讯详情

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

PHP内存泄漏深度指南:从垃圾回收原理到常驻进程排查实战

PHP内存泄漏深度指南:从垃圾回收原理到常驻进程排查实战 有一次帮朋友排查一个PHP CLI脚本任务很简单循环处理一批订单数据可跑着跑着内存就往上涨到了第8000多条订单时直接OOM崩溃。他第一反应是“PHP不是自带垃圾回收吗怎么还会内存泄漏”这句话我听过太多次了几乎是每个PHP开发者早晚都要问一次的问题。这篇文章就把我这几年排查“PHP内存泄漏”的经验梳理一遍内容包括PHP里的内存泄漏到底是什么、哪些场景最容易触发、怎么用工具快速定位以及代码层面怎么防。不管你是刚接触PHP不久还是在维护定时脚本、队列Worker、Swoole服务的老手只要你的代码需要在“一个进程里长期运行”这篇文章就很值得从头到尾看一遍。传统Web请求模式下内存问题不明显恰恰是这种长生命周期代码会慢慢把问题暴露出来而且一暴露就是线上事故。1. PHP内存泄漏的真相请求结束自动清理不代表永远没泄漏1.1 PHP-FPM模式为什么很难遇到真正的“泄漏”很多PHP开发者都习惯了“写一次请求完事就丢”的开发模型。PHP-FPM为每个请求分配一个Worker进程请求开始处理后变量被创建请求结束之后Zend引擎会把这次请求里创建的所有变量彻底释放。就算代码里哪里出现了循环引用或者某个全局数组里面塞了一堆东西只要进程被回收清空内存就会回到请求之前的状态。这就是为什么在纯FPM短生命周期模式下大部分人基本遇不到内存波动。某个接口内存占用高通常也只是单次请求峰值暴增响应结束之后进程内存就降下来了。真正需要担心的是某些扩展或底层资源没有跟着PHP请求一起释放导致Worker每处理一个请求内存涨一点处理几百上千个请求之后进程越来越臃肿。这也是运维配置里经常出现pm.max_requests这个参数的原因——你不清楚谁在底层泄漏那就定期把Worker杀掉重启用这种“脏活”方式兜底。1.2 长驻内存场景才是泄漏高发区CLI脚本、队列Worker、Swoole常驻服务、Workerman监听进程这些才是理解PHP内存泄漏最适合的地方。它们的进程不会在某个请求结束后立刻销毁变量会一遍又一遍地执行同一段业务逻辑。你写的循环体也许某一轮只多消耗几百KB内存感觉无所谓但当循环轮数达到几万、几十万次时这几百KB就会被放大成几百MB甚至几个GB。我记得一次排查队列任务代码本身逻辑并不复杂从RabbitMQ拉消息调用一个内部HTTP接口再写数据库。问题出在模型层的某个静态数组每处理一条消息就塞一个模型对象进去说是为了后面“可能用到”但后面从来没清空过。消息量一上来进程内存就以肉眼可见的速度增长直到被监控系统杀掉。这类问题用短请求模式很难复现因为请求结束全局静态变量也会跟着释放但放到常驻队列里就是定时炸弹。所以排查内存泄漏的第一步不是急着看工具而是先确认代码跑在什么生命周期里。请求型代码和常驻型代码对“泄漏”的定义和处理策略完全不同。2. PHP垃圾回收机制里最容易被误解的3个点2.1 引用计数对象什么时候才真正释放PHP里每个变量、数组元素、对象属性都对应一个叫做zval的结构zval里有个refcount字段记录当前这个值被多少个变量引用。当一个变量被unset、或者被赋成新值时refcount就减一。只有refcount归零Zend引擎才会把这个变量内部真正释放掉。很多人以为“给变量赋值null”或者“unset一个变量”就能立刻让内存空出来其实不一定。比如下面这个例子$data loadBigData(); $alias $data; unset($data); // 此时$alias还活着大数据不会释放只要还有别名变量指向同一块数据内存就不会真的被清理。更隐蔽的情况是对象之间互相引用A对象保存了B对象的引用B对象又保存了A对象的引用哪怕外部变量全部unset这两个对象的refcount也不会归零。PHP需要借助垃圾收集器来发现这种“谁也够不到但引用计数不为0”的对象网将它们标记成垃圾并清理。2.2 unset不是万能钥匙内存也不会立刻还给系统“为什么我unset一个大数组memory_get_usage还是没降下去”这个提问我见过无数次。结论要分两层说第一如果确实没有其他变量再引用那份数据Zend引擎会释放它但PHP内建的内存管理器未必会把内存马上归还给操作系统而是先保留在进程的内存池里等下一次代码再需要内存时直接复用减少系统调用开销。$start memory_get_usage(false); $big str_repeat(a, 64 * 1024 * 1024); echo memory_get_usage(false) - $start, PHP_EOL; // 输出大概64MB unset($big); echo memory_get_usage(false) - $start, PHP_EOL; // 可能还会看到一些残留第二如果你用的是memory_get_usage(true)它拿到的数字是从操作系统申请来的真实内存这背后还包含了内存池里的空闲块、碎片、扩展占用的空间。所以就算代码层面变量都释放了这个接口显示的数字也可能不降反升。判断有没有内存泄漏更靠谱的指标是多次循环后“当前使用内存”是否呈现持续上升趋势而不是看某一次unset之后数字有没有立刻掉下来。2.3 循环引用和周期收集器什么时候会触发从PHP 5.3开始Zend引擎引入了周期收集器专门处理循环引用问题。它维护一个“根缓冲区”当疑似成环的zval被放进缓冲区后如果缓冲区里的根节点数量达到阈值垃圾收集器会被触发一次完整扫描。这个过程在PHP脚本退出之前也会被主动调用所以绝大多数单次请求脚本里的循环引用其实都能被回收。真正需要注意的是常驻进程里如果在循环体内不断创建相互引用的对象而又不及时unset或者无法打破引用环那么这些“垃圾根”会持续累积到缓冲区阈值触发一轮自动GC。但GC触发有时间差积压过多时内存峰值依然会很不好看。你可以在业务逻辑的关键节点调用gc_collect_cycles()来主动触发先看内存是否出现阶梯式下降以此判断问题是否跟循环引用有关。$before memory_get_usage(); $cleaned gc_collect_cycles(); $after memory_get_usage(); printf(collected%d, saved%d bytes\n, $cleaned, $before - $after);如果手动GC之后内存下降明显说明确实存在可回收的循环引用垃圾如果手动GC之后内存纹丝不动那多半是某些结构还“活”着代码里还有变量在引用它们。这个区分能帮你少走很多弯路。3. 实操复盘线上最常见的5类内存暴涨场景3.1 静态缓存或单例容器把数据越堆越多静态属性和单例模式最常见的隐患是“没有上限的缓存”。我见过一个很典型的例子业务代码里面有一个校验方法接收用户ID后先查一次数据库再用静态数组把结果缓存下来想着同一个用户在一次请求里不要重复查询。单个请求里这当然好用问题出在跑脚本批量处理用户时class UserInfoCache { private static array $cache []; public static function getUserName($id): string { if (!isset(self::$cache[$id])) { self::$cache[$id] Database::query(SELECT name FROM user WHERE id ?, [$id]); } return self::$cache[$id]; } } foreach ($userIds as $id) { echo UserInfoCache::getUserName($id), \n; // $cache数组越来越大脚本越跑越慢内存越涨越高 }这类缓存设计放在短生命周期Web请求里是正确的但放进常驻循环里就变成了内存增长源。修复方案很简单给静态缓存加一个容量上限超过后清空最旧的数据或者在批量任务场景改用只处理当前批次的局部变量不要设计成跨批次长期缓存。如果业务确实需要在长驻进程里做很多对象的缓存也要考虑换成WeakMap或者带TTL清理机制的缓存容器。3.2 循环里不断拼接或保存大字符串PHP字符串是不可变类型每次用 . 生成新字符串时旧的字符串会被释放新的字符串重新分配内存。如果只是简单循环拼接一个小字符串Zend内存池会复用旧空间内存占用不一定爆炸。真正危险的是在循环里保留每一次拼接结果比如把日志或全量数据堆到一个数组里$logs []; foreach ($newsList as $news) { $logs[] $news-getTitle() . | . $news-getContent(); // 每一轮都往数组里塞新字符串所有内容都存活 } file_put_contents(export.txt, implode(\n, $logs));这段代码的目的如果是最后一次性写入文件那完全可以一边遍历一边写入文件流完全没必要把内容全部攒在内存里。还有一个类似的场景是处理Excel或CSV不要先把几千行数据构建成一个大数组再导出正确做法是用生成器逐行处理、逐行写入。我早期做PHP图书管理系统的时候就犯过这种错导出一份上万条馆藏记录用数组收集所有行再生成Excel最后在数据量大的分馆直接内存溢出。改成边读边写之后内存占用从几百MB降到几十MB。3.3 长驻Worker里模型事件或钩子把对象绑住用Laravel、ThinkPHP这类框架写队列任务时很多人会直接在模型事件、模型观察者里面做一些额外操作比如更新缓存、写操作日志。模型事件很多时候是全局注册的而事件处理器如果捕获了模型实例那么当你的循环里不断创建、保存新模型时这些模型会被事件系统引用着迟迟释放不掉。// 在某个ServiceProvider中 User::created(function (User $user) { ActivityLog::log(user {$user-id} created); });上面的闭包看起来只是接收了一个$user参数本身不会把模型长期持有。但如果你换一种写法把模型扔进一个自己写的静态集合里做标记或者注册了一个实例方法级别的回调就得仔细检查回调对象的归属。实操中我排查过这样的案例一个Swoole Worker里每接收到一条消息就创建一次User模型模型的boot方法通过事件回调将当前对象附加到一个框架内部的Listeners集合导致每个请求处理后模型对象都留在静态属性里。最后发现是业务代码里把回调写成了对象方法数组等于让监听容器一直引用着该对象。排查这类问题有个很粗暴的办法在代码里定期调用gc_status()观察root数量是否随任务处理数量线性增长。如果root数量一直在涨就说明有对象被某个容器引用形成了无法回收的“活对象链”。3.4 闭包隐式携带$this把对象生命周期拉长了闭包的内存泄漏在PHP里非常隐蔽尤其是类方法内部创建闭包的时候。看下面这类代码class EventDispatcher { private array $handlers []; public function registerHandler(): void { $closure function () { $this-doSomething(); // 闭包作用域绑定到当前对象 }; $this-handlers[] $closure; } private function doSomething(): void { // ... } }闭包一创建PHP会默认把当前$this绑定到闭包上。如果这个闭包被保存到一个不属于当前对象的容器里那么当前对象的生命周期就会被闭包无限拉长即使你原来调用registerHandler的那个变量已经unset了对象也不会被回收。解决方式不复杂能写成static function就尽量用static。如果必须在闭包内访问当前对象的属性那就要留意闭包存放的位置和清理逻辑。比如把闭包收集到一个数组之前先想清楚这个数组的生命周期跟对象生命周期是否一致如果不一致就需要在合适的时机把闭包移除。这类问题在事件注册、路由收集、定时器回调里特别容易出现排查时可以把debug_zval_dump或者反射辅助函数打在可疑对象上看看它的refcount是不是一直没降下来。3.5 扩展层和外部句柄PHP管不到的“内存”有时候PHP本身的内存统计一切正常但进程内存还在持续上涨。这种情况十有八九出在扩展层或者外部句柄上。比如使用Imagick处理图片每次new之后不调用clear()或destroy()扩展在C层分配的内存就不会被PHP的垃圾回收机制感知再比如用mysqli或PDO建立连接后不主动关闭虽然对象释放时会触发析构但在常驻循环里如果连接对象被静态属性或者全局容器保存连接就永远不关闭。while ($task getTask()) { $image new Imagick($task[path]); // 处理图片... // 忘了$image-clear()和$image-destroy() }这类扩展内存问题的通用排查手段是先在CLI脚本里跑一小段复现代码然后用系统工具观察进程RSS变化。如果PHP用户态变量没有明显增长但RSS不断上涨基本可以断定瓶颈在扩展层。我建议给涉及图片处理、压缩解压、加密解密等操作的对象统一封装一个“用完即销毁”的辅助方法避免业务层忘记调用销毁接口。4. 排查内存泄漏的实操方法从打点到工具链4.1 先用memory_get_usage做“踩点”把范围缩小拿到一个“越跑越慢”的长驻脚本第一步不要慌着装Profiler先往疑似循环体里打几个点把内存变化打出来。$counter 0; foreach ($items as $item) { // 业务逻辑... if ($counter % 1000 0) { printf( %d: current%d peak%d\n, $counter, memory_get_usage(false), memory_get_peak_usage(false) ); } }我通常是先在任务开始、25%、50%、75%、结束这五个位置打印。如果某一阶段内存涨幅远超其他阶段就说明问题代码集中在那个区间如果内存是标准的线性增长则多半是循环体内部有数组在不断累积。继续缩小范围就用“二分排除法”在怀疑区间的中间位置再加一次统计把嫌疑代码段持续缩小到几十行之内。这种方式不需要安装任何扩展生产环境也能直接临时加日志等确认后再去掉。4.2 用gc_status观察垃圾收集器是不是在“空转”PHP 7.3开始提供了gc_status()函数可以查看当前垃圾收集器的状态。排查循环引用时这个函数很有用。在常驻进程里我一般会让每轮循环结束后打印roots数量正常情况下它会在一个小范围内上下波动对应GC触发后的清空再积累过程。如果roots数量只增不减说明垃圾收集器被什么东西拖住了或者阈值一直没触发又或者不断产生新的容器引用。$status gc_status(); printf(runs%d roots%d collected%d threshold%d\n, $status[runs], $status[roots], $status[collected], $status[threshold] );如果roots数量长期在阈值附近徘徊内存还在涨就在循环末尾手动执行gc_collect_cycles()并对比效果。手动GC后如果roots降下来了且内存出现明显回落那就能很肯定地确认是循环引用对象积累过多。如果手动GC之后roots降了但内存没降那就说明引用环虽然被打破了但Zend内存池还没把空间还给系统也就是前面说过的情况这时候需要持续观察多轮看趋势是否仍然向上。4.3 用debug_zval_dump查某个对象到底被谁引用想弄明白一个特定对象为什么迟迟不被释放最简单的办法是看它的引用计数。debug_zval_dump是PHP自带函数可以输出变量的refcount详情。class OrderService { public function process(): void { $order Order::find(123); debug_zval_dump($order); } }不过直接对一个对象调用debug_zval_dump输出结果会带上函数调用本身带来的临时引用数字看起来可能比预期大1甚至更多这点要知道。要更精确地观察谁引用了它可以配合ReflectionProperty等反射工具遍历对象属性并检查哪些属性指向同一个实例。这个过程比较笨重但对定位“静态集合里存了一堆模型对象”这类问题非常有效。我在实际排查中一般先把可疑对象用spl_object_id打一个唯一标识再全局搜索这个标识被哪些数组或对象持有基本能锁定引用链。4.4 给生产环境上XHProf或Blackfire做分配分析如果代码规模太大手工打点可能太慢这时候可以用Profiler来做一次全量采样。PHP生态里最经典的是XHProf扩展和它的兼容实现Tideways它们可以在函数级别记录CPU和内存增量。Blackfire则是商业方案集成比较方便适合团队规范比较规范的场景。使用XHProf类的扩展时一般只需要在脚本入口开启xhprof_enable(XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY); // 业务代码... $data xhprof_disable();然后通过xhprof_lib自带的UI查看各个函数的inclusive/inclusive内存增量。注意XHProf记录的内存增量是基于memory_get_usage差值计算的它有精度限制不能替代精细打点但足以帮你快速找到分配内存最多的那几个函数。通常数据一出来问题就非常显眼了某个方法的inclusive内存高达几百MB你点进去还会看到它内部调用了哪些方法逐层往下就能找到罪魁祸首。Blackfire相比之下连调用栈动态分配都更清楚但由于需要专用工具和导Profile流程适合在预发环境针对性跑一次不适合作为常规线上排查手段。当然它定位深度问题比XHProf直观。4.5 扩展层C内存泄漏时用Valgrind做深入检查当怀疑底层扩展有真正的C内存泄漏PHP用户态工具基本就派不上用场了。这时候需要让PHP绕过Zend内存管理器把内存分配交给系统去管再交给Valgrind检查。USE_ZEND_ALLOC0 valgrind --toolmemcheck --leak-checkfull php leak.php这个命令会强制PHP内部所有内存操作都经过系统mallocValgrind才能记录到每块内存的分配和释放位置。跑完之后仔细看leak summary如果某个扩展的函数名频繁出现在definitely lost的记录里那基本就是扩展自身的问题。此时最现实的方案不是自己改扩展而是升级扩展版本、换一个替代扩展或者调整调用方式。这个方向我只建议在CLI环境做线上服务千万别随意加USE_ZEND_ALLOC0性能衰减非常明显。5. 常见误区与问题速查5.1 三个“不是内存泄漏”却长得很像的情况第一种是内存池预热。进程启动后第一次执行大量字符串拼接或数组操作时Zend内存池会向系统申请一大块内存之后即使业务内存都释放了RSS依然会保持在一个比较高的位置。这不是泄漏而是分配器的常见策略。判断方法很简单多跑几轮同样的批次任务前几轮RSS上涨后如果后面趋于平稳就是“预热”完成。第二种是单次请求峰值过高被误判。一个接口就把一个几百MB的文件整个读进内存又处理后释放Web请求结束内存会还原但其实单次请求的峰值已经把PHP进程推到了几百MB。在高并发下这种峰值会造成大量Worker内存同时飙升看起来就像泄漏。解决思路是减少单次请求的内存峰值而非去找谁“忘了释放”。第三种是FPM进程数量多导致总体内存占用大。每个PHP-FPM进程空闲时都要占用几十MB甚至上百MB这不是泄漏是进程模型决定的。只要单个Worker在处理完请求后内存能回落到初始水平就不需要Perl级重构。要优化的应该是最多是pm.max_requests让Worker定期回收。5.2 内存问题排查速查表现象可能原因建议操作队列或CLI脚本处理N条数据后内存持续上升全局/静态数组缓存数据没有上限给静态缓存加容量上限或TTL改局部变量内存阶梯式上涨手动gc_collect_cycles后下降循环引用对象过多检查对象互相引用打破环主动触发GC内存上涨但gc_collect_cycles无效活对象被容器引用用debug_zval_dump/spl_object_id找引用链PHP内存统计很低进程RSS一直涨扩展层或外部句柄泄漏排查Imagick/PDO/Redis等资源的创建销毁用Valgrind定向检查某个方法内存占用极高单次把大量数据载入内存改成生成器/分批处理/流式读写FPM一个Worker处理很多请求后内存上涨扩展底层残留或请求间状态没清理设置pm.max_requests定期重启Worker升级扩展6. 代码习惯上真正有效的预防措施6.1 给静态缓存和单例加上“边界意识”预防内存泄漏比排查更省力。静态属性和单例不是不能用而是必须清楚它存活多久。如果你写的是常驻进程代码任何静态数组本质上都可能成为无界缓存。最简单的规则是普通Web请求里尽量直接查库把“缓存”交给Redis这类外部组件真要用进程内缓存就限制容量超出后清理最早的数据。对WeakMap其实不少PHP开发者还不熟悉。它允许你用对象做键但不会阻止对象被垃圾回收。像下面这样$cache new WeakMap(); $obj new stdClass(); $cache[$obj] some data; unset($obj); // $cache里对应的键值会随着对象回收自动消失当你需要在长驻服务里临时关联一些对象信息时WeakMap往往比普通数组和SplObjectStorage安全得多因为对象销毁后不会留着旧键造成内存膨胀。这也是PHP 8.0开始推荐的做法。6.2 长驻循环里尽量避免“跨轮引用”队列Worker、消息循环这种场景每一轮任务尽量保持独立的变量作用域。把逻辑拆成独立函数或方法调用不依赖上一轮留下的静态容器。尤其要注意循环体内创建模型后要做持久化操作时不要把它塞进某个全局集合里等待后续统一保存省那点数据库连接机会往往还不够填内存的坑。while ($msg $queue-receive()) { $user new User(); $user-fill(json_decode($msg, true)); $user-save(); }想确认自己的代码有没有“跨轮引用”有个直观的办法在每个循环末尾打印一次当前内存、峰值内存、已经存在的对象数跑个几千轮看看曲线。如果曲线是向上的就顺着这个思路继续向下查。6.3 把内存检查加入CI和上线前自查我还习惯在耗时较长的处理类脚本里加一个“内存断言”比如跑完一批测试数据后如果memory_get_peak_usage超过预期阈值就直接失败。这能帮你在一开始就阻止问题引入主干而不是等上线后跑崩了再救火。在正式发布的常驻进程里最好也给监控层加上“内存达到某个百分比自动重启”的保护措施。这只是一个兜底不是根治手段。真正要根治的还是从业务代码层面意识到每一个static数组、每一份保存下来的闭包、每一条没关闭的连接都可能成为压垮长驻内存进程的那根稻草。最后再分享一个我自己的小习惯每次写完一段循环体我都会下意识问一句“这一轮产生的对象下一轮还会有人用吗”如果答案是不确定那我宁可把状态封装得更局部一点。排查过太多次内存问题之后你会发现大部分内存崩溃都不是什么高深的技术难题而是那些一开始觉得“先放这里后面可以用”的代码把内存慢慢塞满了。
返回列表