设计一个分布式锁服务

本文系统设计一个分布式锁服务:需求澄清与量级估算、Redis 与 ZooKeeper/etcd 两种实现对比、加锁解锁原子性与看门狗续期、Redlock 争议与 fencing token、可重入与公平锁、锁超时与死锁治理,并给出 Lua 脚本、状态机与容错权衡。

分布式锁(Distributed Lock)是分布式系统的「临界区守门人」:秒杀扣库存、订单幂等、定时任务防重复执行、资源独占分配,都靠它保证同一时刻只有一个节点进入临界区。它看着简单——不就是 SETNX 吗——但真正难的是在进程暂停、网络分区、时钟漂移、节点宕机时依然正确。本文按照系统设计面试的标准答题结构,设计一个支持高并发、可重入、带自动续期与故障自愈的分布式锁服务。

一句话:分布式锁的核心不是「加锁」,而是「在持有者挂掉后锁能被安全释放,且被释放期间旧持有者不会误操作」——前者靠租约(lease)+ 续期,后者靠 fencing token。

一、需求澄清与量级估算

1.1 需求澄清

  • 语义:互斥锁(同一时刻仅一个持有者),还是要读写锁、信号量(限流并发数)?
  • 可重入:同一线程重复加锁是否需要可重入?
  • 公平性:是否需要按请求顺序(FIFO)获锁,还是允许插队?
  • 持有时长:临界区是毫秒级短任务,还是分钟级长任务?
  • 正确性等级:容忍极端情况下「两个节点同时持锁」(效率优先),还是绝对不允许(正确性优先)?

明确假设(面向面试的合理假设):

需求项假设
语义互斥锁 + 可重入 + 读写锁(可选)
持有时长秒级为主,长任务用续期
可用性99.99%,加锁延迟 P99 < 10ms
正确性允许「效率型」场景容忍极罕见双持;「正确性型」用 fencing token 兜底
规模峰值 100 万次加锁/秒

1.2 量级估算

指标估算值推导
加锁 QPS~100 万秒杀、任务调度、库存扣减等场景叠加
平均持有时长50ms短临界区为主
并发锁对象~1000 万不同业务 key 数量
锁元数据~GB 级1000 万 × 每 key 几十字节
续期请求~20 万 QPS长任务按 1/3 时长续期

一句话:分布式锁是「高频、短持有、易失」的元数据,天然适合 Redis 这类内存 KV;只有当正确性要求极高时,才上 ZooKeeper/etcd 这类共识系统。

二、高层架构设计

     ┌──────────┐   ┌──────────┐   ┌──────────┐
     │ 服务 A    │   │ 服务 B    │   │ 服务 C    │   (各持 LockClient SDK)
     └────┬─────┘   └────┬─────┘   └────┬─────┘
          │ lock/unlock   │              │
   ┌──────▼───────────────▼──────────────▼──────┐
   │           分布式锁服务 (Lock Service)         │
   │  ┌────────────┐  ┌────────────┐  ┌────────┐ │
   │  │ 加锁/解锁 API│  │ 看门狗续期  │  │ 锁监控  │ │
   │  └────────────┘  └────────────┘  └────────┘ │
   └──────────────────┬──────────────────────────┘
                      │
   ┌──────────────────▼──────────────────────────┐
   │        锁后端 (Lock Backend)                  │
   │  Redis 集群 (SET NX PX + Lua)                 │
   │   或 etcd/ZooKeeper (临时顺序节点 + Watch)     │
   └─────────────────────────────────────────────┘

三层职责:

  1. 客户端 SDK:封装加锁/解锁/续期,处理重试与 fencing token。
  2. 锁服务:可选的统一入口(鉴权、限流、监控),也可让业务直连后端。
  3. 锁后端:真正存储锁状态的地方,Redis 或 etcd/ZooKeeper。

2.1 后端选型:Redis vs etcd/ZooKeeper

维度Redis(单实例/主从)Redis(Redlock 多实例)etcd / ZooKeeper
一致性异步复制,可能丢锁多数派写入Raft/ZAB 强一致
性能极高(10 万+ QPS)高中(万级 QPS)
正确性弱(主从切换可能双持)中(仍有争议)强
实现复杂度低中中(临时节点 + Watch)
适用效率型锁折中正确性型锁

