同样写着@Transactional,数据库操作却可能没有按预期回滚。本文从 Spring 代理、事务边界、异常类型、JPA flush 到 MySQL 存储引擎逐层分析,并给出可落地的代码、排查方法与实践建议。

在 Java 数据库开发中,事务问题往往不是“数据库不支持回滚”,而是业务代码根本没有进入我们以为的那个事务。

一个典型场景是:订单创建成功了,库存扣减也执行了,但后续写入操作抛出异常,订单和库存却没有回滚。开发者检查代码,发现方法上明明有 @Transactional,于是开始怀疑 MySQL、连接池或 ORM。实际上,问题经常出在事务边界、Spring 代理和异常处理方式上。

本文以 Spring Boot、Spring Data JPA 和 MySQL 为例,讨论一条事务从调用开始到数据库提交之间究竟发生了什么。

一、先明确事务到底保护什么

事务保护的不是某个 Java 方法,而是一次数据库连接上的一组数据库操作。通常可以用四个特征概括:原子性、一致性、隔离性和持久性。

在 Spring 中,@Transactional 的主要作用是让事务拦截器在目标方法执行前开启事务,在方法正常结束时提交,在符合规则的异常发生时回滚。它并不会把方法内部的普通 Java 操作自动变成数据库事务,也不会改变数据库表的存储引擎。

下面是一段常见的订单服务代码:

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final AccountRepository accountRepository;
    private final AuditLogRepository auditLogRepository;

    public OrderService(OrderRepository orderRepository,
                        AccountRepository accountRepository,
                        AuditLogRepository auditLogRepository) {
        this.orderRepository = orderRepository;
        this.accountRepository = accountRepository;
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional
    public void createOrder(Long accountId, BigDecimal amount) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new IllegalArgumentException("账户不存在"));

        account.debit(amount);
        accountRepository.save(account);

        Order order = Order.create(accountId, amount);
        orderRepository.save(order);

        auditLogRepository.save(AuditLog.orderCreated(order.getId()));
    }
}

如果所有仓储使用同一个数据源和事务管理器,且这些表使用支持事务的存储引擎,那么该方法中的查询、更新和插入通常会加入同一个事务。任何符合回滚规则的异常从方法中抛出后,之前的数据库修改都会回滚。

关键在于:调用者必须通过 Spring 管理的代理对象调用 createOrder

二、@Transactional 为什么可能没有生效

1. 对象不是 Spring 创建的

如果代码中直接使用 new OrderService(...) 创建服务对象,Spring 不会为它建立事务代理,注解也就不会被拦截。

OrderService service = new OrderService(orderRepository,
        accountRepository, auditLogRepository);
service.createOrder(accountId, amount);

实际项目中更常见的形式是把服务类交给 Spring 管理,然后通过构造器注入使用:

@RestController
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/orders")
    public void create(@RequestParam Long accountId,
                       @RequestParam BigDecimal amount) {
        orderService.createOrder(accountId, amount);
    }
}

这里的 orderService 通常是代理对象。代理先处理事务,再调用真正的业务对象。

2. 同类内部调用绕过代理

事务注解最容易被忽略的一点是:同一个类中的 this 调用不会经过代理。

@Service
public class OrderService {

    public void importOrder(Long accountId, BigDecimal amount) {
        this.createOrder(accountId, amount);
    }

    @Transactional
    public void createOrder(Long accountId, BigDecimal amount) {
        // 数据库操作
    }
}

外部调用 importOrder 时,程序已经进入目标对象内部,this.createOrder(...) 不会再次经过事务拦截器。因此 createOrder 上的注解可能完全没有机会执行。

更稳妥的做法是把事务边界放在外部可调用的服务方法上:

@Service
public class OrderImportService {

    private final OrderService orderService;

    public OrderImportService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void importOrder(Long accountId, BigDecimal amount) {
        orderService.createOrder(accountId, amount);
    }
}

或者把需要独立事务的操作拆到另一个 Spring Bean 中。这样事务边界更清楚,也避免通过 AopContext.currentProxy() 之类的方式依赖代理上下文。

3. 异常被方法内部吞掉

事务拦截器需要观察方法是否以异常结束。如果异常在业务方法内部被捕获并正常返回,拦截器会认为方法执行成功,随后提交事务。

@Transactional
public void createOrder(Long accountId, BigDecimal amount) {
    try {
        // 保存订单和账户信息
        saveData(accountId, amount);
    } catch (RuntimeException ex) {
        log.error("创建订单失败", ex);
        // 没有继续抛出异常
    }
}

如果业务确实需要记录日志后回滚,可以重新抛出异常:

@Transactional
public void createOrder(Long accountId, BigDecimal amount) {
    try {
        saveData(accountId, amount);
    } catch (RuntimeException ex) {
        log.error("创建订单失败", ex);
        throw ex;
    }
}

也可以显式标记当前事务回滚,但这通常应该作为少数特殊场景的补充手段:

catch (RuntimeException ex) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    throw ex;
}

