系统设计:分布式锁服务

从零设计一个分布式锁服务,对比 Redis 锁与 Redlock 争议、ZooKeeper 与 etcd 租约实现,详解 fencing token 与安全性、续租与故障恢复、性能与选型对比、常见误用,包含容量估算、架构图、数据模型与面试追问。

系统设计:分布式锁服务

分布式锁是并发控制的最后一道防线:定时任务防重复执行、库存扣减防超卖、资源操作防并发冲突。看似简单的一个「加锁解锁」,却因为网络分区、时钟漂移、进程暂停而充满陷阱,连 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)
      │
   ┌──┴──────────────┐
   ▼                 ▼
 锁元数据          事件通知(释放唤醒等待者)

加锁流程

  1. SDK 生成唯一持有者标识(实例 ID + 线程 ID + UUID)
  2. 向协调层发起原子「不存在则设置」操作
  3. 成功则启动后台续租线程,失败则退避重试或排队
  4. 业务执行期间持有 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 顺序临时节点

  1. 在 /lock/{resource} 下创建临时顺序节点
  2. 若自己序号最小则获得锁
  3. 否则监听前一个节点的删除事件,被唤醒后重试
  4. 会话断开时临时节点自动删除,锁自动释放

优点是公平(顺序)+ 自动释放(临时节点),且基于 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 单机RedlockZooKeeper/etcd
一致性弱(异步复制)中(多数派但依赖时钟)强(共识协议)
性能最高高中(需多数派确认)
公平性无无有(顺序节点)
自动释放TTLTTL会话/租约
正确性低中高

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. 常见误用与面试常见问题

常见误用

  1. SETNX + EXPIRE 分两条命令:中间崩溃会导致无 TTL 的死锁。必须用 SET NX PX。
  2. 解锁不校验持有者:直接 DEL 会删掉别人刚拿到的锁。必须用 Lua 校验 owner。
  3. TTL 设置过短:业务未完成锁就过期,引发双持有者。
  4. 把锁当唯一保证:没有 fencing token 时,锁无法阻止失锁客户端继续写。
  5. 锁粒度太粗:锁整个资源导致并发度骤降,应细化到具体业务键。

面试常见问题

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。把这一点讲清楚,再补上续租、误用清单与选型表,这道题就能答得比大多数候选人深。

继续阅读

探索更多技术文章

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

全部文章 返回首页