@Transactional做不到的5件事,我用这6种方法解决了
看Mall项目订单代码时发现:一个方法操作6张表,14步业务逻辑,全在一个事务里,居然没炸。
研究了两天,发现了6种比@Transactional更灵活的玩法。写了个demo项目验证了一遍。
我们要解决的痛点
日常开发中,@Transactional解决不了的几个问题:
- 库存不足时:想保留订单记录标记"待补货",但不知道怎么不回滚
- 发MQ消息:在事务里发了消息,结果事务回滚了,消息却发出去了
- 批量操作:100个订单发货,1个失败就全部回滚,但其实想让成功的继续
- 记录日志:业务失败了也想记录日志,但事务回滚了日志也没了
- 隔离级别/超时 :不知道
@Transactional那些参数怎么用
这篇文章会用实际代码演示6种解决方案。
目录
关于demo项目
特点:集成测试框架,通过反射自动构建参数,启动即测试,自动生成markdown报告。不用手动准备数据,不用一个个跑测试用例。
导入数据库脚本(doc/simple-transactional-init.sql),改下配置,启动项目就能看到完整测试结果。
编程式事务:区分业务失败和系统异常
这是我在Mall里发现的一个场景:订单创建后要调用风控服务检查。
- 风控不通过(业务规则):订单要保留,标记"待审核",人工复核
- 风控服务挂了(系统故障):订单要回滚,不能留脏数据
用@Transactional做不到。因为它只能靠抛异常触发回滚,无法区分这两种情况。
TransactionTemplate可以动态控制
java
public OrderResult createOrder(OrderParam param) {
return transactionTemplate.execute(status -> {
try {
// 1. 创建订单
Order order = buildOrder(param);
orderMapper.insert(order);
// 2. 创建订单商品
List<OrderItem> items = buildOrderItems(order);
orderItemMapper.batchInsert(items);
// 3. 锁定库存
lockStock(param.getItems());
// 4. 调用风控服务检查
RiskCheckResult riskResult = riskService.check(order);
if (!riskResult.isPass()) {
// 风控不通过 - 业务失败,但不回滚
order.setStatus(OrderStatus.WAIT_AUDIT); // 待审核
order.setNote("风控检查未通过:" + riskResult.getReason());
orderMapper.updateById(order);
// 关键:不调用 status.setRollbackOnly()
// 订单和商品明细都会保留
return OrderResult.fail("订单需人工审核");
}
// 风控通过,订单正常
return OrderResult.success(order.getId());
} catch (RiskServiceException e) {
// 风控服务异常 - 系统故障,必须回滚
log.error("风控服务异常", e);
status.setRollbackOnly();
return OrderResult.error("系统异常,请稍后重试");
} catch (Exception e) {
// 其他异常也回滚
status.setRollbackOnly();
return OrderResult.error(e.getMessage());
}
});
}
画个图就明白了
这才是编程式事务的价值
| 场景 | @Transactional | TransactionTemplate |
|---|---|---|
| 风控不通过 | 抛异常→全回滚 | 不回滚,保留订单 |
| 风控服务挂了 | 抛异常→全回滚 | 回滚,不留脏数据 |
| 库存不足 | 抛异常→全回滚 | 保留订单,标记"待补货" |
核心区别:能区分"业务失败"和"系统异常",动态决定要不要回滚。
我测试了一下:
bash
# 测试风控不通过(高金额订单)
POST /programmatic/risk-check
# 结果
订单ID:8
订单状态:待审核
订单备注:风控检查未通过:金额过高
数据库:订单和商品明细都保留了
这玩意儿我之前真不知道能这么用。
@Transactional的参数,我被坑过
Mall的商品创建方法是这么写的:
java
@Transactional(
isolation = Isolation.REPEATABLE_READ,
propagation = Propagation.REQUIRED,
timeout = 30,
rollbackFor = Exception.class
)
public int createProduct(ProductParam param) {
// 插入8张表...
}
我之前都是直接@Transactional,从来不加参数。后来踩了几次坑才知道这些参数的用处。
isolation这个参数要注意
有次数据库从MySQL换成PostgreSQL,突然出现了幻读问题。
原因是:
- MySQL默认
REPEATABLE_READ(可重复读) - PostgreSQL默认
READ_COMMITTED(读已提交)
如果代码里没显式指定隔离级别,换数据库就可能出问题。
所以建议:
java
// 明确指定隔离级别,不依赖数据库默认值
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void someMethod() {
// 避免环境切换导致行为变化
}
timeout和rollbackFor简单说两句
timeout:防止长事务锁表
java
@Transactional(timeout = 30) // 30秒超时
public void complexTask() {
// ...
}
rollbackFor:Spring默认只有RuntimeException才回滚,Checked Exception不回滚
java
@Transactional(rollbackFor = Exception.class) // 明确指定
public void createOrder() throws Exception {
// ...
}
这两个参数记得加上,能避免很多坑。
事务提交后发MQ,我之前都做错了
订单创建成功后,要发个MQ消息(30分钟后自动取消未支付订单)。
我之前是这么写的:
java
@Transactional
public void createOrder() {
orderMapper.insert(order);
// 直接发MQ
mqSender.send("order.cancel.delay", order.getId());
}
看起来没问题吧?实际上有个致命问题。
问题出在时机上
画个图就明白了:
还没提交 S->>MQ: 2. 发送MQ消息 Note over MQ: 消息已发出 S->>DB: 3. 后面某步失败 DB-->>S: 4. 事务回滚 Note over DB: 订单被删除 Note over MQ,DB: 问题:消息发了
但数据没了 end
问题本质:MQ消息发出去了,但事务回滚了,订单根本不存在。30分钟后消费者去取消订单,发现订单不存在。
这就是副作用的时机与事务一致性问题:
- 订单插入、库存扣减 → 在同一个事务里,要么全成功,要么全回滚
- MQ消息 → 不在这个事务里,发出去就收不回来了
事务同步器解决这个问题
Spring提供了事务生命周期的钩子,让你在特定阶段执行回调:
java
@Transactional
public void createOrder() {
orderMapper.insert(order);
// 注册事务同步器
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 只有事务提交成功,这里才会执行
mqSender.send("order.cancel.delay", order.getId());
log.info("MQ消息已发送");
}
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
log.info("事务回滚,MQ消息不会发送");
}
}
}
);
}
现在的时序是这样:
发送MQ消息 Note over MQ: 数据和消息一致 else 事务失败 S->>DB: 3. ROLLBACK Note over DB: 数据被删除 Note over S: afterCommit不执行 Note over MQ: 消息不会发送 end end
核心区别:只有订单真正提交到数据库后,才发MQ消息。事务回滚了,消息就不发。
4个生命周期钩子
事务同步器提供了4个回调点:
java
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void beforeCommit(boolean readOnly) {
log.info("【阶段1-beforeCommit】事务即将提交");
// 最后的数据校验
}
@Override
public void beforeCompletion() {
log.info("【阶段2-beforeCompletion】事务即将完成");
// 清理临时资源
}
@Override
public void afterCommit() {
log.info("【阶段3-afterCommit】事务已提交");
// 发MQ、清缓存(数据已持久化)
}
@Override
public void afterCompletion(int status) {
String statusStr = (status == STATUS_COMMITTED) ? "提交" : "回滚";
log.info("【阶段4-afterCompletion】事务已完成,状态:{}", statusStr);
}
}
);
执行顺序是固定的:
哪些场景必须用afterCommit
所有"对外的副作用"都应该放在afterCommit里:
场景1:发MQ消息
java
@Override
public void afterCommit() {
// 延迟取消订单
mqSender.send("order.cancel.delay", orderId);
}
场景2:清理缓存
java
@Override
public void afterCommit() {
// 清理商品缓存
redisTemplate.delete("product:" + productId);
}
场景3:记录日志到另一个库
java
@Override
public void afterCommit() {
// 写到日志库(不在当前事务)
logMapper.insert(businessLog);
}
场景4:调用外部服务
java
@Override
public void afterCommit() {
// 通知第三方
thirdPartyService.notify(order);
}
核心原则:只有订单数据真正持久化了,外部世界才能知道。
同库的日志也要用afterCommit吗?
理论上,如果日志表和订单表在同一个数据库、同一个事务里,写早了会一起回滚,不会有问题。
但实际业务中,我们希望:
- 解耦:订单业务和日志记录分离
- 性能:日志操作不影响主事务耗时
- 重试:日志失败可以独立重试,不影响订单
所以建议还是放在afterCommit里。
我测试了一下
bash
# 运行测试
POST /synchronization/phases
# 控制台输出
【阶段1-beforeCommit】事务即将提交
【阶段2-beforeCompletion】事务即将完成
【阶段3-afterCommit】事务已提交
【阶段4-afterCompletion】事务已完成,状态:提交
MQ消息已发送
顺序是固定的,非常可靠。
事务事件监听:解耦副作用操作
订单创建成功后,要做3件事:发MQ、记录日志、发通知。
如果都写在一个方法里,代码会很臃肿:
java
@Transactional
public void createOrder() {
orderMapper.insert(order);
// 业务逻辑越来越多
mqSender.send(...);
logService.save(...);
notifyService.send(...);
}
而且事务范围太大了,发短信也在事务里?
事务事件监听可以解耦
第1步:定义事件
java
@Getter
@AllArgsConstructor
public class OrderCreatedEvent {
private String orderSn;
private Long memberId;
private BigDecimal amount;
}
第2步:发布事件
java
@Service
public class OrderService {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Transactional
public void createOrder(OrderParam param) {
// 创建订单
orderMapper.insert(order);
// 发布事件(立即发布,但监听器何时处理取决于监听方式)
OrderCreatedEvent event = new OrderCreatedEvent(
order.getOrderSn(),
order.getMemberId(),
order.getTotalAmount()
);
eventPublisher.publishEvent(event);
log.info("事件已发布");
}
}
第3步:监听事件
java
@Component
public class OrderEventListener {
// 关键:@TransactionalEventListener + AFTER_COMMIT
// 事件会被"挂起",等事务提交成功后才处理
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
log.info("监听到订单创建:{}", event.getOrderSn());
// 这些操作在事务提交后才执行
mqSender.send("order.cancel", event.getOrderSn());
logMapper.insert(log);
notifyService.send(event.getMemberId());
}
// 事务回滚后执行
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailed(OrderCreatedEvent event) {
log.info("订单创建失败:{}", event.getOrderSn());
}
}
事件发布与事务的关系
这里容易混淆的点:publishEvent本身与事务无关,但监听器的执行时机取决于监听方式。
画个图说明:
普通监听器 participant L2 as @TransactionalEventListener
AFTER_COMMIT participant DB as Database rect rgb(255, 245, 240) Note over S,DB: 方法有 @Transactional S->>DB: 1. INSERT订单 S->>E: 2. publishEvent(event) E->>L1: 3. 立即同步调用 Note over L1: 普通监听器立即执行
此时事务还没提交 E->>L2: 4. 事件挂起 Note over L2: AFTER_COMMIT监听器不执行
等待事务提交 alt 事务提交成功 S->>DB: 5. COMMIT DB->>L2: 6. 触发AFTER_COMMIT Note over L2: 监听器执行
数据已持久化 else 事务回滚 S->>DB: 5. ROLLBACK Note over L2: AFTER_COMMIT不执行 end end
关键区别:
| 监听方式 | 执行时机 | 事务回滚影响 |
|---|---|---|
| @EventListener | 立即执行 | 已执行的副作用无法撤销 |
| @TransactionalEventListener(AFTER_COMMIT) | 事务提交后 | 事务回滚则不执行 |
| @TransactionalEventListener(AFTER_ROLLBACK) | 事务回滚后 | 只有回滚才执行 |
4个事务阶段
java
// 提交前(做最后校验)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(OrderCreatedEvent event) {
// 事务即将提交,可以做最后校验
}
// 提交后(发副作用)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(OrderCreatedEvent event) {
// 数据已持久化,可以安全地发MQ、清缓存
}
// 回滚后(记录失败)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollback(OrderCreatedEvent event) {
// 事务失败了,记录失败日志
}
// 完成后(清理资源)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void afterCompletion(OrderCreatedEvent event) {
// 无论成功失败都会执行
}
几个要注意的地方
1. 必须在事务方法里发布
java
// 错误:方法没有 @Transactional
public void createOrder() {
orderMapper.insert(order);
eventPublisher.publishEvent(event); // AFTER_COMMIT监听器不会触发!
}
// 正确:方法有 @Transactional
@Transactional
public void createOrder() {
orderMapper.insert(order);
eventPublisher.publishEvent(event); // 监听器会在提交后触发
}
2. 子事务的事件跟随子事务
java
@Transactional
public void parentMethod() {
// 父事务
childMethod(); // 子事务(REQUIRES_NEW)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void childMethod() {
orderMapper.insert(order);
eventPublisher.publishEvent(event); // 监听器跟随子事务的提交
}
3. 如果没有事务怎么办
java
// 监听器默认不执行,除非加 fallbackExecution=true
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true // 没有事务也会执行
)
public void handleEvent(OrderCreatedEvent event) {
// ...
}
对比事务同步器
| 方式 | 代码耦合度 | 扩展性 | 适用场景 |
|---|---|---|---|
| TransactionSynchronization | 高(在方法里注册) | 低 | 简单场景,1-2个操作 |
| @TransactionalEventListener | 低(发布订阅) | 高 | 复杂场景,多个操作 |
我的建议:
- 只有1-2个操作,用TransactionSynchronization
- 有多个操作,或者可能扩展,用@TransactionalEventListener
好处是代码解耦了,要加新功能,写个监听器就行,不用改原方法。
批量操作必须用手动事务
批量发货100个订单,其中1个失败了咋办?
如果用@Transactional:
java
@Transactional
public void batchDelivery(List<Long> orderIds) {
for (Long orderId : orderIds) {
// 发货逻辑
}
}
问题:100个订单在一个事务里,1个失败全部回滚。
但实际需求是:成功的正常发货,失败的记录下来。
用PlatformTransactionManager手动控制
java
@Service
public class OrderBatchService {
@Autowired
private PlatformTransactionManager transactionManager;
public BatchResult batchDelivery(List<Long> orderIds) {
List<Long> success = new ArrayList<>();
List<String> failed = new ArrayList<>();
for (Long orderId : orderIds) {
// 每个订单一个独立事务
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 发货逻辑
Order order = orderMapper.selectById(orderId);
order.setStatus(2); // 已发货
orderMapper.updateById(order);
reduceStock(order);
// 手动提交
transactionManager.commit(status);
success.add(orderId);
} catch (Exception e) {
// 手动回滚
transactionManager.rollback(status);
failed.add("订单" + orderId + ":" + e.getMessage());
}
}
return new BatchResult(success, failed);
}
}
高级用法:设置事务属性
对于定时任务、后台批处理这种场景,可以显式控制事务属性:
java
public BatchResult batchCloseOrder(List<Long> orderIds) {
for (Long orderId : orderIds) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
// 强制新事务(无论外层是否有事务)
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
// 降低隔离级别,减少锁争用
def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
// 设置超时,避免长事务阻塞
def.setTimeout(10);
TransactionStatus status = transactionManager.getTransaction(def);
try {
Order order = orderMapper.selectById(orderId);
order.setStatus(4); // 已关闭
orderMapper.updateById(order);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
}
}
}
三种方式对比
| 方式 | 事务范围 | 一条失败影响 | 适用场景 |
|---|---|---|---|
| @Transactional | 整个批次 | 全部回滚 | 不适合批量 |
| 手动事务(默认属性) | 每条独立 | 只回滚这条 | 普通批处理 |
| 手动事务(定制属性) | 每条独立 | 只回滚这条 | 高并发批处理 |
我测试了100个订单,97个成功,3个失败。成功的都发货了,失败的记录下来了。
核心价值:每条数据独立事务,部分失败不影响其他。
事务传播机制:3种常用场景
创建订单时,要调另一个方法插入订单商品。两个方法都有@Transactional,会咋样?
7种传播机制,常用的是3种:REQUIRED、REQUIRES_NEW、NESTED。
REQUIRED(默认):同成同败
行为:有事务就加入,没有就新建。父子方法共享同一个事务。
java
// 父方法
@Transactional(propagation = Propagation.REQUIRED)
public void createOrder() {
orderMapper.insert(order);
createOrderItems(order.getId()); // 加入当前事务
}
// 子方法
@Transactional(propagation = Propagation.REQUIRED)
public void createOrderItems(Long orderId) {
itemMapper.batchInsert(items);
}
关键点:
- 父子方法在同一个事务里
- 子方法抛异常 → 整个事务回滚(父也一起回滚)
- 订单和订单商品"同成同败"
适用场景:一个业务流程内的多步骤需要"同成同败"。80%的场景都用这个。
REQUIRES_NEW:独立事务
行为:挂起当前事务,开启一个全新的事务,独立提交/回滚。
java
// 父方法
@Transactional
public void createOrder() {
orderMapper.insert(order);
logService.saveLog(log); // 新事务,独立提交
// 后面的代码可能失败
}
// 子方法
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Log log) {
logMapper.insert(log);
}
关键点:
- 子方法失败只影响子事务,父事务不受影响
- 父事务后续回滚,子事务已提交的结果也保留
- 日志一定会保存,即使订单创建失败
适用场景:必须独立持久化的动作,如:
- 记录审计日志
- 写消息表
- 发送通知记录
即使主流程失败也不能丢。
NESTED:局部回滚
行为:在同一物理事务内使用"保存点"(Savepoint),子方法相当于子事务。
java
// 父方法
@Transactional
public void createOrder() {
orderMapper.insert(order);
try {
createGift(order.getId()); // 嵌套事务
} catch (Exception e) {
// 赠品创建失败,但订单继续
log.warn("赠品创建失败,继续处理订单");
}
// 订单正常提交
}
// 子方法
@Transactional(propagation = Propagation.NESTED)
public void createGift(Long orderId) {
giftMapper.insert(gift);
}
关键点:
- 子方法回滚只回滚到保存点,不影响父方法已做的操作
- 父方法回滚会连同子方法一起回滚
- 需要数据库支持保存点(InnoDB支持)
适用场景:主流程可继续,但某个子步骤允许"局部失败回滚"。
- 批处理中某条失败不影响前面已写入的步骤
- 赠品、优惠券等可选功能
注意:
- 需要使用
DataSourceTransactionManager(JPA的不支持) - 数据库必须支持保存点(InnoDB支持,MyISAM不支持)
三种传播行为对比
| 传播行为 | 事务关系 | 子方法失败影响 | 父方法失败影响 | 典型场景 |
|---|---|---|---|---|
| REQUIRED | 共享事务 | 整个事务回滚 | 整个事务回滚 | 订单+订单商品 |
| REQUIRES_NEW | 独立事务 | 只回滚子事务 | 子事务已提交 | 审计日志 |
| NESTED | 保存点 | 回滚到保存点 | 整个事务回滚 | 赠品、优惠券 |
选型建议
- 默认用REQUIRED:80%的场景都是"同成同败"
- 需要独立落盘的用REQUIRES_NEW:审计日志、消息表
- 需要局部回滚的用NESTED:可选功能、批处理
我测试了一下,这3种传播行为都符合预期。
几个要注意的地方
事务范围要小
java
// 不好的写法
@Transactional
public void process() {
List<Data> data = queryBigData(); // 慢查询,不需要事务
Data result = calculate(data); // 计算,不需要事务
mapper.save(result); // 真正需要事务
}
// 改成这样
public void process() {
List<Data> data = queryBigData();
Data result = calculate(data);
saveInTransaction(result);
}
@Transactional
private void saveInTransaction(Data data) {
mapper.save(data);
}
只把写操作放事务里。
批量插入要用batchInsert
java
// 慢
@Transactional
public void save(List<Item> items) {
for (Item item : items) {
mapper.insert(item); // N次数据库访问
}
}
// 快
@Transactional
public void save(List<Item> items) {
mapper.batchInsert(items); // 1次数据库访问
}
我之前不知道这个,踩过坑。1000条数据,循环插入要10秒,批量插入只要0.5秒。
长事务要设置超时
java
@Transactional(timeout = 30)
public void longTask() {
// 防止锁表
}
生产环境一定要加这个。
总结一下
这6种玩法,每个都能解决实际问题:
- 编程式事务 → 库存不足保留订单
- @Transactional参数 → 隔离级别、超时、回滚规则
- 事务同步器 → 事务提交后发MQ
- 事务事件监听 → 解耦业务逻辑
- 手动控制事务 → 批量操作
- 事务传播机制 → 日志记录、赠品创建
80%的场景,@Transactional就够了。遇到特殊情况,再用对应的高级用法。
别过度设计,够用就行。
代码在这里
所有代码都是可以跑的,有完整测试用例。
数据库脚本在 doc/simple-transactional-init.sql,导入就能用。
你们平时用Spring事务都遇到过什么坑?
或者有什么好的实践经验?
欢迎在评论区聊聊,我也想学习学习。
特别是事务传播机制那块,我自己还没完全搞透。如果有大佬愿意指点一下,那就太好了。
如果这篇文章对你有帮助,麻烦点个赞👍,让更多人看到。
这篇文章从研究Mall源码到写demo,再到写文章、画图、测试,前后花了两天时间。
希望能帮你解决实际问题。