缓存击穿是热点key过期瞬间多请求并发回源压垮数据库,ThinkPHP需用Redis原子SETNX加锁(如set($lockKey, 1, ['nx','ex'=>5]))、Lua脚本校验释放、锁缓存同DB、封装Mutex服务并设兜底策略。

缓存击穿不是“有没有缓存”的问题,而是热点数据过期那一瞬间,多个请求同时发现缓存失效、全去查库、全去写缓存,把数据库打崩。ThinkPHP 原生不带互斥锁机制,必须自己补,但不能靠简单 setnx + sleep 就完事——容易锁错、删错、等死、重建失败还卡住。
Redis SETNX + 过期时间是基础门槛
用 Redis 实现轻量互斥锁,核心是原子性占位:
- 锁 key 必须和业务缓存 key 对应且隔离,比如缓存用
goods:123,锁就该用mutex:goods:123,避免误删或跨业务干扰 - 必须带过期时间(如 5 秒),防止进程崩溃后锁一直挂着:
$redis->setex($lockKey, 5, uniqid()) - TP 5.1 推荐用
Cache::handler()->set($lockKey, 1, ['nx', 'ex' => 5]),前提是驱动是 Redis 且配置了'persistent' => true和'select' => 0(确保锁和缓存落在同一个 DB)
释放锁必须用 Lua 脚本校验 value
直接 $redis->del($lockKey) 是危险操作:A 进程加锁后卡住超时,B 进程抢到锁并完成重建,A 恢复后一删,就把 B 的锁干掉了——下个请求进来,A 和 B 又同时重建,击穿重现。
安全释放方式(原子执行):
立即学习“PHP免费学习笔记(深入)”;
eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 mutex:goods:123 1678901234
其中 ARGV[1] 是加锁时写入的唯一标识(如 uniqid()),确保只删自己设的锁。
别在模型或控制器里零散写锁逻辑
手写锁容易漏 del、难复用、多实例部署时锁失效:
- 不要塞进模型的
getAttr或访问器里——列表页批量查时,单个模型无法感知全局竞争 - 不要用
Cache::tag()管理锁——Tag 底层不保证原子性,部分驱动还不支持 - 建议封装成独立
Mutex服务类,统一处理加锁、等待、重试、释放,并内置随机退避(如 10–50ms)和最大重试次数(如 3 次)
重建失败要有兜底,不能让请求无限等
互斥锁只管“谁来重建”,不管“重建失败怎么办”:
- 锁 TTL 必须严格小于缓存 TTL(如缓存 60 秒,锁最多设 5 秒),否则锁释放了但缓存没写上,又触发下一轮穿透
- 查库失败时,主动删锁 + 返回旧缓存(哪怕已过期):
Cache::get($key, null, true)(需 Redis 驱动支持强制读取过期值) - 对极敏感接口,可加 APCu 做本地内存锁(
apcu_add())作为第一道过滤,但仅限单机 FPM 环境



















