分布式锁这个东西,网上一搜一大片,但说实话很多文章都是互相抄,真正能落地、经得起线上考验的并不多。我做了这么多年的后端,从最开始用 setnx 加锁,到后来被线上故障折腾得焦头烂额,再到自己重写了一套跨机房的高可用锁组件,踩过的坑比写过的代码还多。这篇文章不只是讲原理,更会把你把“高可靠性”这三个字背后的一系列问题扒干净:什么情况下分布式锁会失效?主从切换会不会丢锁?业务执行时间超过了锁的过期时间怎么办?为什么你明明加了锁,线上还是出现了并发问题?
这篇文章不是给你讲怎么背面试题的,而是基于真实业务场景,把分布式锁从设计到上线,再到最佳实践完整过一遍。无论你是刚接触分布式系统的初级工程师,还是已经在维护高并发系统的老鸟,都会有一些可以参考的地方。
1. 从单机锁到分布式锁:问题到底出在哪
1.1 你写的锁到底锁住了什么
很多人在学习分布式锁之前,可能连单机锁都没吃透。理解分布式锁的第一步,是先搞清楚单机锁和分布式锁的本质区别。
单机环境下,比如你在一个 JVM 进程内使用 synchronized 或者 ReentrantLock,你的锁对象是某个类的实例或 Class 对象,JVM 内存是共享的,所以线程之间靠内存里的一个 flag 就能完成互斥。这个锁的生命周期是跟着 JVM 进程走的。
分布式环境下,你的代码跑在多个机器上,每个机器上的 JVM 内存是隔离的。你现在想要实现对共享资源的互斥访问,比如同时扣减同一个商品的库存,就需要一个所有进程都能访问到的“公共状态”来充当锁的载体。这个载体可以是一台 Redis、一个 Zookeeper 集群、一个 etcd,或者是一张数据库表。
这就是分布式锁的入门认知:如果 A 进程拿到了锁,那么 B、C、D 进程在尝试获取锁的时候必须被拦住。但就是这三岁小孩都懂的互斥要求,在分布式场景下会衍生出非常多的边界问题。
1.2 分布式锁的三大核心难题
我对团队里新同学讲分布式锁的时候,从来不会一上来就讲 Redis 命令怎么写,而是先把这三道坎立在那里:
第一道坎是死锁防护。假设 A 进程拿到了锁,但还没来得及释放,进程就 crashed 了。如果这把锁没有自动过期机制,整个系统的其他进程会永远卡死。所以任何分布式锁方案都必须要有一个“租约”或者“TTL”的概念,锁最终一定是要能被自动释放的。
第二道坎是锁的归属权。进程 A 拿到锁之后,因为 GC 停顿等原因阻塞了很长时间,锁已经过期了。此时进程 B 成功加锁,然后进程 A 恢复过来了,继续执行自己的业务代码,然后以为自己在持锁状态,直接释放了锁——这就把进程 B 的锁给删掉了,后果是灾难性的。这就是著名的“误删他人锁”问题。
第三道坎是互斥的绝对性与可用性之间的权衡。你选择牺牲一致性去换取更大的可用性,还是要严格互斥哪怕系统不可用?这个问题不会有绝对的答案,完全取决于你的业务场景:库存扣多了是钱的问题,而有些场景下互斥失效可能带来更严重的后果,这就对锁的可靠性提出了更高的要求。
这些难题不是简单用 Redis 一条命令就能解决的,而是需要从整体设计上规避。下面我会把主流的几种实现方案逐一拆开,仔细分析它们的实现原理和可靠性边界。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis 分布式锁的演进路程:从 setnx 到原子指令再到可重入与红锁
2.1 第一代方案:setnx 加 expire 的“经典”死锁陷阱
网上很多老项目里还在用这套方案:
code复制SETNX key 1
EXPIRE key 10
// 业务逻辑
DEL key
先说结论:这套方案存在明显的设计缺陷。SETNX 和 EXPIRE 是两条独立执行的命令,如果在 SETNX 成功之后、EXPIRE 执行之前,进程突然崩溃了,那么这个 key 就永久存在,其他进程永远拿不到锁,死锁。
当然有解决办法,就是改用 Lua 脚本把两条命令原子化:
lua复制if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
但即使这样,也不能解决前面讲的“误删他人锁”的问题。具体场景就是:A 进程拿到锁,业务执行时间太长锁过期了,B 进程进来拿了锁,然后 A 业务执行完,执行 DEL key,把 B 的锁给删了。所以你在写解锁逻辑的时候,一定要先比较 value 是不是自己的,如果是才删,而且这个比较和删除也必须是原子操作,否则又有误删的风险。
2.2 第二代方案:一条命令搞定加锁与过期时间
到了 Redis 2.6.12 之后,官方推荐用 SET key value NX EX timeout 这种方式,直接把加锁和设置过期时间合并为一条指令:
code复制SET stock_lock_001 "unique_token_001" NX EX 30
这条命令本身就是原子的,不存在中间状态,相比 SETNX + EXPIRE 的组合从根源上解决了部分问题。申请锁的值(unique_token_001)必须是一个全局唯一、不可预测的字符串,后面解锁的时候要用它来判断锁的归属。
解锁必须用 Lua 脚本:
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
这样一个简单的版本,已经可以称得上“能用的分布式锁”了。它能保证互斥、防死锁、防误删。
但这只是入门级别。为什么说它只是“能用”?因为高可靠性还没有解决:如果 Redis 是单节点,它挂了,整个系统就不可用;就算你用了主从架构,写进主节点的锁还没同步到从节点,主节点挂了,哨兵把从节点晋升为主节点,新的主节点没有那把锁,锁就丢失了,后续会有多个进程同时拿到锁,互斥性被打破。
2.3 关于“可重入”的必要性与实现
可重入的意思是:同一个线程(或者说同一个持有者标识)在持有锁的情况下,可以再次获取同一把锁而不发生死锁。比如你有一个服务方法 A 加了锁,它在内部又调用了另一个方法 B,B 也加了同一把锁。如果不能重入,自己就会把自己阻塞死。
Redis 分布式锁的可重入实现,比单机锁要复杂一些,因为你需要记录持有者的身份以及重入的次数。一个简单的方案是使用 Redis 的 Hash 结构:
code复制HINCRBY stock_lock_001 holder_001 1
EXPIRE stock_lock_001 30
释放时递减计数器,减到 0 才删除 key。
但这里有一个很关键的决策:你真的需要可重入吗?我见过很多团队在锁组件里实现了可重入,但实际业务根本没用到,反而增加了锁的复杂度和出错概率。而且,可重入锁的“同一个线程”在分布式环境里怎么定义?跨机器的调用算不算同一个持有者?如果简单地用同一个 value 来表示持有者,那任何拿到同一个 value 的进程都可以重入,这实际上可能反而是安全问题。所以我的建议是:默认不做可重入,明确知道业务有嵌套调用场景的时候,再单独设计带重入计数的版本。
2.4 继续聊聊 Redlock 以及它对高可靠的真正价值
说到高可靠性,Redis 官方后来提出了 Redlock 算法。核心思路是:不要在一个 Redis 实例上做文章,而是在多个独立的 Redis 节点上同时对某个 key 加锁。客户端需要向超过半数(N/2+1)的节点成功获取锁,并且总耗时小于锁的有效期,才算真正拿到锁。释放锁时向所有节点发送释放命令。
思想上是利用多数派协议,避免单点故障导致锁丢失。
但 Redlock 在业界是有很大争议的。其中比较有代表性的批判来自 Martin Kleppmann(《数据密集型应用系统设计》的作者),他指出 Redlock 在本质上是一个“为了效率而设计的分布式锁”,而不是一个“为了正确性而设计的分布式锁”。他举了一个重要的反例:如果某个节点上的锁被一个进程拿到后,这个进程发生了长时间的 GC 停顿,导致锁过期了,另一个进程又重新拿到了锁,两个进程同时进入临界区。这种问题 Redlock 一样无法解决。
反过来说,如果我们的场景对正确性要求极高,那么唯一可靠的做法是在业务层面做 fencing token 机制,也就是每次加锁都获得一个单调递增的 token,在写入资源的时候带上这个 token,由资源服务器来判断 token 的新旧。如果资源服务器不支持这种判断,那么任何分布式锁方案都不能保证绝对的正确性。
那我的建议是什么?如果业务系统里已经有 Redis 集群,且不想引入新的组件,那么基于 Redis 的锁算法(不一定是 Redlock,可以是简单的加锁+Lua脚本解锁)配上良好的监控和超时设置,在绝大多数业务场景下已经够用。当你对互斥性的要求严苛到“宁可系统不可用也不能并发”的程度,那么请转向 Zookeeper 或 etcd 方案。下面就来聊聊它们。
3. ZooKeeper 与 etcd 实现方案:为什么它们被认为是“CP”系统的较量
3.1 ZooKeeper 的临时顺序节点与 Watch 机制
ZooKeeper 做分布式锁的思路,和 Redis 完全不一样。它利用了 ZK 的“临时顺序节点”和“Watch 监听”机制。
具体流程是这样:
- 客户端 A 在
/locks/节点下创建一个临时顺序节点/locks/lock_000000001。 - 客户端 B 也来创建临时节点,得到
/locks/lock_000000002。 - 每个客户端获取锁的时候,会检查自己创建的节点是不是
/locks/子节点中序号最小的,如果是就认为自己拿到了锁,否则就监听自己前一个节点的删除事件。 - 当序号最小的节点被删除(锁被释放或持有者崩溃),监听了这个节点的客户端会收到通知,再重新检查自己是否是最小序号。
这个机制写出来很容易,但真正理解它为什么“可靠”,需要掌握两个关键点。
第一个关键点:临时节点。客户端与 ZooKeeper 之间有一个 Session(会话),如果客户端进程崩溃了,或者网络长时间不可用,Session 会超时,这个临时节点会被 ZooKeeper 自动删除。这样就天然实现了“持有者挂掉,锁自动释放”,不需要像 Redis 那样靠 TTL 过期来兜底,而是依赖“会话超时”这一机制。不过需要注意,会话超时时间需要配置合理区间——设置太短,网络抖动就会导致会话误判超时,锁被提前释放;设置太长,客户端真正故障时,其他进程要等很久才能拿到锁。
第二个关键点:顺序节点 + Watch。相比所有客户端都去监听同一个父节点,ZooKeeper 的这种“链式监听”方式能避免惊群效应。
3.2 etcd 的租约与 Revision 机制(CAS)
etcd 是另一种被广泛使用的分布式锁实现方案,架构上比 ZooKeeper 更轻量,协议上用的是 Raft。etcd 实现分布式锁依赖两个核心概念:Lease(租约)和 Revision(版本号)。
- 客户端先创建一个 Lease,设置一个 TTL,比如 10 秒。
- 然后通过
Txn事务将某个 key 绑定到这个 Lease 上,并写入自己的持有者信息。 - 获取锁时使用 etcd 的事务 + 版本比较:如果 key 的
CreateRevision为 0(说明 key 不存在),就执行 put 操作,写入 key,这时客户端就算拿到锁了。如果 key 已经存在,说明锁已被别人持有,客户端就需要Watch这个 key,等待它被删除。 - 拿到锁后,客户端需要开启一个 goroutine 定时续约(KeepAlive),避免 TTL 到期锁被自动释放。
- 释放锁时,客户端通过事务比较 key 的当前值是否还是自己的持有者标识,相同才删除。
etcd 做锁的最大优势在于它的事务机制,也就是 Compare-And-Swap(CAS),你可以在一次事务里完成“判断 key 是否存在”和“写入自己的值”这两步,保证原子性。同时,由于 etcd 本身就是一个 CP 系统,所有节点数据强一致,不用担心 Redis 主从切换时的锁丢失问题。
3.3 三种方案的横向对比与选型建议
| 方案 | 一致性强弱 | 故障自动释放机制 | 重新获取锁的等待方式 | 运维复杂度 | 性能 |
|---|---|---|---|---|---|
| Redis(普通锁) | AP 系统,极端情况可能丢锁 | TTL 过期 | 循环重试或 Redis 阻塞命令 | 低 | 极高 |
| Redis(Redlock) | 多数派确认,仍受时钟和 GC 影响 | TTL 过期 | 循环重试 | 中 | 高 |
| ZooKeeper | CP 系统,强一致 | 会话超时自动删除临时节点 | Watch 监听前节点事件 | 偏高 | 中 |
| etcd | CP 系统,强一致 | 租约过期 + 续约,进程崩溃自然过期 | Watch 监听 key 删除事件 | 中 | 中 |
从选型角度出发,我的经验是:
如果业务对性能要求比较高,且资源冲突不频繁,用 Redis 的分布式锁配合 token 校验,在绝大多数场景下是可行的。如果团队已经在用 ZooKeeper 做服务协调,比如 Kafka、Dubbo 都在用 ZK,那就直接用 ZooKeeper 实现锁,不用额外引入组件。如果是容器化、云原生的新系统,etcd 是更现代的选择,尤其搭配 Kubernetes 环境很方便。
但不管用哪个组件,有一件事始终不能忘:分布式锁本身只能阻止并发访问临界区,它不能保护你的业务数据的正确性。 对数据的正确性保证,最终还是得靠业务层自己实现幂等、版本控制等手段。
4. 高可靠性分布式锁的最佳实践:从锁设计到故障演练
4.1 锁的粒度与业务场景设计
很多人在设计分布式锁的时候,会掉进“一把锁锁全量请求”的陷阱。比如一个订单系统,要防止用户重复下单,最粗暴的做法就是用 order_user_lock 作为 key,把所有用户的请求都锁到同一把锁上。后果是所有用户的请求都串行化了,系统吞吐量直接被腰斩。
锁的粒度需要结合业务场景仔细设计。防重复下单,正确做法是用 user_123_order_lock 或者更细粒度到商品维度。库存扣减场景,使用 product_456_stock_lock 锁住单个商品的库存。锁的粒度越细,并发能力越高,但代码复杂度也越高。一个好的思路是:先分析你的并发冲突集中在哪个维度上,锁只覆盖这个维度。
4.2 超时时间与续租机制:永远不要赌业务能在固定时间内跑完
锁的超时时间(TTL)是分布式锁中最难设置的一个参数。设置太短,业务还没跑完锁就过期了,后续的请求会拿到新锁,导致并发执行。设置太长,如果持有锁的节点挂掉,其他节点等待的时间就会过长。
业界常见的方案是引入“看门狗”续期机制:获取锁的同时启动一个后台守护任务,定期(比如 TTL 的 1/3 或 1/2)检查业务是否还在执行,如果是,就自动续期。这样可以保证锁的生命周期和业务生命周期基本一致。Redis 客户端如 Redisson 内置了 WatchDog,etcd 的 KeepAlive 也是一种续租机制。如果你是自己写锁组件,续期逻辑一定要用单独的线程池或协程来做,并且要处理续期线程自身的异常。
续租机制有一个需要警惕的隐患:如果业务线程因为无限循环或者非常慢的远程调用卡住了,看门狗会一直在续租,导致锁永远不会释放,其他请求永远等待。所以最好给业务执行时间设置一个“硬上限”,超过这个上限直接由 Lock 组件主动释放锁,而不是无脑续租。
4.3 加锁失败后的重试策略与阻塞策略
客户端获取不到锁的时候,通常有两种处理方式:立即失败返回和阻塞等待。
- 立即失败适合对用户体验比较敏感的场景。比如一个用户提交订单,系统判断没有抢到锁,直接返回“操作太频繁,请稍后再试”,避免用户长时间无响应。
- 阻塞等待适合内部定时任务、数据迁移等异步任务场景。任务拿不到锁就一直等待,等锁释放后再抢占。
阻塞等待的实现,最简单的就是 while(true) tryLock,每次间隔 50ms 到 200ms 重试。但重试不能无限进行,要设置一个总超时时间,比如 30 秒,超过这个时间就放弃并返回失败,避免客户端线程堆积成不可控的数量。对于分布式任务调度来说,主节点竞争锁失败时,通常应该作为 Backup 节点,被动等待锁的通知,而不是高频轮询,这个设计更优雅,也更可靠。
4.4 故障演练验证可靠性:脑裂、延迟、主从切换下的表现
很多公司把分布式锁部署上去,从来没有做过故障演练,直到线上出问题才知道锁的可靠性有多差。我自己带团队时,会定期做下面三类混沌实验:
第一,主节点故障。在 Redis 主从架构下,杀掉主节点,观察锁是否丢失、是否出现两个进程同时持锁。如果用的是单节点 Redis,一定会出现整体不可用。如果用的是 Redisson 的哨兵模式,主从切换的瞬间可能丢锁。这一实验能直接测出你的锁方案是 AP 还是 CP。
第二,进程 GC 停顿。用工具强制触发 Full GC,让持有锁的进程停顿几十秒。观察其他进程是不是拿到了新锁,而旧进程恢复后是不是在生产生了一条重复处理的数据。这个实验对 Redis 锁几乎必然翻车,对 ZK 锁也不一定会好——因为 ZK 会话超时时间如果比 GC 时间还长,旧进程恢复后可能还握着旧锁。
第三,网络分区。用 tc netem 或者防火墙规则,人为隔离一个节点与锁集群的网络。观察锁组件能不能正确识别网络不可用并快速释放锁资源,还是在等待会话超时的过程中把大量请求阻塞了。
做好这些演练,你会对“高可靠性”这四个字有非常清晰的感受。多数情况下,结论就是:不存在绝对完美的分布式锁,只有在一定前提条件下足够好的分布式锁。
4.5 一套我踩坑之后沉淀的锁组件设计清单
如果你打算自己写一个分布式锁组件,而不仅仅是 pull 别人的库,下面这些设计要点建议逐条核对:
- 加锁返回值明确。加锁成功、加锁失败、等待超时,三种状态要定义清楚,不要用布尔值糊弄。
- token 必须全局唯一。为每一次加锁分配一个新的 UUID 或者雪花 ID,保证不可猜测、不重复。
- 解锁必须校验 token。并且校验和删除必须写在一个脚本(Redis)或一个事务(etcd)里,不能拆成两条语句。
- 锁自动过期时间设置合理。原则上 TTL 至少是业务 P99 耗时的 2 到 3 倍,同时做好续租。
- 在高争用场景下,锁请求要有限流甚至熔断,防止无限制重试打爆 Redis 或 ZK。
- 组件内部所有操作都要有监控埋点。锁获取耗时、等待耗时、锁过期被提前删除的次数、续租失败次数,这些指标比业务指标更能反映锁的健康度。
- 考虑降级策略。局部锁组件不可用时,是否允许业务直接跳过锁进入临界区?大多数情况下绝对不允许,但在某些读多写少的场景,可以考虑降级为无锁读,这需要业务团队评估并确认风险可接受。
5. 面试与团队评审视角:分布式锁的高频考点与评审清单
5.1 面试官真正想听到的“高可靠性”回答逻辑
面试时只要提到分布式锁,十个人里面有九个会回答“用 Redis 的 setnx 加锁,然后 lua 脚本解锁”,然后就没有然后了。但面试官如果继续追问“这套方案有什么问题”,很多人就卡壳了。
面试官想听到的回答,根本不是“背命令”,而是一条完整的链路:
- 锁的三个核心诉求:互斥、防死锁、防误删。
- Redis 方案存在主从切换丢锁以及业务超时时锁被提前释放的问题。
- 解决互斥性问题的方案里有 Redlock,但 Redlock 也受 GC 停顿和时钟跳跃影响,并没有从根本上解决问题。如果对正确性要求更高,需要引入 fencing token 或者直接用 ZK/etcd。
- 最终落点应该是对业务场景的分析:对锁的可靠性要求是什么级别,允许的并发冲突概率是多大,成本如何权衡。
只要按照“问题识别 → 方案演进 → 边界分析 → 场景适配”这条线去回答,面试官会感觉到你真的理解分布式锁,而不是只背了几个名词。
5.2 团队评审时我会追问的几个“反常识”问题
团队里做代码审查时,看到有人引入分布式锁,我一般会连环追问几个问题:
第一个问题:这个锁要防的是什么冲突?如果回答不清楚,说明这个锁大概率是多余的设计。分布式锁应该加在真正会并发访问且可能出错的资源上,而不是为了“显得严谨”四处加锁。
第二个问题:锁的粒度多大?是全局锁还是分段锁?加锁代码块包含了哪些操作?只加锁核心写操作,而不是把耗时的远程调用也包在锁里,这个非常关键。我曾经见过把整个 HTTP 请求都包进锁里的代码,后面一个服务级别的抖动,直接把整个集群拖死了。
第三个问题:如果锁组件本身挂了,业务怎么办?是否做了降级方案和告警?很多团队对这个问题的回答是“我们还没想过”。
第四个问题:锁的 key 怎么设计?是否能体现业务维度?好的 key 设计能保证锁的自然分散,避免热点冲突;设计不好,所有业务操作都堵在同一把锁上。
6. 结语:分布式锁只是工具,可靠性取决于你如何驾驭它
写到这里,我把分布式锁从原理、实现到最佳实践完整地过了一遍。总结成一句话:分布式锁不是银弹,它只是你在分布式环境里保证互斥访问的一种工具。工具的可靠性不仅取决于工具本身,还取决于使用工具的人是否真正理解它的边界和限制。
Redis 的锁足够快,但你的业务要接受它极端情况下的不确定性;ZooKeeper 和 etcd 更可靠,但要接受额外的运维复杂度。真正的高可靠性,一定来自于严谨的设计、充分的故障演练以及对业务场景深入的认知。
最后再分享一个我在实际项目里的小习惯:每次上线前,我会跑到压测环境把时间调到 TTL 过期后的瞬间,人为制造一波并发请求,亲眼看看锁被提前释放后业务系统的行为是什么。也许是多扣了一次库存,也许只是多产生了一条重复数据。提前知道了这些代价,你才能做出真正有意识的架构决策,而不是等出了故障再解释“这是分布式锁的固有问题”。
