Redis 锁是并发安全的吗?怎么保证不被抢夺?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
-
基础掌握度:面试官不仅仅是想知道 Redis 锁安不安全,更是想知道你是否清楚 Redis 单线程模型如何保证命令原子性,以及
SETNX在并发场景下为什么不会冲突。 -
边界问题意识:考察你是否能识别出 "锁被别人抢走" 的各种边界场景 —— 加锁不原子、解锁误删、锁超时释放、主从同步延迟等,以及对应的解决方案。
-
方案选型能力:能否根据业务对可靠性的不同要求,在 Redis 单节点、RedLock、ZooKeeper 之间做出合理选型。
核心答案
Redis 锁 在正确使用的前提下是并发安全的,但需要解决以下 3 个关键问题 才能保证不被抢夺:
| 问题 | 风险 | 解决方案 |
|---|---|---|
| 加锁非原子 | 两条命令之间宕机,导致死锁 | SET key value NX EX 一条命令搞定 |
| 解锁误删别人的锁 | 判断和删除之间有时间窗口 | Lua 脚本 保证原子释放 |
| 锁提前过期 | 业务没执行完,锁就释放了,别人抢到了 | Redisson 看门狗 自动续期 |
一句话结论:Redis 单线程模型保证了命令级别的原子性,但 多命令组合操作(加锁 + 过期、判断 + 删除)需要额外手段(原子命令 / Lua 脚本 / 框架)来保证安全。生产环境用 Redisson 可以一站式解决。
深度解析
一、为什么 Redis 天然具备并发安全的基础?
上图解释了 Redis 锁的并发安全基础。核心原理:
- 单线程执行模型:Redis 使用单线程处理命令,所有客户端的命令会进入一个 FIFO 队列,逐条执行,不会出现两条命令同时执行的情况。
SETNX语义:SET if Not eXists—— 只有 key 不存在时才设置成功。即使 100 个客户端同时发来SETNX,Redis 也会串行处理,只有第一个会成功。- 一条命令就是原子的:单个 Redis 命令(如
SET、SETNX、DEL)天然具有原子性,不存在中间状态。
所以,单条命令层面,Redis 锁是绝对并发安全的。问题出在 "多命令组合" 上。
二、三大 "锁被抢" 场景及解决方案
场景一:加锁不原子 → 死锁导致别人永远拿不到锁
上图展示了加锁非原子导致的死锁风险:
- 问题:
SETNX和EXPIRE是两条命令,中间如果宕机,锁永远不过期,所有人都拿不到锁。 - 解决:使用
SET key value NX EX seconds一条命令完成,Redis 保证这条命令是原子的。
场景二:解锁误删 → 把别人的锁删了,别人就能抢到了
上图展示了经典的 "误删别人的锁" 场景:
- 问题:
GET判断和DEL删除之间有时间窗口。锁可能刚好在判断成功后过期,另一个客户端趁机加锁,然后你把别人的锁删了。 - 解决:使用 Lua 脚本 将 "判断 + 删除" 合并为一个原子操作:
-- Lua 脚本:原子释放锁
-- KEYS[1] = 锁的 key,ARGV[1] = 自己的唯一标识
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
为什么 Lua 脚本能解决?因为 Redis 执行 Lua 脚本时是 单线程原子执行 的,脚本中的多条 Redis 命令不会被打断,不存在中间时间窗口。
场景三:锁提前过期 → 业务没做完,锁就被人抢走了
上图展示了锁提前过期导致的并发问题及看门狗解决方案:
- 问题:业务执行时间不确定,锁过期后其他客户端可以抢到锁,导致多个客户端同时操作共享资源。
- 解决:使用 Redisson 的 看门狗(Watchdog)机制,默认每 10 秒(锁过期时间 30 秒的 1/3)自动续期,业务没执行完就不会让锁过期。
三、完整的安全加锁释放流程
将上面的三个解决方案组合起来,就是一套完整的安全方案:
// ==================== 安全的加锁与释放 ====================
// 1. 生成唯一标识,防止误删别人的锁
String lockValue = UUID.randomUUID().toString();
// 2. 原子加锁(SET NX EX 一条命令)
SetParams params = SetParams.setParams().nx().ex(30);
String result = jedis.set("lock", lockValue, params);
if ("OK".equals(result)) {
try {
// 3. 执行业务逻辑
doBusiness();
} finally {
// 4. Lua 脚本原子释放锁
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
jedis.eval(luaScript,
Collections.singletonList("lock"),
Collections.singletonList(lockValue));
}
}
四、Redis 锁的安全性边界
需要诚实地说,Redis 锁在极端情况下 仍然存在极小概率的不安全:
| 场景 | 风险 | 概率 | 应对策略 |
|---|---|---|---|
| 主从切换锁丢失 | Master 加锁后宕机,数据未同步到 Slave | 极低 | RedLock 多节点方案 |
| GC 停顿导致锁过期 | 长 GC(Full GC)暂停导致看门狗无法续期 | 极低 | 调优 GC,避免 Full GC |
| 时钟跳跃 | Redis 服务器时间被修改,影响过期判断 | 极低 | 禁止手动修改系统时间 |
如果你的业务 绝对不能容忍任何锁丢失(比如金融交易),应该考虑 ZooKeeper 或 etcd,它们通过共识协议(ZAB / Raft)保证锁的强一致性。
面试高频追问
-
追问一:为什么 Redis 单线程还能这么快?
- 纯内存操作,读写速度极快(10 万+ QPS)。
- 单线程避免了上下文切换和锁竞争的开销。
- 使用 IO 多路复用(
epoll),一个线程处理大量连接。
-
追问二:RedLock 真的安全吗? 有争议。分布式系统专家 Martin Kleppmann 曾指出 RedLock 依赖 系统时钟假设,在时钟跳跃或 GC 停顿的场景下仍可能出问题。Redis 作者 antirez 进行了反驳。实际生产中,大部分场景用 Redisson 单节点或哨兵模式就够了,不必过度追求理论上的绝对安全。
-
追问三:Redis 和 ZooKeeper 分布式锁怎么选?
- 选 Redis:追求高性能(微秒级),允许极端情况下锁丢失(比如缓存扣库存、限流)。
- 选 ZooKeeper:追求强一致,绝对不能丢锁(比如金融交易、分布式事务)。
常见面试变体
- 变体一:"Redis 的
SETNX为什么是线程安全的?" - 变体二:"Redis 分布式锁有哪些坑?怎么解决?"
- 变体三:"Redisson 的看门狗机制了解吗?什么情况下会失效?"
- 变体四:"Redis 主从切换时锁会丢吗?怎么处理?"
记忆口诀
保证 Redis 锁不被抢,牢记 "三板斧":
- 加锁原子:
SET NX EX一条命令搞定。 - 释放原子:Lua 脚本判断 + 删除一气呵成。
- 锁不提前过期:Redisson 看门狗自动续期。
底层靠什么? 单线程模型 + 原子命令 + Lua 脚本原子性。
总结
Redis 锁的并发安全性依赖三个层次:单线程模型 保证单命令原子性,SET NX EX 保证加锁原子且不死锁,Lua 脚本 保证释放时不误删别人的锁。生产环境用 Redisson(看门狗自动续期 + Hash 可重入 + Lua 原子释放)可以一站式解决大部分问题。如果对可靠性要求极高,可考虑 RedLock 或 ZooKeeper。