前面学习 MySQL 事务时,我们讨论过行锁、间隙锁和 Next-Key Lock。
对于单个数据库中的并发操作,MySQL 已经提供了相对完整的事务与锁机制。
例如库存扣减:
sql
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 1001
AND stock > 0;
配合影响行数检查和事务,就能处理很多常见的并发扣减问题。
但在微服务系统中,有些操作并不局限于某一个数据库事务。
例如我们部署了三个 Order Service 实例,每个实例都具备执行订单超时处理任务的能力:
text
Order Service Pod A
Order Service Pod B
Order Service Pod C
系统每分钟执行一次超时订单扫描。
如果三个实例同时执行同一批任务,就可能产生重复调度、重复调用下游接口等问题。
这时候,我们可能希望:
对于同一项任务,在同一时间尽量只由一个实例负责执行。
这就是分布式锁经常出现的场景。
但问题来了:如果实例 A 成功获得 Redis 分布式锁,在任务执行过程中突然卡住,导致锁过期,实例 B 又成功获得锁,那么 A 恢复以后还能继续操作吗?
答案是:它在技术上仍然可能继续执行。
这说明 Redis 分布式锁虽然能协调多个实例的执行资格,但不能天然保证外部业务操作永远不会被旧持锁者执行。
要真正理解这个问题,需要从最基本的 Redis 加锁方式开始。
一、为什么普通的互斥锁不能解决微服务的并发问题?
假设我们只有一个 Java 服务实例。
其中有两个线程同时执行同一项任务:
text
Java Process
|
+-- Thread A
|
+-- Thread B
如果不希望两个线程同时修改同一份内存数据,可以使用:
java
synchronized (lock) {
// 执行需要互斥的操作
}
或者使用 ReentrantLock。
这类锁能够协调同一个 JVM 中的线程。
但现在微服务部署在三个不同的 Pod 中:
text
Pod A Pod B Pod C
| | |
JVM A JVM B JVM C
每个 JVM 都拥有自己的内存空间。
因此:
text
JVM A 中的 synchronized
无法阻止:
text
JVM B 中的线程
执行相同业务。
即使三个实例使用完全相同的代码,也不意味着它们共享同一个本地互斥锁。
所以我们需要一个能够被多个实例共同访问的协调组件。
Redis 就是一种常见选择。
text
Pod A ──┐
|
Pod B ──┼──> Redis Lock
|
Pod C ──┘
所有实例在执行任务前,都先向 Redis 申请同一把锁。
只有成功获取锁的实例,才进入相应的业务流程。
但这里要注意:
Redis 分布式锁首先提供的是跨实例协调机制,而不是对所有下游资源的强制访问控制。
这一区别贯穿了整篇文章。
二、Redis 分布式锁最基本的实现:SET NX EX
我们之前讲 Redis 时,已经介绍过 String 以及原子命令。
Redis 分布式锁最常见的基础写法是:
redis
SET lock:order:10001 "request-A-uuid" NX EX 30
这里有三个关键部分。
NX:只有 Key 不存在时才设置成功。
如果另一个实例已经创建了这个 Key,当前实例就不能再次成功设置。
EX 30:设置 30 秒过期时间。
避免获得锁的实例崩溃后,锁长期不被释放。
request-A-uuid:锁的持有者标识。
用于区分这把锁到底由谁获得。
例如:
text
Pod A:
SET lock:order:10001 UUID-A NX EX 30
|
v
OK
说明 A 成功获得锁。
随后 Pod B 也尝试:
text
Pod B:
SET lock:order:10001 UUID-B NX EX 30
|
v
未成功
因为锁 Key 已经存在,B 不能获得锁。
为什么需要把 NX 和 EX 写在同一条命令里?
假设我们采用两条命令:
redis
SETNX lock:order:10001 UUID-A
然后:
redis
EXPIRE lock:order:10001 30
这两条命令虽然各自具有原子执行语义,但组合起来并不是一个不可分割的操作。
可能出现:
text
SETNX 成功
|
v
程序崩溃
|
v
EXPIRE 还没执行
结果就是锁已经创建,却没有设置过期时间。
如果没有其他清理机制,这把锁可能长时间存在。
因此,使用一条命令:
redis
SET key value NX EX seconds
能够把"只有不存在才能创建"与"同时设置过期时间"合并到一次原子操作中。
锁的 Key 应该怎么设计?
例如:
text
lock:order:10001
表示针对订单 10001 的协调锁。
如果业务需要的是防止同一个订单被并发处理,那么不同订单就应该使用不同的锁 Key。
但如果我们希望整个系统在同一时刻只运行一个全局统计任务,则可能使用:
text
lock:daily-statistics
这里涉及锁粒度。
锁粒度太大,会让原本可以并行执行的任务互相等待。
锁粒度太小,则可能无法覆盖真正需要互斥的业务范围。
所以设计分布式锁之前,首先应该明确:
到底是哪个资源、哪种操作需要互斥?
三、为什么释放锁不能直接使用 DEL?
现在假设 Pod A 成功获得锁:
redis
SET lock:order:10001 UUID-A NX EX 30
执行任务完成后,A 想要释放锁。
最简单的想法是:
redis
DEL lock:order:10001
但这种写法存在严重问题。
一个典型的并发场景
假设 A 获得锁以后,开始执行一个比较慢的任务。
text
T1:
Pod A 获取 Redis 锁
lock = UUID-A
TTL = 30 秒
随后任务执行时间过长:
text
T2:
30 秒过去
Redis 锁过期
这时 Pod B 成功获得相同的锁:
text
T3:
Pod B 获取 Redis 锁
lock = UUID-B
但 Pod A 并不知道锁已经过期。
A 的任务终于结束,准备释放锁:
text
T4:
Pod A 执行
DEL lock:order:10001
结果:
A 删除了 B 刚刚获得的锁。
text
Pod A:旧持锁者
Pod B:当前持锁者
此时 B 的锁被错误删除,Pod C 可能又成功获取锁。
于是:
text
Pod B 仍在执行
Pod C 也开始执行
原本希望实现的互斥关系被破坏。
因此:
释放分布式锁时,不能只检查 Key 是否存在,还必须确认锁仍然属于当前请求。
正确的释放思路:比较所有者,再删除
前面加锁时,我们保存了:
text
UUID-A
释放锁时应该先判断:
text
Redis 中的锁标识 == UUID-A?
如果相同,才能删除。
如果不同,说明当前锁可能已经过期并被其他实例重新获得。
这时 A 不应该再删除它。
但这里又有一个细节:
不能简单写成两条独立命令:
redis
GET lock:order:10001
判断相等后再执行:
redis
DEL lock:order:10001
因为 GET 和 DEL 之间,锁仍然可能过期并被其他实例获得。
所以需要将"比较"和"删除"作为原子操作执行。
Redis Lua 脚本是一种常见做法:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
调用时传入:
text
KEYS[1] = lock:order:10001
ARGV[1] = UUID-A
如果 Redis 中当前锁属于 UUID-A,就删除。
如果当前锁已经属于 UUID-B,则不进行删除。
由于这段脚本由 Redis 原子执行,其他普通命令不会在脚本执行过程中插入比较与删除之间。
这样就避免了旧持锁者误删新持锁者锁的问题。
不过,这只是解决了安全释放锁的问题。
它还没有解决真正危险的情况:
旧持锁者即使不再拥有锁,也可能继续修改业务数据。
四、Redis 锁过期后,旧持锁者为什么还能继续执行?
这是本篇最重要的部分。
假设两个服务实例都需要修改同一份共享业务数据。
我们先忽略具体业务类型,只关注并发顺序。
正常情况下
text
Pod A
|
v
获取 Redis 锁
|
v
修改业务数据
|
v
释放 Redis 锁
Pod B 必须等 A 释放锁以后,才能进入临界区。
但现在假设 A 执行过程中发生长时间暂停。
例如:
- Java 发生较长时间的 GC 停顿。
- 容器资源竞争严重,线程长时间没有得到执行。
- 网络请求阻塞,等待远端响应。
- 某个外部 API 执行时间超过预期。
- 服务与 Redis 之间暂时无法通信。
这里使用 GC 停顿举例。
故障发生的过程
T1:Pod A 获得锁。
text
Redis:
lock:resource = UUID-A
TTL = 30 秒
T2:Pod A 发生长时间暂停。
text
Pod A:
获得锁成功
|
v
GC Pause
|
v
暂停 40 秒
T3:Redis 锁到期。
Redis 认为这把锁已经失效。
text
lock:resource 不再有效
T4:Pod B 获得新锁。
text
Redis:
lock:resource = UUID-B
Pod B 进入临界区,开始修改共享资源。
T5:Pod A 恢复执行。
A 的程序继续运行:
text
Pod A:
GC 结束
|
v
继续执行之前的业务代码
注意,此时 A 可能根本没有再次访问 Redis。
它只是继续执行原来已经开始的程序。
因此,虽然 Redis 中的锁已经过期,A 仍然可能调用 MySQL、文件系统或外部 API。
最后就可能出现:
text
Pod A:旧持锁者,仍然执行写操作
Pod B:新持锁者,也在执行写操作
这说明一个非常关键的事实:
Redis 锁过期,并不会自动终止持锁进程的代码执行。
Redis 只负责管理锁 Key 的状态。
它并不能强制停止一个已经在执行中的 Java 线程。

