欢迎访问晨星博客!

  • 当前位置: 首页 PHP开发 正文

    PHP 高并发场景下的内存优化:从垃圾回收机制到内存泄漏排查

    生成摘要
    AI 生成,仅供参考

    凌晨三点,监控告警又响了:跑在 Swoole 上的订单 API 服务,worker 进程内存从启动时的几十 MB 缓慢爬到了 memory_limit 上限,被系统直接杀掉。重启后一切正常,三天后再次重演。如果你维护过常驻进程的 PHP 服务——无论是 Swoole、RoadRunner 还是消息队列消费者——这类"慢性失血"几乎迟早都会遇到。

    传统 PHP-FPM 模式下,请求结束进程内所有变量被整体回收,每次泄漏几 KB 根本无感。常驻进程则把每一次泄漏都留了下来:每个请求漏 100 KB,一万个请求就是 1 GB。要根治这个问题,得先搞清楚 PHP 到底什么时候会回收内存、什么时候不会。

    常驻 PHP 进程内存持续增长的抽象示意图

    PHP 的垃圾回收:引用计数之外的第二道防线

    PHP 变量底层都是 zval 结构,每个 zval 带着一个引用计数(refcount)。赋值、传参时计数加一,unset 或离开作用域时减一,归零的那一刻内存立刻释放——这是第一道防线,也是绝大多数变量的一生。

    问题出在循环引用。对象 A 的属性持有对象 B,B 又引用回 A,即使外部已经没有任何变量指向它们,两者的 refcount 都不为零,谁也无法被释放。PHP 5.3 引入的垃圾回收器(GC)就是为此准备的第二道防线:引擎会把"可能是循环引用根"的数组和对象放进一个根缓冲区(root buffer),当缓冲区攒满 10000 个候选根时,GC 启动一轮遍历,对每个候选根模拟引用计数减一,找出那些从外部不可达的对象簇,整体回收。

    对常驻进程来说,这套机制有三个事实特别值得记住:

    • GC 是被动的,只有根缓冲区攒满才会触发,除非手动调用 gc_collect_cycles()。循环引用产生得慢,泄漏对象就会在内存里躺很久。
    • gc_collect_cycles() 的返回值是本次回收的循环数量,这个数字本身是排查时的重要信号。
    • GC 有 CPU 成本,高并发下每轮回收都要遍历候选根。有些常驻服务会用 zend.enable_gc=0 关掉它、改在请求间隙手动回收——这可行,但前提是确定代码里没有循环引用,否则等于关掉了唯一的止血手段。

    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() 建立第一道监控

    怀疑泄漏之后,第一步不是直接上重型工具,而是把内存增长变成可观测的数字。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 与 Valgrind:从"哪条路在漏"到"哪一行在漏"

    打点只能确认泄漏存在并圈出大致范围,要定位到具体函数调用,开发环境里可以借助 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 增长速率做告警。泄漏可以慢慢查,但服务不能半夜再死一次。

    声明:原创文章请勿转载,如需转载请注明出处!

    下一篇

    没有了,已经是最新文章

    • 抢沙发

    请登陆后再发表您的观点吧!

    账号登陆

    快捷登陆