一句话:要「快」用 Redis,要「对」用 etcd/ZooKeeper;如果既要快又怕双持,就在 Redis 锁之上叠加 fencing token 让下游自己拒绝过期持有者。

三、核心组件设计

3.1 Redis 加锁:原子性与安全释放

加锁必须原子(判断 + 设置),用 SET key value NX PX ttl:

# value 必须是「唯一持有者标识」(如 uuid:thread_id),用于安全解锁
SET lock:order:42 "9f8a-uuid-42" NX PX 30000
# 返回 OK 表示加锁成功;返回 nil 表示已被占用

解锁必须校验持有者再删,否则会误删别人的锁。校验 + 删除也要原子,用 Lua:

-- unlock.lua: 只有 value 匹配才删除
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

可重入用 Hash 计数(value 存 holder + 重入次数):

-- lock.lua: 可重入加锁
local key, holder, ttl = KEYS[1], ARGV[1], tonumber(ARGV[2])
if redis.call("EXISTS", key) == 0 then
    redis.call("HSET", key, holder, 1)
    redis.call("PEXPIRE", key, ttl)
    return 1
elseif redis.call("HEXISTS", key, holder) == 1 then
    redis.call("HINCRBY", key, holder, 1)   -- 重入 +1
    redis.call("PEXPIRE", key, ttl)         -- 刷新 TTL
    return 1
else
    return 0
end

一句话:加锁用 SET NX PX,解锁用 Lua「校验持有者再删」——永远不要裸 DEL,否则一个超时的旧持有者会删掉新持有者的锁。

3.2 租约与看门狗续期

锁必须有 TTL(租约),否则持有者崩溃后锁永不释放(死锁)。但 TTL 太短,长任务会被误释放;太长,崩溃后恢复慢。解法是看门狗(Watchdog)自动续期:

class Watchdog:
    def __init__(self, client, key, holder, ttl_ms=30000):
        self.client, self.key, self.holder, self.ttl = client, key, holder, ttl_ms
        self.running = False

    def start(self):
        self.running = True
        # 每 ttl/3 续期一次,保证 TTL 不会自然到期
        self.thread = threading.Thread(target=self._loop, daemon=True)
        self.thread.start()

    def _loop(self):
        while self.running:
            time.sleep(self.ttl / 3000)      # ttl/3 秒
            # 仅当自己仍是持有者才续期
            self.client.eval(RENEW_LUA, 1, self.key, self.holder, self.ttl)

    def stop(self):
        self.running = False

要点:续期脚本同样要校验持有者;进程崩溃则看门狗线程随之消失,TTL 到期自动释放。

3.3 Redlock 与争议

Redlock 用 N(通常 5)个独立 Redis 实例,多数派加锁成功才算成功:

1. 记录起始时间 T0
2. 依次向 N 个实例用同一 key/value 加锁(每个带短超时)
3. 若成功实例数 ≥ N/2+1,且总耗时 < TTL,则加锁成功
4. 实际有效时间 = TTL - (当前时间 - T0)
5. 若失败,向所有实例发送解锁

争议(Martin Kleppmann vs antirez):Redlock 依赖时钟假设和进程不会长时间 GC 停顿,在 GC 停顿 + 时钟漂移叠加时仍可能双持。结论:Redlock 提升可用性但不提供绝对正确性;要求正确性时,加 fencing token。

3.4 Fencing Token

即使锁被误判双持,也能靠 fencing token 保证下游只接受最新的持有者:

1. 锁服务每次发锁时递增一个全局单调 token(如 Zookeeper zxid、etcd revision)
2. 持有者访问资源时带上 token
3. 存储层记录「见过的最大 token」,拒绝小于它的请求
Client A 拿锁 token=33 → GC 停顿 → 锁超时
Client B 拿锁 token=34 → 写资源 (token=34, 存储接受, max=34)
Client A 恢复 → 写资源 (token=33, 存储拒绝, 33 < 34) ✅

