分布式系统学习笔记(三):分布式锁

写在前面

本文讲分布式锁——多个服务/节点之间如何互斥访问共享资源。它有几种经典实现(数据库、Redis、Zookeeper、etcd),各有优劣。理解它们的差异,才能在不同场景选对方案。


一、为什么需要分布式锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
单机:用 lock / Mutex / 信号量(进程内)

分布式:多个进程/机器要互斥
  单机锁管不到其他机器
  需要一个"全局可见"的锁

典型场景:
  - 防止重复操作(下单、扣库存、发券)
  - 限流(全局 QPS)
  - 选主(只一个节点执行定时任务)
  - 资源独占(同时只一个能改配置)
1
2
3
4
5
6
7
分布式锁的必要条件:
  ✓ 互斥(Mutual Exclusion)— 任意时刻只有一个客户端持有
  ✓ 避免死锁 — 持有者崩溃,锁要能自动释放
  ✓ 可重入(可选)— 同一持有者可重复获取
  ✓ 高可用 — 锁服务不能单点
  ✓ 高性能 — 加锁/解锁要快
  ✓ 公平性(可选)— 按请求顺序获取

二、实现方式对比

1
2
3
4
5
6
方式          一致性    性能    可靠性    复杂度    适用
──────────────────────────────────────────────────────────
数据库         强       中      中       低        简单场景
Redis         弱(AP)   极高    中       低        高并发、容忍偶尔失效
Zookeeper     强(CP)   中      高       中        强一致、可靠
etcd          强(CP)   高      高       中        云原生、K8s

三、数据库实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 方式一:唯一索引(加锁 = 插入,解锁 = 删除)
INSERT INTO locks(resource, owner, expire_at) VALUES('order:123', 'uuid', NOW()+10s);
-- 插入成功 = 获取锁;唯一冲突 = 被占
DELETE FROM locks WHERE resource='order:123' AND owner='uuid';

-- 方式二:悲观锁(SELECT ... FOR UPDATE)
BEGIN;
SELECT * FROM account WHERE id=1 FOR UPDATE;   -- 加行锁
-- 业务
COMMIT;
1
2
3
优点:简单,不用引入新组件
缺点:性能差(DB 是瓶颈)、过期清理麻烦、FOR UPDATE 占连接
适用:并发不高、已有数据库、不想引入 Redis/ZK

四、Redis 实现

1
2
3
4
5
6
7
8
# 加锁:SET key value NX EX(原子)
SET lock:order:123 <owner_uuid> NX EX 10
# NX:不存在才设置(互斥)
# EX 10:10 秒过期(防死锁)
# 返回 OK = 获取成功;nil = 已被占

# 解锁:必须验证 owner 再删(Lua 保证原子)
# 否则可能删掉别人的锁
1
2
3
4
5
6
-- 解锁脚本(原子)
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

Redis 锁的问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
问题1:业务执行超过锁过期时间
  锁到期自动释放,别的客户端拿到锁 → 两个客户端同时执行
  解决:看门狗续期(定时延长过期时间)
  Redisson 框架自带看门狗

问题2:Redis 主从切换丢锁
  主节点加锁成功 → 还没同步到从 → 主挂了 → 从升主(没锁数据)→ 别人能加锁
  解决:Redlock(向多个独立 Redis 实例加锁,多数成功才算成功)

问题3:Redlock 的争议
  Martin Kleppmann 认为 Redis(AP)做锁不可靠
  因为 GC 停顿、时钟漂移可能导致锁失效
  Redis 作者 antirez 反驳(认为实际够用)
1
2
3
4
5
Redis 锁的定位:
  ✓ 高性能、高并发
  ✗ 不是绝对可靠(AP 系统的特性)
  适用:对偶尔失效容忍的场景(如限流、防重复提交)
  不适用:绝对不能错的场景(如扣款、扣库存)→ 用 ZK/etcd

五、Zookeeper 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
ZK 锁基于「临时顺序节点」+ Watch:

1. 创建顺序临时节点 /lock/node-0001, /lock/node-0002 ...
2. 判断自己是不是序号最小的
   - 是 → 获得锁
   - 否 → 监听前一个节点(只监听前一个,避免羊群效应)
3. 前一个节点删除(释放锁)→ 自己被唤醒 → 成为最小 → 获得锁
4. 持有者崩溃 → 临时节点自动删除 → 后继者被唤醒

特点:
  ✓ 强一致(ZAB 共识)
  ✓ 公平锁(顺序节点)
  ✓ 会话失效自动释放(临时节点)
  ✓ 无看门狗问题(会话心跳保活)
  ✗ 性能不如 Redis(CP,要多数确认)
1
2
3
羊群效应(Herd Effect):
  ✗ 所有等待者都监听同一个节点 → 释放时全部唤醒抢锁 → 惊群
  ✓ 改进:每个等待者只监听「前一个」节点 → 顺序唤醒

六、etcd 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
etcd 锁基于 Lease(租约)+ 事务(TXN)+ Revision(全局递增版本):

1. 创建带 Lease 的 key(Lease 保活,崩溃自动过期)
2. 用事务获取所有锁 key,判断自己 Revision 是否最小
   - 是 → 获得锁
   - 否 → 监听前一个 key
3. 类似 ZK 的顺序等待

特点(类似 ZK):
  ✓ 强一致(Raft)
  ✓ Lease 自动过期(防死锁)
  ✓ 比 ZK 更轻量、性能更好、云原生标配
  K8s 的分布式锁多用 etcd(或基于 etcd 的 leader election)
1
2
3
4
5
6
// etcd 分布式锁示例(Go concurrency 包)
m, err := concurrency.NewSession(client)
l := concurrency.NewMutex(m, "/lock/order/123")
l.Lock(ctx)      // 加锁
defer l.Unlock(ctx)
// 业务

七、CP vs AP 锁的本质区别

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Redis 锁(AP):
  返回"加锁成功"时,可能主从还没同步
  极端情况下锁可能失效(但你以为成功)
  → 快,但不可靠

ZK / etcd 锁(CP):
  返回"加锁成功"时,数据已通过共识写入多数节点
  强一致保证,不会假成功
  → 可靠,但慢

选择:
  需要绝对可靠(金融、库存)→ CP(ZK/etcd)
  容忍偶尔失效、追求性能   → AP(Redis)

八、使用建议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
场景                          推荐
──────────────────────────────────────────────────
高并发、容忍偶尔失效(限流)   Redis
防重复提交、幂等               Redis
扣库存、扣款(绝对不能错)     ZK / etcd / 数据库
选主(Leader Election)        ZK / etcd
配置/资源互斥                  etcd(云原生)/ ZK

通用建议:
  优先用成熟框架(Redisson、curator、etcd concurrency)
  别自己造轮子(边界情况极多)
  锁要设过期时间 + 校验 owner
  业务要考虑"锁失效"的兜底(幂等)

九、小结

  • 必要条件:互斥、防死锁、高可用、(可选)可重入/公平
  • 数据库锁:简单但性能差,适合低并发
  • Redis 锁(AP):极高性能,但不绝对可靠;Redlock 有争议
  • Zookeeper 锁(CP):顺序临时节点,强一致公平锁
  • etcd 锁(CP):Lease + 事务,云原生首选
  • 本质:CP 锁可靠慢,AP 锁快但可能假成功;按业务对可靠性的要求选

下一篇讲分布式事务——跨服务/数据库如何保证一致性。