一个 @Async,把 @Transactional 的事务边界打穿了
订单接口明明加了
@Transactional,主流程也抛异常了。结果订单状态回滚了,异步日志却插进库了。
这不是 Spring 事务失效,是你把事务边界理解错了。
一、事故现场
线上有一个订单确认接口,逻辑很常见:
- 更新订单状态
- 扣减库存
- 异步写操作日志
- 如果后续流程失败,整个订单确认要回滚
代码大概是这样:
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
@Autowired
private LogService logService;
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
stockMapper.deduct(orderId);
logService.writeLogAsync(orderId, "CONFIRM_ORDER");
throw new RuntimeException("模拟后续流程失败");
}
}
异步日志方法:
java
@Service
public class LogService {
@Autowired
private OperationLogMapper operationLogMapper;
@Async
public void writeLogAsync(Long orderId, String operation) {
operationLogMapper.insert(new OperationLog(orderId, operation));
}
}
看起来没什么问题。
外层 confirmOrder() 加了事务,最后抛了异常,订单状态和库存都会回滚。
但线上排查发现:
text
订单状态:已回滚
库存扣减:已回滚
操作日志:插入成功
也就是说,主事务回滚了,异步线程里的数据库操作却提交了。
很多人第一眼会以为 @Transactional 又失效了。
其实不是。
真正原因是:@Async 把代码切到了另一个线程里。
二、事务不是空气,它绑在线程上
Spring 事务的核心不是"注解贴在哪里",而是"当前线程有没有事务上下文"。
Spring 会把事务信息绑定到当前线程,底层依赖的是 ThreadLocal。
你可以简单理解成:
text
线程 A:
开启事务
执行 SQL
异常回滚
线程 B:
没有线程 A 的事务上下文
自己执行自己的 SQL
外层方法执行时:
java
@Transactional
public void confirmOrder(Long orderId) {
// 当前线程:main-thread-1
}
Spring 在当前线程里开启事务。
但调用 @Async 后:
java
@Async
public void writeLogAsync(Long orderId, String operation) {
// 当前线程:async-thread-1
}
这个方法已经跑到线程池里的另一个线程了。
线程变了,事务上下文也就断了。
所以外层事务回滚,只能影响外层线程里执行的数据库操作。异步线程里执行的 SQL,它管不到。
三、最小复现
准备两张表:
sql
CREATE TABLE order_info (
id BIGINT PRIMARY KEY,
status VARCHAR(32)
) ENGINE = InnoDB;
CREATE TABLE operation_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT,
operation VARCHAR(64)
) ENGINE = InnoDB;
业务代码:
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private LogService logService;
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
logService.writeLogAsync(orderId);
throw new RuntimeException("确认订单失败");
}
}
异步代码:
java
@Service
public class LogService {
@Autowired
private OperationLogMapper operationLogMapper;
@Async
public void writeLogAsync(Long orderId) {
operationLogMapper.insert(orderId, "CONFIRM_ORDER");
}
}
启动异步:
java
@EnableAsync
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
调用:
java
try {
orderService.confirmOrder(1001L);
} catch (Exception e) {
log.error("确认订单失败", e);
}
最后看数据库:
sql
SELECT * FROM order_info WHERE id = 1001;
SELECT * FROM operation_log WHERE order_id = 1001;
你会看到:
text
order_info:状态没有变成 CONFIRMED
operation_log:多了一条 CONFIRM_ORDER 日志
这就是 @Transactional + @Async 最典型的数据不一致现场。
四、很多人会误判的点
误判 1:外层有事务,里面所有方法都在事务里
不一定。
如果里面的方法还在同一个线程,并且数据库连接参与了当前事务,那它可以加入外层事务。
但 @Async 会切线程。
切线程后,事务上下文不会自动传过去。
所以这句话要改成:外层有事务,只能保证同一线程、同一事务上下文里的数据库操作受它控制。
误判 2:异步方法抛异常,外层事务会回滚
也不会。
比如:
java
@Async
public void writeLogAsync(Long orderId) {
operationLogMapper.insert(orderId, "CONFIRM_ORDER");
throw new RuntimeException("异步日志失败");
}
这个异常发生在异步线程里。
外层方法不会等它,甚至已经回滚了。
异步线程抛出的异常,不会自动抛回外层事务方法。
所以你不能指望异步方法失败让外层事务回滚。这在模型上就不成立。
误判 3:给异步方法也加 @Transactional 就能加入外层事务
很多人会这样改:
java
@Async
@Transactional(rollbackFor = Exception.class)
public void writeLogAsync(Long orderId) {
operationLogMapper.insert(orderId, "CONFIRM_ORDER");
}
这段代码的含义不是"加入外层事务",而是在异步线程里重新开一个新的事务。
它有自己的事务边界。外层回滚,不影响它。它自己失败,也不会自动影响外层。
五、还有一个更隐蔽的坑:同类自调用
如果你把两个方法写在同一个类里,问题会更复杂。
java
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
writeLogAsync(orderId);
throw new RuntimeException("确认订单失败");
}
@Async
public void writeLogAsync(Long orderId) {
operationLogMapper.insert(orderId, "CONFIRM_ORDER");
}
}
注意这句:
java
writeLogAsync(orderId);
它其实等价于:
java
this.writeLogAsync(orderId);
同类内部调用不会经过 Spring 代理。
结果就是:
text
@Async 不会生效
方法仍然同步执行,跑在当前线程里
这里有个容易忽略的点:@Async 没生效,意味着方法还是在原来的线程里跑。那它反而会加入外层事务------日志会和订单状态一起回滚。
你以为是异步独立提交,实际上变成了同步事务内执行。行为和你预期的完全不一样。
这类问题最烦,因为你看代码会觉得注解都加了,运行也没报错,但行为是错的。
所以建议把异步方法拆到另一个 Spring Bean 里:
java
@Service
public class OrderService {
@Autowired
private OrderAsyncService orderAsyncService;
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
orderAsyncService.writeLogAsync(orderId);
throw new RuntimeException("确认订单失败");
}
}
java
@Service
public class OrderAsyncService {
@Async
public void writeLogAsync(Long orderId) {
operationLogMapper.insert(orderId, "CONFIRM_ORDER");
}
}
至少这样 @Async 会真正走代理。但还是那句话,走代理不代表加入外层事务,它只是异步生效了。
六、那异步日志到底该怎么写?
要看你的业务目标。
异步日志分两类。
1. 可以独立存在的日志
比如:
text
用户点击日志
接口访问日志
操作轨迹日志
失败也不影响主流程的埋点
这种日志可以异步写。
即使主事务回滚了,日志保留下来也不一定是错的。
比如你就是想记录:
用户尝试确认订单,但最后失败了。
那异步日志独立提交是合理的。你可以明确给它一个自己的事务:
java
@Async
@Transactional(rollbackFor = Exception.class)
public void writeAttemptLog(Long orderId) {
operationLogMapper.insert(orderId, "TRY_CONFIRM_ORDER");
}
这叫"记录行为",不是"跟随主业务状态"。
2. 必须和主业务一致的日志
比如:
text
订单状态流转日志
账户资金流水
库存扣减流水
积分变更记录
审核状态记录
这种数据不能随便异步写。
如果主事务回滚了,流水却留下来了,后面排查会非常痛苦。
这种情况应该放在主事务里同步写:
java
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
stockMapper.deduct(orderId);
operationLogMapper.insert(orderId, "CONFIRM_ORDER");
}
让状态和流水一起提交,一起回滚。
七、如果既想异步,又想最终一致呢?
如果操作比较重,不想放在主事务里,但又不能丢,可以用"事务消息"或"本地消息表"。
一个常见做法是 Outbox 模式。
主事务里只做两件事:
java
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
outboxMapper.insert(new OutboxEvent(
"ORDER_CONFIRMED",
String.valueOf(orderId),
"NEW"
));
}
注意:order_info 和 outbox_event 在同一个本地事务里。
如果订单确认回滚,事件也回滚。
如果订单确认提交,事件也提交。
然后用定时任务或消息投递器异步扫描:
java
@Scheduled(fixedDelay = 1000)
public void publishEvents() {
List<OutboxEvent> events = outboxMapper.selectNewEvents();
for (OutboxEvent event : events) {
try {
messageProducer.send(event);
outboxMapper.markPublished(event.getId());
} catch (Exception e) {
log.error("投递事件失败,等待下次重试", e);
}
}
}
这样就把问题拆开了:
text
主事务:保证订单状态和事件记录一致
异步任务:保证事件最终投递出去
这比直接在事务里开 @Async 稳得多。
⚠️ 有一个坑要注意:如果
messageProducer.send()成功了,但markPublished()失败了(比如网络抖动),下次扫描会重复发送。所以消费端必须做幂等处理,比如用事件 ID 去重。
八、@Transactional 和 @Async 的正确关系
记住几条就行。
1. 外层事务管不到异步线程
java
@Transactional
public void outer() {
asyncService.innerAsync();
throw new RuntimeException();
}
outer() 回滚,不代表 innerAsync() 回滚。
2. 异步方法的事务是自己的事务
java
@Async
@Transactional
public void innerAsync() {
// 这是异步线程里的新事务
}
它不是外层事务的一部分。
3. 异步异常不会自动影响外层事务
java
@Async
public void innerAsync() {
throw new RuntimeException();
}
这个异常不会自动让外层事务回滚。
4. 同类调用会绕过代理
java
this.innerAsync();
这种调用方式下,@Async 很可能根本没生效。
九、上线前 checklist
| 检查项 | 风险 | 建议 |
|---|---|---|
事务方法里是否调用了 @Async |
主事务管不到异步线程 | 明确是否允许异步独立提交 |
| 异步逻辑是否写数据库 | 数据可能和主事务不一致 | 强一致数据不要异步写 |
| 异步方法是否也加了事务 | 容易误以为加入外层事务 | 理解它是新事务 |
| 是否同类内部调用异步方法 | 绕过 Spring 代理 | 拆到另一个 Service |
| 异步异常是否需要感知 | 外层默认感知不到 | 用回调、Future、消息状态表 |
| 是否涉及资金、库存、订单状态 | 数据一致性风险高 | 优先同步事务或 Outbox |
| 是否只是行为日志/埋点 | 可以独立提交 | 异步可接受 |
十、总结
@Transactional 和 @Async 单独看都不难,难的是放在一起时,很多人的直觉是错的。
一句话:Spring 事务上下文绑定在线程上,@Async 切换线程后,就不再属于外层事务。
所以不要以为外层加了 @Transactional,里面所有数据库操作都会一起回滚。
这句话少了一个前提:必须在同一个事务上下文里。
真正上线时,可以按这个原则判断:
text
必须和主业务一致的数据:放进主事务同步写
可以独立存在的日志:允许异步独立提交
需要异步又不能丢的数据:用本地消息表或事务消息
事务问题最怕的不是报错,最怕的是你以为它们在同一个事务里,实际上它们早就跑到两个线程、两个事务、两套提交逻辑里了。