1. 这个问题的本质:是技术陷阱,更是思路试金石
先说结论:所有基于 Redis 的分布式锁方案,在极端情况下都存在锁丢失的可能。这不是某个产品的 bug,而是分布式系统里一个绕不开的取舍问题。如果你在面试中真的被问到这句话,考官不是等着听你选一个“安全可靠”的方案,而是想看清楚你有没有从“实现一个功能”上升到“设计一个系统”的思维层次。
我们需要把这个问题拆成几层来理解。第一层:面试官说的“Redis 宕机”,具体指什么形态的宕机?是单机 Redis 全部不可用,还是主从架构下主节点挂掉、从节点顶上?这两种情况对锁的影响完全不同。第二层:锁“丢”了之后,到底会引发什么后果?是锁被其他线程重新获取,还是锁没释放导致死等?第三层:业务上能不能容忍这个问题?比如,一个“扣减库存”的操作锁失效可能导致超卖,一个“发送短信”的操作锁失效可能只是多发一条短信,两者的安全阈值完全不同。
我在带团队的时候经常拿这个问题做面试题。很多人第一反应是“那我去用 Redlock”,或者“加持久化、加哨兵就能解决”,这两个回答都不算错,但都只摸到了问题的外壳。真正有价值的回答应该先承认一个前提:只要用了 Redis,你的锁就不是绝对的锁,而是一个“大概率不发生冲突的协调令牌”。基于这个前提,再谈如何把失效概率压低、如何做兜底、如何设计业务逻辑让锁失效也不出错。
这也解释了为什么很多成熟团队的方案不是“某个中间件一步到位”,而是“分布式锁负责拦截,业务层负责兜底”的双保险结构。接下来,我们从最基础的实现开始,逐步把整个问题的脉络缕清楚。
2. 单机 Redis 分布式锁的基本套路:SET NX EX 与“裁判”角色
2.1 一条命令搞定加锁与过期时间
大多数团队最初接触分布式锁时,用的都是 SETNX 加 EXPIRE。早期版本需要两条命令分步执行,先 SETNX 成功,再 EXPIRE 设置过期时间。这里有一个非常经典的坑:如果 SETNX 执行之后、EXPIRE 执行之前,进程突然崩溃或网络超时,锁就永远不会过期,其他线程再也拿不到锁。所以后来官方给出了原子性的写法:
SET lock_key unique_value NX EX 30000NX:只有当 key 不存在时才设置成功,保证同一时刻只有一个客户端能拿到锁。EX 30000:锁的自动过期时间,单位是毫秒,这里表示 30 秒。unique_value:一个全局唯一的随机值,用于释放锁时校验“这把锁是不是我自己的”。
释放锁的时候用 Lua 脚本保证原子性,先比对 unique_value 再删除,避免误删其他线程刚获取的锁:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这套方案在单机模式下是有效的,也是理解后续所有问题的地基。它把“谁拥有锁”的问题简化成“谁在 Redis 里写入了一个唯一的 key”。
2.2 为什么必须“唯一值+原子性验证”
很多新手容易忽略 unique_value 的作用,觉得反正del lock_key就能释放锁,搞个随机字符串不是多此一举吗?我举个真实场景你就明白了。
假设线程 A 拿到锁,因为 GC 停顿或网络抖动卡了超过 30 秒,锁自动过期了。这时候线程 B 过来成功 SET NX,拿到了锁。A 终于缓过来,执行完业务逻辑,随手执行del lock_key,这一下就把 B 正在持有的锁给删了。此时如果再来一个线程 C,发现锁不存在,又会成功 SET NX,于是 B 和 C 同时进入临界区,锁完全失效。
加了 unique_value 校验之后,A 删除锁前先比对当前 value 是否还是自己的唯一值。因为 B 写入时已经换了新值,A 比对不通过,删除失败,B 的锁得以保留。这本质上是一个“持有者身份验证”的机制,和登录系统里的 Token 设计思路完全一致。
2.3 过期时间定多长才算合适
过期时间设定本身就是个博弈。设短了,业务逻辑还没执行完锁就被迫释放,其他线程趁虚而入;设长了,如果持有锁的节点真的挂了,其他线程要等很久才能恢复。我见过不少团队把过期时间拍脑袋定成 30 秒或 60 秒,然后线上出问题。
更稳妥的做法是:先评估业务临界区的最长执行时间,再留出 3 到 5 倍的余量。比如一个库存扣减操作正常在 1 秒内完成,最坏情况因为 GC 停顿可能拖到 5 秒,那锁过期时间至少给到 15 秒以上。同时配套“续期机制”——后台守护线程定期检查锁是否还属于自己,如果快到期了还没执行完,就延长过期时间。Redisson 的 watchdog 就是这个思路,默认每 10 秒检查一次,把锁续到 30 秒。
无论过期时间怎么设置,我们都必须清醒地认识到:这不是一把绝对互斥的锁,而是一把有“生命期”的临时令牌。只要生命期到了,无论持有者是否还在工作,锁都会消失。宕机只是提前触发这个风险的极端手段而已。
3. 主从架构下的宕机:锁丢失的两种典型路径
3.1 异步复制导致的主节点更新丢失
单机 Redis 宕机的情况相对简单:缓存数据本身会丢,锁也没了,所有线程发现锁不存在,可能同时涌入临界区。但如果部署的是主从架构,问题就复杂得多。Redis 主从复制默认为异步复制,主节点写入后立即返回成功,数据能否同步到从节点完全看网络和运气。
分布式锁的完整故障链路通常是这样的:
- 客户端 A 向主节点写入锁,主节点返回 SET NX OK。
- 异步复制尚未完成,主节点突然宕机。
- 哨兵或集群选主逻辑把某个从节点提升为新的主节点。
- 这个新主节点上没有 A 写入的锁数据。
- 客户端 B 来尝试加锁,发现锁不存在,成功获取锁。
- 此时 A 和 B 同时认为自己持有锁,互斥被打破。
这是一个非常现实的场景。Redis 官方文档其实也明确承认了这一局限。换句话说,在主从模式下,宕机导致的锁丢失,核心原因不是“锁过期”而是“锁根本没来得及被复制到从节点”。
3.2 持久化策略带来的另一个隐藏窗口
即使数据复制到了从节点,持久化策略不达标,锁也可能重启后丢失。比如 Redis 默认的 RDB 快照策略,可能在宕机前还没触发快照,内存中的数据丢了。如果配置成 AOF(Append Only File),但 appendfsync 设置为 everysec,那么最多会丢失 1 秒的写入数据。锁恰好在这一秒内写入,重启后锁就没了。
还有一种是极端场景下的全量宕机:主节点和从节点恰好同时挂掉。比如同一机架上断电,或者机房级别故障。这种情况下无论复制配得多完善、AOF 刷得多频繁,只要没有跨机房的同步副本,锁一样消失。
我想强调一个容易被忽视的视角:Redis 分布式锁丢失,不只是“锁数据丢失”这么简单,它意味着两个线程可能同时进入临界区。而锁的唯一价值,恰恰就是阻止这种事情发生。一旦锁失效,后续业务逻辑的正确性就完全暴露在并发风险之下,没有任何中间层保护。
3.3 用一张表看清各种故障组合
| 故障场景 | 锁是否会丢失 | 风险等级 | 原因 |
|---|---|---|---|
| 单机 Redis 正常宕机 | 会丢失 | 高 | 内存数据全部丢失,锁不存在 |
| 主从架构下主节点宕机,未完成复制 | 会丢失 | 高 | 从节点提升后没有锁数据 |
| 主从架构下主节点宕机,已完成复制 | 可能丢失 | 中 | 从节点数据完整,但需确认持久化级别 |
| 主从全挂/机房级故障 | 会丢失 | 极高 | 没有任何节点能对外提供锁服务 |
| 正常运行但锁超时释放 | 会丢失 | 高(随时间推移) | 业务执行时间超过过期时间 |
表格里的“风险等级”不是绝对的,取决于你业务的容忍度。比如秒杀系统里出现两次扣减,库存直接变成负数,那就是事故;但日志处理系统里偶然多执行一次任务,可能只是多打几条日志,无伤大雅。理解了差异化容忍度,你才能真正理解为什么没有“银弹式”的锁方案。
4. Redlock 是解药吗:多个独立节点能否救回锁
4.1 Redlock 的设计思路与加锁流程
面对单点故障和主从切换的问题,Redis 的作者提出了 Redlock 算法。它的核心思想很简单:既然一个 Redis 实例可能宕机,那我就用多个互相独立的 Redis 实例,只要大多数实例同意,锁就算成立。
算法大致流程是这样的:
- 客户端获取当前时间戳。
- 依次向 N 个独立的 Redis 实例发送 SET NX EX 命令,每个实例设置一个极短的超时时间(比如 50ms),避免在某个宕机实例上长时间阻塞。
- 客户端计算成功加锁的实例数量,如果大于等于 N/2 + 1,并且总耗时小于锁的过期时间,就认为加锁成功。
- 加锁成功后,锁的自动释放时间要减去“获取锁消耗的总耗时”。
- 如果某个环节未满足条件,客户端需要向所有实例发起释放锁请求(用 Lua 脚本或直接 DEL 都行)。
N 的典型取值为 5 个独立节点,这样任何一台机器宕机,剩下 4 台中的 3 台仍能响应请求,锁的可用性大幅提升。
4.2 从理论到现实的质疑:时钟与网络的双重不可靠
Redlock 看起来无懈可击,但在分布式系统领域,它一直处于争议中心。核心争论点在于:算法假设不同节点上的时钟是同步的,并且进程暂停时间是可忽略的。现实显然不满足这两个假设。
举个例子:客户端 A 拿到了 3 个节点上的锁,但此时 A 所在机器因为 GC 停顿了 10 秒。等 A 恢复时,锁已经在 5 秒后全部过期,节点上的锁数据已被删除。客户端 B 在 A 停顿期间发现锁不存在,也成功拿到锁。于是 A 和 B 又同时进入临界区。这个“进程暂停”问题,Redlock 算法本身并不能解决。
更麻烦的是时钟漂移。若某个节点的系统时钟被人工回调,那么“过期时间”的相对性就乱了。A 拿到锁后节点 1 的时钟往前拨了 10 秒,锁立即过期,B 乘虚而入。理论上分布式系统里所有时间判断都有风险,但这并不意味着我们只能放弃。工程上要权衡的是:在绝大多数正常运行的系统中,Redlock 确实能把锁丢失概率压到一个极低的量级,但永远做不到数学上的绝对互斥。
4.3 为什么很多团队最终没有用 Redlock
就我接触过的项目而言,国内真正在核心链路上跑 Redlock 的团队并不多。原因很直接:
- 部署成本高:需要至少 5 个独立 Redis 实例,运维复杂度和资源开销成倍增长。
- 性能损耗:加锁过程要从串行写一个节点,变成同时写多个节点,延迟明显增加。
- 收益不直观:大多数业务场景下,单实例锁加上业务兜底,已经能覆盖 99.9% 的并发问题。
- 维护成本高:跨机房部署时,多个实例之间的网络质量很难保证,一个实例网络抖动可能导致整体加锁失败。
Redlock 更像是一种“理论完备”的方案,在常规业务里,它的优势被成本稀释了。它不是不能用,而是要清楚地知道它解决的是哪一类问题,并且愿意为它付出对应的代价。
5. 工程上真正可落地的组合拳:锁是拦截器,不是救世主
5.1 方案选型前要先回答三个问题
在动手写任何分布式锁代码之前,我建议团队先回答三个问题:
- 锁失效时,业务损失的上限是多少?
- 业务操作本身是否具备幂等性?
- 能否在数据库或业务状态上做最终的冲突校验?
这三个问题的答案,直接决定了你的锁要设计得多重。
如果业务场景是“防止重复发放优惠券”,锁失效最多导致多发一张券,损失可控,用单机 Redis 锁完全够。如果场景是“扣减库存”,锁失效会导致超卖,必须在锁之外设计数据库层面的库存校验。如果场景是“分布式定时任务调度”,锁失效会导致重复执行,而重复执行同一任务通常可以接受或通过任务表唯一索引兜底。
5.2 一套常见的生产级配置思路
我自己在项目里常用的做法是:Redis 分布式锁负责“快速拦截”绝大部分并发冲突,业务层再配一道“最终防线”。
具体来说,加锁时用 SET NX EX 加唯一请求 ID;释放锁用 Lua 脚本校验;过期时间设为正常执行时间的 5 倍以上;如果业务确实超时,保护线程帮忙续期;同时业务代码里保证核心写操作的幂等性。
举个例子,一个简单的防重复提交接口:
// 加锁 String lockKey = "order:pay:lock:" + orderId; String lockValue = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 执行核心业务 doPay(orderId); } finally { // Lua 脚本释放锁 releaseLock(lockKey, lockValue); } }注意,这段代码并不能保证“绝对不重复支付”,它能保证的是:正常情况下两个请求同时到达时,只有一个能进入 doPay 方法。万一出现问题,比如锁在这 30 秒内意外失效,另一个请求也进来了,那数据库层面还应该有支付状态字段或唯一流水号来拦截,这才是真正兜底。
5.3 为什么“幂等设计”比锁本身更重要
这里我想多说一句:如果你把分布式锁当成解决并发问题的唯一手段,那你的系统一定会在某个凌晨三点出事故。因为锁只能控制临界区的进入,不能控制业务逻辑的执行结果。而真正能保证数据正确的,往往是业务操作本身的幂等性和持久层的唯一约束。
比如用数据库的唯一联合索引约束“userId + activityId + type”,即使两个线程都冲进了临界区,第二个写入会因为违反唯一约束而失败,系统的最终状态依然一致。又比如在支付回调场景里,用“支付流水号 + 已处理标记”来保证重复回调只会产生一次实际扣款。
设计原则很简单:让锁失效的后果尽可能小,让业务逻辑本身具备自愈能力。锁就好比小区门口的门卫,正常情况下拦住无关人员;但真正防止小偷的,是每家每户的房门锁。你不能指望门卫把所有事都干了,他偶尔打个盹,家里的门还得自己锁好。
5.4 只要追求绝对互斥,就要走出 Redis
如果你所在的业务领域,锁失效的代价极其高昂,例如资金清算、跨行转账、订单全额一致性校验等,那么即便加上再多兜底,Redis 锁带来的不确定性也让人不安。这时候,考虑引入 ZooKeeper 或 etcd 这类强一致协调服务会更合适。
ZooKeeper 的分布式锁基于临时顺序节点实现,客户端与 ZK 之间维持会话。一旦客户端断连,临时节点自动删除,锁自动释放。相比 Redis 的过期时间机制,ZK 的锁生命周期更贴近“持有者还活着”的语义。etcd 基于 Raft 协议实现强一致性,配合租约和续约机制,也能实现类似效果。
但引入这类组件同样有成本:部署复杂度提升、运维知识要求更高、性能通常比 Redis 低一个量级。所以在选型时,我会画一张简单的决策表:
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 防重复点击、缓存更新 | 单机 Redis 锁 | 轻量、低延迟、够用 |
| 秒杀扣库存、优惠券领取 | Redis 锁 + MySQL 唯一约束 | 锁拦截大部分,数据库兜底极限并发 |
| 定时任务调度、分布式批处理 | Redis 锁 + watchdog 续期 | 任务执行时间可预测,续约处理长任务 |
| 资金交易、严格一致流程 | ZK / etcd | 强一致,临时节点/租约语义更安全 |
6. 面试回答的正确姿势:从“怎么做”跳到“为什么这样做”
6.1 考官想听到的回答结构
如果面试官直接抛出这个问题,最佳的回答结构,我的建议是“漏斗式”:先承认误区,再拆解场景,再讲兜底,最后讲选型。
你可以这样组织语言:
- 第一步:明确回答“Redis 分布式锁不是绝对可靠的,在宕机场景下确实可能丢失”。
- 第二步:分场景解释丢失的路径。比如主从异步复制丢数据、持久化级别导致重启丢数据、业务超时导致锁自动释放。
- 第三步:说明如何降低概率。比如 Redlock、持久化配置、watchdog 续期机制。
- 第四步:说明如何兜底。比如业务幂等、唯一索引、状态校验,并强调锁只是拦截器,不是万能钥匙。
- 第五步:给出选型建议。严格一致场景用什么,普通高并发场景用什么,把思考层级拔高到“根据一致性需求选择工具”。
6.2 一些加分项与常见的扣分项
加分项包括:主动提到唯一 value 防止误删锁、主动提 Lua 脚本保证释放原子性、主动提 GC 停顿和时钟漂移对锁的影响、主动把话题引向业务幂等性设计。
扣分项包括:直接说“用 Redlock 就能解决”,却没有解释 Redlock 的细节和局限;或者直接说“Redis 做不了分布式锁,要用 ZK”,完全否定了 Redis 锁在绝大多数场景下的实用性;再或者一直纠结于“过期时间设多少”,却没有意识到核心问题是“锁失效后的业务影响”。
我常跟团队成员说,面试回答不追求“完美无缺的方案”,而追求“条理清晰、层层递进、有理有据”的思考过程。一个问题能答出五个层次,比背出十种解决方案更能体现水平。
7. 最后聊一点我自己的实操体会
分布式锁这个话题,我在不同公司踩过完全不同的坑。第一次是在早期项目里,用 SETNX + EXPIRE 两条命令写锁,上线后出现过锁永久不释放的线上事故,排查半天才发现是第二步 EXPIRE 没执行。第二次是用了 Redisson 之后,发现 watchdog 确实解决了超时问题,却忽略了锁失效导致两个线程同时执行批处理任务,好在任务本身支持重复执行。第三次是在支付链路上,我坚持加了一层唯一索引兜底,结果真的在一次 Redis 故障恢复期间,索引兜住了本可能造成重复扣款的问题。
这三段经历让我总结出一条很朴素的规律:分布式锁的价值,不是避免出问题,而是在出问题时,让系统还能恢复正确。你可以拥抱 Redis 锁的简单和高效,但必须时刻保留一份“锁可能会失效”的清醒。设计业务时,把最坏情况想清楚,把兜底做扎实,锁才能安心地上线。
如果你现在正准备面试,我建议把这个问题背后的原理吃透,而不是背几个概念。因为这个问题的本质,是考察你对分布式系统局部模块与全局可靠性之间关系的理解深度。而这个理解,恰恰是很多高级工程师与普通开发者的分水岭。