三、异常类型决定默认回滚行为

Spring 默认对未检查异常和 Error 回滚,对普通检查异常通常不自动回滚。

@Transactional
public void createOrder() throws IOException {
    saveOrder();
    throw new IOException("外部文件处理失败");
}

上面的 IOException 是检查异常。如果异常从方法中抛出,默认情况下事务可能仍然提交。需要明确声明回滚规则:

@Transactional(rollbackFor = IOException.class)
public void createOrder() throws IOException {
    saveOrder();
    throw new IOException("外部文件处理失败");
}

实践中,业务层可以统一使用业务运行时异常,例如 OrderCreationException extends RuntimeException。这样异常语义更一致,也减少每个方法重复配置回滚类型的机会。但这不是绝对规则,跨越文件、网络或消息处理边界时,仍然要根据业务结果明确设置。

四、JPA 的保存不等于立刻执行 SQL

使用 JPA 时,save() 通常只是把实体交给持久化上下文管理。SQL 可能在事务提交前的 flush 阶段才真正发送到数据库。

@Transactional
public void changeStatus(Long orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("订单不存在"));

    order.changeStatus(OrderStatus.PAID);
    // 事务提交前,JPA 会根据实体状态生成更新 SQL
}

因此,看到 save() 返回并不能证明数据库已经提交。另一方面,JPA 也可能因为执行查询、显式调用 flush() 或事务提交而提前把变更同步到数据库,但 flush 仍然不等于 commit。flush 失败时事务应该回滚,commit 成功后数据才真正完成提交。

在需要尽早发现约束错误的地方,可以显式调用:

orderRepository.save(order);
orderRepository.flush();

不过,flush() 会增加数据库交互,不应当为了“看起来更及时”而在循环中无条件调用。更重要的是确认事务确实存在,并理解 ORM 的持久化上下文生命周期。

五、MySQL 表引擎也决定能否回滚

Spring 只能协调事务,不能让不支持事务的表获得事务能力。MySQL 生产环境通常使用 InnoDB,因为它支持行级锁、事务和崩溃恢复。若表使用不支持事务的存储引擎,部分写操作可能不会随事务回滚。

可以检查表的存储引擎:

SHOW TABLE STATUS LIKE 'orders';
SHOW TABLE STATUS LIKE 'accounts';

还要确认应用连接的确实是预期数据库,尤其是在开发环境存在读写分离、多个数据源或测试数据库时。一个常见误判是:日志显示业务方法抛出了异常,但开发者查看的是另一套数据库实例。

六、排查事务问题的一条路径

遇到“没有回滚”时,可以按以下顺序检查:

  1. 查看服务对象是否由 Spring 管理,调用是否来自另一个 Bean 或控制器。
  2. 检查事务方法是否为适合代理拦截的公开方法,并排除同类内部调用。
  3. 确认异常是否从事务方法边界抛出,是否被捕获后正常返回。
  4. 确认异常类型与 rollbackFor 配置是否匹配。
  5. 检查数据源、事务管理器和 ORM 是否使用同一套配置。
  6. 检查 MySQL 表是否为 InnoDB,并确认实际连接的数据库实例。
  7. 打开 SQL 和事务日志,区分 flush、SQL 执行与 commit 三个阶段。

不要只在数据库里观察最终结果。可以在事务方法入口、异常处理处和事务提交附近打印业务标识,同时打开框架提供的事务日志。日志的目的不是证明“有一个注解”,而是确认事务何时创建、是否加入已有事务以及最终是提交还是回滚。

七、实践建议

事务边界应当围绕一个完整的业务动作设计,而不是给所有仓储方法都随手添加注解。读取方法可以考虑 readOnly = true,但它主要表达意图和帮助框架优化,不能当作数据库层面的只读安全保证。

不要在数据库事务中执行长时间的 HTTP 调用、文件上传或人工等待。事务持续时间越长,连接和锁占用越久,系统并发能力越容易下降。可以先完成必要的数据库状态变更,再通过可靠消息或事务事件通知其他系统;如果业务必须保证外部动作与数据库状态一致,则需要进一步设计幂等、补偿和状态机,而不是单靠 @Transactional

同时,事务方法最好保持边界清晰、异常语义明确,并为回滚场景编写集成测试。测试不应只验证方法抛出了异常,还要在事务结束后重新查询数据库,确认相关表的状态确实符合预期。

总结

Spring 的事务不是注解本身,而是代理、事务管理器、数据库连接、ORM 持久化上下文和 MySQL 存储引擎共同完成的一条链路。任何一环没有接上,代码表面上仍然可以正常运行,却可能失去原子性。

排查这类问题时,先确认调用是否经过代理,再确认异常是否到达事务边界,随后区分 ORM 的 flush 与数据库 commit,最后检查数据源和表引擎。把“注解存在”转换成“事务边界和提交结果可验证”,才是数据库事务实践真正可靠的起点。

最后修改:2026 年 08 月 21 日
如果觉得我的文章对你有用,请随意赞赏