五、Lease:为什么分布式锁更像一种有期限的执行许可?
前面我们使用:
redis
SET lock:resource UUID-A NX EX 30
获得一个具有过期时间的锁。
这种带有有限有效期的协调方式,可以从 Lease(租约)的角度理解。
Lease 的含义是:
某个参与者在约定的有效期内拥有特定操作资格,到期以后就不能再假设自己仍然拥有该资格。
例如:
text
Pod A:
获得 Lease
有效期 30 秒
A 应该理解为:
text
在有效期内,我可能拥有这项任务的执行资格。
而不是:
text
只要我曾经获得成功响应,
以后无论多久都仍然有资格修改资源。
1. 为什么不能直接把 TTL 设置得很长?
一种想法是:
redis
SET lock:resource UUID-A NX EX 3600
把锁设置为一个小时。
这样任务应该就不会轻易超过锁的有效期。
但问题是:
如果 Pod A 在获得锁以后立即崩溃:
text
Pod A 获取锁
|
X
Pod A 崩溃
其他实例可能需要等待很久,才能重新获得锁。
因此,锁的 TTL 需要在两个风险之间做权衡。
TTL 太短:
正常任务还没完成,锁就可能过期。
TTL 太长:
实例崩溃以后,其他实例需要等待较长时间才能恢复执行。
2. 那能不能自动续期?
可以。
例如 A 获得锁后,每隔一段时间尝试延长 TTL:
text
Pod A 获得锁
|
v
正常执行任务
|
+---- 续期
|
+---- 续期
|
+---- 续期
如果任务仍然在运行,而且 A 仍然持有锁,就可以延长有效期。
不过,续期必须验证锁仍然属于 A。
不能直接执行:
redis
EXPIRE lock:resource 30
因为如果原来的锁已经过期,并被 B 获得,那么 A 可能错误地延长 B 的锁。
一个常见的原子续期脚本是:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
其中:
text
KEYS[1] = lock:resource
ARGV[1] = UUID-A
ARGV[2] = 30000
表示只有当前锁确实属于 UUID-A,才将 TTL 延长为 30000 毫秒。
如果续期失败,A 应该停止继续发起需要持锁资格的新操作,并进入相应的失败处理流程。
3. 为什么有了自动续期,仍然不绝对安全?
因为续期本身也依赖程序能够正常运行并与 Redis 通信。
例如:
text
Pod A 获取锁
|
v
程序暂停 40 秒
|
v
续期线程也无法执行
|
v
锁过期
当 A 恢复执行时,它可能已经失去锁。
即使程序在每次业务写入前都重新查询锁是否仍然属于自己,也存在:
text
检查锁成功
|
v
锁恰好过期
|
v
另一个实例获得锁
|
v
旧实例继续写入
这样的时间窗口。
也就是说:
单独的"检查锁是否仍然有效"和后续"修改业务资源",如果不是由同一个下游原子机制保护,就无法完全排除检查之后状态立即变化的问题。
这就需要引入下一部分的 Fencing Token。
六、Fencing Token:为什么锁还需要一个递增编号?
我们之前学习分布式锁时,重点讨论过 Fencing Token(栅栏令牌)。
它的核心思路不是让旧实例在本地绝对无法继续执行,而是:
让被保护的下游资源能够识别过时的操作,并拒绝它们覆盖已经接受的更新操作。
1. 先区分两种 Token
前面 Redis 加锁时保存:
text
UUID-A
它的主要作用是:
text
确认锁是否仍然属于 A。
但 UUID-A 本身不包含时间先后关系。
例如:
text
UUID-A
UUID-B
我们无法仅凭这两个随机字符串判断哪一个锁更新。
Fencing Token 则不同。
它使用具有单调递增关系的编号。
例如:
text
Pod A 获取锁:
Fencing Token = 41
之后:
text
Pod B 获取锁:
Fencing Token = 42
这时:
text
42 > 41
下游可以判断 42 对应的是更新一代的锁持有资格。
注意:
随机所有者标识和 Fencing Token 是两种不同用途的标识。
| 标识 | 主要作用 |
|---|---|
| UUID / Owner Token | 判断锁是不是自己持有,防止误删或误续期 |
| Fencing Token | 判断操作所属的授权代次,阻止旧代次覆盖新代次结果 |
2. 一个典型的旧持锁者问题
假设 Pod A 首先获得锁:
text
Pod A:
Token = 41
但 A 发生长时间暂停。
锁到期后,Pod B 获得新的锁:
text
Pod B:
Token = 42
B 先向 MySQL 提交数据更新:
text
Pod B
Token = 42
|
v
MySQL
|
v
更新成功
随后 A 恢复执行,仍然尝试提交自己之前准备的旧操作:
text
Pod A
Token = 41
|
v
MySQL
如果 MySQL 不检查 Token,A 的旧操作就可能覆盖 B 的新结果。
例如:
text
B 已经写入新值:
value = NEW
A 随后写入:
text
value = OLD
最终数据库错误地保留了旧值。
3. 让下游资源检查 Fencing Token
现在我们为被保护的业务记录增加一个字段:
text
last_fence_token
假设初始状态:
text
value = INITIAL
last_fence_token = 40
Pod B 使用 Token 42 更新数据:
text
请求 Token = 42
42 > 40
数据库允许写入,并记录:
text
value = NEW
last_fence_token = 42
随后 A 携带 Token 41 请求写入:
text
请求 Token = 41
41 < 42
数据库拒绝这次旧代次更新。
最终仍然保持:
text
value = NEW
last_fence_token = 42
这样,即使 A 已经失去锁却继续运行,也无法在 B 的更新已经被下游接受以后,再用旧代次请求覆盖 B 的数据。
这就是 Fencing Token 的核心作用。

