缓存击穿是高并发下热点数据过期瞬间大量请求穿透缓存直打数据库,因ThinkPHP缓存操作无原子性且缺互斥锁机制,需用Redis SETNX配合Lua脚本实现带校验的分布式锁,并注意锁超时、释放安全及重建失败兜底。

缓存击穿在ThinkPHP里到底怎么发生的
高并发下,某个热点数据过期瞬间大量请求穿透缓存直打数据库,这就是缓存击穿。ThinkPHP默认的 Cache::get() 和 Cache::set() 不带原子性,多个请求同时发现缓存失效,全去查库、全去写缓存,结果数据库被压垮、缓存写入混乱。
关键不是“有没有缓存”,而是“缓存失效那一刻谁有资格重建”。ThinkPHP原生没提供互斥锁(Mutex)机制,得自己补。
用Redis SETNX实现轻量互斥锁
最直接的方式是借Redis的 SETNX(SET if Not eXists)命令,在缓存失效时抢一个临时锁key,抢到的进程负责查库+回填缓存,其余等待或重试。
-
lock_key建议拼成"mutex:goods:".$goods_id,和业务缓存key隔离,避免误删 - 必须设过期时间(比如10秒),防止进程崩溃导致死锁:
$redis->setex($lock_key, 10, time()) - 不能只靠
SETNX后 sleep 再查——要循环检查锁是否释放,或用短超时+随机退避 - 释放锁必须用 Lua 脚本校验 value,避免A进程锁过期后B进程覆盖写入,A又误删B的锁
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
ThinkPHP中封装Mutex类要注意的三个坑
直接在控制器里手写锁逻辑容易漏释放、错判断、不兼容多实例部署。建议封装为独立服务类,但注意:
立即学习“PHP免费学习笔记(深入)”;
- 别用
Cache::tag()管理锁——Tag底层依赖文件或Redis的key前缀,不保证原子性,且部分驱动不支持 - 别把锁逻辑塞进模型的
getAttr或访问器里——缓存穿透常发生在列表页批量查,单个模型层无法感知全局竞争 - Redis连接要用
Pool或长连接,避免每次新建连接拖慢锁判定;若用phpredis扩展,确认开启了connect_timeout防卡死
缓存重建失败时的兜底策略
互斥锁只解决“谁来重建”,不解决“重建失败怎么办”。线上见过太多因为DB慢查询、API超时,导致持有锁的进程卡住或异常退出,其他请求无限等待。
- 锁超时时间必须 严格小于 缓存本身的过期时间(比如缓存TTL=60s,锁TTL=5s),否则锁释放了但缓存还没写上,又触发下一轮穿透
- 查库失败时,不要简单抛异常,应主动删除锁key,并返回旧缓存(哪怕过期几秒)——用
Cache::get($key, null, true)强制读取过期值(需驱动支持) - 对极敏感接口,可加本地内存锁(如
apcu_add())做第一道过滤,但仅限单机,跨机器仍需Redis
真正难的不是写一个能抢锁的函数,而是让锁在超时、失败、网络抖动、进程重启各种边界下都不留空档。线上跑一周比本地压测十次更说明问题。


















