最稳的缓存穿透解决方案是用 set 命令原子性加锁(nx+px),配合 lua 脚本安全释放;本地锁因无法跨进程/机器失效,必须用 redis 分布式锁;setnx+expire 分步操作必然导致死锁,lua 释放可避免误删他人锁。

直接用 SET 带 NX 和 PX 参数加锁,再配合 Lua 脚本安全释放,是解决缓存穿透最稳的路径。别用 SETNX + EXPIRE 分两步,那不是“可能出错”,而是“一定会在高并发下漏锁”。
为什么缓存穿透必须用分布式锁,而不是本地锁
本地锁(比如 flock 或 apcu_store)只在单进程内生效。当 PHP-FPM 有 20 个 worker、Nginx 启了多台机器、或服务跑在 Kubernetes 多个 Pod 里时,本地锁完全失效——1000 个请求同时发现缓存空,1000 个都去查 DB。
Redis 分布式锁能跨进程、跨机器、跨容器起作用,前提是所有实例连的是同一个 Redis(或 Redlock 多节点)。穿透问题本质是“多个客户端同时击穿缓存”,解法只能是“让它们排队查一次,其余等结果”。
SET 命令必须带 NX 和 PX,不能拆开
常见错误写法:
$redis->setnx($lockKey, $value); // 成功返回 1 $redis->expire($lockKey, 10); // 这一步可能失败(网络抖动、超时、Redis 拒绝)
一旦 EXPIRE 失败,$lockKey 就永久存在,变成死锁。正确做法是原子执行:
-
SET $lockKey $value NX PX 10000—— 10 秒过期,毫秒级,防误删 -
NX保证互斥:键已存在则整个命令失败,不覆盖 -
PX(不是EX)更精确,避免秒级精度导致临界时间误差 - 必须传
$value(如uniqid('', true)),后续释放锁时靠它验身份
释放锁必须用 Lua 脚本,不能先 GET 再 DEL
以下代码看似合理,实则危险:
if ($redis->get($lockKey) === $myValue) {
$redis->del($lockKey);
}
问题在于:GET 和 DEL 是两个网络往返,中间可能被其他客户端抢先删除又重设,导致你删掉了别人的锁。正确方式是用 Lua 原子执行:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
调用时传入 [$lockKey] 和 [$myValue],Predis 支持直接执行:
$script = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
$redis->eval($script, [$lockKey], [$myValue]);
实际业务中容易忽略的三个细节
穿透场景往往发生在“缓存未命中 + 查询慢”的组合上,比如统计接口查 3 秒 DB。这时候光加锁不够,还得处理:
- 锁获取失败时不能直接报错,要 fallback 到“等待后重试”或“走降级逻辑”,否则用户看到 503
- 锁超时时间(
PX)必须大于业务查询最大耗时,否则锁自动释放,别人又进来查一遍——建议设为max(DB_query_time, 3000) + 2000毫秒 - 如果用的是 Redis 集群(非单节点),
SET命令可能因 key slot 分布被重定向,Predis 默认不重试;要用cluster连接模式,或改用 Redlock(需 ≥3 个独立节点)
最麻烦的不是写锁,而是确认“谁该删这个锁”和“删的时候它还在不在”。只要 $value 生效、Lua 脚本到位、超时留足余量,穿透就卡死在 Redis 层,DB 不会感知到压力突增。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