七、Fencing Token 为什么必须由下游验证?
这一点非常重要。
假设 Redis 为两个实例分别发放:
text
Pod A:Token 41
Pod B:Token 42
但是它们执行数据库更新时,并没有把 Token 传递给 MySQL:
text
Pod A ──> MySQL
Pod B ──> MySQL
那么 MySQL 根本不知道:
text
哪个请求属于旧的持锁代次?
Redis 虽然知道当前锁的所有者已经发生变化,但无法强制阻止一个旧进程使用独立的数据库连接写入数据。
所以:
Fencing Token 必须进入实际受保护资源的访问路径,并且由该资源或其可信访问层完成验证。
对于 MySQL,可以通过原子条件更新实现一种简单的栅栏检查。
假设业务表为:
sql
CREATE TABLE resource_state (
id BIGINT PRIMARY KEY,
value VARCHAR(255) NOT NULL,
last_fence_token BIGINT NOT NULL
);
当前:
text
id = 10001
value = INITIAL
last_fence_token = 40
Pod B 携带 Token 42:
sql
UPDATE resource_state
SET value = 'NEW',
last_fence_token = 42
WHERE id = 10001
AND last_fence_token < 42;
更新成功,影响行数为 1。
接着 Pod A 携带 Token 41:
sql
UPDATE resource_state
SET value = 'OLD',
last_fence_token = 41
WHERE id = 10001
AND last_fence_token < 41;
由于数据库中的:
text
last_fence_token = 42
条件不成立:
text
42 < 41
因此影响行数为 0,旧请求不能覆盖新状态。
这里的关键是:
Token 检查与数据修改必须在同一个原子数据库操作或事务保护范围内完成。
不能先查询 Token,再在不受保护的操作中修改数据,否则仍然存在并发检查窗口。
Fencing Token 也有边界
这里需要特别强调一个容易被简化的事实。
如果 Pod B 虽然已经获得 Token 42,但还没有向下游写入,而 Pod A 的 Token 41 先到达数据库,那么仅依靠"拒绝小于已接受最大 Token"的机制,数据库仍可能接受 A 的操作。
因为此时数据库可能还不知道 Token 42 已经发放。
因此,Fencing Token 常见的保证是:
一旦下游已经接受较新的 Token,之后到达的旧 Token 就不能再覆盖它。
它并不天然等于"旧 Lease 一旦在 Redis 过期,所有尚未执行的旧操作立刻在整个世界中失效"。
如果业务要求严格阻止租约到期后的任何旧写入,就需要让授权状态与下游强制校验建立更紧密的协调机制。
这也是为什么我们要区分:
text
锁服务发放执行资格
和:
text
下游资源实际接受修改
两个不同阶段。
八、Fencing Token 从哪里来?是不是 Redis INCR 就可以?
前面使用:
text
41
42
43
作为递增 Token。
那么这些数字如何生成?
首先必须明确要求:
同一受保护资源的新一代授权,必须获得比旧一代更大的 Token。
例如:
text
第一次获取锁 → 41
第二次获取锁 → 42
第三次获取锁 → 43
编号可以存在间隔,但不能在需要比较的授权历史中倒退或重复使用旧编号。
Redis 提供:
redis
INCR fence:resource
能够原子递增计数器。
但这里有两个需要注意的问题。
1. Token 必须与成功获得锁的授权对应
不能随意让每个没抢到锁的请求都被当成新持锁者。
需要有明确的机制保证:
text
获得锁成功
|
v
获得相应 Fencing Token
这两个步骤的关系正确。
在单个 Redis 实例的原子执行范围内,可以通过脚本等方式协调锁状态与计数器操作。
但具体实现仍然要考虑失败重试、脚本执行结果未知和锁失效等问题。
2. 递增编号本身也要有可靠性保证
例如一个 Redis 主节点发放了:
text
Token = 42
但该计数器最新状态还没有可靠复制或持久化。
如果随后发生故障切换,新主节点可能只保存了:
text
Token = 41
那么之后就可能错误地重新发放已经使用过的编号。
这会破坏 Token 的单调性要求。
因此:
Redis INCR 的单条命令原子性,不等于该计数器在所有故障切换情况下都具有严格单调、永不回退的持久授权语义。
如果业务必须依赖 Fencing Token 提供强保护,就需要让编号发放机制满足相应的持久性和故障恢复要求。
这可以由具备适当事务或一致性能力的权威存储、协调服务来承担。
这与上一篇 Raft 的知识就联系起来了。
Raft 通过多数派和日志安全规则,可以为某些需要可靠授权顺序的协调机制提供基础。
不过,并不是所有分布式锁都需要自己实现 Raft,也不意味着只要用了共识服务,就不需要下游资源继续验证 Token。
可靠发放 Token 和下游强制执行 Token,是两个都需要考虑的环节。
九、为什么 Redis 主从切换也可能影响分布式锁?
前面学 Redis 时,我们已经知道主从复制可以提高数据可用性,但异步复制可能存在延迟。
现在假设 Redis 使用主从架构:
text
Redis Primary
|
v
Redis Replica
Pod A 向 Primary 申请锁:
redis
SET lock:resource UUID-A NX EX 30
Primary 返回:
text
OK
此时 A 认为自己成功获得了锁。
但锁 Key 尚未复制到 Replica。
紧接着 Primary 故障:
text
Redis Primary ×
系统将 Replica 提升为新的 Primary。
由于 Replica 没有收到之前的锁记录,所以:
text
lock:resource 不存在
Pod B 再次执行:
redis
SET lock:resource UUID-B NX EX 30
也可能得到:
text
OK
结果:
text
Pod A:
此前收到加锁成功
text
Pod B:
故障切换后也收到加锁成功
两个实例可能同时认为自己拥有执行资格。
这说明:
单个 Redis 实例上的 SET NX 原子性,不能直接扩展成异步主从故障切换时的严格互斥保证。
即使加锁、释放锁和续期逻辑都正确,锁服务自身的复制与故障切换语义仍然会影响整体安全性。
因此,设计分布式锁时要考虑:
text
锁服务是否可能丢失最近写入?
故障切换后是否可能重新授予相同资源?
旧持锁者是否可能继续执行?
下游是否具备幂等或强制校验?
如果某项业务只是减少重复任务执行,那么短暂的重复可能可以通过业务幂等处理。
但如果某项操作涉及不可重复的资金扣减或其他重大业务后果,就不能仅靠 Redis 锁来保证正确性。
十、是不是所有并发问题都需要分布式锁?
不是。
这也是前面学习 MySQL 时,我们特别强调过的一个观点。
假设有一个商品:
text
stock = 1
两个用户同时购买。
很多人第一反应是:
text
先加 Redis 分布式锁
|
v
检查 MySQL 库存
|
v
修改库存
|
v
释放 Redis 锁
这种方式能够提供额外的协调,但也引入了:
text
锁获取失败
锁过期
锁续期
Redis 故障
旧持锁者
锁释放异常
等问题。
而对于单个 MySQL 数据库中的库存扣减,可以直接使用:
sql
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 1001
AND stock > 0;
通过 MySQL 的条件更新和行级并发控制来保证库存不会被错误扣成负数。
这通常比额外引入一把 Redis 锁更加直接。
什么场景更适合考虑分布式锁?
例如:
多个服务实例执行同一项定时任务。
text
Pod A
Pod B
Pod C
|
v
竞争同一个任务执行资格
或者:
多个实例不希望同时对同一项外部资源执行昂贵的操作。
例如缓存重建、批量文件生成、某些资源协调任务等。
这些场景中,分布式锁可以减少重复工作和资源冲突。
但即使使用了锁,也要根据业务风险决定是否需要进一步添加幂等、状态检查或 Fencing Token。
选择锁之前先问什么?
可以先判断:
| 问题 | 更适合优先考虑的机制 |
|---|---|
| 同一个 JVM 内的线程互斥 | 本地 Mutex / ReentrantLock |
| 单条 MySQL 记录的条件修改 | 原子 SQL、事务、行锁 |
| 防止重复创建业务记录 | 唯一约束、幂等键 |
| 多个实例协调同一项任务 | 分布式锁或任务领取机制 |
| 旧持锁者可能覆盖新结果 | 下游 Fencing Token 校验 |
| 跨服务业务失败后需要恢复 | Saga、状态机、补偿 |
| 需要可靠发布数据库变更事件 | Transactional Outbox |
最重要的原则是:
不要把分布式锁当成所有并发正确性问题的统一解法。
有些问题数据库本身就能更可靠地解决。
有些问题需要业务幂等。
还有一些问题需要明确的状态机和故障恢复机制。
分布式锁只是其中一种协调工具。
十一、一个完整场景:多个实例竞争执行数据处理任务
最后把前面的知识放到一个具体场景中。
假设有三个 Worker:
text
Worker A
Worker B
Worker C
它们负责执行同一项报表生成任务:
text
report_id = 10001
业务要求:
- 尽量避免多个 Worker 重复计算同一份报表。
- 任务执行时间可能比较长。
- Worker 可能崩溃或者与 Redis 断连。
- 不能让较旧的计算结果覆盖已经写入的新结果。
第一层:Redis 协调任务执行资格
Worker A 尝试:
redis
SET lock:report:10001 UUID-A NX EX 30
成功以后开始生成报表。
其他 Worker 暂时不能获得同一把锁。
第二层:合理续期与失效处理
Worker A 在正常运行期间,通过验证所有者的原子操作续期。
如果发现锁已经不属于自己,就停止发起新的受保护操作。
但仍然必须考虑已经在执行中的外部请求无法立即中止的情况。
第三层:下游保存 Fencing Token
假设 A 的 Token 为:
text
41
之后 A 失去锁,Worker B 获得:
text
42
B 完成新报表并写入数据库:
text
report_id = 10001
result = NEW_REPORT
last_fence_token = 42
如果 A 随后试图写入:
text
result = OLD_REPORT
token = 41
数据库通过条件更新拒绝旧代次的请求。
这样,在 B 的较新写入已经被数据库接受之后,A 就不能再用旧结果覆盖它。
第四层:业务幂等与任务状态
如果同一个报表生成请求被重复提交,还需要判断:
text
这是不是同一个业务任务?
是否已经有成功结果?
是否应该重新计算?
因此,可以结合:
text
task_id
report_id
generation_version
status
等字段管理任务状态。
例如:
text
PENDING
RUNNING
SUCCESS
FAILED
通过数据库事务确保任务领取与最终结果写入符合业务规则。
这样就形成了三层保护:
text
Redis Lock
|
v
减少重复执行
|
v
Fencing Token
|
v
阻止旧代次覆盖已接受的新结果
|
v
数据库约束与业务幂等
|
v
保护最终业务状态
这里要注意,三层保护不是所有场景都必须全部使用。
如果任务结果完全可重复计算、重复执行代价很低,也许只需要任务唯一约束和幂等处理。
如果数据库事务已经能完成可靠任务领取,也不一定需要 Redis 锁。
选择方案时,应该根据正确性要求和故障模型决定,而不是为了架构复杂而增加组件。

