在分布式系统中,“同一份资源同时只能被一个节点操作"是再常见不过的需求:扣减库存、发放优惠券、幂等写入、定时任务防重。单机时代的 synchronized 与 ReentrantLock 只对单个 JVM 进程内的线程生效,一旦系统拆成多节点,锁就必须"跨进程、跨节点"工作——这就是分布式锁要解决的问题。本指南从原理出发,讲透 Redis、etcd、ZooKeeper 三种主流实现,并给出选型与避坑建议。
关键概念:分布式锁 = 在分布式环境中协调"互斥访问共享资源"的机制。一个合格的分布式锁必须同时满足三个条件:互斥性(任意时刻只有一个客户端持有)、安全性(锁释放后其他客户端才能获取,不会死锁)、活性(持有者崩溃后锁能被自动回收,其他客户端能继续获取)。
一、为什么需要分布式锁
1.1 单机锁的局限
在单机单进程时代,锁通过内存中的对象状态实现,JVM 内的所有线程共享同一把锁。
单机锁:
synchronized / ReentrantLock
→ 作用于「单个 JVM 进程」内的线程
→ 进程内互斥有效
问题:系统拆成 N 个节点后
- 每个节点各自 JVM 内互斥,节点间不互斥
- 两个节点可以同时执行同一段临界区代码
→ 需要「跨进程」的互斥机制 = 分布式锁
典型的并发问题场景:分布式定时任务在多个节点同时触发,如果没有锁,同一批数据会被重复处理;秒杀扣库存时多个节点同时执行"查库存-减库存”,就会超卖。
1.2 分布式锁的核心条件
一个可靠的分布式锁,业界通常要求满足以下几点:
| 条件 | 含义 | 不满足的后果 |
|---|---|---|
| 互斥性 | 任一时刻只有一个客户端持锁 | 并发执行临界区,数据被破坏 |
| 安全性 | 持锁者才能释放,释放后他人可取 | 锁被误删,出现"插队" |
| 活性 | 崩溃/超时后锁自动回收 | 持锁者宕机,系统永久死锁 |
| 公平性(可选) | 等待者按先来后到获取 | 饥饿、羊群效应 |
ℹ️ 核心:分布式锁本质上是在一个"共享的、高可用的"存储上做一次原子性的"占用标记"。它的可靠性上限,取决于底层存储的共识能力。
二、基于 Redis 的分布式锁
2.1 最简单的 SETNX 实现
Redis 2.6.12 之后提供了原子的 SET key value NX EX ttl,这是实现分布式锁的最小内核。
# 加锁:仅当 key 不存在时设置,并带过期时间(原子操作)
SET lock:order product-01 NX EX 30
# 返回 OK → 加锁成功;返回 nil → 锁已被占用
# 业务处理...
# 释放:必须校验 value(自己的标识)后才能删,防止误删别人的锁
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
value 用 UUID/业务标识,是为了防止"把别人的锁删掉":如果 A 持锁超时被释放,B 拿到锁,A 业务结束直接 del 会把 B 的锁误删。所以删除前必须用 Lua 脚本比较 value。
2.2 看门狗(Watchdog)自动续期
业务处理时间超过 TTL,锁就会在业务执行中被自动释放,造成并发进入。解决思路是"锁快过期时自动续期"——Redisson 的看门狗机制就是干这个的。
看门狗机制(Redisson):
- 默认锁超时 30s,看门狗每 10s 检测一次
- 若业务未结束,自动把锁续期到 30s(续命)
- 业务结束 → 主动释放锁 → 看门狗停止
→ 既防止业务过长锁被误释放,又防止持锁者宕机锁永不过期
自定义方案:
用一个后台线程定时执行 Lua:若 value 匹配则 EXPIRE 续期
→ 续期前确认锁仍属于自己,否则停止续期
2.3 主从切换的丢锁问题
Redis 分布式锁最大的隐患:主从异步复制。加锁写入主节点后,主节点宕机,从节点提升为主节点,但锁还没来得及同步——锁"丢了",另一个客户端可以再次拿到锁。
问题场景:
1. A 在主节点加锁成功
2. 主节点宕机,锁未复制到从节点
3. 从节点晋升为主节点,B 加锁成功
→ A 和 B 同时持锁,互斥被打破
应对:
- 业务上容忍极低概率的双执行(幂等兜底)
- 或使用 RedLock(多节点投票,下面讲)
- 或直接改用 etcd/ZooKeeper(强一致存储)
三、基于 etcd 的分布式锁
etcd 是强一致(Raft 共识)的键值存储,天然解决 Redis 主从复制丢锁的问题。etcd 分布式锁借助 租约(Lease)+ Revision + 前缀 Watch 实现。
3.1 原理
基于 etcd 的分布式锁(etcdv3 API):
1. 创建租约 Lease(如 10s),自动续约
2. 用租约关联一个 Key:client 在指定前缀下写入
/lock/xxx/client-1(带 lease id)
→ 只有写入成功且 Revision 最小的那个 client 持锁
3. 客户端 Watch 前缀 /lock/xxx/
→ 当前持锁者释放/超时后,下一个 Revision 最小者获锁
4. 释放 = 删除自己的 Key(或租约过期自动删除)
Revision(全局单调递增版本号)保证了公平性:
先写入者 Revision 小 → 先获锁
etcd 的 Key 是持久化的,但关联了租约,租约过期 Key 会被自动删除——这保证了"持锁者宕机,锁自动回收",满足活性要求。etcd 用 Raft 在多数节点间达成一致,不存在主从切换丢锁的问题。
3.2 用 etcd 实现分布式锁的要点
核心要点:
- 用 Lease + KeepAlive 保持锁活跃,业务结束 revoke 租约
- 用前缀 + Revision 实现公平排队(FIFO)
- Watch 前一个持锁者,避免所有等待者同时抢(羊群效应)
- 释放用事务 CompareAndSwap:确认 Revision 是自己才删除
与 Redis 锁的关键差异:
- etcd 是强一致 → 无丢锁窗口
- 但吞吐低于 Redis,锁获取延迟略高
- 适合对一致性要求高的场景(配置、选主、分布式事务协调)
四、基于 ZooKeeper 的分布式锁
ZooKeeper 用 ZAB 协议保证顺序一致性,其**临时顺序节点(EPHEMERAL_SEQUENTIAL)**天然适合做分布式锁。
4.1 原理
基于 ZooKeeper 的分布式锁:
1. 在锁节点 /lock 下创建临时顺序子节点 /lock/seq-0000000001
2. 检查自己是不是序号最小的节点
→ 是 → 获得锁
→ 否 → 监听前一个(序号比自己小且最接近的)节点
3. 前一个节点释放(删除/会话超时)→ 触发通知 → 再检查自己
4. 获得锁的节点 = 序号最小者;释放 = 删除自己的节点
临时节点的特性:
- 客户端会话断开 → 临时节点自动删除 → 锁自动释放
→ 持锁者宕机不会死锁(活性)
→ 且会话必须靠心跳维持,网络抖动时锁可能短暂释放
4.2 与 etcd 对比
两者都是"强一致协调服务",锁的实现思路同构:ZooKeeper 用会话 + 临时顺序节点,etcd 用租约 + Revision。差异主要在协议与生态:
| 维度 | ZooKeeper | etcd |
|---|---|---|
| 一致性协议 | ZAB | Raft |
| 锁的载体 | 临时顺序节点 | Lease + Revision Key |
| 自动释放 | 会话超时删临时节点 | 租约过期删 Key |
| 公平性 | 序号 FIFO | Revision FIFO |
| 运维 | 独立集群,偏重 | 与 Kubernetes 同源,轻量 |
五、RedLock:多节点投票锁的争议
Redis 官方曾提出 RedLock,在 N 个独立 Redis 节点上"多数派加锁"来缓解单点主从丢锁问题。它备受争议,Martin Kleppmann 与 Salvatore Sanfilippo 有著名论战。
5.1 RedLock 原理
RedLock 加锁流程(N 通常为 5):
1. 对每个独立 Redis 实例依次 SET key value NX EX ttl
2. 记录总耗时,若满足:
加锁成功的实例数 > N/2,且总耗时 < TTL
→ 加锁成功
3. 释放:对所有实例执行删除(Lua 校验 value)
思想:
用「多数派」抵御单点故障
→ 即便个别节点宕机/丢锁,多数派仍持锁
5.2 争议与结论
RedLock 存在几个理论缺陷:依赖各节点时钟(时钟跳变会破坏判断)、无法处理"客户端 GC 暂停期间锁已过期"、多数派加锁仍有竞态窗口。业界主流观点:
实践结论:
- 若要求"绝对互斥",RedLock 并不能提供数学上的保证
- 若业务可以容忍极小概率双执行(配合幂等兜底),
单 Redis + 看门狗通常够用
- 若业务绝对不能接受双执行(扣款、转账),
建议直接上 etcd/ZooKeeper 或分布式事务
六、三种方案对比与选型
| 维度 | Redis | etcd | ZooKeeper |
|---|---|---|---|
| 一致性 | 主从异步(弱) | Raft 强一致 | ZAB 顺序一致 |
| 吞吐 | 最高(10w+ QPS) | 中(万级) | 中(万级) |
| 加锁延迟 | 最低(亚毫秒) | 低 | 中(需建连接/监听) |
| 自动释放 | TTL / 看门狗 | 租约 | 会话超时 |
| 公平排队 | 需自实现 | Revision 天然 | 序号天然 |
| 丢锁风险 | 主从切换有窗口 | 无 | 会话抖动短暂释放 |
| 运维成本 | 低(已有 Redis) | 中 | 高(独立集群) |
| 典型场景 | 秒杀、缓存防击穿 | 选主、配置、事务协调 | 老系统协调服务 |
选型建议:
高并发、可容忍极小概率双执行
→ Redis + 看门狗(最常见,成本最低)
高并发且绝对不容忍双执行
→ etcd(云原生标配)或 ZooKeeper
已有 ZooKeeper/etcd 集群,不想多维护一套
→ 直接用现有协调服务做锁
补充:无论哪种锁,业务侧都应做「幂等兜底」
→ 锁只是降低并发概率,幂等保证最终正确
ℹ️ 核心:锁的选型本质是"一致性要求 vs 性能要求"的权衡。记住一个原则:能用幂等兜底解决的问题,不必强上强一致锁。
七、常见避坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 忘记设 TTL | 持锁者宕机永久死锁 | 加锁必带过期时间 |
| TTL 太短 | 业务未完锁被释放 | 看门狗自动续期 |
| 删锁不校验 value | 误删他人锁 | Lua 脚本校验后删除 |
| 主从切换 | 锁丢失双执行 | 强一致存储或幂等兜底 |
| 长事务持锁 | 锁成为瓶颈 | 缩小临界区,锁内不做 IO |
| 等待端全部自旋 | 羊群效应 | 用监听/Watch 排队唤醒 |
| 只靠锁不幂等 | 极端情况仍出错 | 业务侧幂等兜底 |
八、最佳实践清单
□ 加锁用 SET key value NX EX 原子命令(Redis)
□ value 用业务唯一标识,释放前 Lua 校验
□ 必设 TTL + 看门狗续期,防死锁防误释放
□ 临界区尽量小,锁内不做重 IO
□ 绝对互斥场景选用 etcd/ZooKeeper
□ 高并发场景配合幂等兜底(唯一索引/状态机)
□ 加锁失败采用监听唤醒而非盲目自旋
□ 定期检查锁的超时设置与业务耗时匹配度
一句话原则
分布式锁 = 互斥 + 防死锁 + 防误删,
选型上「性能优先用 Redis、一致性强求用 etcd/ZK」,业务侧永远配幂等兜底。
小结
分布式锁的本质,是在一个共享的高可用存储上做"原子占用标记"。Redis 用 SET NX EX + 看门狗 提供最高吞吐,但主从切换存在丢锁窗口;etcd 与 ZooKeeper 凭借强一致的 Raft/ZAB 天然消除丢锁,代价是吞吐与运维成本。落地时记住五件事:加锁必带 TTL、释放必校验 value、绝对互斥用强一致存储、临界区尽量小、业务侧始终配幂等兜底。当你能区分"可以容忍极小概率双执行"和"绝对不能双执行"两类场景时,分布式锁就不再是玄学,而是一个清晰的技术决策。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。