一句话:fencing token 是分布式锁的「最后防线」——锁本身可能失效,但下游用单调 token 拒绝过期持有者,就能把「效率型锁」变成「正确型锁」。

3.5 基于 etcd/ZooKeeper 的实现

ZooKeeper:在锁路径下创建临时顺序节点,序号最小者获锁,其余 Watch 前一个节点。

/locks/order-42/
  ├── lock-0000000001  ← 最小,持有锁
  ├── lock-0000000002  ← Watch 0001,等它删除
  └── lock-0000000003  ← Watch 0002

etcd:用 Lease + 事务(Compare-And-Swap)+ Watch,语义等价。两者都靠共识协议保证「同一时刻只有一个持有者」,且会话断开时临时节点自动删除 = 天然的故障释放。

四、数据模型

存储结构用途
Redislock:{key} String/Hash锁持有者 + 重入计数 + TTL
Redislock:queue:{key} List公平锁排队(可选)
etcdLease + key强一致锁,会话断开自动释放
MySQLlock_audit锁获取/释放审计日志
Prometheus指标加锁成功率、持有时长、等待队列长度

字段规范:锁 key 命名 lock:{业务}:{资源id};value = {owner_uuid}:{thread_id};TTL 按业务临界区 P99 时长 × 3 设定。

五、关键流程

5.1 加锁时序(Redis)

客户端           Redis
  │ SET k v NX PX 30000
  ├──────────────────────▶│
  │◀── OK ────────────────┤  成功 → 启动看门狗续期
  │◀── nil ───────────────┤  失败 → 退避重试(指数退避 + 抖动)

重试策略:首次失败后等待 50ms,之后指数退避到最大 1s,加随机抖动避免惊群。

5.2 解锁时序

客户端           Redis
  │ EVAL unlock.lua (k, v)
  ├──────────────────────▶│  校验 GET k == v ? DEL : 0
  │◀── 1 / 0 ─────────────┤
  │ 停止看门狗              │

5.3 公平锁(FIFO)

非公平锁允许插队(后到者可能先得),高并发下会饿死先到者。公平锁用排队 + 通知:

1. 加锁失败 → 取当前序号 seq,在 ZSet 中入队 (seq, now)
2. 轮询/等待:自己是否为队首?是则尝试加锁
3. 释放时删除队首,唤醒下一个

Redis 无阻塞原语,公平锁常用「ZSet 排队 + 客户端轮询」,或用 etcd/ZooKeeper 的 Watch 天然实现。

六、可靠性与一致性

6.1 主从切换的锁丢失

Redis 主从异步复制下,主节点在锁写入后、复制到从节点前宕机,从节点升主后不知道这把锁存在,别人可以再次加锁 → 双持。缓解手段:

  1. 用 Redlock(多实例多数派),降低但不消除风险。
  2. 加 fencing token,让下游拒绝旧持有者。
  3. 高正确性场景直接用 etcd/ZooKeeper。

6.2 死锁治理

  • TTL 兜底:所有锁必带 TTL,杜绝永久死锁。
  • 续期防误释放:看门狗保证长任务不被误释放,但续期必须校验持有者。
  • 主动巡检:监控「持有超时异常长」的锁,告警并人工介入。
  • 释放顺序:多个锁按固定顺序加锁,避免环路死锁。

6.3 惊群与性能

  • 随机退避:大量客户端同时抢锁,重试加抖动避免同一时刻齐发。
  • 本地锁先行:先抢进程内锁,减少对 Redis 的无效请求。
  • 锁分片:热点资源按 key % N 拆成 N 把锁,提升并发(代价是资源语义要能拆)。

一句话:分布式锁的可靠性 = 租约防死锁 + 看门狗防误释放 + fencing token 防双持 + 后端选型匹配正确性要求,四者缺一不可。

