请求原子性实现方式:Redis 互斥与数据库事务协同

请求原子性实现方式: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 获取锁,就可能各自持有一把锁并等待另一把。

所有实例统一执行以下规则:

  1. 在进入受保护读取前收集全部资源。
  2. 按最终锁键去重、排序。
  3. 取得全部锁后才开启事务。
  4. 无法取得全部锁时,不执行最终写入,并清理已取得或获取结果未知的锁。
  5. 结束后逆序清理。
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、原子性、数据库事务、并发控制

相关推荐
Navicat中国1 小时前
如何将 Navicat 的 AI 助手连接到本地 LLM
数据库·人工智能·navicat·本地llm
Gauss松鼠会1 小时前
GaussDB 内存相关参数及故障诊断
运维·数据库·sql·oracle·性能优化·架构·gaussdb
斯普信专业组2 小时前
Redis RESP3 协议规范:从入门到实战
数据库·redis·缓存
sunburn-2 小时前
数据库操作完整教程:从基础到实战
java·数据库·mysql
bksczm2 小时前
MySQL之读写分离与分库分表
数据库·mysql
姜鱼问生2 小时前
报数据库连不上?Docker Compose 启动顺序假象:depends_on 不是万能的!
数据库·docker·容器
SHENGWEI胜为2 小时前
服务器 IP 地址冲突如何排查?从 DHCP 租约到 ARP 缓存定位故障
服务器·tcp/ip·缓存
csdn_aspnet2 小时前
在 .NET Core 中使用 Redis
redis·.netcore
梅孔立2 小时前
.mdb Access_MDB历史数据文件迁移MySQL实战教程-可直接投喂给ai参考这个帮你完成迁移
数据库·mysql·adb