必须在每条日志中显式携带协程id(co::getcid())和请求id($request->getattribute('request_id')),禁用全局变量记录,否则多协程日志混杂导致泄漏点无法关联。

协程日志混乱导致泄漏点无法关联
协程环境下,多个请求共享同一个 worker 进程,日志混在一起,根本分不清哪条是哪个协程打的——这会让内存泄漏排查变成“盲人摸象”。关键不是日志没打,而是打了也找不到上下文。
必须在每条日志里显式带上两个标识:\Swoole\Coroutine::getCid()(协程 ID)和 $request->getAttribute('request_id')(请求 ID)。不能依赖 $_SERVER 或全局变量,它们在协程间不隔离。
- 错误写法:
$this->logger->info('user login')—— 无上下文,多协程并发时日志全搅在一起 - 正确写法:
$this->logger->info('user login start', ['cid' => \Swoole\Coroutine::getCid(), 'rid' => $request->getAttribute('request_id') ?? 'unknown']) - 如果用的是自定义 Formatter,需确保
LineFormatter的include_stacktraces参数设为true,否则协程堆栈直接丢弃
定时任务执行后内存不回落
Worker 进程 ID 持续增长(比如跑到 78 万),且内存上涨节奏与定时任务触发时间吻合(如整点、每分钟),基本可锁定是 @Cron 任务引发的累积型泄漏。它不像单次请求那样结束后自动清理,而是在常驻进程里反复执行、变量越积越多。
常见泄漏模式:
-
static $cache = [];或全局数组反复[]=追加,从不unset或clearstatcache() - 闭包
use ($container)捕获了整个容器实例,导致所有服务、连接池对象无法被 GC 回收 -
Redis::pipeline()->multi()后异常跳出,未调用exec(),连接卡在协程上下文里不归还 - 忘记在循环中主动触发
gc_collect_cycles(),尤其当任务内创建大量小对象(如模型实例、DTO)时
异步队列 Job 实例长期驻留内存
异步队列消费者进程不退出,Job 对象一旦初始化就一直活着,哪怕 handle() 执行完了也不会自动销毁——只要它持有对 $container、$logger 或自身实例的强引用,GC 就永远绕不开它。
最隐蔽的泄漏源是日志记录:
- 禁止写
$logger->info('job done', ['job' => $this]),$this会被日志 handler 强持有 - 构造函数里直接赋值
$this->container = $container是高危操作,应改为在handle()中按需获取 - 检查
config/autoload/async_queue.php:若'max_attempts'> 1 且失败任务堆积,会反复 new Job 实例,资源翻倍占用 - 紧急止血可用
php bin/hyperf.php async-queue:flush-failed,但注意该命令不可逆
用 Swoole Tracker 抓真实泄漏点
靠日志猜不如用工具看——swoole_tracker 是目前 Hyperf 生产环境最有效的内存泄漏定位手段,它能捕获每次 malloc 调用的调用栈,精准定位到哪行 PHP 代码申请了没释放的内存。
配置要点(必须严格):
- 启用
tracker.enable_malloc_hook=1,同时tracker.enable=0(二者不能同时开) -
tracker.sampling_rate=100确保全量采集,采样率低于 100% 会漏掉关键分配点 - 必须用与当前 PHP 版本、Swoole 版本完全匹配的
swoole_tracker.so,否则扩展加载失败或数据错乱 - 配合
tracker.enable_memcheck=1启动后,运行一段时间再执行php bin/hyperf.php tracker:dump查看泄漏报告
真正难的不是跑起来,而是解读 dump 结果里那一长串 C 层调用栈——它往往指向 PDO、Redis 或 Swoole 底层,得结合业务代码反推哪段 PHP 触发了这些底层分配,这才是泄漏定位的最后一公里。











