Skip to content

设计一个分布式锁系统

分布式锁是分布式系统中最基础也最容易被误解的组件之一。面试中「设计一个分布式锁」几乎必考,但大多数人只停留在「Redis SETNX」层面,一旦被追问 Redlock 缺陷、Fencing 机制、etcd Lease 就露馅了。

问题拆解:分布式锁到底要解决什么?

单机场景下,synchronizedReentrantLock 就能搞定互斥。但多节点部署后,多个进程同时操作共享资源(比如扣库存、抢红包、定时任务调度),必须有一个跨进程的互斥机制

分布式锁的核心需求就三个:

  • 互斥性:同一时刻只有一个客户端持有锁
  • 可用性:锁服务挂了不会导致死锁(自动释放)
  • 可重入性:同一线程可重复获取同一把锁

这三个要求看似简单,实际实现时处处是坑。

方案一:Redis 分布式锁(最常用,但争议最大)

基础实现

Redis 单节点锁用一条命令搞定:

bash
SET lock:order:1001  unique_value  NX  PX  30000
  • NX:key 不存在才设置,保证互斥
  • PX 30000:30 秒自动过期,防止客户端挂了锁不释放
  • unique_value:用 UUID 或 requestId 标识锁持有者,解锁时校验:只能释放自己的锁

释放锁要用 Lua 脚本保证原子性:

lua
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

WatchDog 续期:一个 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 有根本性问题:

  1. 依赖时钟同步:Redlock 假设各节点时钟是精确同步的,但实际中 NTP 同步误差、时钟跳跃都可能导致锁提前过期或被错误延长。某公司实测 5 个 Redis 节点在 AWS 上时钟偏差最大达 120ms。
  2. 无法保证 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 的临时顺序节点

操作流程:

  1. 客户端创建锁的持久节点(如 /locks/order_1001
  2. 客户端在锁节点下创建临时顺序节点(EPHEMERAL_SEQUENTIAL),如 /locks/order_1001/lock_0000000001
  3. 客户端获取锁节点下的所有子节点,排序后判断自己是不是最小的那个
  4. 是最小节点 → 获取锁成功
  5. 不是最小节点 → 监听前一个节点,前一个节点被删除(释放锁)时唤醒自己

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):

java
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 选型

维度etcdZooKeeper
一致性协议Raft(Leader 选举更快,约 300ms)Zab(Leader 选举约 1-3s)
锁到期机制Lease TTL + KeepAlive(gRPC 长连接)临时节点 + 会话超时(TCP 心跳)
客户端语言Go 原生,多语言 gRPCJava 为主,多语言 Curator
运维复杂度低(etcdctl + 内置 metrics)高(JMX + 4 字命令 + 独立监控)
写入延迟约 1-3ms(3 节点,同机房)约 2-5ms(3 节点,同机房)
最大吞吐约 10K QPS约 1W TPS
典型场景K8s 调度 + 云原生组件Hadoop 生态 + 老系统

当前云原生时代,etcd 几乎成为分布式锁的默认选择。

Fencing 机制:锁的最后一层保障

不管用哪种方案实现锁,都存在一个理论问题:客户端拿到锁后,GC 停顿 / 网络延迟导致锁超时,但客户端还在写数据

解决方案是Fencing Token(隔离令牌)

  1. 每次获取锁时,锁服务分配一个单调递增的 token
  2. 客户端写数据时,把 token 带上
  3. 服务端(或数据库)校验 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│
    │                      │  → 拒绝写入 ⛔          │
java
// 伪代码:带 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)< 1msWatchDog(有风险)缓存更新、非关键互斥(如防缓存击穿)
Redlock有争议5-20msWatchDog不推荐使用
ZooKeeper强(CP)2-5ms会话超时(40s 窗口)需自己实现遗留系统、Java 生态
etcd强(CP)1-3msLease KeepAlive(gRPC)内置 revision云原生、K8s 组件

选型建议:新项目无脑选 etcd。如果团队对 Redis 运维有足够信心,且场景允许短暂的不一致(如缓存重建防击穿),Redis 单节点锁 + WatchDog 也够用。涉及资金扣减、订单状态变更等关键写操作,必须用 etcd/ZK + Fencing Token。


参考:Martin Kleppmann《How to do distributed locking》、etcd 官方文档(分布式锁示例)、ZooKeeper 分布式锁原理、Redlock 争议原文、Redisson 源码分析(WatchDog 实现)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。