系统设计:分布式锁服务
分布式锁是并发控制的最后一道防线:定时任务防重复执行、库存扣减防超卖、资源操作防并发冲突。看似简单的一个「加锁解锁」,却因为网络分区、时钟漂移、进程暂停而充满陷阱,连 Redlock 这样的知名方案都被质疑过正确性。
1. 需求分析
功能性需求
- 互斥:同一时刻只有一个持有者
- 加锁/解锁:支持阻塞与非阻塞获取
- 超时自动释放:持有者崩溃后锁不能永久卡死
- 可重入(可选):同一持有者可多次加锁
- 公平性(可选):按等待顺序获取
非功能性需求
- 延迟:加锁 P99 低于 10ms
- 吞吐:单集群十万级 QPS
- 可用性:99.99%,不能成为业务单点
- 安全性:绝不出现两个持有者同时持锁
关键矛盾
安全性与可用性天然冲突:要绝对安全就需要强一致共识(多数派),会牺牲可用性;要极致可用(如 Redis 主从异步复制)则可能在故障切换时短暂出现双持有者。面试时务必先讲清这个 trade-off。
2. 容量与性能目标
- 业务服务实例:5000 个
- 每实例每秒加锁:20 次 → 10 万 QPS
- 平均持锁时长:50ms → 并发持锁约 5000 个
- 锁记录:单条约 200 字节 → 5000 × 200B ≈ 1 MB(内存中微不足道)
结论:锁数据量极小,瓶颈在协调开销而非存储。因此实现重点在「共识协议的性能」与「网络往返次数」,而非容量。
3. 整体架构
业务服务(多个实例)
│ 加锁/解锁请求
▼
锁服务 SDK(重试、续租、fencing token 注入)
│
▼
锁协调层(Redis 集群 / etcd / ZooKeeper)
│
┌──┴──────────────┐
▼ ▼
锁元数据 事件通知(释放唤醒等待者)
加锁流程
- SDK 生成唯一持有者标识(实例 ID + 线程 ID + UUID)
- 向协调层发起原子「不存在则设置」操作
- 成功则启动后台续租线程,失败则退避重试或排队
- 业务执行期间持有 token,写下游资源时携带 token
解锁流程
校验持有者标识匹配后删除键;若是续租型锁,先停止续租再删除。
4. 数据模型
Redis 键结构
lock:{resource} -> value = owner_id (唯一标识)
TTL = 锁过期时间(如 30s)
etcd 键结构
/locks/{resource}/{lease_id} -> value = owner_id
lease TTL 与租约绑定,客户端定期续租
锁记录表与可选审计
lock_audit (
id BIGINT PRIMARY KEY,
resource VARCHAR(256),
owner_id VARCHAR(128),
acquired_at TIMESTAMP,
released_at TIMESTAMP,
ttl_ms INT,
status TINYINT -- 持有中/已释放/超时释放
)
设计要点
- value 必须唯一,用于校验解锁者身份,防止误删他人的锁
- TTL 必须存在,防止持有者崩溃后死锁
- 审计表只用于排障,不参与锁判定(否则又引入一致性问题)
5. 基于 Redis 的实现与 Redlock 争议
单机 Redis 锁
import uuid, redis
r = redis.Redis()
def acquire(resource, ttl_ms=30000):
owner = str(uuid.uuid4())
ok = r.set(f"lock:{resource}", owner, nx=True, px=ttl_ms)
return owner if ok else None
def release(resource, owner):
# Lua 保证「校验 + 删除」原子性
script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
return r.eval(script, 1, f"lock:{resource}", owner)
两个关键点:加锁用 SET NX PX 一步完成(不要 SETNX + EXPIRE 两条命令);解锁用 Lua 脚本保证校验与删除的原子性。Redis 的持久化与主从切换语义可参考 分布式缓存设计。
Redlock 算法
为摆脱单点,Redlock 提出向 N 个(通常 5 个)独立 Redis 节点依次加锁,超过半数成功且总耗时小于锁有效期才算加锁成功。
1. 记录开始时间 T1
2. 依次向 5 个节点用相同 owner + TTL 加锁
3. 若 ≥3 个成功且 (T2 - T1) < TTL,则加锁成功
4. 否则向所有节点发起解锁,返回失败
争议焦点
反对者(如 Martin Kleppmann)指出:Redlock 依赖各节点时钟,若某节点发生时钟跳变或 GC 停顿,锁可能在业务未完成时过期,导致双持有者;且它无法提供 fencing token,因此不能保证下游资源的互斥。
支持者(Redis 作者 antirez)认为:在有界的时钟漂移与合理 TTL 下,Redlock 足够实用,且可通过自动续租缓解。
面试结论:Redlock 是「性能优先、可用性优先」的方案,适用于对偶发冲突不敏感的场景(如防重复任务);对正确性零容忍的场景(如资金、库存)必须用带 fencing token 的共识方案。
6. 基于 ZooKeeper 与 etcd 的租约锁
ZooKeeper 顺序临时节点
- 在
/lock/{resource}下创建临时顺序节点 - 若自己序号最小则获得锁
- 否则监听前一个节点的删除事件,被唤醒后重试
- 会话断开时临时节点自动删除,锁自动释放
优点是公平(顺序)+ 自动释放(临时节点),且基于 ZAB 共识保证一致;缺点是会话超时判定依赖心跳,长 GC 可能导致误判失锁。
etcd 租约 Lease
# 申请租约,TTL 15 秒
etcdctl lease grant 15
# 用租约创建锁键,仅当键不存在时成功
etcdctl put /locks/order-123 owner-a --lease=694d7f3f8c6a5e1b
# 后台 KeepAlive 自动续租
etcdctl lease keep-alive 694d7f3f8c6a5e1b
etcd 的租约与键解耦,可让多个键共享同一租约,续租成本低;配合事务(Txn)可做 CAS 加锁,是 Kubernetes 等系统选用的方案。
三种实现对比
| 维度 | Redis 单机 | Redlock | ZooKeeper/etcd |
|---|---|---|---|
| 一致性 | 弱(异步复制) | 中(多数派但依赖时钟) | 强(共识协议) |
| 性能 | 最高 | 高 | 中(需多数派确认) |
| 公平性 | 无 | 无 | 有(顺序节点) |
| 自动释放 | TTL | TTL | 会话/租约 |
| 正确性 | 低 | 中 | 高 |
7. fencing token 与安全边界
为什么需要 fencing token
问题场景:客户端 A 获得锁后发生长时间 GC,锁 TTL 到期被自动释放,客户端 B 获得锁。此时 A 恢复并继续写入下游资源,与 B 冲突——锁失效了,但 A 不知道。
fencing token 的原理
每次加锁成功返回一个单调递增的 token(如 ZooKeeper 的 zxid、etcd 的 revision)。客户端写下游资源时携带 token,下游存储拒绝小于已见 token 的写入。
A 获得锁, token=33 → A 停顿
锁超时释放
B 获得锁, token=34 → B 写入成功(下游记录 34)
A 恢复, 携带 token=33 写入 → 被下游拒绝(33 < 34)
工程含义
- 锁服务本身只能保证「加锁互斥」,无法阻止已失锁的客户端继续操作
- 真正的安全需要下游配合校验 token,这是很多人忽略的关键点
- 若下游无法校验 token,则分布式锁不能作为资金类操作的最后保证,需改用数据库乐观锁或唯一约束
8. 续租、故障恢复与选型
自动续租
持锁期间后台线程按 TTL 的 1/3 周期续租,续租失败则视为失锁并主动停止业务操作(避免带病运行)。
def auto_renew(resource, owner, ttl_ms):
while still_holding(resource, owner):
sleep(ttl_ms / 3000) # 每 1/3 TTL 续一次
if not renew(resource, owner, ttl_ms):
signal_lock_lost() # 通知业务尽快中止
return
故障恢复
- 持有者崩溃:TTL 到期自动释放
- 协调层主节点故障:Redis 主从切换可能短暂丢锁(安全风险);etcd/ZooKeeper 多数派存活则不受影响
- 网络分区:少数派一侧的客户端续租失败,应主动放弃锁
选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 防重复任务、缓存重建 | Redis 单机/Redlock | 性能优先,偶发冲突可容忍 |
| 配置变更、选主 | etcd 租约 | 强一致、与 K8s 生态契合 |
| 需要公平排队 | ZooKeeper 顺序节点 | 天然公平 |
| 资金/库存等强一致 | 数据库行锁或唯一约束 | 锁服务无法保证端到端安全 |
9. 常见误用与面试常见问题
常见误用
- SETNX + EXPIRE 分两条命令:中间崩溃会导致无 TTL 的死锁。必须用
SET NX PX。 - 解锁不校验持有者:直接
DEL会删掉别人刚拿到的锁。必须用 Lua 校验 owner。 - TTL 设置过短:业务未完成锁就过期,引发双持有者。
- 把锁当唯一保证:没有 fencing token 时,锁无法阻止失锁客户端继续写。
- 锁粒度太粗:锁整个资源导致并发度骤降,应细化到具体业务键。
面试常见问题
Q: 分布式锁和本地锁的区别?
本地锁(如 Java 的 synchronized)只在单进程内互斥,跨进程无效;分布式锁依赖外部协调服务,代价是网络往返与一致性风险。
Q: Redis 锁在主从切换时会不会丢锁?
会。主节点写入锁后未同步到从节点即宕机,从节点升主后锁丢失,可能出现双持有者。Redlock 用多数派降低概率,但无法根除。
Q: 为什么解锁必须用 Lua?GET 校验与 DEL 删除之间存在时间窗口,期间锁可能过期并被他人获取,导致误删。Lua 在 Redis 内原子执行,消除该窗口。
Q: 如何实现可重入锁?
在 value 中记录 owner 与重入计数(Hash 结构),同一 owner 再次加锁时计数加一,解锁时减一,减到零才真正删除。
Q: 锁等待怎么实现?
非阻塞重试(自旋 + 退避)简单但浪费请求;etcd 的 Watch、ZooKeeper 的监听可在释放时主动唤醒等待者,效率更高。
Q: 怎么避免锁过期导致业务未完成?
合理估算最长执行时间设置 TTL,并配合自动续租;更根本的是让下游支持 fencing token 校验。
Q: 锁服务和业务在同一进程还是独立服务?
通常锁服务是独立的基础设施(Redis/etcd 集群),业务通过 SDK 使用。少数场景(如数据库行锁)可复用业务库,但要注意锁与业务数据的一致性。
总结
分布式锁的答题主线是先讲正确性要求,再讲实现取舍,最后落到 fencing token:Redis 锁快但弱一致、Redlock 靠多数派但依赖时钟、etcd/ZooKeeper 强一致且支持公平排队。无论选哪种,都要回答「锁过期后失锁的客户端仍在写怎么办」——答案就是下游校验单调递增的 fencing token。把这一点讲清楚,再补上续租、误用清单与选型表,这道题就能答得比大多数候选人深。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。