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/

面试考察点

  1. 基础掌握度:面试官不仅仅是想知道 Redis 锁安不安全,更是想知道你是否清楚 Redis 单线程模型如何保证命令原子性,以及 SETNX 在并发场景下为什么不会冲突。

  2. 边界问题意识:考察你是否能识别出 "锁被别人抢走" 的各种边界场景 —— 加锁不原子、解锁误删、锁超时释放、主从同步延迟等,以及对应的解决方案。

  3. 方案选型能力:能否根据业务对可靠性的不同要求,在 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 命令(如 SETSETNXDEL)天然具有原子性,不存在中间状态。

所以,单条命令层面,Redis 锁是绝对并发安全的。问题出在 "多命令组合" 上。

二、三大 "锁被抢" 场景及解决方案

场景一:加锁不原子 → 死锁导致别人永远拿不到锁

上图展示了加锁非原子导致的死锁风险:

  • 问题SETNXEXPIRE 是两条命令,中间如果宕机,锁永远不过期,所有人都拿不到锁。
  • 解决:使用 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)保证锁的强一致性。

面试高频追问

  1. 追问一:为什么 Redis 单线程还能这么快?

    • 纯内存操作,读写速度极快(10 万+ QPS)。
    • 单线程避免了上下文切换和锁竞争的开销。
    • 使用 IO 多路复用(epoll),一个线程处理大量连接。
  2. 追问二:RedLock 真的安全吗? 有争议。分布式系统专家 Martin Kleppmann 曾指出 RedLock 依赖 系统时钟假设,在时钟跳跃或 GC 停顿的场景下仍可能出问题。Redis 作者 antirez 进行了反驳。实际生产中,大部分场景用 Redisson 单节点或哨兵模式就够了,不必过度追求理论上的绝对安全。

  3. 追问三:Redis 和 ZooKeeper 分布式锁怎么选?

    • 选 Redis:追求高性能(微秒级),允许极端情况下锁丢失(比如缓存扣库存、限流)。
    • 选 ZooKeeper:追求强一致,绝对不能丢锁(比如金融交易、分布式事务)。

常见面试变体

  • 变体一:"Redis 的 SETNX 为什么是线程安全的?"
  • 变体二:"Redis 分布式锁有哪些坑?怎么解决?"
  • 变体三:"Redisson 的看门狗机制了解吗?什么情况下会失效?"
  • 变体四:"Redis 主从切换时锁会丢吗?怎么处理?"

记忆口诀

保证 Redis 锁不被抢,牢记 "三板斧"

  1. 加锁原子SET NX EX 一条命令搞定。
  2. 释放原子:Lua 脚本判断 + 删除一气呵成。
  3. 锁不提前过期:Redisson 看门狗自动续期。

底层靠什么? 单线程模型 + 原子命令 + Lua 脚本原子性。

总结

Redis 锁的并发安全性依赖三个层次:单线程模型 保证单命令原子性,SET NX EX 保证加锁原子且不死锁,Lua 脚本 保证释放时不误删别人的锁。生产环境用 Redisson(看门狗自动续期 + Hash 可重入 + Lua 原子释放)可以一站式解决大部分问题。如果对可靠性要求极高,可考虑 RedLock 或 ZooKeeper。