十二、实际使用 Redis 分布式锁时,应该测试哪些故障?
分布式锁最容易出问题的地方,通常不是正常加锁和释放锁,而是锁有效期与业务执行之间的故障窗口。
因此,不能只测试:
text
A 获取锁成功
B 获取锁失败
A 释放锁
B 获取锁成功
还需要关注以下情况。
1. 锁过期后旧实例释放锁
text
A 获得锁
|
v
锁过期
|
v
B 获得新锁
|
v
A 执行释放
需要验证 A 不能误删 B 的锁。
2. 旧实例长时间暂停后恢复
text
A 获得锁
|
v
暂停超过 TTL
|
v
B 获得新锁并写入
|
v
A 恢复后尝试写入
需要验证旧操作能否被正确拦截,或者业务是否能够安全处理其副作用。
3. 续期请求超时
text
A 发起续期
|
v
Redis 响应超时
应用不能仅凭"曾经持有锁",就继续无限期执行需要锁保护的操作。
如果无法确认续期状态,应当按照保守的失效处理策略执行。
4. Redis 主从故障切换
text
A 获得锁成功
|
v
Primary 故障
|
v
尚未复制的锁记录丢失
|
v
B 重新获得锁
需要验证系统能否容忍这种风险,或者是否需要更强的协调机制。
5. Fencing Token 发放或校验失败
例如:
text
旧 Token = 41
新 Token = 42
需要检查:
- 新授权编号是否始终单调递增。
- 故障恢复后是否可能重复发放旧编号。
- 下游是否在原子更新中验证 Token。
- 所有修改路径是否都经过相应验证。
- Token 校验失败时,应用是否正确处理,而不是强行重试旧操作。
真正重要的是:
测试系统在失去锁、恢复执行、重新获得授权这些时间交错下,是否仍然能够保证最终业务正确。
十三、总结:分布式锁保护的是执行资格,不是进程的生命期
这一篇从普通互斥锁开始,逐步讨论了 Redis 分布式锁、Lease 和 Fencing Token。
整个知识链可以整理为:
text
多个服务实例需要互斥
|
v
Redis SET NX EX
|
v
只有一个实例成功获得锁
|
v
锁具有有效期
|
v
执行可能超过 TTL
|
v
旧持锁者可能继续执行
|
v
Fencing Token
|
v
下游识别旧代次操作
其中最需要记住的是以下几个结论。
第一,SET NX EX 是基础,但不是全部
它能够原子地完成锁 Key 的创建与过期时间设置。
但真正使用时,还需要考虑:
text
唯一持有者标识
安全释放
锁续期
过期后的旧请求
Redis 故障切换
这些情况。
第二,释放锁必须确认所有者
不能因为自己曾经获得锁,就在任务结束时直接执行 DEL。
锁可能已经过期,并由其他实例重新获得。
因此需要使用原子的比较与删除操作。
第三,Lease 到期不会自动停止旧进程
锁过期以后,旧持锁者可能由于 GC、网络延迟或其他原因恢复执行。
Redis 无法直接撤销它已经开始的外部操作。
第四,Fencing Token 必须由下游验证
Fencing Token 的核心价值,是让被保护资源知道某次操作属于哪一代授权。
它必须具有可靠的单调递增关系,并且需要在下游原子更新时进行校验。
但它也有明确边界:常见的最大 Token 校验能够阻止旧代次覆盖已经接受的新代次更新,并不天然保证旧 Lease 一过期,所有未完成的旧请求就会立即被拒绝。
第五,业务正确性不能只依靠锁
例如 MySQL 库存扣减、业务记录去重、消息重复消费等问题,通常还需要使用:
text
原子条件更新
数据库唯一约束
幂等键
事务
业务状态机
等机制。
最后用一句话总结:
Redis 分布式锁负责协调谁有资格执行,Fencing Token 帮助下游拒绝过时代次的写入,而真正的业务正确性仍然需要由资源自身的原子操作和业务约束共同保证。
这也是为什么分布式锁并不是一个简单的 SETNX 问题。
理解了这一点,分布式锁的使用边界就清晰多了。
下一篇我们会进入微服务故障治理,继续学习 Timeout、Retry、熔断、限流和 Backpressure。
它们同样需要面对一个非常重要的事实:
当下游服务已经出问题时,错误的重试和流量放大机制,可能让一次局部故障演变成整个系统的级联故障。