简介:在Spring Boot项目中使用@Transactional并不等于事务一定生效。本文围绕事务不生效、数据未回滚等常见线上问题,梳理八类高频开发陷阱,从代理机制、异常类型、方法可见性到多线程边界逐一拆解,帮助开发者准确判断事务边界,减少因“以为加了注解”而导致的数据不一致事故。
在很多Spring Boot项目里,开发者对事务的直觉通常很直接:只要在Service方法上加上@Transactional,方法内部一旦抛出异常,持久层操作就应该整体回滚。这个认知本身没有问题,问题在于它建立在一个隐含前提之上——这个方法确实运行在Spring事务代理中。
工程实践里更常见的情况是:代码在形式上进入了某个被@Transactional标注的方法,但实际执行的却是一个没有经过事务代理的普通对象方法。此时注解虽然存在,却不会触发任何事务逻辑。于是订单写进了数据库、库存扣减完成,只有最后一步流水生成失败,结果接口返回失败,数据却已经“部分成功”。
这不是数据库事务机制失效,而是应用层的事务边界判断出现了偏差。下面按照实战中出现频率,依次梳理八个几乎每个后端开发者都可能遇到的场景。
假设有一个用户注册业务,对外暴露一个register方法,内部调用另一个createAccount方法:
@Servicepublic class UserService {@Transactionalpublic void register(User user) {userRepository.save(user);createAccount(user);throw new RuntimeException("测试异常");}public void createAccount(User user) {accountRepository.save(new Account(user.getId(), 0));}}
开发者的预期是:执行到最后一行抛异常,用户保存和账户创建都应该回滚。实际上,createAccount方法虽然被放在同一个类中,却只是一个普通方法调用,它不会重新经过Spring代理对象,因此它本身并不具备独立的事务能力。
但更关键的一点在于:此时register方法本身已经开启了事务,所以createAccount虽然不开启新事务,但它会在register的事务上下文中执行。也就是说,这个例子中事务其实仍然会回滚。真正容易出问题的是相反情况:
@Servicepublic class UserService {public void register(User user) {userRepository.save(user);createAccount(user);}@Transactionalpublic void createAccount(User user) {accountRepository.save(new Account(user.getId(), 0));}}
此时入口是register,它没有事务注解,而createAccount虽然有注解,但由于是同类内部调用,Spring事务代理根本没有介入。结果是:用户保存成功,账户创建失败时,用户数据不回滚。
解决办法一般有两种:一是将需要事务能力的逻辑拆分到独立的Bean中,通过注入方式调用;二是在合适的位置重新设计事务边界,让真正需要原子性的方法成为外部入口。
Spring事务管理依赖AOP代理,而默认代理方式下,只有public方法才会被代理拦截。如果注解被加在private、protected或者包级可见的方法上,代理层无法对该方法进行增强。
@Servicepublic class OrderService {@Transactionalprivate void handlePay(Order order) {orderRepository.save(order);inventoryRepository.deduct(order.getSkuId());}}
这段代码在运行时不会产生任何事务效果。即便该方法内部抛出异常,已执行的数据库操作也会被立即提交。
需要特别说明的是,某些AOP框架可以通过特殊配置拦截非public方法,但在Spring Boot默认体系下,依赖public方法作为事务入口是更稳妥的选择。如果某个方法确实不能设计为public,通常意味着它不适合作为事务边界入口。
Spring事务在默认配置下,只在遇到RuntimeException或者Error时触发回滚。对于受检异常,也就是Exception及其子类中除运行时异常以外的部分,事务管理器不会主动回滚。
@Transactionalpublic void processFile(File file) throws IOException {fileRecordRepository.save(file);parse(file); // 抛出IOException}
上面的代码中,如果parse方法抛出IOException,事务不会回滚。虽然这种默认行为可以通过rollbackFor参数进行配置:
@Transactional(rollbackFor = Exception.class)
但更推荐的做法是,在业务设计阶段就明确哪些异常应当触发回滚,并在注解中显式声明,而不是依赖“默认碰巧正确”的行为。
当外部调用链绕过了Spring代理层时,事务自然无法生效。常见情况包括:
new手动创建Service对象;事务之所以存在,是因为调用方拿到的是Spring生成的代理对象。一旦调用链中出现“不经容器、不经注入”的对象引用,事务增强就完全失效。因此,排查事务问题时,首先要确认调用入口处拿到的对象是否是Spring容器管理的Bean。
事务上下文通常与当前线程绑定。Spring默认使用ThreadLocal保存事务资源和连接信息,这意味着一旦方法执行被切换到另一个线程,原线程的事务状态不会随之迁移。
@Transactionalpublic void batchHandle(List<Order> orders) {executor.execute(() -> {orderRepository.saveAll(orders);});}
这段代码中,真正执行数据库操作的线程已经不属于事务线程。即便是同一个JVM,甚至是同一个Bean,事务也不会对异步线程生效。
如果业务上确实需要在异步任务中进行持久化,应当在异步线程内部重新开启独立事务,或者将事务边界放到各个线程内部,而不是依赖外层事务传递。
在单数据源项目中,Spring事务管理较为直接。但一旦接入多个数据源,问题就会明显复杂化。不同数据源往往需要不同的事务管理器,而@Transactional默认只使用配置好的某一个。
如果事务中同时操作两个数据源,实际生效的可能只有一个数据源连接参与回滚,另一个数据源已经独立提交。此时即使看到日志中显示事务开启,数据仍然可能部分回滚、部分保留。
解决思路不是简单增加注解,而是需要在配置层明确事务管理器与数据源的绑定关系,同时在业务划分上尽量避免跨数据源的强一致事务,改为最终一致性方案。
事务回滚的触发条件是异常被传播到事务代理层。如果异常在业务代码内部被提前捕获并处理掉,代理层就感知不到任何异常,自然也不会触发回滚。
@Transactionalpublic void saveLog(Log log) {try {logRepository.save(log);externalService.send(log);} catch (Exception e) {log.warn("外部服务调用失败,忽略");}}
这里externalService.send抛出异常后,在方法内部被吞掉,事务最终正常提交。后来业务又依赖这个外部调用结果,就会出现数据状态不一致。
如果某个异常确实不需要回滚,应当明确记录日志并评估业务影响;如果异常应当导致回滚,就必须让异常继续向上抛出,或通过编程式事务主动标记当前事务为回滚状态。
除了上述场景,还有一些容易造成误解的配置问题。例如propagation设置不当,导致方法加入了一个并不存在的事务上下文;readOnly被误解为“可以随意操作”;多个事务管理器同时存在时,没有为@Transactional指定具体使用哪一个。
这些问题在单机小规模项目中可能并不明显,一旦项目规模扩大、依赖增多,事务管理的复杂度会成倍上升。排查时不应只盯着业务代码,还要结合事务管理器配置、切面顺序、传播属性等整体判断。
当发现“数据没有回滚”时,建议按照以下顺序排查:
public方法;很多问题都不是事务机制本身出错,而是调用路径和事务边界出现了偏差。理解Spring事务代理的工作原理,才是彻底避免这类问题反复出现的关键。