PHP内存泄漏深度指南:从垃圾回收原理到常驻进程排查实战
2026/9/9 4:05:13 网站建设 项目流程

有一次帮朋友排查一个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_ALLOC=0 valgrind --tool=memcheck --leak-check=full php leak.php

这个命令会强制PHP内部所有内存操作都经过系统malloc,Valgrind才能记录到每块内存的分配和释放位置。跑完之后仔细看leak summary,如果某个扩展的函数名频繁出现在definitely lost的记录里,那基本就是扩展自身的问题。此时最现实的方案不是自己改扩展,而是升级扩展版本、换一个替代扩展,或者调整调用方式。这个方向我只建议在CLI环境做,线上服务千万别随意加USE_ZEND_ALLOC=0,性能衰减非常明显。

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数组、每一份保存下来的闭包、每一条没关闭的连接,都可能成为压垮长驻内存进程的那根稻草。

最后再分享一个我自己的小习惯:每次写完一段循环体,我都会下意识问一句“这一轮产生的对象,下一轮还会有人用吗?”如果答案是不确定,那我宁可把状态封装得更局部一点。排查过太多次内存问题之后你会发现,大部分内存崩溃都不是什么高深的技术难题,而是那些一开始觉得“先放这里,后面可以用”的代码把内存慢慢塞满了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询