单纯用setnx不够用,因其无法保证加锁、查库、写缓存三步原子性,且无自动过期易致死锁;必须用lua脚本在redis端原子执行全流程。

缓存击穿时直接用 SETNX 为什么不够用
缓存击穿本质是:某个热点 key 过期瞬间,大量并发请求同时发现缓存缺失,全部穿透到 DB,造成瞬时压力。单纯用 SETNX 加锁只能解决“谁来重建缓存”,但无法保证“重建完立刻回填、且后续请求能拿到新值”——因为 SETNX 成功者查库、写缓存、释放锁是三步非原子操作,中间任意环节失败(比如写缓存前进程挂了),锁就丢了,其他请求又会重复击穿。
更麻烦的是,多个客户端对同一 key 竞争锁时,SETNX 没有自动过期机制,容易死锁;而加 EXPIRE 又不是原子的,存在竞态窗口。
所以必须把“尝试加锁 + 设置过期时间 + 查询 DB + 写缓存 + 返回结果”这几步压进一个原子执行单元里——Lua 脚本就是 Redis 唯一能保证这点的方式。
用 EVAL 执行 Lua 实现“查缓存→未命中则加锁并查库→写缓存→返回”全流程
核心思路是:所有逻辑在 Redis 服务端一次性跑完,避免客户端往返和状态不一致。脚本里用 redis.call("GET", KEYS[1]) 先查缓存;命中直接返回;未命中则用 redis.call("SET", KEYS[1]..":lock", ARGV[1], "NX", "EX", ARGV[2]) 尝试加带过期时间的锁(注意 key 名要隔离,避免和业务 key 冲突);加锁成功才去模拟查库(实际用 redis.call("HGETALL", "db:fake") 或其他方式占位),然后 redis.call("SET", KEYS[1], result, "EX", ARGV[3]) 回填缓存。
关键细节:
-
KEYS[1]是业务 key(如"user:1001"),ARGV[1]是唯一锁标识(建议用客户端生成的 UUID,防止误删他人锁) -
ARGV[2]是锁过期时间(秒),设为 5–10 秒较安全;ARGV[3]是业务 key 的缓存 TTL,通常更长(如 300 秒) - 加锁失败时不能直接返回空,要等待一小段时间后重试(客户端控制),否则可能集体放弃导致 DB 压力转移
- 脚本末尾必须用
return显式输出结果(缓存值或空),方便客户端判断是否需要走降级逻辑
示例简化脚本(生产环境需补全错误分支):
if redis.call("GET", KEYS[1]) ~= false then
return redis.call("GET", KEYS[1])
else
if redis.call("SET", KEYS[1]..":lock", ARGV[1], "NX", "EX", ARGV[2]) then
local db_result = "fake_data_from_db" -- 实际替换为真实查询逻辑
redis.call("SET", KEYS[1], db_result, "EX", ARGV[3])
redis.call("DEL", KEYS[1]..":lock")
return db_result
else
return nil -- 表示锁竞争中,客户端应 sleep 后重试
end
end
为什么不用 Redlock 或 Redisson 的分布式锁
Redlock 理论上更“严谨”,但它依赖多个独立 Redis 实例和时钟一致性,在大多数单集群部署场景下属于过度设计;而且它解决的是跨节点故障下的锁可靠性,而缓存击穿问题本身只发生在单个 key 的本地并发,用单实例 Lua 锁已足够。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
Redisson 的 RLock 底层也是靠 Lua 脚本实现加锁/续期/释放,但它封装太厚:每次调用都走网络、序列化、连接池管理,对高频热点 key 来说延迟和资源开销明显更高;而手写 Lua 可以把整个“查-锁-查库-写-返”压成一次 EVAL 请求,吞吐量提升显著。
真正要注意的是锁标识(ARGV[1])必须全局唯一且不可预测,否则可能被恶意 DEL;另外脚本里禁止调用可能阻塞的命令(如 KEYS、SLOWLOG),否则会影响整个 Redis 实例。
线上踩过的坑:锁误删、脚本超时、缓存值为空怎么处理
最常见问题是锁标识复用或硬编码,导致 A 客户端加的锁被 B 客户端用相同字符串 DEL 掉。务必让每个客户端生成自己的随机 token(如 UUID.randomUUID().toString()),并在释放锁时用 EVAL 脚本比对再删,而不是简单 DEL。
Redis 默认脚本超时是 5 秒,如果查库逻辑(比如调外部 HTTP)放在 Lua 里会直接报错 ERR Error running script (call to f_...): @user_script:xx: user_script:xx: script tried to execute a command that may have side effects——Lua 里根本不能发网络请求。所有“查库”动作必须由客户端完成,Lua 只负责协调状态和缓存写入。
如果 DB 查出来是空值(比如用户已被删除),不能不缓存,否则每次都会击穿。应该缓存一个特殊标记(如 "NULL" 或 "MISS")并设置较短 TTL(如 60 秒),避免永久性空缓存污染。
复杂点永远在边界:锁过期时间怎么设才既防死锁又不导致重复重建?空值缓存该不该压缩?Lua 脚本要不要预加载(SCRIPT LOAD)?这些没标准答案,得看你的 QPS、DB 抗压能力、以及能接受的缓存不一致窗口。










