设计一个分布式锁系统
分布式锁是分布式系统中最基础也最容易被误解的组件之一。面试中「设计一个分布式锁」几乎必考,但大多数人只停留在「Redis SETNX」层面,一旦被追问 Redlock 缺陷、Fencing 机制、etcd Lease 就露馅了。
问题拆解:分布式锁到底要解决什么?
单机场景下,synchronized 或 ReentrantLock 就能搞定互斥。但多节点部署后,多个进程同时操作共享资源(比如扣库存、抢红包、定时任务调度),必须有一个跨进程的互斥机制。
分布式锁的核心需求就三个:
- 互斥性:同一时刻只有一个客户端持有锁
- 可用性:锁服务挂了不会导致死锁(自动释放)
- 可重入性:同一线程可重复获取同一把锁
这三个要求看似简单,实际实现时处处是坑。
方案一:Redis 分布式锁(最常用,但争议最大)
基础实现
Redis 单节点锁用一条命令搞定:
SET lock:order:1001 unique_value NX PX 30000NX:key 不存在才设置,保证互斥PX 30000:30 秒自动过期,防止客户端挂了锁不释放unique_value:用 UUID 或 requestId 标识锁持有者,解锁时校验:只能释放自己的锁
释放锁要用 Lua 脚本保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endWatchDog 续期:一个 Java 生产事故
Redisson 的 WatchDog 机制是后台线程每隔 10 秒检查一次,锁剩余过期时间 < 锁超时时间 / 3 就续期。但下面这个场景就出过事:
时序流程:
时间线 →
T+0s 客户端 A 获取锁(TTL=30s),开始处理订单
T+5s WatchDog 续期成功,TTL 重置为 30s
T+10s 客户端 A 发生 Full GC(STW 长达 15s)
T+10s WatchDog 线程也被 GC 暂停,无法续期
T+20s 锁 key 过期
T+22s 客户端 B 获取锁,开始处理同一订单
T+25s 客户端 A GC 恢复,认为锁还在手里,继续写数据
T+25s → 同一订单被处理两次,库存扣减两次真实参数: 某电商秒杀系统,Redis 单节点锁 TTL 设为 30s,WatchDog 每 10s 续一次。大促时 Young GC 频繁(约 3 秒一次),但有一次 Full GC 耗时 18 秒,恰好赶在 WatchDog 续期窗口内,导致锁过期。当天多扣了 2000 件库存,凌晨对账才被发现,紧急回滚补偿。
改进方案: TTL 设足够长(60s 起步),配合 GC 日志监控 + 即时告警。但根本解法是加 Fencing Token(见下文)。
Redlock 的争议
Redis 原作者 Antirez 提出 Redlock 算法来解决 Redis 集群下的锁可靠性:向 N 个(通常 5 个)独立 Redis 节点依次加锁,超过半数成功才算加锁成功。
Redlock 加锁流程(时序描述):
客户端请求加锁 →
向 node1 发送 SET key NX PX 100 → 成功(1ms)
向 node2 发送 SET key NX PX 100 → 成功(2ms)
向 node3 发送 SET key NX PX 100 → 超时(跳过,12ms)
向 node4 发送 SET key NX PX 100 → 成功(3ms)
向 node5 发送 SET key NX PX 100 → 成功(1ms)
→ 5 节点中成功 4 个,超过半数(3/5),加锁成功
→ 但已经耗时 19ms,如果锁 TTL 只设了 20ms,留给临界区的时间只有 1ms上面这个例子暴露了 Redlock 的时钟依赖问题:如果各节点时钟不同步,或者网络延迟波动大,加锁耗时可能吃掉大部分 TTL 窗口。
但分布式系统专家 Martin Kleppmann 写过一篇著名的**《How to do distributed locking》**,直接指出 Redlock 有根本性问题:
- 依赖时钟同步:Redlock 假设各节点时钟是精确同步的,但实际中 NTP 同步误差、时钟跳跃都可能导致锁提前过期或被错误延长。某公司实测 5 个 Redis 节点在 AWS 上时钟偏差最大达 120ms。
- 无法保证 Fencing:Redlock 没有提供单调递增的 fencing token,客户端可能因为 GC 停顿(Stop-The-World)导致锁超时后还在写数据,造成并发写入。
2026 年的共识是:不要在生产环境用 Redlock。Redis 官方也在 2024 年的文档中降低了 Redlock 的推荐优先级,转而建议用 etcd 或 ZK 做强一致锁。
方案二:ZooKeeper 临时顺序节点(CP 优先)
ZK 基于 Zab 协议(Zookeeper Atomic Broadcast)保证强一致性,天然适合做分布式锁。
实现原理
zk 锁节点结构:
/locks/order_1001/ ← 持久节点(锁根)
├── lock_0000000001 ← 客户端 A 的临时顺序节点
├── lock_0000000002 ← 客户端 B 的临时顺序节点
└── lock_0000000003 ← 客户端 C 的临时顺序节点操作流程:
- 客户端创建锁的持久节点(如
/locks/order_1001) - 客户端在锁节点下创建临时顺序节点(EPHEMERAL_SEQUENTIAL),如
/locks/order_1001/lock_0000000001 - 客户端获取锁节点下的所有子节点,排序后判断自己是不是最小的那个
- 是最小节点 → 获取锁成功
- 不是最小节点 → 监听前一个节点,前一个节点被删除(释放锁)时唤醒自己
Watch 链的时序:
客户 ZK 集群
A ├─ 创建 /locks/lock_0000000001 ──→ 最小节点,获取锁 ✅
B ├─ 创建 /locks/lock_0000000002 ──→ 不是最小,Watch /locks/lock_0000000001
C ├─ 创建 /locks/lock_0000000003 ──→ 不是最小,Watch /locks/lock_0000000002
A ├─ 释放锁(删除节点) ──→ ZK 通知 B
B ├─ 接到通知,再次检查:自己是 /locks 下最小节点,获取锁 ✅
B ├─ 创建 /locks/lock_0000000004 ──→ 通知 C
C ├─ 检查 → 最小,获取锁 ✅关键优势
- 临时节点:客户端断开连接,ZK 自动删除节点,锁自动释放,不会死锁
- 顺序节点 + Watch 机制:天然公平锁,每个客户端只监听前一个节点,避免惊群效应(如果全部监听同一个节点,一个节点释放会唤醒所有等待者,N 个客户端同时抢锁,大量无效请求打向 ZK)
- 强一致性:Zab 协议保证集群内所有节点数据一致,不存在脑裂
代价
- 性能不如 Redis:ZK 写操作需要 Leader 选举共识,TPS 约 1 万左右。实测 ZK 3 节点集群,在 4 核 8G 机器上锁的获取/释放延迟约 2-5ms,而 Redis 单节点在同样机器上不到 0.5ms
- 客户端会话超时(Session Timeout)时,锁可能意外释放——如果客户端只是网络抖动,ZK 判定会话过期删了节点,但客户端还在跑,它以为锁还在手里。Curator 客户端默认会话超时 40s,这个窗口足够触发一次 GC 停顿
方案三:etcd Lease + 续期(当前最佳实践)
etcd 用 Raft 保证强一致性,设计上比 ZK 更适合分布式锁场景。
实现原理
1. 创建租约(Lease),设置 TTL(如 10 秒)
2. 将锁 key 绑定到该租约
3. 启动 KeepAlive 协程,定时向 etcd 发送心跳续期
4. 获取锁 → 执行临界区 → 释放锁etcd 锁的时序:
客户端 etcd 集群
│ 创建 Lease (TTL=10s)
│─────────────────────────→ 返回 LeaseID: 12345
│ 创建 /locks/order/1001 (绑定 LeaseID)
│─────────────────────────→ 成功,获锁
│ KeepAlive (每 3s 一次)
│─────────────────────────→ TTL 重置为 10s
│ KeepAlive ...
│─────────────────────────→
│ 执行临界区(耗时 5s)
│ 释放锁 /locks/order/1001
│─────────────────────────→ 成功
│ KeepAlive 停止etcd Java 客户端代码示例(使用 jetcd):
public class EtcdDistributedLock {
private final Client client;
private final String lockKey;
private long leaseId;
private ScheduledExecutorService keepAliveExecutor;
public EtcdDistributedLock(String endpoints, String lockKey) {
this.client = Client.builder().endpoints(endpoints).build();
this.lockKey = lockKey;
}
public boolean tryLock(long ttlSeconds, long timeoutMs) throws InterruptedException {
// 1. 创建租约
LeaseGrantResponse grantResp = client.getLeaseClient()
.grant(ttlSeconds).get();
this.leaseId = grantResp.getID();
// 2. 启动 KeepAlive(etcd 客户端自动续期)
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
keepAliveExecutor.scheduleAtFixedRate(() -> {
try {
client.getLeaseClient().keepAliveOnce(leaseId).get();
} catch (Exception e) {
log.warn("KeepAlive failed, lease may expire", e);
}
}, 0, ttlSeconds / 3, TimeUnit.SECONDS);
// 3. 尝试加锁(设置 key 绑定 lease)
try {
ByteSequence key = ByteSequence.from(lockKey, StandardCharsets.UTF_8);
ByteSequence value = ByteSequence.from("locked", StandardCharsets.UTF_8);
PutResponse putResp = client.getKVClient()
.put(key, value, PutOption.newBuilder()
.withLeaseId(leaseId)
.build())
.get(timeoutMs, TimeUnit.MILLISECONDS);
return true;
} catch (TimeoutException e) {
// 超时未获取到锁
client.getLeaseClient().revoke(leaseId);
return false;
}
}
public void unlock() {
if (keepAliveExecutor != null) {
keepAliveExecutor.shutdown();
}
if (leaseId > 0) {
try {
client.getLeaseClient().revoke(leaseId).get();
} catch (Exception ignored) {}
}
}
}etcd 客户端(如 etcd-client / jetcd)的 KeepAlive 是主动续期,心跳中断则租约到期、锁自动释放。相比 Redisson 的 WatchDog(后台线程 10 秒续一次),etcd 的机制更可靠,因为续期走 gRPC 长连接,连接断开服务端立刻感知。
etcd vs ZK 选型
| 维度 | etcd | ZooKeeper |
|---|---|---|
| 一致性协议 | Raft(Leader 选举更快,约 300ms) | Zab(Leader 选举约 1-3s) |
| 锁到期机制 | Lease TTL + KeepAlive(gRPC 长连接) | 临时节点 + 会话超时(TCP 心跳) |
| 客户端语言 | Go 原生,多语言 gRPC | Java 为主,多语言 Curator |
| 运维复杂度 | 低(etcdctl + 内置 metrics) | 高(JMX + 4 字命令 + 独立监控) |
| 写入延迟 | 约 1-3ms(3 节点,同机房) | 约 2-5ms(3 节点,同机房) |
| 最大吞吐 | 约 10K QPS | 约 1W TPS |
| 典型场景 | K8s 调度 + 云原生组件 | Hadoop 生态 + 老系统 |
当前云原生时代,etcd 几乎成为分布式锁的默认选择。
Fencing 机制:锁的最后一层保障
不管用哪种方案实现锁,都存在一个理论问题:客户端拿到锁后,GC 停顿 / 网络延迟导致锁超时,但客户端还在写数据。
解决方案是Fencing Token(隔离令牌):
- 每次获取锁时,锁服务分配一个单调递增的 token
- 客户端写数据时,把 token 带上
- 服务端(或数据库)校验 token:只接受 token 大于当前最新记录的写操作
带 Fencing 的完整时序图:
时间线 →
Redis/etcd 客户端 A 数据库
│ │ │
│ acquire lock │ │
│←──── token=42 ──────│ │
│ │ GC STW (18s) │
│ lock expired │ (还在停) │
│ (TTL=30s) │ │
│ │ │
│ acquire lock │ │
│←──── token=43 ──────│ 客户端 B 也拿到了锁 │
│ │ │
│ B writes │ │
│ (token=43) ────────│→ db write │
│ │ token=43 > 最新记录 42│
│ │ → 写入成功 ✅ │
│ │ │
│ A GC 恢复 │ │
│ A writes │ │
│ (token=42) ────────│→ db write │
│ │ token=42 ≤ 最新记录 43│
│ │ → 拒绝写入 ⛔ │// 伪代码:带 Fencing Token 的分布式锁
long token = lockService.acquire("order:1001"); // 返回 42
if (token > storage.getLastToken("order:1001")) {
storage.deduct("order:1001", 1, token); // 写操作带上 token
lockService.release("order:1001");
}etcd 天然支持 Fencing Token:etcd 的 revision 就是单调递增的版本号,每次写入都会递增,服务端可以通过 mod_revision 做冲突检测。
Redis 没有内置的单调递增 token,需要自己实现,比如用 Redis 的 INCR 命令生成一个全局自增 ID,但 INCR 本身不是强一致的(Redis 集群模式下 INCR 不能跨节点保证单调性)。
总结
| 方案 | 一致性 | 延迟(P99) | 自动续期 | Fencing | 推荐场景 |
|---|---|---|---|---|---|
| Redis SETNX | 弱(AP) | < 1ms | WatchDog(有风险) | 无 | 缓存更新、非关键互斥(如防缓存击穿) |
| Redlock | 有争议 | 5-20ms | WatchDog | 无 | 不推荐使用 |
| ZooKeeper | 强(CP) | 2-5ms | 会话超时(40s 窗口) | 需自己实现 | 遗留系统、Java 生态 |
| etcd | 强(CP) | 1-3ms | Lease KeepAlive(gRPC) | 内置 revision | 云原生、K8s 组件 |
选型建议:新项目无脑选 etcd。如果团队对 Redis 运维有足够信心,且场景允许短暂的不一致(如缓存重建防击穿),Redis 单节点锁 + WatchDog 也够用。涉及资金扣减、订单状态变更等关键写操作,必须用 etcd/ZK + Fencing Token。
参考:Martin Kleppmann《How to do distributed locking》、etcd 官方文档(分布式锁示例)、ZooKeeper 分布式锁原理、Redlock 争议原文、Redisson 源码分析(WatchDog 实现)