Hyperf协程内存居高不下是堆外泄漏,需检查连接池、协程数与游标行为;禁用PDO缓冲并改用游标读取;Context::set须配对del;Job类避免构造函数强引用。

Hyperf 协程内存居高不下,先确认是不是堆外泄漏
看到 OOMKilled(exit code 13)但日志里没有 Fatal error: Allowed memory size of ... exhausted,基本可断定是堆外泄漏——PHP 堆内存没爆,但 Swoole 协程栈、Redis 连接池、PDO 缓冲结果集这些堆外资源占满了系统内存。
此时改 memory_limit 或调 gc_collect_cycles() 没用,得盯住连接池、协程上下文和游标行为:
- 执行
php bin/hyperf.php info | grep -E "(coroutine|pool)"查当前协程数与连接池配置是否严重失配 - 用
ss -s | grep ESTAB看 ESTABLISHED 连接数是否长期逼近pool.max_connections - 检查
co::stats()返回的coroutine_num和coroutine_peak_num是否持续上涨且不回落
禁用 PDO 缓冲 + 强制游标读取是硬性操作
Db::select() 或 User::query()->get() 默认启用 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,哪怕只 fetch 一行,整张关联表(比如订单+明细连表后 50 万行)也会全量加载进内存。这不是 SQL 问题,是 PDO 默认行为。
必须在数据库配置中显式关闭缓冲:
'options' => [
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,
]
同时禁止使用 get() / all(),改用:
- Hyperf 3.x:
DB::query()->cursor()(真游标,逐行拉取) - Hyperf 2.x 或需兼容场景:手写
while ($row = $stmt->fetch()) { ... }配合原生query() - 连表查询务必加
limit+offset分批,或用主键范围分片(id BETWEEN ? AND ?)
Context::set() 必须配对 Context::del(),否则内存缓慢爬升
协程退出时,Context::set('key', $bigArray) 存进去的数据不会自动释放。常见于中间件、装饰器、日志上下文注入等场景,积累数小时后 RSS 内存单向增长,重启才回落。
所有 Context::set() 调用都应有明确清理路径:
- 在
try/finally中配对Context::del(),尤其涉及 DB 连接、Redis 实例、大对象缓存时 - 避免把
$container、$redis、$logger等强引用塞进上下文 - Hyperf 3.2+ 可启用
hyperf/memory-leak-detector扫描长期驻留的上下文键 - 验证方式:在
onRequest开头设Context::set('trace_id', uniqid()),并发请求中var_dump(Context::get('trace_id'))应互不相同;若出现重复或残留,说明上下文污染
异步队列 Job 类必须切断闭包强引用
Job 构造函数里直接注入 ContainerInterface 或赋值 $this->logger = $logger,会导致整个容器或日志实例被 Job 对象长期持有,GC 无法回收。更危险的是 logger->info('xxx', ['job' => $this]),会把 Job 实例本身传入日志 handler,形成强引用闭环。
重构要点很具体:
- 构造函数只接收基础参数(如
$taskId,$type),不传任何服务对象 - 所有依赖延迟到
handle()中通过$this->container->get()获取 - 日志 extra 数组禁止传
$this、$container、__FILE__等可能引发引用的对象 - 关闭自动重试:
'max_attempts' => 1,防止失败任务反复初始化资源
协程内存精简不是调几个参数就能解决的事——它本质是资源生命周期管理意识的问题。只要 Context::set 不配 del、pipeline 不包 try/finally、Job 还在构造函数里 hold 容器,再小的并发也能把内存撑爆。


















