请求原子性实现方式:Redis 互斥与数据库事务协同
- [1. 整体描述](#1. 整体描述)
- [2. 具体描述](#2. 具体描述)
-
- [2.1 为什么需要把多个步骤放在同一个保护范围内](#2.1 为什么需要把多个步骤放在同一个保护范围内)
- [2.2 各组件负责什么](#2.2 各组件负责什么)
- [2.3 根据共享资源确定锁键](#2.3 根据共享资源确定锁键)
- [2.4 将准备工作放在锁外](#2.4 将准备工作放在锁外)
- [2.5 使用原子 SET 获取锁](#2.5 使用原子 SET 获取锁)
- [2.6 保证序列化一致,并原子释放锁](#2.6 保证序列化一致,并原子释放锁)
- [2.7 多资源请求使用统一顺序和整单预算](#2.7 多资源请求使用统一顺序和整单预算)
- [2.8 明确数据库事务的开始和结束](#2.8 明确数据库事务的开始和结束)
- [2.9 提交前检查与剩余租约](#2.9 提交前检查与剩余租约)
- [2.10 区分失败、已提交和结果未知](#2.10 区分失败、已提交和结果未知)
- [2.11 固定租约的故障边界](#2.11 固定租约的故障边界)
- [2.12 验证原子性与互斥的观察点](#2.12 验证原子性与互斥的观察点)
- [3. 总结](#3. 总结)
- 附:实现检查表
1. 整体描述
一个请求可能包含多个步骤:读取共享状态、计算新状态、执行多条写入,再返回结果。多线程或多实例同时处理相同资源时,如果这些步骤可以交叉执行,即使每条 SQL 都能正常完成,最终状态仍可能不符合预期。
实现这类请求时,需要区分两个目标:
- 数据库操作的原子性:同一事务内的写入整体提交或回滚。
- 请求之间的互斥:访问相同资源的请求按照统一协议进入临界区,避免读取、计算和写入相互穿插。
本文采用 Redis 分布式锁协调请求,使用数据库事务管理持久化操作,处理顺序如下:
text
校验参数、准备不依赖共享状态的数据
↓
确定资源集合,去重并统一排序
↓
取得全部 Redis 锁
↓
开启数据库事务
↓
读取已提交状态 → 计算新状态 → 执行写入 → 提交事务
↓
释放仍属于本请求的锁
↓
返回结果
需要明确:这并没有创建一个横跨 HTTP、Redis 和数据库的分布式原子事务。原子提交的范围是同一数据库事务中的操作;Redis 锁只协调遵循相同协议的请求。固定租约、网络故障和响应丢失仍有各自的处理边界。
2. 具体描述
2.1 为什么需要把多个步骤放在同一个保护范围内
以更新共享状态为例,假设两个请求都先读取 value,再计算 value + 1 并写回:
| 执行顺序 | 请求 A | 请求 B |
|---|---|---|
| 第一步 | 读取 value = 0 | 读取 value = 0 |
| 第二步 | 计算新值 1 | 计算新值 1 |
| 第三步 | 写入 value = 1 | 写入 value = 1 |
两个请求都返回成功,但最终值只增加了一次。这是一个抽象的"读取---修改---写入"交叉执行示例。
如果只是简单计数,优先考虑数据库原子更新语句,无需为它引入整套分布式锁。本文关注的是包含多次读取、计算和写入,需要共同保护多个步骤的请求。
仅增加普通数据库事务,并不自动保证其他事务不能同时读取相同的旧状态。具体行为取决于 SQL、隔离级别及数据库约束。采用 Redis 协调时,读取共享状态、根据状态计算、写入和提交都必须位于同一持锁区间内。
2.2 各组件负责什么
| 组件或机制 | 负责的内容 | 不代表什么 |
|---|---|---|
| Redis 互斥锁 | 协调参与同一协议的请求访问共享资源 | 数据库写入与 Redis 操作原子提交 |
| 数据库事务 | 同一事务中写入的整体提交或回滚 | 所有请求自动按顺序执行 |
| 唯一 token | 区分锁的不同持有者 | 识别重复请求 |
| 锁的 TTL | 持有者失联后,锁能够过期 | 旧线程或旧事务已经停止 |
| HTTP 超时 | 调用方结束等待 | 服务端已经取消执行或回滚 |
事务范围还取决于使用的事务管理器和参与资源。外部 HTTP 调用、独立 Redis 写入、其他数据源上的操作,不会因为当前数据库事务回滚而自动撤销。
2.3 根据共享资源确定锁键
锁键应使用稳定的资源标识:
text
request:lock:{encodedResourceId}
相同资源的请求必须生成相同的锁键;不同资源没有必要使用同一把全局锁。请求编号、线程编号和实例编号不能替代资源标识,否则两个访问相同资源的请求仍可能拿到不同的锁。
资源由多个字段组成时,可以分别编码后再拼接,避免分隔符碰撞。编码规则和排序规则必须在所有实例中一致。Base64 仅用于编码,不提供加密或匿名化能力。
2.4 将准备工作放在锁外
锁外可以完成参数校验、配置读取、静态数据准备等不依赖受保护状态的步骤。
取得锁后,再读取共享状态,并以该状态作为最终计算依据。不能在锁外读取状态,取得锁后继续使用旧值,否则互斥范围虽然存在,读取依据仍可能过期。
text
锁外:校验输入、准备执行参数、确定资源集合
锁内:读取共享状态、计算、写入、提交
这既缩短持锁时间,也使临界区的职责更清晰。
2.5 使用原子 SET 获取锁
Redis SET 支持 NX 和过期参数,可将"不存在才写入"与"设置有效期"放在一次命令中完成,详见 Redis SET 文档。
java
String token = UUID.randomUUID().toString();
Boolean acquired = lockTemplate.opsForValue().setIfAbsent(
lockKey, token, Duration.ofSeconds(45));
每次锁获取流程生成独立 token,不复用请求编号或固定值。多个资源由同一请求共同持有时,可以共用这个 token。
不要把获取锁拆成独立的 SETNX 和 EXPIRE。两条命令之间发生进程退出,可能留下没有过期时间的锁。
命令调用异常也不意味着 Redis 一定没有写入。响应可能丢失,而 SET 已经成功。因此,获取结果未知的当前资源也应纳入后续按 token 校验的清理范围。
2.6 保证序列化一致,并原子释放锁
如果已有模板使用 JSON 值序列化,锁值与 Lua 参数的实际字节内容可能不符合直接字符串比较的预期。
可以复用已有连接工厂,初始化一个专用字符串模板:
java
RedisTemplate<String, String> lockTemplate = new RedisTemplate<>();
lockTemplate.setConnectionFactory(existingConnectionFactory);
lockTemplate.setDefaultSerializer(new StringRedisSerializer());
lockTemplate.afterPropertiesSet();
模板按服务实例复用,不按请求创建;无需因此创建新的连接池,也无需改变既有缓存的序列化规则。
释放锁需要在 Redis 内原子完成"比较 token,再删除":
lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
脚本的 key 和 token 使用框架参数传入,不拼接到脚本文本。唯一值及所有权校验后释放的原理,可参阅 Redis 分布式锁文档。
不能先 GET 再 DEL,因为两条命令之间锁可能已经过期并被其他请求取得。也不能直接 DEL,否则旧请求可能删除新请求的锁。
2.7 多资源请求使用统一顺序和整单预算
如果请求 A 按 X、Y 获取锁,请求 B 按 Y、X 获取锁,就可能各自持有一把锁并等待另一把。
所有实例统一执行以下规则:
- 在进入受保护读取前收集全部资源。
- 按最终锁键去重、排序。
- 取得全部锁后才开启事务。
- 无法取得全部锁时,不执行最终写入,并清理已取得或获取结果未知的锁。
- 结束后逆序清理。
java
List<String> orderedKeys = resourceIds.stream()
.map(this::buildLockKey)
.distinct()
.sorted()
.toList();
等待时间按整个请求统一计算,不能给每个资源分别分配完整等待预算。
text
获取截止时间 = 单调时钟当前值 + 整单获取预算
剩余等待时间 = 获取截止时间 - 单调时钟当前值
Java 可使用 System.nanoTime() 计算持续时间。重试间隔不能超过剩余预算;线程中断时保留中断标记并退出。
但应用层截止时间不能自行终止正在阻塞的 Redis 网络调用。有限重试次数与底层网络超时,是两个不同层次的限制。
2.8 明确数据库事务的开始和结束
不要持有数据库连接或事务去等待另一请求的 Redis 锁。应先取得全部锁,再开启事务。
编程式事务可以显式表达"事务结束后才解锁"的顺序。相关 API 见 Spring 编程式事务文档。
下面是编排骨架,不是可直接运行的完整组件;锁管理、状态记录和异常分类需要由对应组件实现:
java
rejectExistingTransaction();
LockBatch batch = lockService.acquireAll(orderedKeys);
try {
return transactionTemplate.execute(status -> {
batch.checkOwnershipAndBudget();
State current = repository.readCommittedState();
State updated = calculate(current, input);
batch.checkOwnershipAndBudget();
repository.write(updated);
registerBeforeCommitCheck(batch);
return updated;
});
} finally {
// 逐锁捕获清理异常,不覆盖原始失败或已提交的成功结果。
batch.releaseOwnedLocksInReverseOrder();
}
其中 acquireAll() 负责获取失败时的部分锁清理。入口拒绝已有外层事务,内部使用明确的独立事务边界,避免返回后还需要等待外层提交。
采用 READ_COMMITTED,并确保参与写入的方法使用同一数据源对应的事务管理器。前一个请求提交之后,后一个请求才进入临界区读取状态;所有相关写入入口都必须遵循同一锁协议。
不能仅在带 @Transactional 的方法体 finally 中解锁后,就认定事务已经提交。方法体结束与代理层完成提交之间可能存在间隔。
2.9 提交前检查与剩余租约
取得多个锁时,每把锁分别开始计时。共同保护截止时间由最早到期的锁决定,而不是从全部锁获取成功后重新计算。
下面是一组示意参数,不是通用最佳值或性能保证:
| 参数 | 示例值 | 用途 |
|---|---|---|
| 固定租约 | 45 秒 | 获取锁时设置,不续期 |
| 整单获取预算 | 1 秒 | 全部资源共用 |
| 事务预算 | 最多 10 秒 | 受剩余租约进一步约束 |
| 清理预留 | 5 秒 | 预算预留,不保证一定足够 |
text
事务预算 = min(事务上限, 最早到期锁的剩余时间 - 清理预留)
不足以执行时停止。计时起点可保守取成功 SET 的调用开始前,不应从响应到达时重新给予完整租约。
所有权检查可以通过单 key Lua 原子比较 token 并读取 PTTL。检查不能刷新 TTL。至少在读取与计算阶段、写入前、真正提交前检查所有权及剩余预算。
提交前检查应进入事务同步回调,而不是只放在计算方法末尾。事务提交被确认后,也要记录真实提交状态,避免后续清理异常被误认为请求没有执行成功。
事务超时、连接获取超时、SQL 执行超时及网络超时不能混为一谈。仅设置 TransactionTemplate 超时,不代表慢提交、回滚或所有底层阻塞都能在租约内停止;需要核对实际组件的有效行为。
2.10 区分失败、已提交和结果未知
| 状态 | 返回与清理原则 |
|---|---|
| 尚未写入即失败 | 不继续执行,清理本请求相关锁 |
| 事务失败且确认回滚 | 返回失败,不保留本事务的部分写入 |
| 所有权丢失或租约不足 | 停止新增写入,未提交事务进入回滚流程 |
| 数据库确认提交,之后解锁失败 | 保留已提交的成功结果,记录清理异常 |
| 提交后连接中断,结果无法确认 | 标记结果未知,不宣称已经回滚 |
非成功 HTTP 响应不一定意味着数据库没有提交。调用方超时或连接中断后,直接重试也不能自动获得请求原子性或幂等性。
释放某把锁失败时,继续清理其他锁;清理尝试有界,不覆盖原始异常。可记录请求关联标识、资源标识、等待耗时、持锁耗时、事务结果和清理状态,不打印 token 或凭据。
2.11 固定租约的故障边界
假设请求 A 取得锁后长时间暂停,租约到期,请求 B 取得同一资源;A 恢复时,旧数据库事务仍可能继续执行。
Lua token 校验可以避免 A 删除 B 的锁,但不能阻止 A 继续写数据库。所有权检查与数据库提交也不能跨系统原子完成,检查之后仍可能发生长时间停顿或网络故障。
所以,这套方式不能被表述为"整个请求在任意故障下都严格原子"。其能力边界是:
- 数据库事务约束其参与写入的提交与回滚。
- Redis 互斥协调遵循协议且处于有效租约保护内的请求。
- Redis 故障切换、网络分区、进程长停顿及超时提交,需要额外的一致性设计。
如果要求旧持有者失去资格后绝不能写入,需要写入侧 fencing、数据库原子条件更新或其他可证明的约束机制。这些机制不应被误认为已经由 token 和 TTL 自动提供。
同样,防止并发交叉不等于防止同一请求重复执行。幂等标识和结果复用需要单独设计。
2.12 验证原子性与互斥的观察点
验证应覆盖执行顺序和数据库状态,不能只统计 HTTP 成功数量。
| 场景 | 应检查的性质 |
|---|---|
| 相同资源并发 | 受保护读取、修改、提交不交叉 |
| 不同资源并发 | 不存在无必要的全局串行化 |
| 多资源反向输入 | 统一加锁顺序,无交叉等待 |
| 获取部分锁后失败 | 没有最终写入,已获取资源进入清理 |
| 多条写入中途失败 | 确认回滚后没有部分提交 |
| 前一请求正在提交 | 后一请求不能提前进入受保护读取 |
| 提交完成后清理失败 | 不误报为执行未成功 |
| 提交结果未知 | 不用超时响应证明已经回滚 |
| 旧请求执行解锁脚本 | token 不匹配时不删除新锁 |
| 多实例运行 | 使用相同资源编码、排序和 Redis 协议 |
还需要观察实际线程、数据库连接和锁资源是否恢复。只看到锁 TTL 到期,不能证明旧事务已经结束。本文不提供未经测量的吞吐量、成功率或测试通过结论。
3. 总结
请求涉及多步共享状态操作时,数据库事务与请求互斥需要分别设计,再按明确的生命周期组合起来。
第一,使用稳定资源标识建立互斥关系,先取得全部锁,再开启数据库事务。
第二,把受保护读取、计算、写入和提交都放在持锁范围内,事务结束之后才释放锁。
第三,获取时原子设置 TTL,释放时原子校验 token,多资源使用统一顺序和整单预算。
第四,按真实提交状态处理响应。确认回滚、确认提交和结果未知不能混为一谈,清理失败也不应覆盖已提交的成功结果。
第五,准确限定原子性的范围。Redis 互斥与数据库事务协同,不等于 HTTP、Redis 和数据库已经组成一个跨系统原子事务。
附:实现检查表
| 检查项 | 需要确认的内容 |
|---|---|
| 资源标识 | 相同共享资源在所有实例中生成相同锁键 |
| 获取操作 | NX 与过期时间由同一条命令完成 |
| 所有权 | 每次流程独立 token,序列化一致 |
| 多资源 | 去重、排序、统一等待预算、部分失败清理 |
| 事务范围 | 同一事务管理器,明确参与的持久化资源 |
| 解锁时机 | 数据库事务结束后执行 |
| 释放操作 | Lua 原子比较并删除,单锁失败不影响其他清理 |
| 超时 | 区分应用层预算、事务超时和底层网络超时 |
| 返回结果 | 不把提交后清理失败误判为执行失败 |
| 故障边界 | 不将固定租约误认为写入侧 fencing 或请求幂等 |
标签: Java、Spring Boot、Redis、原子性、数据库事务、并发控制