七、性能与扩展

  • Redis 单分片:单实例 10 万+ QPS;集群按 key 哈希分片,线性扩展。
  • 批量与 Pipeline:批量加锁用 pipeline 减少 RTT。
  • 本地缓存锁状态:短暂本地缓存「已知被占用的锁」避免无效请求(容忍一点过期)。
  • 锁粒度:尽量细粒度(lock:stock:sku:1001 而非 lock:stock),减少竞争。
  • 无锁替代:能用「乐观锁 + CAS」「唯一索引」「队列串行化」解决的,就别用分布式锁。

容量与热点

  • 1000 万把并发锁 × 每把约 100 字节 ≈ 1GB,单集群绰绰有余。
  • 热点锁(爆款秒杀):先本地限流/预扣,再用细粒度锁 + 队列削峰,避免所有请求打同一把锁。

八、权衡与备选

决策点本文选型备选权衡说明
后端Redis(+ fencing)etcd/ZooKeeperRedis 快;etcd 正确性强但慢,按正确性需求选
解锁Lua 校验持有者裸 DELLua 安全;裸 DEL 会误删他人锁
续期看门狗自动续期固定 TTL续期支持长任务;固定 TTL 简单但长任务易误释放
公平性默认非公平 + 可选公平全公平非公平吞吐高;公平防饿死但有排队开销
正确性fencing token只靠锁token 兜底双持;只靠锁在 GC/分区下会出错

关键取舍

  • 性能 vs 正确性:Redis 锁快但弱一致;正确性场景用共识后端或 fencing token,牺牲部分延迟换安全。
  • TTL 长 vs 短:短 TTL 释放快但易误释放(靠续期补救);长 TTL 安全但崩溃后恢复慢。
  • 自研 vs 用库:Redisson/Curator 已封装可重入、看门狗、公平锁,起步优先用成熟库。

九、扩展场景与面试追问

9.1 用分布式锁实现幂等

「同一订单只处理一次」可用锁 + 唯一键双保险:先加 lock:order:{id} 串行化,再靠唯一索引兜底(重复插入失败)。这与 分布式系统幂等设计 的思路一致。

9.2 秒杀与库存扣减

秒杀场景「同一 SKU 不能超卖」,锁粒度是 lock:stock:{sku},但更推荐「Redis 预扣 + 异步落库 + 唯一约束」,把锁换成原子 DECR,性能高一个数量级,参见 设计一个秒杀系统 。锁本身依赖的存储与 设计一个分布式缓存系统 同源,可共用集群。

9.3 定时任务防重复执行

多实例部署的定时任务,靠 lock:cron:{job}:{触发时间} 保证同一时刻只有一台执行;锁 TTL 略大于任务时长,配合 分布式任务调度 的选举机制更稳妥。

9.4 面试常见追问

追问关键回答
SETNX 就够了吗?不够,必须带 TTL 防死锁、value 标识持有者、Lua 校验后删
主从切换丢锁怎么办?Redlock 降低概率,fencing token 兜底,或改用 etcd
长任务 TTL 到期锁没了?看门狗按 TTL/3 自动续期,续期前校验持有者
怎么防惊群?指数退避 + 随机抖动 + 本地锁先行 + 细粒度分片
分布式锁能保证绝对正确吗?不能,需 fencing token 让下游拒绝过期持有者
什么场景不该用锁?能用唯一索引/CAS/队列串行化解决的,优先无锁方案

十、总结

模块关键设计一句话记忆
加锁SET NX PX + 唯一 value原子设置,value 标识持有者
解锁Lua 校验后删绝不裸 DEL
续期看门狗 TTL/3长任务不误释放
双持防护fencing token下游只认最新 token
后端Redis 快 / etcd 对按正确性需求选
治理TTL + 退避 + 细粒度防死锁、防惊群、减竞争

一句话:分布式锁的面试核心是讲清楚「为什么 SETNX 不够(要 TTL + 标识 + Lua 删)、主从切换丢锁怎么兜(Redlock + fencing token)、看门狗如何续期、以及什么场景该用 etcd 而不是 Redis」,把「锁可能失效、下游要自保」这条正确性底线挂在嘴边,而不是只会写 SETNX。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个视频会议系统(WebRTC SFU)
  2. 设计一个 A/B 测试与实验平台
  3. 设计一个 LBS 附近的人系统(Geohash 与空间索引)