缓存击穿在hyperf中需用set的nx+px原子加锁、lua脚本校验token释放锁、未抢到锁时轮询+双检,且锁变量须限于方法作用域或协程上下文,避免单例service状态污染。

缓存击穿在 Hyperf 里不是靠“加个锁就完事”,而是必须结合协程生命周期、Redis 原子性、以及锁释放的可靠性来设计;否则容易出现死锁、锁误删、或 fallback 失效。
用 set 的 NX + PX 实现安全互斥锁
Hyperf 的 Redis 客户端支持原生命令参数,set($key, $value, ['NX', 'PX' => 5000]) 是唯一推荐的加锁方式。它保证原子性:不存在才设值 + 自动过期,避免手动 set + expire 的竞态问题。
常见错误是只用 setnx(即 SETNX),但没配超时,一旦进程崩溃或协程异常退出,锁永远不释放。
-
NX确保只有一个请求能抢到锁 -
PX必须设(建议 3–10 秒),防止锁滞留 - 不要用
INCR或GETSET模拟锁——它们无法同时满足原子性和自动过期
锁释放必须用 Lua 脚本校验 token
直接 del($lockKey) 是危险操作:A 请求拿到锁、查 DB 耗时略长,B 请求等超时后也去查 DB 并写缓存,接着 A 才执行 del,结果把 B 刚设的锁删了——后续所有请求都绕过锁直冲 DB。
正确做法是生成随机 token(如 bin2hex(random_bytes(8))),加锁时存为 value,释放时用 Lua 校验 value 是否匹配:
$lua = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
$redis->eval($lua, [$lockKey, $token], 1);
这个脚本在 Redis 端原子执行,杜绝误删。
未抢到锁时不能立即返回 null,要轮询 + 双检
如果没抢到锁就直接返回空或抛异常,等于放弃缓存,所有请求都会打 DB。必须让等待方短暂休眠后主动重读缓存(double-check):
- 用
usleep(50000)(50ms)比sleep(0.05)更轻量,避免协程调度开销 - 重读后仍为空,**不重试加锁**,而是直接返回 null 或走降级逻辑——避免雪崩式重试
- 轮询次数建议 ≤ 3 次,否则可能拖慢整体响应
注意 Hyperf 单例 Service 的状态污染风险
缓存击穿逻辑常封装在 Service 中,而 Hyperf 默认 Service 是单例。如果在方法里用了类属性临时存 $lockKey 或 $token,多个协程会互相覆盖:
class UserService {
private $lockKey; // ❌ 危险!跨协程共享
public function getUser(int $id) {
$this->lockKey = "lock:user:{$id}"; // 下一个协程进来就覆盖了
}
}
所有锁相关变量必须严格限定在方法作用域内,或通过 Context::set() 绑定当前协程上下文。
真正难的不是写对一行 set,而是确保整个锁流程在协程模型下不泄漏、不残留、不干扰其他请求——这要求你对 Hyperf 的生命周期和 Redis 原语都有明确边界感。











