长时间运行的 PHP 应用已经很常见了。Swoole、WebMan、Laravel Octane、RoadRunner、ReactPHP 等框架都可以让单个进程持续在后台运行。直到某一天,突然收到通知,你得服务内存爆了。
这不是“memory_get_usage 值很高,赶紧 unset($array)”这种简单问题。真正麻烦的内存泄漏来自于隐藏的引用持有者——那些你没意识到还在持有的引用——它们阻止了垃圾回收器(GC)释放那些你以为已经是“临时对象”的内存。要捕获这些隐藏的引用,需要两个强有力的工具:弱引用用来监控对象是否按预期被释放,堆快照用来发现谁还在持有这些对象。
下面是一份实战指南,包含了常见陷阱、调试技巧和可复现的解决方案,帮助你解决长驻进程的内存膨胀问题。
PHP 的垃圾回收器(GC)其实很靠谱。PHP 引擎会在对象没有任何引用时自动释放内存,并且能够处理大部分的循环引用。真正导致内存被“钉住”的原因通常不是 GC 的问题,而是你忘记了还存在的那些引用:
foreach (&$x)
循环后忘记调用 unset($x)
,导致最后一个元素的引用仍然存在。当你阅读代码时,这些都不像“泄露”。每个看起来都很合理。但是问题就隐藏在它们创建的隐形持有者中。
监控进程的常驻内存大小(RSS)随时间的变化,而不仅仅是看 memory_get_usage(true)
的值。你需要关注的是在不同作业之间内存是否有持续上升的趋势。
在每个作业完成后手动触发垃圾回收(gc_collect_cycles(); gc_mem_caches();
),然后观察 RSS 是否有明显下降。如果 GC 后内存仍然没有释放,说明某个地方还在持有强引用。
设置内存或作业数的软限制,在达到阈值时主动重启进程(例如处理了 N 个作业或运行了 M 秒)。这不是治本的方案,但可以作为调试期间的保护措施。
做好了这些预防措施,现在来看看如何定位和解决实际的泄漏问题。
弱引用可以让你监控对象的生命周期,而不会影响其生命周期。在 PHP 8+ 中,WeakMap 非常适合用来监控“这些对象应该在作业完成后被释放”。
<?php
final class LeakWatch
{
/** @var WeakMap<object,array{label:string,createdAt:float,trace?:array}> */
private WeakMap $seen;
public function __construct()
{
$this->seen = new WeakMap();
}
/**
* 跟踪一个应该短生命周期的对象。
* @param array $context 例如 ['label' => 'Order DTO', 'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)]
*/
public function watch(object $o, array $context): void
{
$context['createdAt'] = microtime(true);
$context['label'] ??= get_debug_type($o);
$this->seen[$o] = $context;
}
/** @return array<int,array{label:string,count:int,oldest:float,newest:float}> */
public function histogram(float $olderThanSeconds = 2.0): array
{
$now = microtime(true);
$buckets = [];
foreach ($this->seen as $obj => $meta) {
$age = $now - $meta['createdAt'];
if ($age < $olderThanSeconds) {
continue; // 最近创建的;不可疑
}
$label = $meta['label'];
$buckets[$label]['label'] = $label;
$buckets[$label]['count'] = ($buckets[$label]['count'] ?? 0) + 1;
$buckets[$label]['oldest'] = isset($buckets[$label]['oldest'])
? min($buckets[$label]['oldest'], $meta['createdAt'])
: $meta['createdAt'];
$buckets[$label]['newest'] = isset($buckets[$label]['newest'])
? max($buckets[$label]['newest'], $meta['createdAt'])
: $meta['createdAt'];
}
// 按数量倒序排列
usort($buckets, fn($a,$b) => $b['count'] <=> $a['count']);
return array_values($buckets);
}
}
在那些应该是短生命周期的对象上使用:每条消息创建的 DTO、每个请求加载的 ORM 实体、临时事件监听器、作业级别的服务对象。
$leakWatch = new LeakWatch();
function handleJob(Job $job, LeakWatch $leakWatch) {
$dto = OrderDto::from($job->payload);
$leakWatch->watch($dto, [
'label' => 'OrderDto',
// 保持回溯小巧;如果需要可以在标志后面添加
]);
// ... 处理 ...
}
现在可以定期(比如每 30 个作业)输出统计信息:
if ($i % 30 === 0) {
$suspicious = $leakWatch->histogram(olderThanSeconds: 5.0);
foreach ($suspicious as $row) {
fprintf(STDERR, "[leakwatch] %s stuck x%d (oldest %.1fs ago)\n",
$row['label'], $row['count'], microtime(true) - $row['oldest']);
}
}
如果对象在多个作业之间仍然存在,而你没有主动保持引用,说明有其他地方在持有它们。
WeakMap 的妙处在于:当对象的最后一个强引用消失时,对应的条目会自动从 WeakMap 中消失。最终留在映射中的只有那些“幸存者”——也就是你的潜在内存泄露——而且监控本身不会影响对象的正常释放。
$listener = function (Order $order) use ($container, $logger) {
// ...
};
$dispatcher->addListener('order.created', $listener);
// ... 稍后 ...
// $dispatcher 从不移除 $listener 并保持对它的强引用。
闭包捕获了 $container
和 $logger
。如果调度器在整个 worker 生命周期中都存在,那么闭包也会一直存在,被捕获的变量也无法释放。
修复
优先使用像 [$service, 'onOrderCreated']
这样的方法回调,通过支持弱引用的容器注册,或者显式调用 removeListener
。
如果必须使用闭包,尽量减少捕获的上下文:
$dispatcher->addListener('order.created', static function (Order $o) {
OrderCreatedHandler::handle($o); // 没有 $this,没有捕获的 $container
});
对于自定义调度器,存储观察者的 WeakReference 并在调度时删除死的。
final class WeakListenerBus
{
/** @var array<string, array<int, WeakReference>> */
private array $listeners = [];
public function listen(string $event, object $listener): void
{
$this->listeners[$event][] = WeakReference::create($listener);
}
public function dispatch(object $event): void
{
$alive = [];
foreach ($this->listeners[get_class($event)] ?? [] as $ref) {
if ($obj = $ref->get()) {
$obj($event);
$alive[] = $ref;
}
}
$this->listeners[get_class($event)] = $alive; // 清扫死的
}
}
ORM 会持有实体引用来进行变更跟踪。在短生命周期的请求中这没问题,但在长驻进程中会造成内存不断累积。实体在作业结束后仍然存在,是因为 UnitOfWork 还在引用它们。
修复
每个作业完成后调用 $em->clear();
或针对特定类型的 clear(ClassName::class)
。
不要在长生命周期服务上存储实体;改为存储标识符。
在作业代码和长生命周期基础设施之间的边界优先使用分离的数据(数组/DTO)。
一个缓存最近 N 条日志记录的日志器看起来无害,直到长驻进程处理了成千上万个作业。一些日志处理器会持续累积记录数组或闭包,等待后续的批量处理。
修复
在长驻进程中禁用缓冲或每个作业都冲洗。
在长驻进程中将同步阻塞处理器替换为快速、非缓冲的传输。
那种“随手加个静态数组做缓存”的做法在长驻进程中是危险的,因为它永远不会清理。
修复
如果缓存的 key 是对象,优先使用 WeakMap,这样当 key 对象被释放时缓存条目也会自动清理。
为静态缓存设置大小限制和过期时间,或者将它们封装在可重置的服务中。
WeakMaps 告诉你什么没有死亡;快照告诉你谁在保持它们活着。
有一些生产友好的选项:
一个最小的快照钩子(在环境标志后面保护)看起来像这样:
function heap_snapshot(string $why): void {
if (!function_exists('meminfo_dump')) {
return;
}
$ts = date('Ymd-His');
$file = "/tmp/heap-{$ts}-" . preg_replace('/\W+/', '_', $why) . ".json";
meminfo_dump($file); // 或你的工具期望的格式
fprintf(STDERR, "[heap] dumped snapshot: %s\n", $file);
}
在稳定点丢弃它:就在 worker 因通过软内存上限而重启之前,或者在 N 个作业后当你的 WeakMap 直方图仍显示幸存者时。
当分析快照时,你在寻找保留大小和根路径。一个常见的冒烟枪:
Closure
└── static $this => Some\LongLived\Service
└── $container => Pimple\Container
└── services => array
└── ...
或者:
Doctrine\ORM\UnitOfWork
└── identityMap => array
└── App\Entity\Order#123 => Order
这个图确切地告诉你哪个服务、数组或静态属性在持有引用。在源头修复它。
技巧: 拍两个快照 —— 一个在"好"作业后,另一个在你运行了几个"坏"作业后。对它们进行差异比较。自动加载类的噪音消失;真正的增长突出显示。
一个强化的循环结合了仪表、清洁和逃生舱:
<?php
final class Worker
{
public function __construct(
private MessageQueue $queue,
private LeakWatch $leakWatch,
private int $maxJobs = 500,
private int $softBytes = 256 * 1024 * 1024,
) {}
public function run(): void
{
gc_enable(); // 在 workers 中明确启用
$jobs = 0;
while (true) {
$msg = $this->queue->reserve(timeout: 5);
if (!$msg) {
$this->housekeeping($jobs);
continue;
}
try {
$this->handle($msg);
} finally {
$this->afterJob($jobs);
$jobs++;
if ($jobs >= $this->maxJobs || memory_get_usage(true) > $this->softBytes) {
heap_snapshot('soft-exit');
fwrite(STDERR, "[worker] Soft exit after {$jobs} jobs; RSS=".memory_get_usage(true)."\n");
return; // 让监督器重启我们
}
}
}
}
private function handle(Message $msg): void
{
// 典型的观察位置
$dto = PayloadDto::from($msg->body);
$this->leakWatch->watch($dto, ['label' => 'PayloadDto']);
// ... 域逻辑 ...
}
private function afterJob(int $jobs): void
{
// 清洁
gc_collect_cycles();
gc_mem_caches();
// ORM/客户端重置(示例)
if (isset($this->em)) { $this->em->clear(); }
if ($this->http) { $this->http->reset(); } // 例如,如果需要,丢弃保持活着的池
// 定期内省
if ($jobs % 50 === 0) {
foreach ($this->leakWatch->histogram(olderThanSeconds: 10.0) as $row) {
fprintf(STDERR, "[leakwatch] %s stuck x%d (oldest %.1fs)\n",
$row['label'], $row['count'], microtime(true) - $row['oldest']);
}
}
}
private function housekeeping(int $jobs): void
{
// 定时任务的地方;空闲时避免漂移
}
}
这个 Worker 循环的设计特点:
现象:每处理大约 200 个作业后,RSS 内存会增加 1-2 MB。通过 WeakMap 发现 OrderDto 对象在作业完成几分钟后仍然没有被释放。
堆快照显示:闭包 → $this → OrderEventSubscriber → $container → 监听器数组。问题在于订阅者是每个作业都注册一次,而不是在程序启动时注册一次。
修复:
removeListener($subscriber)
(或使用在范围退出时自动取消注册的 DisposableListener 包装器)。修复后:“幸存者”从 WeakMap 统计中消失;堆快照也不再显示 OrderDto 被调度器引用。
现象:一个 Messenger 进程每处理 50 个数据库读取作业后,内存会增加 3-4 MB。
堆快照显示:Doctrine\ORM\UnitOfWork → identityMap 中累积了来自之前作业的数百个实体对象。
修复:
EntityManager::clear()
;如果完全清理太昂贵,也可以对热实体执行 clear(Class::class)
。修复后:内存使用量在作业之间返回到稳定的基线水平。
在使用 Swoole 协程或 Fiber 时,回调函数可能比创建它的原始上下文存在更久。比如一个定时器或延迟任务中的闭包可能捕获了大量上下文数据。
$this
的闭包;改为调用静态方法或使用专门的轻量级回调对象。finally
块中移除它们。unset($var)
,但这个变量同时被闭包或全局作用域引用——这样做无法真正释放内存。gc_collect_cycles()
来解决实际上是强引用造成的问题。GC 只能处理循环引用,但无法断开正在被使用的强引用。实现服务重置机制:在 Symfony 中可以实现 ResetInterface 接口,在每个作业间调用重置方法。在 Laravel Octane 中可以设置最大请求 N 次之后重启服务。
设置缓冲区上限:对于任何会累积数据的组件(如日志缓冲区、内存队列),都要设置合理的上限,超过限制时及时清理或刷新。
使用 WeakMap 存储对象元数据:如果需要为对象附加元数据(比如缓存与对象相关的计算结果),优先使用 WeakMap<object,mixed>
,这样当对象被释放时元数据也会自动清理。
避免在长生命周期的事件总线上注册临时监听器。如果必须按作业注册,记得在 finally 块中及时移除。
设置安全防线:设置最大作业数、最大运行时间和软内存限制,超过阈值时优雅重启进程。
在开发标志后面丢掉这个。它打印 10 个最老的幸存者,这样你可以转向快照。
final class TopSurvivors
{
/** @var WeakMap<object,array{label:string,createdAt:float}> */
private WeakMap $w;
public function __construct() {
$this->w = new WeakMap();
}
public function mark(object $o, string $label = null): void {
$this->w[$o] = ['label' => $label ?? get_debug_type($o), 'createdAt' => microtime(true)];
}
public function report(int $limit = 10, float $age = 5.0): void {
$now = microtime(true);
$rows = [];
foreach ($this->w as $obj => $m) {
$a = $now - $m['createdAt'];
if ($a < $age) continue;
$rows[] = [$m['label'], $a];
}
usort($rows, fn($a,$b) => $b[1] <=> $a[1]);
foreach (array_slice($rows, 0, $limit) as [$label, $ageSec]) {
fprintf(STDERR, "[survivor] %-30s %.1fs old\n", $label, $ageSec);
}
}
}
在你的处理器中标记热对象;每 N 个作业调用 report()
。如果幸存者持续出现,年龄跨越作业边界,获取快照并跟随根路径。
zend.enable_gc=1
)。在作业之间调用 gc_collect_cycles()
。&
引用在 foreach (&$x) {}
后徘徊;循环后 unset($x)
。$this
。PHP 长驻进程中的内存泄漏其实并不神秘。它们本质上就是一些"不受欢迎的客人"——那些本该被清理掉却还在占用内存的引用。弱引用可以帮你发现哪些对象该死不死;堆快照能告诉你究竟是谁还在抓着它们不放。再配合一些进程清理策略和重启机制,那些令人头疼的内存增长曲线就会变成一条平稳的直线——这才是运维团队最想看到的监控图表。