本文由GPT-5.6生成
分布式锁深度剖析:从“互斥”到 Fencing Token 的工程实践
摘要:分布式锁解决的是多个分布式客户端对共享资源进行并发协调的问题。真正困难的地方并不是“怎么把锁加上”,而是客户端可能在拿到锁之后长时间暂停、网络可能分区、租约可能过期、业务操作可能跨越多个系统。
目录
- 分布式锁到底要解决什么问题
- 先区分两个概念:锁服务的一致性与业务数据正确性
- 租约/超时机制为什么无法彻底避免“旧客户端苏醒”
- 经典场景:GC、进程暂停与锁过期
- Fencing Token:真正解决“旧持有者继续写”的办法
- Fencing Token 的正确使用方式
- Redis + Redisson
- ZooKeeper
- etcd
- MySQL
SELECT ... FOR UPDATE - MySQL 自定义锁表
- 各方案横向比较
- 分布式锁中的常见错误认识
- 工程选型
- 最终结论
1. 分布式锁到底要解决什么问题
先用一张图建立整体认知:
flowchart LR
A[多个客户端] --> B[分布式锁服务]
B --> C{谁获得锁}
C --> D[Client A]
C --> E[Client B]
D --> F[执行业务]
F --> G[最终受保护资源]
E --> G
G --> H[数据库 / Redis / 文件 / 外部资源]
这里要特别注意:锁服务决定的是“谁被认为拥有锁”,而最终业务资源决定的是“谁真正能够完成有效写入”。
分布式锁的核心目标,可以概括为:
1.1 互斥
对于同一个逻辑资源,例如:
order:10001
user:123
inventory:SKU-A
job:daily-settlement
在正常的锁协议下,同一时刻应该只有一个客户端被认为拥有该锁。
1.2 自动释放
如果持有锁的进程彻底宕机,不能因为它永远没有执行 unlock() 而导致锁永久存在。
因此 Redis、ZooKeeper、etcd 等方案都会引入某种形式的:
- TTL
- Lease
- Session
- 心跳/续期
1.3 可重入
可重入不是所有分布式锁都必须具备,但 Java 业务中经常需要。
例如:
lock.lock();
try {
doSomething();
doSomethingAgain(); // 内部再次获取同一把锁
} finally {
lock.unlock();
}
如果同一线程再次获取同一把锁不会阻塞,那么就是可重入锁。
1.4 更重要:锁不是数据正确性的最终边界
这是理解分布式锁最重要的一句话:
分布式锁是并发协调机制,而不是数据正确性的最终保障。
例如:
Client A
|
| 获取 Redis 锁
v
Redis
|
| 执行业务
v
MySQL
即使 Redis 判断 A 当前持有锁,也不能证明:
- A 的 JVM 一定还在运行;
- A 的线程一定没有暂停;
- A 到 MySQL 的网络一定正常;
- A 的业务请求一定还在当前执行上下文中;
- A 的锁在整个业务执行期间一定没有失效。
因此高风险业务通常需要:
分布式锁
+
数据库事务/条件更新
+
幂等
+
必要时 Fencing Token
2. 先区分两个概念:锁服务的一致性与业务数据正确性
很多文章容易把这两个问题混在一起。
2.1 锁服务保证什么
例如 Redis、ZooKeeper、etcd 都可以维护:
lock_key -> owner
锁服务需要保证自己的数据结构在其设计的故障模型下具有相应的一致性语义。
例如:
Client A 获取锁
|
v
Lock Server
|
+---- A 是当前 owner
但锁服务并不知道:
A 的业务线程现在是否正在运行
A 是否卡在 GC
A 是否卡在系统调用
A 是否已经无法访问数据库
A 是否已经失去继续执行该业务的资格
因此:
“锁服务认为 A 持有锁”与“A 当前仍然应该被允许修改业务资源”不是完全等价的命题。
3. 租约/超时机制为什么无法彻底避免“旧客户端苏醒”
3.1 为什么需要超时
假设没有 TTL:
Client A
|
| 获取锁
v
Lock Server
A 突然宕机
如果锁没有任何自动释放机制:
lock -> 永远属于 A
那么其他客户端永远无法获取。
所以分布式锁必须解决:
“客户端死亡以后,锁怎么释放?”
最常见的答案就是:
TTL / Lease / Session
3.2 但是超时无法完美区分两种状态
锁服务看到:
一段时间没有收到客户端续期
它无法仅凭这个事实判断:
情况 A:客户端真的死了
Client A
X
情况 B:客户端活着,但是暂时无法发送续期
Client A
|
| JVM STW GC
|
|----------------|
|
| 恢复
从锁服务的视角,两者都可能表现为:
长时间没有心跳/续期
因此锁服务只能依据租约规则做出判断。
4. 经典场景:GC、进程暂停与锁过期
考虑:
锁租约 = 30 秒
时序:
sequenceDiagram
participant A as Client A
participant L as Lock Server
participant B as Client B
participant DB as Business DB
A->>L: 获取锁
L-->>A: 获取成功,token=33
A->>A: 开始执行业务
Note over A: JVM 长时间暂停 / STW GC
Note over A: 续期线程也无法正常执行
L->>L: TTL/Lease 到期
L-->>L: 释放 token=33 对应的锁
B->>L: 请求获取锁
L-->>B: 获取成功,token=34
B->>DB: 携带 token=34 写入
DB-->>B: 写入成功
Note over A: A 恢复执行
A->>DB: 携带旧 token=33 写入
DB-->>A: 拒绝旧 token
此时最危险的地方来了:
Client A 恢复
A 可能并不知道:
自己已经失去锁
如果 A 继续:
UPDATE business_table ...
就可能与 B 的业务操作发生冲突。
4.1 重要纠正:不一定只有 JVM STW GC
“长时间 GC”只是非常典型的一种场景。
类似问题还可能来自:
- JVM 长时间 Stop-The-World;
- 进程被操作系统暂停;
- 容器/虚拟机发生严重调度延迟;
- CPU 长时间饥饿;
- 网络长时间不可用;
- 应用线程池耗尽;
- 客户端与锁服务之间发生网络分区;
- 应用自身出现长时间阻塞。
所以更准确的表述应该是:
长时间暂停(pause)是租约锁的核心风险场景之一,GC 只是其中最常见的示例。
5. Fencing Token:真正解决“旧持有者继续写”
5.1 核心思想
每一次成功获取锁,都分配一个递增的 token:
Client A -> token=33
Client B -> token=34
Client C -> token=35
token 表示:
这个客户端获得锁的“世代”比之前的客户端更新。
5.2 经典时序
sequenceDiagram
participant A as Client A
participant L as Lock Service
participant B as Client B
participant DB as Business Resource
A->>L: acquire lock
L-->>A: token=33
A->>A: 执行业务
Note over A: 长时间 pause
L->>L: lease expired
L-->>L: 释放旧锁
B->>L: acquire lock
L-->>B: token=34
B->>DB: write(token=34)
DB-->>B: 接受并记录最新 token=34
Note over A: A 恢复
A->>DB: write(token=33)
DB-->>A: 拒绝:33 < 34
数据库保存:
current_token = 34
此时 A 恢复:
A -> DB
token=33
数据库发现:
33 < 34
因此拒绝 A 的写操作。
6. Fencing Token 的正确使用方式
这里需要特别纠正一个常见说法。
6.1 不应简单理解为“只有 token 更大才能更新”
假设数据库记录:
current_token = 34
如果 Client B 持有:
token = 34
那么它第一次写入时应该被允许。
如果之后 B 继续持有同一 token 执行多个相关更新,也不能因为:
34 > 34
不成立而把自己的后续操作全部拒绝。
因此实际设计需要明确:
token 是“锁世代”还是“资源当前版本”,两者不要混为一谈。
一种典型设计是把资源记录中的 token 视为“最近一次被接受的 fencing token”,要求:
UPDATE business_table
SET content = ?, token = ?
WHERE id = ?
AND token <= ?;
或者更常见地,将“当前资源版本”和“fencing token”结合到具体业务模型中设计。
核心要求不是某一条固定 SQL,而是:
旧 token 必须无法覆盖已经被新 token 写入/推进过的状态。
6.2 一个更清晰的例子
初始:
DB token = 33
Client A:
token = 33
第一次写:
33 >= 33
允许,并将 DB token 保持/推进为 33。
后来 A 暂停。
Client B:
token = 34
B 写入:
34 > 33
DB token 变成:
34
A 恢复后再次写:
33 < 34
拒绝。
6.3 Fencing Token 的能力边界
Fencing Token 能解决的是:
旧客户端对受保护资源的过期访问。
它不能让旧客户端神奇地停止执行。
例如:
Client A
|
+-- 计算
+-- 调用第三方 API
+-- 发 MQ
+-- 写文件
+-- 写数据库
即使 A 最终因为 token 过期无法写数据库:
第三方 API 已经调用
MQ 消息可能已经发送
这些副作用不会自动撤销。
因此:
Fencing Token 不是分布式事务,也不是万能的回滚机制。
7. Redis + Redisson
7.1 Redisson RLock 的基本机制
Redisson 的 RLock 是基于 Redis/兼容 Redis 的分布式可重入锁。
默认情况下,如果调用:
lock.lock();
并且没有显式指定 leaseTime,Redisson 会使用 watchdog 机制维护锁的过期时间。
默认 watchdog timeout 为:
30 秒
它会在锁仍然属于当前 Redisson 实例时持续延长过期时间。
7.2 显式 leaseTime 与 watchdog 不要混淆
例如:
lock.lock();
与:
lock.lock(10, TimeUnit.SECONDS);
语义不同。
前者使用 watchdog 维护锁。
后者指定了固定 lease time,锁会在指定时间后自动释放,不应再把它理解成“watchdog 会无限续期”。
这是实际工程中非常容易踩的坑。
7.3 Redisson 的 Fencing 能力需要更新认识
原文将 Redisson 描述为:
无原生 Fencing Token
这个表述已经过时。
当前 Redisson 文档已经提供:
FencedLock
其目的就是处理:
客户端获取锁
|
| 长时间 GC / pause
v
租约失效
|
v
另一个客户端获取锁
|
v
旧客户端恢复
FencedLock 会产生 fencing token,业务资源需要自行检查该 token 是否仍然有效。
因此更准确的说法是:
普通
RLock不等于 Fencing Lock;如果业务需要 fencing 语义,应使用相应的 FencedLock 能力,并让受保护资源真正执行 token 校验。
7.4 Redis 锁适合什么场景
适合:
- 高并发;
- 任务防重复;
- 定时任务选主;
- 缓存重建;
- 普通业务资源的并发协调;
- 业务本身具备幂等能力。
但对于高资损场景,不应该简单设计成:
Redis 锁
↓
业务操作
↓
认为一定安全
更合理:
Redis/Redisson
+
DB 条件更新 / 乐观锁 / 唯一约束
+
幂等
+
必要时 Fencing Token
8. ZooKeeper
8.1 基本机制
ZooKeeper 提供 ephemeral node:
/locks/order/lock-00000001
/locks/order/lock-00000002
/locks/order/lock-00000003
客户端创建临时有序节点。
通常:
最小序号节点
获得锁。
其他客户端监听自己前面的节点,而不是所有客户端都监听同一个节点,可以减少惊群。
8.2 Session 与临时节点
ZooKeeper 的临时节点与 session 生命周期绑定。
当 session 结束时:
ephemeral node
↓
自动删除
因此它同样解决了:
客户端异常退出
导致的永久锁问题。
8.3 GC / pause 风险依然存在
如果客户端长时间暂停:
Client A
|
| 持有 ephemeral lock
|
| 长时间 pause
|
X 无法及时维持 session
当 session 最终过期:
ephemeral node 删除
B 可能获得锁。
A 恢复之后,如果没有进一步的 fencing 机制,它仍可能继续执行已经过期的业务。
因此:
ZooKeeper 本身解决的是协调和 session 生命周期问题,不意味着旧客户端恢复后自动失去所有业务副作用。
9. etcd
9.1 etcd 的特点
etcd 基于 Raft,是强一致分布式 KV 存储系统。
其常见能力包括:
- Lease;
- Watch;
- Transaction;
- Compare-And-Swap;
- Lock;
- revision。
9.2 Lease
典型模型:
Client
|
| keepalive
v
Lease
|
| attach lock key
v
Lock
如果 Lease 到期:
Lease expired
↓
关联 key 删除
↓
锁释放
所以 etcd 同样存在租约锁的 pause 问题:
Client A pause
↓
keepalive 中断
↓
Lease expired
↓
Client B acquire
↓
A 恢复
9.3 revision 与 Fencing Token
这里需要进行一个重要的精确化。
etcd 的 KV 操作带有全局 revision 等版本信息,这些版本号具有单调推进的特征,可以被业务设计为 fencing 信息。
但不能简单理解为:
“调用 etcd Lock API 后,etcd 自动帮你把 token 传递到业务数据库,并自动阻止旧客户端。”
实际上还需要:
etcd
↓
获取锁/版本信息
↓
业务客户端拿到 fencing 信息
↓
业务数据库/资源
↓
检查 fencing token
因此真正的 fencing 是:
锁服务产生顺序信息
+
业务资源执行顺序检查
两部分缺一不可。
10. MySQL SELECT ... FOR UPDATE
10.1 它与 Redis 锁是不同思路
例如:
START TRANSACTION;
SELECT *
FROM account
WHERE id = 1
FOR UPDATE;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
COMMIT;
这里的锁直接属于:
MySQL 事务
而不是:
客户端自定义 TTL
InnoDB 会在事务提交或回滚时释放相关锁。
因此:
线程发生长时间 GC
↓
事务仍然没有提交
↓
数据库锁仍然存在
↓
其他事务等待
它不会像 Redis TTL 那样简单地因为“客户端线程卡住 30 秒”就自动认为锁已经失效。
10.2 但这并不意味着 MySQL 悲观锁没有问题
最大的风险是:
长事务。
例如:
BEGIN
↓
SELECT FOR UPDATE
↓
调用远程服务
↓
等待 5 秒
↓
复杂计算
↓
再调用另一个服务
↓
UPDATE
↓
COMMIT
这会导致:
锁持有时间 = 整个事务生命周期
并发请求可能大量等待。
进一步可能产生:
锁等待
↓
线程堆积
↓
连接池耗尽
↓
请求超时
↓
级联故障
因此数据库悲观锁通常应该:
- 缩短事务;
- 尽量只在事务内执行 DB 操作;
- 避免事务中调用远程服务;
- 为锁定条件建立合适索引;
- 处理 deadlock;
- 必要时使用
NOWAIT/SKIP LOCKED等能力。
10.3 一个需要纠正的说法
不能简单说:
SELECT FOR UPDATE是“分布式锁”。
更准确地说:
它是数据库事务中的行锁/范围锁机制,在多个应用实例共享同一个 InnoDB 数据库时,可以承担跨实例互斥协调的作用。
这也是为什么它在某些业务里可以替代独立的分布式锁。
11. MySQL 自定义锁表
一种常见设计:
CREATE TABLE distributed_lock (
lock_key VARCHAR(128) PRIMARY KEY,
holder VARCHAR(128),
expire_time DATETIME,
version BIGINT
) ENGINE=InnoDB;
可以通过:
INSERT
UPDATE
唯一索引
事务
实现锁竞争。
11.1 一个典型问题
类似:
UPDATE distributed_lock
SET holder = ?,
expire_time = NOW() + INTERVAL 30 SECOND,
version = version + 1
WHERE lock_key = ?
AND (
expire_time < NOW()
OR holder = ?
);
需要非常谨慎。
因为:
expire_time
只是“锁是否被认为过期”的判断条件。
它并不能阻止旧客户端在过期后继续访问业务资源。
因此:
expire_time
+
holder
解决的是:
谁现在被认为持有锁?
而:
version / fencing token
解决的是:
旧持有者恢复后,是否还能修改受保护资源?
这两个概念要分开。
12. 各方案横向比较
| 方案 | 核心机制 | 自动释放 | 长暂停风险 | Fencing | 典型优势 | 主要问题 |
|---|---|---|---|---|---|---|
| Redis + RLock | Redis + TTL + watchdog | ✅ | 存在 | 普通 RLock 不提供;可用 FencedLock | 高吞吐、生态成熟 | 租约失效问题仍需处理 |
| ZooKeeper | Session + ephemeral node | ✅ | 存在 | 需要业务设计 | 协调能力强、顺序节点成熟 | 运维成本、模型较重 |
| etcd | Lease + Raft + Watch/Txn | ✅ | 存在 | 可利用 revision 等顺序信息 | 强一致、事务能力强 | 吞吐通常不以 Redis 级别为目标 |
| MySQL FOR UPDATE | InnoDB 事务锁 | ✅ | 通常表现为等待,而不是租约抢占 | 不需要传统 fencing,但受事务语义约束 | 简单、无需额外中间件 | 长事务、锁等待、死锁 |
| MySQL 锁表 | 行锁 + expire_time | ✅ | 存在 | version 可自行设计 | 简单、复用已有 DB | DB 压力、实现复杂度 |
13. 分布式锁中的常见错误认识
13.1 “有了锁,就不会并发执行”
不完全正确。
更准确:
锁服务视角:
同一时刻只承认一个 owner
但客户端可能:
A 获取锁
A 暂停
锁过期
B 获取锁
A 恢复
于是:
A、B 都可能继续执行自己的业务代码
因此锁的“互斥”与业务代码的“绝对互斥”不是完全等价的。
13.2 “把 TTL 调大就彻底解决问题”
不能。
例如:
TTL = 30s
改成:
TTL = 30min
只能降低正常情况下的误过期概率。
但理论上仍然可能:
pause > 30min
而且 TTL 越大:
客户端真正宕机
↓
其他客户端等待时间越长
所以:
TTL 是可靠性与可用性的工程折中,不是数学上的完美解决方案。
13.3 “watchdog 可以解决 GC”
不能从根本上解决。
watchdog 的作用是:
正常运行
↓
持续续期
↓
避免业务正常执行期间 TTL 到期
但如果整个客户端执行环境都被暂停:
业务线程暂停
watchdog 也可能暂停
最终仍可能发生租约过期。
13.4 “Fencing Token 可以阻止旧客户端继续执行”
不能。
它真正做到的是:
旧客户端
↓
访问受保护资源
↓
资源检查 token
↓
拒绝
所以必须满足:
受保护资源本身必须理解并执行 fencing token。
如果只是:
etcd 产生 token
但是数据库完全不检查:
token
那么 fencing 就没有真正落地。
13.5 “只要 Redis 是强一致/主从切换可靠,问题就消失”
锁服务的一致性与客户端暂停问题是不同维度的问题。
即使:
Lock Server
完全按照协议工作
也无法知道:
Client A 是否正在执行一段已经过期的业务代码
因此:
分布式系统的一致性
≠
客户端执行权的永久有效性
14. 工程选型
14.1 普通高并发业务
例如:
- 防止重复执行任务;
- 缓存重建;
- 非核心资源并发控制;
- 普通订单状态协调。
推荐:
Redis + Redisson
并配合:
幂等
唯一索引
乐观锁
数据库条件更新
14.2 高资损业务
例如:
- 账户余额;
- 库存;
- 结算;
- 重要资源分配。
不要把架构设计成:
Redis 锁
↓
业务执行
↓
认为数据绝对正确
更合理:
flowchart TD
A[分布式锁] --> B[业务逻辑]
B --> C[DB 原子条件更新]
C --> D[乐观锁 / Fencing Token]
D --> E[幂等 / 事务]
如果业务确实需要 fencing,可以考虑:
etcd / Redis FencedLock
+
数据库 token 校验
关键不是“用了哪个锁”,而是:
最终写入业务数据的那个资源,是否能够拒绝过期客户端。
14.3 数据本来就在 MySQL
如果业务是:
修改一行库存
修改账户余额
更新订单状态
而且业务天然适合短事务,那么首先应该考虑:
SELECT ... FOR UPDATE
或者更进一步:
UPDATE inventory
SET stock = stock - 1
WHERE sku_id = ?
AND stock > 0;
很多场景甚至不需要单独引入 Redis 分布式锁。
14.4 不要为了“高级”而使用分布式锁
例如:
更新一条 MySQL 数据
如果直接:
UPDATE ...
WHERE id = ? AND version = ?;
就可以正确解决并发更新,那么再引入:
Redis
+
Redisson
+
watchdog
+
unlock
+
异常恢复
反而增加系统复杂度。
工程上应该优先:
使用最简单、最接近最终数据资源的并发控制方案。
15. 一个完整的思考框架
以后遇到“这个场景要不要加分布式锁”,可以按照下面顺序判断。
第一步:真正共享的资源是什么?
例如:
MySQL row
Redis key
文件
第三方 API
MQ
库存
账户余额
第二步:这个资源自己有没有并发控制能力?
例如:
MySQL
-> transaction / row lock / optimistic lock
Redis
-> atomic command / Lua / transaction
MQ
-> consumer / idempotency / message semantics
如果资源本身已经可以安全地完成并发控制,就不一定需要额外的分布式锁。
第三步:业务是否允许重复执行?
如果允许:
幂等即可
如果不允许:
需要更强的协调机制
第四步:锁持有期间是否存在长时间暂停?
如果存在:
长 GC
远程调用
复杂计算
大文件处理
就需要认真考虑:
lease expiration
+
旧客户端恢复
+
fencing
第五步:最终数据是否有兜底?
理想情况:
分布式锁
↓
业务逻辑
↓
DB 条件更新 / 事务 / 幂等
↓
最终数据正确
而不是:
分布式锁
↓
祈祷客户端永远不会出问题
16. 最终结论
结论 1
分布式锁的本质是分布式协调,而不是最终数据一致性机制。
结论 2
只要锁依赖 TTL、Lease 或 Session 来处理客户端死亡,就必须考虑客户端暂停后“锁已经失效,但旧客户端后来恢复”的问题。
GC 只是这种 pause 的典型案例,并不是唯一案例。
结论 3
Watchdog 解决的是“正常运行时持续续租”,不能从理论上消灭长时间 pause 导致的租约失效。
结论 4
Fencing Token 的核心不是阻止旧客户端运行,而是让最终受保护资源拒绝旧客户端的操作。
因此必须做到:
Token 产生
↓
Token 传递
↓
资源校验 Token
↓
拒绝旧 Token
缺少最后一步,就不能称为完整的 fencing 方案。
结论 5
Redis、ZooKeeper、etcd、MySQL 并不存在简单的“谁最好”。
应该根据:
并发量
数据风险
事务模型
故障模型
部署成本
业务副作用
是否需要 fencing
进行选择。
结论 6
对于很多 Java 后端业务,可以形成一个非常实用的经验:
普通并发协调
-> Redis + Redisson
需要强协调 / Lease / Watch / Txn
-> etcd / ZooKeeper
业务本来就在 MySQL 且可以做短事务
-> SELECT FOR UPDATE / 乐观锁
高资损业务
-> 锁 + DB 原子约束 + 幂等
-> 必要时增加 Fencing Token
附录:一句话理解整个问题
可以把分布式锁想象成一个“停车证”。
flowchart TD
A[A 拿到停车证 33] --> B[A 暂停]
B --> C[停车证 33 过期]
C --> D[B 拿到停车证 34]
D --> E[B 可以停车]
E --> F[A 恢复]
F --> G{检查 Token}
G -->|33 < 34| H[拒绝 A]
如果停车场只检查:
“你以前是不是拿过停车证?”
A 仍然可能进去。
而 Fencing Token 要求停车场检查:
“你现在拿的是不是最新一代停车证?”
于是:
33 < 34
A 被拒绝。
这就是分布式锁里最重要的思想之一:
真正可靠的系统,不是努力让旧客户端永远不醒,而是即使旧客户端醒来,也没有权限破坏已经被更新客户端接管的资源。
参考资料
本文的技术校正主要参考官方文档:
- Redisson Reference Guide:Locks and Synchronizers / Fenced Lock
- Apache ZooKeeper Documentation:Ephemeral Nodes、Watches、Locks
- etcd Concurrency API Reference:Lock、Lease、Response Revision
- MySQL Reference Manual:InnoDB Locking Reads、Transaction Isolation、Deadlocks
摘要:分布式锁解决的是多个分布式客户端对共享资源进行并发协调的问题。真正困难的地方并不是“怎么把锁加上”,而是客户端可能在拿到锁之后长时间暂停、网络可能分区、租约可能过期、业务操作可能跨越多个系统。
目录
- 分布式锁到底要解决什么问题
- 先区分两个概念:锁服务的一致性与业务数据正确性
- 租约/超时机制为什么无法彻底避免“旧客户端苏醒”
- 经典场景:GC、进程暂停与锁过期
- Fencing Token:真正解决“旧持有者继续写”的办法
- Fencing Token 的正确使用方式
- Redis + Redisson
- ZooKeeper
- etcd
- MySQL
SELECT ... FOR UPDATE - MySQL 自定义锁表
- 各方案横向比较
- 分布式锁中的常见错误认识
- 工程选型
- 最终结论
1. 分布式锁到底要解决什么问题
先用一张图建立整体认知:
多个客户端
分布式锁服务
谁获得锁
Client A
Client B
执行业务
最终受保护资源
数据库 / Redis / 文件 / 外部资源
这里要特别注意:锁服务决定的是“谁被认为拥有锁”,而最终业务资源决定的是“谁真正能够完成有效写入”。
分布式锁的核心目标,可以概括为:
1.1 互斥
对于同一个逻辑资源,例如:
order:10001
user:123
inventory:SKU-A
job:daily-settlement
在正常的锁协议下,同一时刻应该只有一个客户端被认为拥有该锁。
1.2 自动释放
如果持有锁的进程彻底宕机,不能因为它永远没有执行 unlock() 而导致锁永久存在。
因此 Redis、ZooKeeper、etcd 等方案都会引入某种形式的:
- TTL
- Lease
- Session
- 心跳/续期
1.3 可重入
可重入不是所有分布式锁都必须具备,但 Java 业务中经常需要。
例如:
lock.lock();
try {
doSomething();
doSomethingAgain(); // 内部再次获取同一把锁
} finally {
lock.unlock();
}
如果同一线程再次获取同一把锁不会阻塞,那么就是可重入锁。
1.4 更重要:锁不是数据正确性的最终边界
这是理解分布式锁最重要的一句话:
分布式锁是并发协调机制,而不是数据正确性的最终保障。
例如:
Client A
|
| 获取 Redis 锁
v
Redis
|
| 执行业务
v
MySQL
即使 Redis 判断 A 当前持有锁,也不能证明:
- A 的 JVM 一定还在运行;
- A 的线程一定没有暂停;
- A 到 MySQL 的网络一定正常;
- A 的业务请求一定还在当前执行上下文中;
- A 的锁在整个业务执行期间一定没有失效。
因此高风险业务通常需要:
分布式锁
+
数据库事务/条件更新
+
幂等
+
必要时 Fencing Token
2. 先区分两个概念:锁服务的一致性与业务数据正确性
很多文章容易把这两个问题混在一起。
2.1 锁服务保证什么
例如 Redis、ZooKeeper、etcd 都可以维护:
lock_key -> owner
锁服务需要保证自己的数据结构在其设计的故障模型下具有相应的一致性语义。
例如:
Client A 获取锁
|
v
Lock Server
|
+---- A 是当前 owner
但锁服务并不知道:
A 的业务线程现在是否正在运行
A 是否卡在 GC
A 是否卡在系统调用
A 是否已经无法访问数据库
A 是否已经失去继续执行该业务的资格
因此:
“锁服务认为 A 持有锁”与“A 当前仍然应该被允许修改业务资源”不是完全等价的命题。
3. 租约/超时机制为什么无法彻底避免“旧客户端苏醒”
3.1 为什么需要超时
假设没有 TTL:
Client A
|
| 获取锁
v
Lock Server
A 突然宕机
如果锁没有任何自动释放机制:
lock -> 永远属于 A
那么其他客户端永远无法获取。
所以分布式锁必须解决:
“客户端死亡以后,锁怎么释放?”
最常见的答案就是:
TTL / Lease / Session
3.2 但是超时无法完美区分两种状态
锁服务看到:
一段时间没有收到客户端续期
它无法仅凭这个事实判断:
情况 A:客户端真的死了
Client A
X
情况 B:客户端活着,但是暂时无法发送续期
Client A
|
| JVM STW GC
|
|----------------|
|
| 恢复
从锁服务的视角,两者都可能表现为:
长时间没有心跳/续期
因此锁服务只能依据租约规则做出判断。
4. 经典场景:GC、进程暂停与锁过期
考虑:
锁租约 = 30 秒
时序:
Business DBClient BLock ServerClient ABusiness DBClient BLock ServerClient AJVM 长时间暂停 / STW GC续期线程也无法正常执行A 恢复执行获取锁获取成功,token=33开始执行业务TTL/Lease 到期释放 token=33 对应的锁请求获取锁获取成功,token=34携带 token=34 写入写入成功携带旧 token=33 写入拒绝旧 token
此时最危险的地方来了:
Client A 恢复
A 可能并不知道:
自己已经失去锁
如果 A 继续:
UPDATE business_table ...
就可能与 B 的业务操作发生冲突。
4.1 重要纠正:不一定只有 JVM STW GC
“长时间 GC”只是非常典型的一种场景。
类似问题还可能来自:
- JVM 长时间 Stop-The-World;
- 进程被操作系统暂停;
- 容器/虚拟机发生严重调度延迟;
- CPU 长时间饥饿;
- 网络长时间不可用;
- 应用线程池耗尽;
- 客户端与锁服务之间发生网络分区;
- 应用自身出现长时间阻塞。
所以更准确的表述应该是:
长时间暂停(pause)是租约锁的核心风险场景之一,GC 只是其中最常见的示例。
5. Fencing Token:真正解决“旧持有者继续写”
5.1 核心思想
每一次成功获取锁,都分配一个递增的 token:
Client A -> token=33
Client B -> token=34
Client C -> token=35
token 表示:
这个客户端获得锁的“世代”比之前的客户端更新。
5.2 经典时序
Business ResourceClient BLock ServiceClient ABusiness ResourceClient BLock ServiceClient A长时间 pauseA 恢复acquire locktoken=33执行业务lease expired释放旧锁acquire locktoken=34write(token=34)接受并记录最新 token=34write(token=33)拒绝:33 < 34
数据库保存:
current_token = 34
此时 A 恢复:
A -> DB
token=33
数据库发现:
33 < 34
因此拒绝 A 的写操作。
6. Fencing Token 的正确使用方式
这里需要特别纠正一个常见说法。
6.1 不应简单理解为“只有 token 更大才能更新”
假设数据库记录:
current_token = 34
如果 Client B 持有:
token = 34
那么它第一次写入时应该被允许。
如果之后 B 继续持有同一 token 执行多个相关更新,也不能因为:
34 > 34
不成立而把自己的后续操作全部拒绝。
因此实际设计需要明确:
token 是“锁世代”还是“资源当前版本”,两者不要混为一谈。
一种典型设计是把资源记录中的 token 视为“最近一次被接受的 fencing token”,要求:
UPDATE business_table
SET content = ?, token = ?
WHERE id = ?
AND token <= ?;
或者更常见地,将“当前资源版本”和“fencing token”结合到具体业务模型中设计。
核心要求不是某一条固定 SQL,而是:
旧 token 必须无法覆盖已经被新 token 写入/推进过的状态。
6.2 一个更清晰的例子
初始:
DB token = 33
Client A:
token = 33
第一次写:
33 >= 33
允许,并将 DB token 保持/推进为 33。
后来 A 暂停。
Client B:
token = 34
B 写入:
34 > 33
DB token 变成:
34
A 恢复后再次写:
33 < 34
拒绝。
6.3 Fencing Token 的能力边界
Fencing Token 能解决的是:
旧客户端对受保护资源的过期访问。
它不能让旧客户端神奇地停止执行。
例如:
Client A
|
+-- 计算
+-- 调用第三方 API
+-- 发 MQ
+-- 写文件
+-- 写数据库
即使 A 最终因为 token 过期无法写数据库:
第三方 API 已经调用
MQ 消息可能已经发送
这些副作用不会自动撤销。
因此:
Fencing Token 不是分布式事务,也不是万能的回滚机制。
7. Redis + Redisson
7.1 Redisson RLock 的基本机制
Redisson 的 RLock 是基于 Redis/兼容 Redis 的分布式可重入锁。
默认情况下,如果调用:
lock.lock();
并且没有显式指定 leaseTime,Redisson 会使用 watchdog 机制维护锁的过期时间。
默认 watchdog timeout 为:
30 秒
它会在锁仍然属于当前 Redisson 实例时持续延长过期时间。
7.2 显式 leaseTime 与 watchdog 不要混淆
例如:
lock.lock();
与:
lock.lock(10, TimeUnit.SECONDS);
语义不同。
前者使用 watchdog 维护锁。
后者指定了固定 lease time,锁会在指定时间后自动释放,不应再把它理解成“watchdog 会无限续期”。
这是实际工程中非常容易踩的坑。
7.3 Redisson 的 Fencing 能力需要更新认识
原文将 Redisson 描述为:
无原生 Fencing Token
这个表述已经过时。
当前 Redisson 文档已经提供:
FencedLock
其目的就是处理:
客户端获取锁
|
| 长时间 GC / pause
v
租约失效
|
v
另一个客户端获取锁
|
v
旧客户端恢复
FencedLock 会产生 fencing token,业务资源需要自行检查该 token 是否仍然有效。
因此更准确的说法是:
普通
RLock不等于 Fencing Lock;如果业务需要 fencing 语义,应使用相应的 FencedLock 能力,并让受保护资源真正执行 token 校验。
7.4 Redis 锁适合什么场景
适合:
- 高并发;
- 任务防重复;
- 定时任务选主;
- 缓存重建;
- 普通业务资源的并发协调;
- 业务本身具备幂等能力。
但对于高资损场景,不应该简单设计成:
Redis 锁
↓
业务操作
↓
认为一定安全
更合理:
Redis/Redisson
+
DB 条件更新 / 乐观锁 / 唯一约束
+
幂等
+
必要时 Fencing Token
8. ZooKeeper
8.1 基本机制
ZooKeeper 提供 ephemeral node:
/locks/order/lock-00000001
/locks/order/lock-00000002
/locks/order/lock-00000003
客户端创建临时有序节点。
通常:
最小序号节点
获得锁。
其他客户端监听自己前面的节点,而不是所有客户端都监听同一个节点,可以减少惊群。
8.2 Session 与临时节点
ZooKeeper 的临时节点与 session 生命周期绑定。
当 session 结束时:
ephemeral node
↓
自动删除
因此它同样解决了:
客户端异常退出
导致的永久锁问题。
8.3 GC / pause 风险依然存在
如果客户端长时间暂停:
Client A
|
| 持有 ephemeral lock
|
| 长时间 pause
|
X 无法及时维持 session
当 session 最终过期:
ephemeral node 删除
B 可能获得锁。
A 恢复之后,如果没有进一步的 fencing 机制,它仍可能继续执行已经过期的业务。
因此:
ZooKeeper 本身解决的是协调和 session 生命周期问题,不意味着旧客户端恢复后自动失去所有业务副作用。
9. etcd
9.1 etcd 的特点
etcd 基于 Raft,是强一致分布式 KV 存储系统。
其常见能力包括:
- Lease;
- Watch;
- Transaction;
- Compare-And-Swap;
- Lock;
- revision。
9.2 Lease
典型模型:
Client
|
| keepalive
v
Lease
|
| attach lock key
v
Lock
如果 Lease 到期:
Lease expired
↓
关联 key 删除
↓
锁释放
所以 etcd 同样存在租约锁的 pause 问题:
Client A pause
↓
keepalive 中断
↓
Lease expired
↓
Client B acquire
↓
A 恢复
9.3 revision 与 Fencing Token
这里需要进行一个重要的精确化。
etcd 的 KV 操作带有全局 revision 等版本信息,这些版本号具有单调推进的特征,可以被业务设计为 fencing 信息。
但不能简单理解为:
“调用 etcd Lock API 后,etcd 自动帮你把 token 传递到业务数据库,并自动阻止旧客户端。”
实际上还需要:
etcd
↓
获取锁/版本信息
↓
业务客户端拿到 fencing 信息
↓
业务数据库/资源
↓
检查 fencing token
因此真正的 fencing 是:
锁服务产生顺序信息
+
业务资源执行顺序检查
两部分缺一不可。
10. MySQL SELECT ... FOR UPDATE
10.1 它与 Redis 锁是不同思路
例如:
START TRANSACTION;
SELECT *
FROM account
WHERE id = 1
FOR UPDATE;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
COMMIT;
这里的锁直接属于:
MySQL 事务
而不是:
客户端自定义 TTL
InnoDB 会在事务提交或回滚时释放相关锁。
因此:
线程发生长时间 GC
↓
事务仍然没有提交
↓
数据库锁仍然存在
↓
其他事务等待
它不会像 Redis TTL 那样简单地因为“客户端线程卡住 30 秒”就自动认为锁已经失效。
10.2 但这并不意味着 MySQL 悲观锁没有问题
最大的风险是:
长事务。
例如:
BEGIN
↓
SELECT FOR UPDATE
↓
调用远程服务
↓
等待 5 秒
↓
复杂计算
↓
再调用另一个服务
↓
UPDATE
↓
COMMIT
这会导致:
锁持有时间 = 整个事务生命周期
并发请求可能大量等待。
进一步可能产生:
锁等待
↓
线程堆积
↓
连接池耗尽
↓
请求超时
↓
级联故障
因此数据库悲观锁通常应该:
- 缩短事务;
- 尽量只在事务内执行 DB 操作;
- 避免事务中调用远程服务;
- 为锁定条件建立合适索引;
- 处理 deadlock;
- 必要时使用
NOWAIT/SKIP LOCKED等能力。
10.3 一个需要纠正的说法
不能简单说:
SELECT FOR UPDATE是“分布式锁”。
更准确地说:
它是数据库事务中的行锁/范围锁机制,在多个应用实例共享同一个 InnoDB 数据库时,可以承担跨实例互斥协调的作用。
这也是为什么它在某些业务里可以替代独立的分布式锁。
11. MySQL 自定义锁表
一种常见设计:
CREATE TABLE distributed_lock (
lock_key VARCHAR(128) PRIMARY KEY,
holder VARCHAR(128),
expire_time DATETIME,
version BIGINT
) ENGINE=InnoDB;
可以通过:
INSERT
UPDATE
唯一索引
事务
实现锁竞争。
11.1 一个典型问题
类似:
UPDATE distributed_lock
SET holder = ?,
expire_time = NOW() + INTERVAL 30 SECOND,
version = version + 1
WHERE lock_key = ?
AND (
expire_time < NOW()
OR holder = ?
);
需要非常谨慎。
因为:
expire_time
只是“锁是否被认为过期”的判断条件。
它并不能阻止旧客户端在过期后继续访问业务资源。
因此:
expire_time
+
holder
解决的是:
谁现在被认为持有锁?
而:
version / fencing token
解决的是:
旧持有者恢复后,是否还能修改受保护资源?
这两个概念要分开。
12. 各方案横向比较
| 方案 | 核心机制 | 自动释放 | 长暂停风险 | Fencing | 典型优势 | 主要问题 |
|---|---|---|---|---|---|---|
| Redis + RLock | Redis + TTL + watchdog | ✅ | 存在 | 普通 RLock 不提供;可用 FencedLock | 高吞吐、生态成熟 | 租约失效问题仍需处理 |
| ZooKeeper | Session + ephemeral node | ✅ | 存在 | 需要业务设计 | 协调能力强、顺序节点成熟 | 运维成本、模型较重 |
| etcd | Lease + Raft + Watch/Txn | ✅ | 存在 | 可利用 revision 等顺序信息 | 强一致、事务能力强 | 吞吐通常不以 Redis 级别为目标 |
| MySQL FOR UPDATE | InnoDB 事务锁 | ✅ | 通常表现为等待,而不是租约抢占 | 不需要传统 fencing,但受事务语义约束 | 简单、无需额外中间件 | 长事务、锁等待、死锁 |
| MySQL 锁表 | 行锁 + expire_time | ✅ | 存在 | version 可自行设计 | 简单、复用已有 DB | DB 压力、实现复杂度 |
13. 分布式锁中的常见错误认识
13.1 “有了锁,就不会并发执行”
不完全正确。
更准确:
锁服务视角:
同一时刻只承认一个 owner
但客户端可能:
A 获取锁
A 暂停
锁过期
B 获取锁
A 恢复
于是:
A、B 都可能继续执行自己的业务代码
因此锁的“互斥”与业务代码的“绝对互斥”不是完全等价的。
13.2 “把 TTL 调大就彻底解决问题”
不能。
例如:
TTL = 30s
改成:
TTL = 30min
只能降低正常情况下的误过期概率。
但理论上仍然可能:
pause > 30min
而且 TTL 越大:
客户端真正宕机
↓
其他客户端等待时间越长
所以:
TTL 是可靠性与可用性的工程折中,不是数学上的完美解决方案。
13.3 “watchdog 可以解决 GC”
不能从根本上解决。
watchdog 的作用是:
正常运行
↓
持续续期
↓
避免业务正常执行期间 TTL 到期
但如果整个客户端执行环境都被暂停:
业务线程暂停
watchdog 也可能暂停
最终仍可能发生租约过期。
13.4 “Fencing Token 可以阻止旧客户端继续执行”
不能。
它真正做到的是:
旧客户端
↓
访问受保护资源
↓
资源检查 token
↓
拒绝
所以必须满足:
受保护资源本身必须理解并执行 fencing token。
如果只是:
etcd 产生 token
但是数据库完全不检查:
token
那么 fencing 就没有真正落地。
13.5 “只要 Redis 是强一致/主从切换可靠,问题就消失”
锁服务的一致性与客户端暂停问题是不同维度的问题。
即使:
Lock Server
完全按照协议工作
也无法知道:
Client A 是否正在执行一段已经过期的业务代码
因此:
分布式系统的一致性
≠
客户端执行权的永久有效性
14. 工程选型
14.1 普通高并发业务
例如:
- 防止重复执行任务;
- 缓存重建;
- 非核心资源并发控制;
- 普通订单状态协调。
推荐:
Redis + Redisson
并配合:
幂等
唯一索引
乐观锁
数据库条件更新
14.2 高资损业务
例如:
- 账户余额;
- 库存;
- 结算;
- 重要资源分配。
不要把架构设计成:
Redis 锁
↓
业务执行
↓
认为数据绝对正确
更合理:
分布式锁
业务逻辑
DB 原子条件更新
乐观锁 / Fencing Token
幂等 / 事务
如果业务确实需要 fencing,可以考虑:
etcd / Redis FencedLock
+
数据库 token 校验
关键不是“用了哪个锁”,而是:
最终写入业务数据的那个资源,是否能够拒绝过期客户端。
14.3 数据本来就在 MySQL
如果业务是:
修改一行库存
修改账户余额
更新订单状态
而且业务天然适合短事务,那么首先应该考虑:
SELECT ... FOR UPDATE
或者更进一步:
UPDATE inventory
SET stock = stock - 1
WHERE sku_id = ?
AND stock > 0;
很多场景甚至不需要单独引入 Redis 分布式锁。
14.4 不要为了“高级”而使用分布式锁
例如:
更新一条 MySQL 数据
如果直接:
UPDATE ...
WHERE id = ? AND version = ?;
就可以正确解决并发更新,那么再引入:
Redis
+
Redisson
+
watchdog
+
unlock
+
异常恢复
反而增加系统复杂度。
工程上应该优先:
使用最简单、最接近最终数据资源的并发控制方案。
15. 一个完整的思考框架
以后遇到“这个场景要不要加分布式锁”,可以按照下面顺序判断。
第一步:真正共享的资源是什么?
例如:
MySQL row
Redis key
文件
第三方 API
MQ
库存
账户余额
第二步:这个资源自己有没有并发控制能力?
例如:
MySQL
-> transaction / row lock / optimistic lock
Redis
-> atomic command / Lua / transaction
MQ
-> consumer / idempotency / message semantics
如果资源本身已经可以安全地完成并发控制,就不一定需要额外的分布式锁。
第三步:业务是否允许重复执行?
如果允许:
幂等即可
如果不允许:
需要更强的协调机制
第四步:锁持有期间是否存在长时间暂停?
如果存在:
长 GC
远程调用
复杂计算
大文件处理
就需要认真考虑:
lease expiration
+
旧客户端恢复
+
fencing
第五步:最终数据是否有兜底?
理想情况:
分布式锁
↓
业务逻辑
↓
DB 条件更新 / 事务 / 幂等
↓
最终数据正确
而不是:
分布式锁
↓
祈祷客户端永远不会出问题
16. 最终结论
结论 1
分布式锁的本质是分布式协调,而不是最终数据一致性机制。
结论 2
只要锁依赖 TTL、Lease 或 Session 来处理客户端死亡,就必须考虑客户端暂停后“锁已经失效,但旧客户端后来恢复”的问题。
GC 只是这种 pause 的典型案例,并不是唯一案例。
结论 3
Watchdog 解决的是“正常运行时持续续租”,不能从理论上消灭长时间 pause 导致的租约失效。
结论 4
Fencing Token 的核心不是阻止旧客户端运行,而是让最终受保护资源拒绝旧客户端的操作。
因此必须做到:
Token 产生
↓
Token 传递
↓
资源校验 Token
↓
拒绝旧 Token
缺少最后一步,就不能称为完整的 fencing 方案。
结论 5
Redis、ZooKeeper、etcd、MySQL 并不存在简单的“谁最好”。
应该根据:
并发量
数据风险
事务模型
故障模型
部署成本
业务副作用
是否需要 fencing
进行选择。
结论 6
对于很多 Java 后端业务,可以形成一个非常实用的经验:
普通并发协调
-> Redis + Redisson
需要强协调 / Lease / Watch / Txn
-> etcd / ZooKeeper
业务本来就在 MySQL 且可以做短事务
-> SELECT FOR UPDATE / 乐观锁
高资损业务
-> 锁 + DB 原子约束 + 幂等
-> 必要时增加 Fencing Token
附录:一句话理解整个问题
可以把分布式锁想象成一个“停车证”。
33 < 34
A 拿到停车证 33
A 暂停
停车证 33 过期
B 拿到停车证 34
B 可以停车
A 恢复
检查 Token
拒绝 A
如果停车场只检查:
“你以前是不是拿过停车证?”
A 仍然可能进去。
而 Fencing Token 要求停车场检查:
“你现在拿的是不是最新一代停车证?”
于是:
33 < 34
A 被拒绝。
这就是分布式锁里最重要的思想之一:
真正可靠的系统,不是努力让旧客户端永远不醒,而是即使旧客户端醒来,也没有权限破坏已经被更新客户端接管的资源。
延伸阅读
- Redis 官方分布式锁文档:redis.io/docs/latest…
- Martin Kleppmann, How to do distributed locking:martin.kleppmann.com/2016/02/08/…
- antirez, Is Redlock safe? :antirez.com/news/101
- etcd API 与 revision 语义:etcd.io/docs/v3.5/l…
- ZooKeeper 程序员指南(Session / 临时节点 / 顺序节点):zookeeper.apache.org/doc/current…
- Redisson 官方文档(看门狗配置):redisson.org/
- MySQL InnoDB 锁与事务模型:dev.mysql.com/doc/refman/…