分布式锁方案设计
# 18.分布式锁方案设计
本篇定位:分布式锁是分布式系统的"互斥保险丝"——多节点要协调访问共享资源时离不开它。本文从一次"超卖 1200 件"的秒杀事故讲起,回答三个核心问题——为什么需要分布式锁?业界三大方案怎么选?怎么避开分布式锁的"九大坑"?
# 目录介绍
# 01.超卖 1200 件
# 1.1 秒杀的灾难
某商城做"iPhone 抢购",库存 1000 台,结果卖出去 2200 台——超卖 1200 台,单台亏 4000 元,直接损失 480 万元。
sequenceDiagram
participant U1 as 用户 A
participant U2 as 用户 B
participant S1 as 应用节点 1
participant S2 as 应用节点 2
participant DB as 数据库
par 同时下单
U1->>S1: 抢购
S1->>DB: SELECT stock<br/>结果: 1
S1->>S1: synchronized 锁本地
S1->>S1: 检查 stock > 0
S1->>DB: UPDATE stock = 0
and
U2->>S2: 抢购
S2->>DB: SELECT stock<br/>结果: 1
S2->>S2: synchronized 锁本地
S2->>S2: 检查 stock > 0
S2->>DB: UPDATE stock = 0
end
Note over DB: 库存被扣 2 次<br/>但实际只有 1 件
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
根因:开发用了 synchronized 做并发控制——单机内有效,但集群部署后每个节点各自有自己的锁,根本不互斥。
# 1.2 故障扩散链路
flowchart TD
A[业务部署 5 个节点] --> B[每个节点 synchronized 各管各的]
B --> C[5 个节点同时通过库存校验]
C --> D[5 个节点同时扣库存]
D --> E[实际扣成 -1200<br/>但代码没校验为负数]
E --> F[订单系统已生成 2200 单]
Cause[根因] --> R1[用了 JVM 进程内锁]
Cause --> R2[没意识到分布式]
Cause --> R3[DB 没用乐观锁<br/>UPDATE WHERE stock>0]
Cause --> R4[订单生成与扣库存非原子]
style E fill:#ffebee
style F fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1.3 反思分布式锁
事后这个团队总结了三个最深刻的教训:
- JVM 锁(synchronized / ReentrantLock)只在单进程内有效
- 多节点部署一定要用分布式锁——这是底线
- 业务正确性"第二道防线"很重要——库存扣减用
UPDATE stock = stock - 1 WHERE stock > 0,DB 也守一道
分布式锁的价值就是把"多机"变回"单机"——让 N 个节点像 1 个节点一样竞争资源。
# 02.要解决的核心矛盾
# 2.1 单机锁失效
graph LR
subgraph "单进程 synchronized 有效"
T1[线程 1] --> Lock1[JVM Monitor]
T2[线程 2] --> Lock1
T3[线程 3] --> Lock1
end
subgraph "多进程 各自管自己"
P1[进程 1] --> Lock2[Monitor 1]
P2[进程 2] --> Lock3[Monitor 2]
P3[进程 3] --> Lock4[Monitor 3]
Lock2 -.无关.- Lock3
Lock3 -.无关.- Lock4
end
style Lock1 fill:#e8f5e8
style Lock2 fill:#ffebee
style Lock3 fill:#ffebee
style Lock4 fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
核心:分布式锁要把锁状态放在所有进程都能访问的"第三方"——Redis、Zookeeper、etcd、DB 都行。
# 2.2 性能与正确
graph LR
A[强一致<br/>Zookeeper/etcd] --> B[性能中等<br/>每次锁要走 Raft]
A --> C[100% 正确]
A2[最终一致<br/>Redis] --> B2[性能极高<br/>10w+ QPS]
A2 --> C2[极端场景可能失效]
A3[DB 锁] --> B3[性能差<br/>每次走 DB]
A3 --> C3[正确]
style B fill:#fff3e0
style B2 fill:#e8f5e8
style B3 fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
# 2.3 可用与一致
CAP 取舍:
| 锁实现 | 倾向 | 取舍 |
|---|---|---|
| Redis 单节点 | AP | 高性能,挂了能重启 |
| Redis 哨兵 | AP | 主备切换瞬间可能丢锁 |
| Redlock | 试图 CP | 实际仍是 AP,争议大 |
| Zookeeper | CP | 强一致,性能略低 |
| etcd | CP | Raft 共识,云原生标配 |
实战经验:
- 大多数业务(防误操作、降低重复率)→ Redis 够用
- 金融、库存等绝不能错 → Zookeeper / etcd
- 业务上还要有"二道防线"(DB 唯一约束、状态机)
# 2.4 分布式锁的本质
分布式锁 = 一个所有节点都看得到的"标记位"
它的核心追求是 互斥:同一时刻最多一个节点持有锁。
但它不是"绝对正确"的银弹——所以分布式锁要和业务幂等设计配合,不要把"系统正确性"完全押在锁上。
# 03.业界主流方案
# 03.1 三大主流方案
Redis(最流行) SETNX + 过期时间,单条命令搞定。配合 Redisson 等框架成熟。互联网公司 80% 选这个。
Zookeeper(最严谨) 临时顺序节点 + Watch 机制。金融系统首选。
etcd(云原生标配) 基于 Raft 的强一致 KV,K8s 生态默认。
flowchart LR
A[DB 锁<br/>1990s] --> B[Zookeeper<br/>2008]
B --> C[Redis 锁<br/>2010s]
C --> D[Redlock<br/>2014]
D --> E[etcd v3<br/>2016]
A1[低性能<br/>少用] -.- A
B1[严谨<br/>金融常用] -.- B
C1[流行<br/>性能好] -.- C
D1[Redis 多节点<br/>有争议] -.- D
E1[云原生<br/>标准] -.- E
style C fill:#e8f5e8
style B fill:#fff3e0
style E fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 03.2 横向对比矩阵
| 维度 | Redis | Zookeeper | etcd | DB |
|---|---|---|---|---|
| 一致性 | AP | CP | CP | CP |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 客户端复杂度 | 中 | 高 | 中 | 低 |
| 死锁风险 | 中(依赖过期) | 低(临时节点) | 低(lease) | 高 |
| Watch / 通知 | 弱 | ✅ 完美 | ✅ 完美 | ❌ |
| 可重入 | 需框架支持 | 需框架支持 | 需框架支持 | 自己实现 |
| 公平锁 | 难 | ✅ 顺序节点 | ✅ | 难 |
| 生态 | Redisson 成熟 | Curator 成熟 | clientv3 | 原生 |
| 适用场景 | 互联网常规 | 金融严谨 | 云原生 | 简单场景 |
# 03.3 Redlock 争议
Redis 之父 Antirez 提出的多节点锁算法 Redlock:在 N 台 Redis 上同时获取锁,多数(N/2+1)成功才算锁住。
graph TB
Client[客户端] -->|同时申请| R1[Redis 1]
Client --> R2[Redis 2]
Client --> R3[Redis 3]
Client --> R4[Redis 4]
Client --> R5[Redis 5]
Note[多数成功 ≥3 才算锁住<br/>否则全部释放]
style R1 fill:#fff3e0
style R2 fill:#fff3e0
style R3 fill:#fff3e0
2
3
4
5
6
7
8
9
10
11
12
Martin Kleppmann 反驳:Redlock 在某些异常场景(GC 暂停、时钟跳变)下仍会出错。真正强一致需要 fencing token(递增序列)。
实战建议:
- 业务能接受小概率失误 → 简单 Redis 单节点足够
- 必须严格正确 → 直接用 Zookeeper / etcd
- 不推荐 Redlock:复杂度上去了正确性还有争议
# 04.设计核心原则
# 04.1 互斥性原则
任何时刻最多一个客户端持有锁——这是分布式锁的灵魂。
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant Redis as Redis
C1->>Redis: SET lock 1, NX, EX 30
Redis-->>C1: OK
C2->>Redis: SET lock 1, NX, EX 30
Redis-->>C2: nil 失败
Note over C2: 重试 / 等待 / 拒绝
2
3
4
5
6
7
8
9
10
11
12
核心命令:SET key value NX EX seconds——原子操作,不存在才设置 + 设置过期时间。
# 04.2 防死锁原则
铁律:锁必须有过期时间——客户端崩溃没释放锁也能自动释放。
// ✅ 正确:原子设置 + 过期
redis.set(key, value, "NX", "EX", 30)
// ❌ 错误:分两步 - 中间崩溃就死锁
redis.setnx(key, value)
redis.expire(key, 30) // 万一崩在这一步...永久死锁
2
3
4
5
6
# 04.3 锁主对应原则
释放锁时必须验证"是不是自己的锁"——避免误删别人的锁。
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant Redis as Redis
C1->>Redis: SET lock A, NX, EX 30
Note over C1: 业务执行 35 秒(超过过期时间)
Note over Redis: 锁过期自动释放
C2->>Redis: SET lock B, NX, EX 30 拿到锁
C1->>Redis: 业务结束 DEL lock
Note over C1,Redis: ❌ 删的是 C2 的锁!
2
3
4
5
6
7
8
9
10
11
12
正确做法:用 Lua 脚本保证"判断 + 删除"原子性:
-- 释放锁的 Lua 脚本
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
2
3
4
5
6
加锁时 value 用 UUID(每个客户端唯一),释放时校验 value 一致才删。
# 04.4 可重入原则
同一个客户端能否多次获取同一把锁?
graph LR
A[业务 A 持有锁] --> B[业务 A 调用业务 B]
B --> C[业务 B 也想获取同一把锁]
C --> D{可重入?}
D -->|是| Allow[允许 计数+1]
D -->|否| Block[阻塞 → 死锁]
style Allow fill:#e8f5e8
style Block fill:#ffebee
2
3
4
5
6
7
8
9
10
实现:value 里存 "客户端 ID + 重入次数",每次加锁判断。Redisson 默认就实现了可重入。
# 05.方案落地实战
# 05.1 整体架构
graph TB
subgraph "客户端"
App1[App 1]
App2[App 2]
AppN[App N]
end
subgraph "锁服务"
LockSvc[锁中间件<br/>Redis/ZK/etcd]
end
subgraph "保险层"
DB[(DB 唯一约束)]
State[状态机]
Idem[幂等键]
end
App1 & App2 & AppN -->|加锁/释放| LockSvc
App1 & App2 & AppN -->|二道防线| DB
App1 & App2 & AppN -->|二道防线| State
App1 & App2 & AppN -->|二道防线| Idem
style LockSvc fill:#fff3e0
style DB fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 05.2 Redis 分布式锁
完整实现:
class RedisLock(private val redis: Jedis, private val key: String) {
private val value = UUID.randomUUID().toString()
fun tryLock(expireSec: Long = 30): Boolean {
val result = redis.set(key, value, SetParams().nx().ex(expireSec))
return "OK" == result
}
fun unlock(): Boolean {
val script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""".trimIndent()
val result = redis.eval(script, listOf(key), listOf(value))
return result == 1L
}
}
// 使用
val lock = RedisLock(redis, "lock:order:$orderId")
if (lock.tryLock(30)) {
try {
doBusinessLogic()
} finally {
lock.unlock()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
关键点:
SET ... NX EX原子操作value用 UUID 确保唯一- 释放用 Lua 脚本
# 05.3 Zookeeper 锁
临时顺序节点机制:
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant ZK as Zookeeper
C1->>ZK: 创建临时顺序节点 /lock/req-001
ZK-->>C1: 你是第一个 → 拿到锁
C2->>ZK: 创建临时顺序节点 /lock/req-002
ZK-->>C2: 不是第一 → Watch /lock/req-001
Note over C1: 业务完成
C1->>ZK: 删除 /lock/req-001
ZK->>C2: 通知 → 你是新的第一 → 拿到锁
Note over C1,ZK: 如果 C1 崩溃<br/>临时节点自动删除
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
优点:
- 天然防死锁:客户端断连临时节点自动删
- 公平锁:按创建顺序排队
- 强一致:基于 ZAB 协议
缺点:性能比 Redis 差一个数量级。
# 05.4 etcd 锁
基于 lease(租约)+ KV 的实现:
// Go 客户端示例
session, _ := concurrency.NewSession(client, concurrency.WithTTL(10))
defer session.Close()
mutex := concurrency.NewMutex(session, "/lock/order")
if err := mutex.Lock(ctx); err != nil {
return err
}
defer mutex.Unlock(ctx)
// 业务逻辑
2
3
4
5
6
7
8
9
10
11
特点:
- Raft 强一致
- 自动续约(lease keep-alive)
- K8s 生态原生支持
# 05.5 Redisson 实战
Java 业界事实标准:
RLock lock = redisson.getLock("order:" + orderId);
// 简单加锁
lock.lock();
try {
// 业务
} finally {
lock.unlock();
}
// 带超时
boolean ok = lock.tryLock(3, 30, TimeUnit.SECONDS);
// 等待 3s,持有最多 30s
// 公平锁
RLock fairLock = redisson.getFairLock("queue:" + bizId);
// 读写锁
RReadWriteLock rwLock = redisson.getReadWriteLock("data");
rwLock.readLock().lock(); // 读
rwLock.writeLock().lock(); // 写
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Redisson 自动解决了 4 个难题:
- ✅ 自动续期(看门狗 watchdog)
- ✅ 可重入
- ✅ Lua 脚本保证原子
- ✅ 多种锁类型(公平 / 读写 / 联锁)
# 06.关键问题解决
# 06.1 锁超时问题
经典场景:业务执行时间不确定,锁过期了业务还没结束。
gantt
title 锁超时危险时间线
dateFormat X
axisFormat %s
section Client 1
持有锁 :a, 0, 30
业务执行 :b, 0, 45
业务超过锁时长! :crit, c, 30, 15
section Client 2
抢到过期锁 :d, 30, 30
业务执行 :e, 30, 30
与 C1 并发! :crit, f, 30, 15
2
3
4
5
6
7
8
9
10
11
12
13
14
解决方案:
- 看门狗(自动续期):Redisson 默认每 1/3 锁时长自动续 1 次
- 业务时长可控:能预估就设大一点(比业务时长长 2 倍)
- 失败补偿:业务结束发现锁已过期 → 触发对账或补偿
# 06.2 锁续期问题
Redisson 看门狗实现思路:
sequenceDiagram
participant C as Client
participant Redis as Redis
participant WD as 看门狗线程
C->>Redis: SET lock, EX 30
C->>WD: 启动续期线程
loop 每 10 秒
WD->>Redis: 检查锁还在吗?
WD->>Redis: EXPIRE lock 30 (续期)
end
Note over C: 业务结束
C->>Redis: 释放锁
C->>WD: 停止续期
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键设计:
- 续期间隔 = 锁时长 / 3(默认)
- 释放锁时停止续期线程
- 客户端宕机 → 续期停止 → 锁自然过期
# 06.3 锁粒度问题
粒度过粗:锁住整张商品表 → 所有商品互相阻塞。 粒度过细:每个 SKU 一把锁 → 锁数量爆炸。
实战经验:
| 场景 | 锁粒度 |
|---|---|
| 秒杀单 SKU | lock:sku:${skuId} |
| 用户操作 | lock:user:${userId}:${action} |
| 订单防重 | lock:order:create:${userId}:${requestId} |
| 全局开关 | lock:global:promotion |
原则:锁粒度 = 业务竞争的最小单元。
# 07.常见陷阱与反例
# 07.1 误删别人锁
反例:
// ❌ 错误
redis.setnx("lock", "1", 30)
doBusiness() // 业务超过 30 秒
redis.del("lock") // 删的可能是别人的锁
2
3
4
修正:用 UUID + Lua 校验删除(前面已展示)。
# 07.2 锁未释放
反例:
// ❌ 错误:异常时锁不释放
lock.tryLock()
doBusiness() // 抛异常
lock.unlock() // 永远不会执行
2
3
4
修正:
// ✅ 正确:finally 兜底
if (lock.tryLock()) {
try {
doBusiness()
} finally {
lock.unlock()
}
}
2
3
4
5
6
7
8
# 07.3 滥用分布式锁
反例:每个 HTTP 请求都加分布式锁——QPS 直接跌 70%。
问题:
- 大多数场景 DB 乐观锁就够
- 分布式锁是网络调用,开销大
- 高竞争场景才有必要
正确:先用乐观锁、唯一索引、状态机;真正高竞争场景才上分布式锁。
mindmap
root((三大反例))
误删别人锁
不带 UUID
不用 Lua 校验
线上灾难
锁未释放
没用 finally
宕机泄漏
死锁
滥用分布式锁
不该用也用
性能下降
该用 乐观锁/状态机
2
3
4
5
6
7
8
9
10
11
12
13
14
# 08.演进路线
# 08.1 V1 数据库锁
特征:业务起步、并发不高。
做法:
- DB 行锁(
SELECT ... FOR UPDATE) - 乐观锁(version 字段)
- 唯一索引
适用阶段:< 1000 QPS
# 08.2 V2 Redis 锁
特征:并发上来、需要更好性能。
做法:
- Redis SETNX + 过期
- Redisson 框架
- 看门狗自动续期
适用阶段:1000-10w QPS
# 08.3 V3 高可用锁集群
特征:金融级 / 严格正确性。
做法:
- Zookeeper / etcd 集群
- fencing token 二次校验
- 业务层补偿对账
适用阶段:金融、库存、票务
flowchart LR
V1[V1 DB 锁<br/>低并发] --> V2[V2 Redis 锁<br/>主流]
V2 --> V3[V3 ZK/etcd<br/>金融级]
style V1 fill:#e3f2fd
style V2 fill:#e8f5e8
style V3 fill:#fff3e0
2
3
4
5
6
7
# 09.总结与决策
# 09.1 上线检查表
引入分布式锁前对照:
- [ ] 真的需要分布式锁吗?(先尝试乐观锁/唯一索引)
- [ ] 锁服务高可用(Redis 哨兵 / ZK 集群)
- [ ] 加锁有过期时间
- [ ] 加锁是原子操作(SET NX EX)
- [ ] value 是唯一标识(UUID)
- [ ] 释放锁用 Lua 脚本校验
- [ ] 锁超时有兜底(自动续期 / 失败补偿)
- [ ] 业务有 finally 保证释放
- [ ] 锁粒度合理(不太粗也不太细)
- [ ] 业务层有"二道防线"(DB 唯一约束 / 状态机)
- [ ] 监控(加锁失败率、持有时长、超时次数)
- [ ] 压测覆盖
# 09.2 选型决策树
flowchart TD
Start([我要做并发控制]) --> Q1{真的多机?}
Q1 -->|否 单机| Local[ReentrantLock 即可]
Q1 -->|是| Q2{业务接受偶尔失误?}
Q2 -->|是 互联网常规| Q3{已有 Redis?}
Q3 -->|是| Redisson[Redisson 推荐]
Q3 -->|否| Redis[搭 Redis]
Q2 -->|否 金融级| Q4{已有 ZK 或 etcd?}
Q4 -->|有 ZK| ZK[Zookeeper + Curator]
Q4 -->|云原生 K8s| Etcd[etcd]
Q4 -->|都没| ZK2[搭 ZK 集群]
Q5([强烈不推荐]) --> Avoid1[Redlock 复杂还有争议]
Q5 --> Avoid2[DB 锁 性能差]
style Local fill:#e3f2fd
style Redisson fill:#e8f5e8
style ZK fill:#fff3e0
style Etcd fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
最后一句话:分布式锁是看似简单实则陷阱多多的组件——开篇 480 万损失只是因为开发用 synchronized 替代了分布式锁。
好的分布式锁设计 = 互斥可靠、防死锁、可重入、二道防线兜底。