开发中的事务管理
这篇解决什么问题
事务管理不只是给方法加 @Transactional。开发时需要先回答三个问题:哪些写入必须一起成功;事务能覆盖哪些资源;提交结果不确定时如何恢复。本文先处理单库本地事务,再讨论数据库与消息、远程服务之间的一致性。
一、本地事务先守住业务不变量
一个事务应该对应一个清晰的不变量。例如“创建订单”至少要保证订单、订单明细和库存预占要么一起提交,要么一起回滚。事务范围应围绕这组数据库写入,而不是围绕整个 Controller 请求。
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderCommand command) {
long orderId = orderRepository.insert(command);
orderItemRepository.batchInsert(orderId, command.items());
inventoryRepository.reserve(command.items());
return orderId;
}
}
这里仍需数据库约束兜底,例如订单号唯一、库存不能小于零。应用层先查询再写入无法阻止并发请求同时通过检查。
Spring 声明式事务的常见边界
@Transactional通常通过 AOP 代理生效;同一个对象内部直接调用事务方法,可能绕过代理。- 默认传播行为是
REQUIRED,默认隔离级别由底层数据源决定,不能看到注解就假设是某个固定隔离级别。 - 默认情况下,未捕获的
RuntimeException和Error会触发回滚,受检异常需要显式配置回滚规则或统一项目策略。 - 常规命令式事务绑定到当前线程;方法中新建线程后,事务上下文不会自动跟过去。
- 捕获异常并返回“失败”可能使事务照常提交。若业务要求回滚,应继续抛出异常或显式标记回滚,并测试最终数据库状态。
二、为什么不应让本地事务等待远程调用
Redis、HTTP/RPC、消息代理和对象存储通常不参加当前数据库事务。在事务中等待这些资源会同时带来两类风险:
| 风险 | 发生方式 | 结果 |
|---|---|---|
| 资源占用 | 网络调用期间仍占用连接和锁 | 吞吐下降,锁等待或死锁概率上升 |
| 结果不确定 | 调用方超时,但远端可能已经成功 | 本地回滚而远端保留结果,重试又可能重复执行 |
超时表达的是“调用方没有按时收到确定结果”,不等于远端失败。此时不能直接把操作当成失败,也不能无条件换一个新幂等键重试。
原则是缩短事务并显式处理不确定状态。只有测量过的短调用、明确的超时预算和可接受的失败语义同时成立时,才考虑在本地事务内同步调用;即便如此,它仍不会自动获得跨资源原子性。
三、按一致性目标选择方案
1. 单库内的多表写入
使用一个本地事务和数据库约束。先写出需要保持的不变量,再决定隔离级别和加锁方式;不要为了“保险”直接升到最高隔离级别。
2. 数据库写入后必须发布事件
优先考虑事务发件箱(Transactional Outbox):业务记录与待发送事件写入同一个数据库事务,由独立转发器在提交后投递消息。
CREATE TABLE outbox_event (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL
);
@Transactional
public Long createOrder(CreateOrderCommand command) {
long orderId = orderRepository.insert(command);
outboxRepository.insert(new OrderCreatedEvent(
UUID.randomUUID().toString(), orderId, command
));
return orderId;
}
转发器可能在“消息已发送、状态尚未更新”之间崩溃,因此消息仍可能重复。消费者必须按 event_id 幂等处理。Outbox 解决的是数据库提交与“事件最终可发送”的缺口,不保证所有下游在同一时刻完成。
RocketMQ 事务消息也属于最终一致方案。它通过半消息、本地事务结果和事务状态回查决定消息是否可投递;下游消费失败仍需消费重试和幂等,不能把它理解成数据库、Broker、消费者共同原子提交。
3. 必须同步得到远端结果
先区分业务是否允许补偿:
- 可以预留资源时,可把流程拆成 Try/Confirm/Cancel,并处理空回滚、悬挂和重复 Confirm/Cancel。
- 长流程或包含外部参与方时,可用 Saga 编排正向步骤和补偿步骤。Saga 提供最终一致,不提供传统数据库事务的隔离;补偿也不等于把时间倒回去。
- 只是查询远端信息时,尽量在开启本地事务之前完成查询;提交前仍要重新校验可能变化的关键条件。
4. 多个数据库资源需要协调
XA、Seata AT、TCC、Saga 的保证和成本不同,不能统一归为“强一致框架”。
| 方案 | 一致性与隔离特点 | 主要成本 | 更适合 |
|---|---|---|---|
| XA / 资源级两阶段提交 | 协调支持 XA 的资源,提交期间可能持锁 | 可用性、延迟、资源支持限制 | 参与者少、流程短、确需资源级协调 |
| Seata AT | 通过数据镜像、全局锁和补偿协调关系型数据库写入 | SQL/数据源兼容性、全局锁与协调器 | 受支持数据库中的短事务 |
| TCC | 业务层预留、确认、取消 | 业务改造和幂等防悬挂复杂度 | 能明确预留资源的核心流程 |
| Saga | 每步提交本地事务,失败时执行补偿 | 无隔离、补偿设计、可见中间状态 | 长流程、跨组织或遗留系统 |
| Outbox + 消息 | 事件最终投递,消费者最终收敛 | 重复消息、延迟、转发器运维 | 允许异步的事件驱动流程 |
选择时先写业务可接受的中间状态、最大收敛时间和补偿责任,再谈框架。不能用“金融业务”四个字直接推出某一种方案。
四、一个可验证的失败时序
验证 Outbox 时不要只跑成功路径,至少注入下面四种故障:
- 业务写入后、事务提交前抛异常:业务表和 outbox 都不应出现记录。
- 事务提交后、转发器发送前退出:重启转发器后事件应继续发送。
- 消息发送后、标记已发送前退出:允许再次发送,但消费者只产生一次业务效果。
- 消费者处理到一半失败:本地事务回滚,重试后完成,去重记录与业务写入处于同一事务。
每个测试都检查最终持久化状态,不要只断言接口返回值或日志中出现了“发送成功”。可结合 幂等、缓存与过期锁实验 理解重复交付与过期执行者问题。
五、上线检查清单
- 事务保护的不变量已经写清,并由数据库约束或锁策略支撑。
- 事务中没有未经评估的网络、文件或长时间计算。
- 明确默认隔离级别、传播行为和异常回滚规则,并有失败用例。
- 对“超时但结果未知”定义了查询、同键重试或人工处置方式。
- 消息转发和消费均假设可能重复,并使用稳定事件 ID 去重。
- 补偿操作本身可重试、可审计,失败后有告警和人工入口。
- 监控事务时长、连接池等待、死锁、outbox 积压和消费失败率。