hyperf中需用协程安全的内存缓存拦截重复数据库查询,避免并发下的缓存击穿、数据不一致和内存泄漏;cache::get()因缺乏原子占位能力会导致多次db查询;应通过co::channel实现请求合并,首次未命中时仅放行一个协程查库并广播结果,其余协程等待取值;同时注意静态channel变量的内存泄漏风险,建议配合ttl清理或使用memory-cache扩展。

Hyperf 中重复数据库查询,不能靠“手动加缓存”硬扛,得用协程安全的内存缓存拦截——否则并发下缓存击穿、数据不一致、内存泄漏全来了。
为什么 Cache::get() 在协程里直接用会出问题
Hyperf 默认的 Cache::get() 底层走的是 Swoole\Coroutine\Channel 或 Co::sleep 等协程调度逻辑,但如果你在高并发场景下多个协程同时查同一个 key,又没加锁,就会触发多次 DB 查询——这就是典型的缓存穿透+重复加载。
- 协程间共享的是进程内存,但
Cache::get()本身不提供“首次未命中时的原子占位”能力 - 常见现象:日志里同一 SQL 在 100ms 内被打印出 5 次,而你只期望查一次
- 根本原因:没有利用
Co::waitGroup或Channel做请求合并(request coalescing)
用 Co::channel() 实现协程安全的内存拦截
核心思路是:首次查询未命中时,让所有同 key 的协程等待一个共享 channel,仅放行第一个去查 DB,查完广播结果,其余协程直接取值返回。
示例封装函数:
use Hyperf\Coroutine\Coroutine;
use Swoole\Coroutine\Channel;
function getWithMemoryLock(string $key, callable $loader, int $ttl = 3600): mixed
{
$cache = \Hyperf\Cache\CacheManager::getInstance()->get('default');
$value = $cache->get($key);
if ($value !== null) {
return $value;
}
// 尝试获取全局 channel(用 key 命名)
$channelKey = 'memlock:' . $key;
$channel = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Psr\Container\ContainerInterface::class)->get(\Hyperf\Contract\StdoutLoggerInterface::class);
// 更稳妥做法:用静态数组 + spl_object_hash 防止 key 冲突,但简单场景可用以下方式
static $channels = [];
if (!isset($channels[$channelKey])) {
$channels[$channelKey] = new Channel(1);
}
$channel = $channels[$channelKey];
// 尝试写入 channel(非阻塞),成功表示我是第一个
if ($channel->push(true, 0.001)) {
try {
$value = $loader();
$cache->set($key, $value, $ttl);
$channel->close(); // 关闭后所有 wait 都会立即返回 false
return $value;
} catch (\Throwable $e) {
$channel->close();
throw $e;
}
}
// 其他协程走到这里,等结果或超时
$result = $channel->pop(3.0);
if ($result === false) {
// 超时了,降级为独立查(避免雪崩)
return $loader();
}
return $cache->get($key);
}
- 注意
$channel->push(..., 0.001)的超时必须极短,否则会卡住首个协程 - 不要在
Channel上做复杂逻辑,查 DB 必须在push成功后的 try 块内 - 务必调用
$channel->close(),否则后续协程永远阻塞在pop
用 Hyperf\AsyncQueue\Job 替代高频写缓存的场景
如果查询本身不重,但写缓存操作(比如序列化大对象、JSON 编码)成了瓶颈,Cache::set() 会同步阻塞协程——这时应把缓存写入异步化。
- 适合场景:用户资料查询后需写入含 20+ 字段的缓存,且 QPS > 500
- 不要在协程中直接
json_encode($bigArray)后set,CPU 密集操作会拖慢整个协程调度 - 改用
AsyncQueue::push(new CacheWriteJob($key, $data, $ttl)),让 worker 进程处理序列化和存储 - 注意:异步写缓存意味着“查的时候可能还是空”,所以仍需配合上面的
getWithMemoryLock使用
容易被忽略的内存泄漏点
静态变量缓存 $channels 看似方便,但若 key 是动态拼接(如带用户 ID、时间戳),不清理会导致常驻内存不断增长。
- 禁止用
md5(json_encode($params))当 key,协程退出后引用还在,GC 不回收 - 建议给 channel 加 TTL:用
hyperf/cache的 tag 功能标记 channel 生命周期,或用Co::sleep(60)后 unset 静态 key - 更稳方案:改用
hyperf/memory-cache扩展(非官方,但社区有轻量实现),它内置了 LRU 和过期自动清理
真正难的不是写一次拦截逻辑,而是让 key 的生命周期、channel 的复用边界、异常分支的清理动作全部对齐——少一个环节,压测时就可能内存翻倍。











