分布式锁(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) │
└─────────────────────────────────────────────┘
三层职责:
- 客户端 SDK:封装加锁/解锁/续期,处理重试与 fencing token。
- 锁服务:可选的统一入口(鉴权、限流、监控),也可让业务直连后端。
- 锁后端:真正存储锁状态的地方,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,语义等价。两者都靠共识协议保证「同一时刻只有一个持有者」,且会话断开时临时节点自动删除 = 天然的故障释放。
四、数据模型
| 存储 | 结构 | 用途 |
|---|---|---|
| Redis | lock:{key} String/Hash | 锁持有者 + 重入计数 + TTL |
| Redis | lock:queue:{key} List | 公平锁排队(可选) |
| etcd | Lease + key | 强一致锁,会话断开自动释放 |
| MySQL | lock_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 主从异步复制下,主节点在锁写入后、复制到从节点前宕机,从节点升主后不知道这把锁存在,别人可以再次加锁 → 双持。缓解手段:
- 用 Redlock(多实例多数派),降低但不消除风险。
- 加 fencing token,让下游拒绝旧持有者。
- 高正确性场景直接用 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/ZooKeeper | Redis 快;etcd 正确性强但慢,按正确性需求选 |
| 解锁 | Lua 校验持有者 | 裸 DEL | Lua 安全;裸 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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。