同样写着@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';还要确认应用连接的确实是预期数据库,尤其是在开发环境存在读写分离、多个数据源或测试数据库时。一个常见误判是:日志显示业务方法抛出了异常,但开发者查看的是另一套数据库实例。
六、排查事务问题的一条路径
遇到“没有回滚”时,可以按以下顺序检查:
- 查看服务对象是否由 Spring 管理,调用是否来自另一个 Bean 或控制器。
- 检查事务方法是否为适合代理拦截的公开方法,并排除同类内部调用。
- 确认异常是否从事务方法边界抛出,是否被捕获后正常返回。
- 确认异常类型与
rollbackFor配置是否匹配。 - 检查数据源、事务管理器和 ORM 是否使用同一套配置。
- 检查 MySQL 表是否为 InnoDB,并确认实际连接的数据库实例。
- 打开 SQL 和事务日志,区分 flush、SQL 执行与 commit 三个阶段。
不要只在数据库里观察最终结果。可以在事务方法入口、异常处理处和事务提交附近打印业务标识,同时打开框架提供的事务日志。日志的目的不是证明“有一个注解”,而是确认事务何时创建、是否加入已有事务以及最终是提交还是回滚。
七、实践建议
事务边界应当围绕一个完整的业务动作设计,而不是给所有仓储方法都随手添加注解。读取方法可以考虑 readOnly = true,但它主要表达意图和帮助框架优化,不能当作数据库层面的只读安全保证。
不要在数据库事务中执行长时间的 HTTP 调用、文件上传或人工等待。事务持续时间越长,连接和锁占用越久,系统并发能力越容易下降。可以先完成必要的数据库状态变更,再通过可靠消息或事务事件通知其他系统;如果业务必须保证外部动作与数据库状态一致,则需要进一步设计幂等、补偿和状态机,而不是单靠 @Transactional。
同时,事务方法最好保持边界清晰、异常语义明确,并为回滚场景编写集成测试。测试不应只验证方法抛出了异常,还要在事务结束后重新查询数据库,确认相关表的状态确实符合预期。
总结
Spring 的事务不是注解本身,而是代理、事务管理器、数据库连接、ORM 持久化上下文和 MySQL 存储引擎共同完成的一条链路。任何一环没有接上,代码表面上仍然可以正常运行,却可能失去原子性。
排查这类问题时,先确认调用是否经过代理,再确认异常是否到达事务边界,随后区分 ORM 的 flush 与数据库 commit,最后检查数据源和表引擎。把“注解存在”转换成“事务边界和提交结果可验证”,才是数据库事务实践真正可靠的起点。