欢迎访问晨星博客!
凌晨三点,监控告警又响了:跑在 Swoole 上的订单 API 服务,worker 进程内存从启动时的几十 MB 缓慢爬到了 memory_limit 上限,被系统直接杀掉。重启后一切正常,三天后再次重演。如果你维护过常驻进程的 PHP 服务——无论是 Swoole、RoadRunner 还是消息队列消费者——这类"慢性失血"几乎迟早都会遇到。
传统 PHP-FPM 模式下,请求结束进程内所有变量被整体回收,每次泄漏几 KB 根本无感。常驻进程则把每一次泄漏都留了下来:每个请求漏 100 KB,一万个请求就是 1 GB。要根治这个问题,得先搞清楚 PHP 到底什么时候会回收内存、什么时候不会。

PHP 变量底层都是 zval 结构,每个 zval 带着一个引用计数(refcount)。赋值、传参时计数加一,unset 或离开作用域时减一,归零的那一刻内存立刻释放——这是第一道防线,也是绝大多数变量的一生。
问题出在循环引用。对象 A 的属性持有对象 B,B 又引用回 A,即使外部已经没有任何变量指向它们,两者的 refcount 都不为零,谁也无法被释放。PHP 5.3 引入的垃圾回收器(GC)就是为此准备的第二道防线:引擎会把"可能是循环引用根"的数组和对象放进一个根缓冲区(root buffer),当缓冲区攒满 10000 个候选根时,GC 启动一轮遍历,对每个候选根模拟引用计数减一,找出那些从外部不可达的对象簇,整体回收。
对常驻进程来说,这套机制有三个事实特别值得记住:
GC 的行为细节也在随版本持续修补。比如 PHP 8.5 中有一项目标很窄的优化:把枚举和静态伪闭包标记为不可回收,避免 GC 在这类特殊变量上做无用扫描(PHP 8.5 垃圾回收改进)。这类改动的存在本身就说明,排查问题时值得先确认自己运行版本的具体 GC 行为,而不是凭旧经验下结论。
GC 能处理循环引用,但它救不了"从根上就可达"的对象。这是常驻进程里最常见的误判:内存一涨就怪 GC 不给力,实际上对象一直被某个活着的引用牢牢抓着,GC 根本没有资格碰它。
看一个在 Swoole 服务里很典型的写法。服务启动时注册了一个事件分发器,业务代码在每次请求里往里挂监听器:
class Dispatcher
{
private static array $listeners = [];
public static function listen(string $event, callable $callback): void
{
self::$listeners[$event][] = $callback;
}
}
// 每个请求的处理逻辑里
Dispatcher::listen('order.created', function () use ($orderService, $requestContext) {
$orderService->notify($requestContext);
});
问题有两层。第一,Dispatcher::$listeners 是静态属性,生命周期和 worker 进程一样长,请求结束不会清空;每处理一个请求,数组里就多一个闭包,只增不减。第二,闭包通过 use 捕获了 $orderService 和 $requestContext——后者往往拖着整棵请求上下文的对象树,包括请求参数、数据库连接、日志上下文。一个闭包挂进去,整棵树都跟着常驻内存。
GC 在这里完全无能为力:这些闭包从静态属性出发是可达的,根本进不了回收候选。这也正是资料中反复总结的几类典型泄漏——循环引用、全局变量或静态属性持续持有引用、事件监听器未解绑,其中后两类在常驻进程里的杀伤力远大于循环引用。
修复方向同样直接:监听器在服务启动时注册一次就好,不要在请求周期里重复挂;请求级的回调用完显式移除;如果确实需要长期持有某个对象又不想阻止它释放,PHP 7.4 引入的 WeakReference 允许持有一个不增加引用计数的弱引用,对象被回收后通过它只能拿到 null。
怀疑泄漏之后,第一步不是直接上重型工具,而是把内存增长变成可观测的数字。memory_get_usage() 是最顺手的探针,但要用对参数:不传参时返回 Zend 内存管理器实际分配给脚本的内存;传入 true 时返回内存管理器从操作系统申请的总块大小,包含内部碎片和对齐浪费。两者配合看才有意义——前者涨说明 PHP 变量真的变多了;前者平稳而后者涨,多半是内存管理器的块没有归还给系统。
在常驻服务里,一个实用的打点模式是:
gc_collect_cycles(); // 先强制回收,排除"可回收但还没回收"的干扰
$before = memory_get_usage();
// ... 处理请求 ...
gc_collect_cycles();
$after = memory_get_usage();
if ($after - $before > 64 * 1024) {
$logger->warning('request leaked', [
'uri' => $uri,
'delta' => $after - $before,
]);
}
前后各调一次 gc_collect_cycles() 是关键动作,它把"垃圾还没轮到回收"和"真的漏了"区分开。如果强制回收后差值仍随请求数线性增长,就可以确认存在真实泄漏;而日志里记下的 URI,能帮你快速圈定是哪条业务路径在漏。PHP 8.3 还提供了 gc_status(),返回 GC 的运行次数、已回收循环数、根缓冲区当前用量等信息,适合作为常驻进程的常规健康指标定期上报。
打点只能确认泄漏存在并圈出大致范围,要定位到具体函数调用,开发环境里可以借助 Xdebug。开启 Xdebug 3 的 trace 功能后,跟踪文件里每一次函数调用都带有内存占用列。把一次泄漏请求的 trace 拉出来,观察哪次调用之后内存只涨不回落,通常能把范围缩小到具体函数。配合前面打点圈出的 URI,复现成本很低。
如果打点显示 memory_get_usage() 平稳、但进程级 RSS(通过 top 或 /proc 观察)持续上涨,泄漏大概率不在 PHP 变量层,而在某个 C 扩展内部——比如扩展申请了内存却没有正确释放 zval。这时 PHP 层的工具全部失效,需要 Valgrind 出场:
USE_ZEND_ALLOC=0 valgrind --tool=memcheck --leak-check=full php reproduce.php
USE_ZEND_ALLOC=0 让 Zend 内存管理器绕过自己的池化分配、直接使用 malloc,这样 Valgrind 才能逐块追踪内存去向。用一个最小复现脚本跑完后,报告里 definitely lost 的条目会指出泄漏发生在哪个扩展的哪段代码。这条路走起来重,但它是扩展层泄漏唯一可靠的定位手段。
到这里,零散的工具可以串成完整流程。开发阶段,给常驻服务加上前面那样的内存打点,压测时让同一请求循环跑几千次,观察强制 GC 后的净增长——任何线性增长都必须在上线前解决。排查时按固定顺序收窄:先 gc_collect_cycles() 区分真假泄漏,再按 URI 圈定业务路径,然后用 Xdebug trace 定位到函数,最后怀疑扩展层时上 Valgrind。
修复手段按场景选择:请求级数据不挂到静态属性和长生命周期对象上;监听器和回调用完即解绑;大数据集用生成器分批产出而不是一次性读进数组;确实需要大型数组时,SplFixedArray 这类紧凑结构能显著降低 zval 开销。PHP 官方手册的垃圾回收章节对引用计数和循环回收的细节有完整说明,值得通读一遍。
最后留一道兜底:即使做了全部排查,常驻进程也建议配置 worker 处理固定请求数后自动重启(如 Swoole 的 max_request),给 memory_limit 留足余量,并对 RSS 增长速率做告警。泄漏可以慢慢查,但服务不能半夜再死一次。
声明:原创文章请勿转载,如需转载请注明出处!