一个 @Async,把 @Transactional 的事务边界打穿了

一个 @Async,把 @Transactional 的事务边界打穿了

订单接口明明加了 @Transactional,主流程也抛异常了。

结果订单状态回滚了,异步日志却插进库了。

这不是 Spring 事务失效,是你把事务边界理解错了。


一、事故现场

线上有一个订单确认接口,逻辑很常见:

  1. 更新订单状态
  2. 扣减库存
  3. 异步写操作日志
  4. 如果后续流程失败,整个订单确认要回滚

代码大概是这样:

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_infooutbox_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 复制代码
必须和主业务一致的数据:放进主事务同步写
可以独立存在的日志:允许异步独立提交
需要异步又不能丢的数据:用本地消息表或事务消息

事务问题最怕的不是报错,最怕的是你以为它们在同一个事务里,实际上它们早就跑到两个线程、两个事务、两套提交逻辑里了。

相关推荐
BothSavage1 小时前
OpenHarness源码研究-3-codex配置到输出对话
后端·架构
SimonKing1 小时前
Google第三方授权登录
java·后端·程序员
codingWhat1 小时前
能效平台设计方案(打通gitlab和飞书)
后端·node.js·koa
宋均浩1 小时前
# REST 的四个成熟度等级:为什么你不需要 Level 3
后端
万少2 小时前
22 点后,我靠这个 AI 工具成了"夜间天才程序员"
前端·后端
IT_陈寒2 小时前
React hooks 闭包陷阱把我的状态吃掉了,原来问题出在这里
前端·人工智能·后端
壹方秘境2 小时前
使用ApiCatcher在 iOS 上像修改 hosts 一样自定义域名解析
前端·后端·客户端
葫芦和十三3 小时前
图解 MongoDB 22|读写关注:持久性与一致性的档位选择
后端·mongodb·agent
葫芦和十三10 小时前
图解 MongoDB 21|选举与 failover:Primary 是怎么选出来的
后端·mongodb·agent