九一八事变纪念日|1931年9月18日,日本侵略者制造九一八事变,开启了长达14年的侵华战争。警钟长鸣,吾辈自强!

PHP 内存泄漏排查:一个长驻进程吃掉 2GB 的真相

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-18 10:30 ·2 浏览 ·0 回复

结论:PHP 长驻进程内存只涨不跌,绝大多数情况不是 PHP 引擎自己在漏,而是**你在一个永不结束的进程里,用全局变量、静态数组或事件注册表,把对象一个一个攒下来却没放手**。定位靠"打点 + 计数 + 二分支出谁在持有引用",修复靠"缓存设上限 + 循环末尾 unset + 分批重启"这三板斧。

先分清"内存涨"和"内存泄漏":CLI 常驻才是真坑

PHP-FPM 模式下不用担心这件事——每个请求结束,所有变量随请求生命周期一起销毁,内存天然回收。真正出问题的是 CLI 长驻进程:队列消费者、WebSocket 服务、定时守护脚本。这类脚本经常被写成 `php -d memory_limit=-1 worker.php`,等于把安全气囊拆了上路,涨到 2GB 是迟早的事。

结论:只有在常驻进程里,"内存只涨不降"才构成泄漏事故;FPM 下看到的 RSS 上涨通常只是进程池复用,不是漏。

判断方法很简单,在循环里打点:

while (true) {
    $job = $queue->pop();
    handle($job);
    if ($i++ % 1000 === 0) {
        echo sprintf("cnt=%d used=%.1fMB real=%.1fMB peak=%.1fMB\n",
            $i,
            memory_get_usage()/1048576,
            memory_get_usage(true)/1048576,
            memory_get_peak_usage(true)/1048576
        );
    }
}

跑十分钟看输出:如果 `used` 呈锯齿状波动(涨上去又掉回来),那是正常的高峰;如果每轮都比上一轮高一截、形成阶梯,就是真泄漏。

第二步:先分辨"真持有"还是"循环引用垃圾"

很多人一看到内存不降就去调 `gc_collect_cycles()`,其实要先确认类型。手动在打点处加一行 `gc_collect_cycles()`,观察内存是否明显回落:

  • 回落了 → 是循环引用(对象互相持有、闭包捕获 `$this`),GC 能收,属于"回收不及时";
  • 一点不降 → 是真持有,有变量或容器一直在引用它,GC 也无能为力。

这一步能省掉大量瞎猜的时间。PHP 用的是引用计数 + 周期性 GC,引用计数归零立刻释放,所以"不降"几乎一定意味着还有人在引用

第三步:四个高频元凶,挨个对号入座

元凶一:静态数组当缓存,只写不删。 最典型的是 `static $cache = [];` 然后在循环里 `$cache[$id] = $row;`。改成带容量上限和过期时间的写法,比如用系统里现成的 `Cache::remember` 这类带 TTL 的缓存设施(Clara BBS 的插件公共设施里就有),而不是自己手搓一个无限增长的静态数组。

元凶二:事件/钩子注册只增不减。 如果每处理一条任务就往监听器列表里 `append` 一个闭包,处理一万条就有一万个闭包挂在内存里。结论:注册动作必须放在循环外面,循环体里只触发、不注册。 这类问题在靠运行时钩子驱动的系统里尤其常见。

元凶三:循环里拼大字符串。 `$log .= $bigContent;` 每次拼接都可能触发字符串复制,峰值内存直接翻倍。改成写文件句柄、或每 N 条 flush 一次。

元凶四:数据库结果集和 PDO 相关的残留。 长连接本身没问题,但把 `fetchAll()` 的结果挂在对象属性上不放,几百轮下来就是几百份数据。每轮结束 `unset($rows, $stmt)`。

排查技巧:除了看内存,更要看计数。`count($cache)`、`count($listeners)` 这种数字比内存数字更早、更准地暴露问题,而且一眼就能看出是哪个容器在长。

第四步:修复三板斧 + 兜底

第一,所有缓存必须有上限和 TTL,超过就淘汰,绝不允许无界增长;第二,循环体末尾 `unset` 掉本轮的大变量,让引用计数归零;第三,也是最省事的一招——分批重启:每处理 500 或 1000 条就 `exit(0)`,交给 supervisor/systemd 拉起来,进程重启等于天然的内存归零。这不是偷懒,队列消费者、邮件推送、定时任务(比如基于 Cron 注册的懒触发任务)用这招都很合适。

最后别把 `memory_limit` 设成 `-1` 上线,设一个比如 512M 的兜底值,让它在失控时崩掉并留下日志,而不是把机器拖死。

两个容易误判的细节

`memory_get_usage(true)` 是向操作系统实际申请的内存量,按段增长(常见 2MB 一段),所以它会跳变;`memory_get_usage(false)` 才是脚本真实使用的字节数。看泄漏趋势要以后者为准,看进程 RSS 才是前者。

另外,PHP 8.x 打开 JIT 后 `opcache.jit_buffer_size` 会占一块固定内存,程序一启动就吃掉几十上百 MB 且不释放,这是设计如此,不是泄漏——排查前先把这个常量基数减掉,别自己吓自己。

总结一下:先打点确认是阶梯式增长而非锯齿波动,再用 `gc_collect_cycles()` 区分"循环引用"和"真持有",然后重点查静态数组、钩子注册表、循环内字符串拼接和未释放的结果集这四个地方,修复时给缓存加上限和 TTL、循环末尾 unset、必要时分批重启,最后用 memory_limit 兜底并做一轮 10 倍数据量的回归验证。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-467.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~