最后更新:2026-10-28
适用场景:Spring Boot 3.x、Spring Framework 6.x、JDBC / MyBatis 单库事务

订单写入成功了,后面扣库存抛了异常,数据库里却只留下半条业务记录。看到 @Transactional,很容易先怀疑数据库。实际排查时,我会先问两个更近的问题:这次调用有没有进入事务代理?异常有没有从事务方法里抛出去?

先用一个可观察的失败验证事务

假设项目已引入 JDBC Starter、配置好单库事务管理器,并在测试库里准备了一张 demo_order 表。然后让一次插入后必然抛出运行时异常:

@Service
public class OrderService {
    private final JdbcTemplate jdbcTemplate;

    public OrderService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void create(long id) {
        jdbcTemplate.update("insert into demo_order(id) values (?)", id);
        throw new IllegalStateException("模拟后续步骤失败");
    }
}

从 另一个 Spring Bean 调用 orderService.create(1001L),捕获异常后查询 id=1001。正常的单库事务应看不到这条记录。不要只看接口返回了 500;返回状态并不能证明数据库回滚了。测试前清掉同 ID 的旧数据,避免把上次的记录当成这次的结果。

想确认当前线程是否真的有事务,可在方法内临时打印:

TransactionSynchronizationManager.isActualTransactionActive()

它返回 false,先查代理与事务管理器;返回 true,再查异常、传播行为和实际使用的数据源。这个值只用于定位,别把它当成业务代码的永久分支。Spring 的声明式事务文档说明了默认代理模式的边界。

最常见的坑:同一个类里自己调用自己

下面的 submit() 调用 save(),没有经过 Spring 代理。save() 上有注解,不代表这次调用被事务拦截:

@Service
public class OrderService {
    public void submit(long id) {
        save(id); // 同类内部调用
    }

    @Transactional
    public void save(long id) {
        // 写订单、扣库存
    }
}

把事务边界放在实际被外部调用的 submit() 上,或者把 save() 拆进另一个 Spring Bean,由 OrderService 注入并调用。通常前者更符合“下单要么全成、要么全败”的业务边界。private 方法、new OrderService() 创建的对象也不适合作为这种代理事务的入口;先确认调用对象确实来自容器。

异常被吃掉,事务会认为方法正常结束

假如 create() 里把库存异常抓住,只打日志,然后正常 return,默认事务拦截器看不到失败,就会提交前面的写入。

@Transactional
public void create(long id) {
    try {
        saveOrder(id);
        reduceStock(id);
    } catch (RuntimeException ex) {
        log.error("下单失败,订单号={}", id, ex);
        throw ex; // 让异常继续穿过事务边界
    }
}

若业务必须在方法内转换异常,就转换成有明确语义的运行时异常并保留 cause;不要吞掉后返回“失败”字符串。Spring 默认对 RuntimeException 和 Error 回滚,受检异常默认不触发回滚。确实要对受检异常回滚,可以写 @Transactional(rollbackFor = IOException.class),但要先确认调用方能处理这种异常,而不是把所有异常一股脑改成 Exception.class。回滚规则有完整说明。

还有事务,但不是同一个事务

排查到这里还不对,再看三类边界:

  1. @Transactional(propagation = REQUIRES_NEW) 会开启独立事务。内层已经提交,外层后来失败,不能指望外层把内层一并撤销。
  2. @Async、新线程或线程池里的数据库操作不自动加入调用方线程的事务。跨线程工作应设计成独立任务,而不是假设注解能传过去。
  3. 项目有多个数据源或事务管理器时,确认注解使用的管理器与实际写入的数据库一致;单库示例不能直接套到多库场景。

MyBatis 项目还要确认 Mapper 参与的是 Spring 管理的会话,不要自行打开 SqlSession 后绕开现有事务配置。事务只负责它管得到的资源;调用第三方接口、发消息、写本地文件并不会因为数据库回滚而自动撤销。

一次排查只改一个变量

先用最小插入加抛异常确认回滚,再逐步加回业务步骤。每次记录:调用入口、事务是否 active、异常类型、异常是否被重新抛出、最终数据库行数。这样能分清“根本没进事务”和“进了事务但提前提交”。

如果你刚在多环境里切换数据库,也顺手核对实际加载的配置;Spring Boot 配置文件怎么写里有 profile 和配置覆盖的基础排查。业务层结构可对照Spring Boot 整合 MyBatis。

参考资料

Spring Framework 6.2:使用 @Transactional
Spring Framework 6.2:事务回滚规则

更新记录

2026-10-28:创建文章,补充代理、自调用、异常类型与事务边界的验证方法。