“这个接口明明加了@Transactional,为什么数据还是没回滚?”
这句话我这两年在排查线上问题的时候,已经听不同的同事说过很多遍了。Spring事务失效场景在Java面试八股文里几乎是必考的,但真正到了生产环境,很少有人能在几分钟内把问题定位清楚。很多情况下,事务不是“没写”,而是“写了但压根没生效”。
这篇文章不打算罗列网上抄来抄去的清单,而是按我自己的排查思路,把Spring事务失效的常见场景拆开来讲。读完你会发现,大多数问题并不神秘,无非就是代理没走通、异常没传出去、配置不对或者数据库不支持这几个原因。如果你正在处理一个“加了事务注解但数据依然写了进去”的诡异Bug,这篇文章可以当排查手册用。
1. 事务失效排查的第一课:先搞清楚Spring事务是怎么“挂”到方法上的
很多人对Spring事务的理解停留在“加个注解就行”的层面,但遇到失效问题时,连从哪里下手都不知道。所以在聊具体的失效场景之前,我必须先讲清楚Spring事务生效的三个前提条件。
1.1 Spring事务由三个角色配合完成
Spring事务不是“一个注解自动完成所有事”,它背后有三个核心角色在配合:
- PlatformTransactionManager(事务管理器):负责真正地开启、提交或回滚数据库事务。在Spring Boot + MyBatis这种组合下,通常是
DataSourceTransactionManager,它内部只是把任务委托给底层的Connection。 - TransactionDefinition(事务定义):决定当前事务的传播行为、隔离级别、超时时间、是否只读等属性。
@Transactional注解里的各种参数,就是用来生成这个定义的。 - TransactionStatus(事务状态):记录当前事务的运行时状态,比如是否是新事务、是否已标记为rollback-only等。
这三者缺一不可。你只配置了@EnableTransactionManagement但没配事务管理器,或者配了事务管理器却没有数据源,事务都不可能正常工作。
1.2 事务拦截器的工作流程:从invoke到commit/rollback
@Transactional只是一个“标记”,真正干活的是AOP拦截器。当Spring容器初始化时,会为带有事务注解的Bean生成代理对象,这个代理对象会在目标方法执行前后插入事务逻辑。
我直接用伪代码描述这个流程,你感受一下:
// 伪代码:TransactionInterceptor内部逻辑简化版 Object invoke(MethodInvocation invocation) { TransactionInfo txInfo = getTransactionAttributeSource().getTransactionAttribute(...); if (txInfo == null) { // 方法上没有事务配置,直接执行 return invocation.proceed(); } // 1. 开启事务 TransactionStatus status = transactionManager.getTransaction(txInfo); try { // 2. 执行业务方法 Object result = invocation.proceed(); // 3. 正常结束则提交 transactionManager.commit(status); return result; } catch (Throwable ex) { // 4. 抛异常则回滚 transactionManager.rollback(status); throw ex; } }这里有一个重点:事务的提交和回滚只能在拦截器层面完成。如果业务方法内部把异常吞掉了,拦截器就认为方法正常返回了,于是选择提交;如果方法内部的异常没有经过这个拦截器,那回滚也无从谈起。
1.3 一套可快速落地的失效排查顺序
我每次遇到“事务不生效”的报障,不会一上来就怀疑某个具体原因,而是按固定顺序排查,避免被症状带偏:
- 先确认调用方拿到的Bean是不是Spring代理对象(看类名里有没有
$$EnhancerBySpringCGLIB或者$Proxy)。 - 确认异常是否被捕获或包装了,导致拦截器感知不到。
- 确认事务管理器是否配置正确,数据库表存储引擎是否支持事务。
- 确认传播行为、隔离级别这些参数是否传达了你想要的语义。
这个顺序可以过滤掉80%的问题。下面我展开讲每种具体场景。
2. 代理没走通:自调用、private方法与new对象的真实失效过程
这一节要讲的三个场景,是事务失效案例中占比最高的一类,根子上都指向同一个结论:事务是通过代理对象才生效的,调用链上没有经过代理,事务就不存在。
2.1 同类自调用绕过了代理对象
这是我在工作中遇到最多的“坑”,很多中高级开发也会踩。先看一段典型的错误代码:
@Service public class OrderService { public void createOrder(OrderDTO dto) { // 处理订单主流程... this.deductStock(dto.getProductId(), dto.getCount()); } @Transactional public void deductStock(Long productId, Integer count) { // 扣减库存,如果出错需要回滚 stockMapper.deduct(productId, count); } }调用createOrder时,deductStock上的@Transactional不会生效。原因很简单:外部调用方拿到的是Spring生成的OrderService代理对象,但createOrder方法内部用this.deductStock()时,this指向的是被代理的目标对象本身,不是代理对象。事务拦截器根本没有机会插入到这次调用中,于是库存扣减操作就脱离了事务管理,直接把数据写进库了。
解决这个问题有三种常见方案,我按推荐程度排一下:
- 方案一:注入自身代理对象。既然问题是因为
this调用跳过了代理,那就让调用方通过代理对象来调用:
@Service public class OrderService { @Autowired @Lazy private OrderService self; public void createOrder(OrderDTO dto) { self.deductStock(dto.getProductId(), dto.getCount()); } @Transactional public void deductStock(Long productId, Integer count) { stockMapper.deduct(productId, count); } }用@Lazy是为了避免构造器阶段的循环引用问题。如果用的是Spring Boot 2.6+,依赖self注入是可以的,但加个@Lazy更稳妥。
方案二:把被调用方法拆到另一个Service里。比如新建一个
StockService,在OrderService里注入它,然后调用stockService.deductStock(...)。因为stockService是独立Bean,注入的是代理对象,事务自然生效。方案三:使用AopContext.currentProxy()。这是最“取巧”的方案,但需要先开启exposeProxy:
// Spring Boot配置 spring.aop.expose-proxy=true // 代码 ((OrderService) AopContext.currentProxy()).deductStock(dto.getProductId(), dto.getCount());老实说,我不太推荐AopContext.currentProxy(),因为代码里到处都是强转,实在谈不上优雅。但如果你是改老项目的存量代码,不想大动干戈,用它兜底是可行的。
2.2 非public方法上的事务注解默认被忽略
网上有一些说法是“private方法是不能加@Transactional的,因为CGLIB无法增强private方法”。这个说法方向对,但严格来说不够准确。就算你用的是JDK动态代理(代理接口方法),private方法本身也不会出现在接口里;更重要的是,Spring在解析事务属性时有一个默认行为:只处理public方法上的@Transactional。
看一下Spring源码中AbstractFallbackTransactionAttributeSource的逻辑,它对非public方法的态度是“直接忽略并打日志”,连代理增强的机会都没有。
// Spring源码摘要(6.x版本中方法名可能略有变化,但行为一致) protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) { if (!allowPublicMethodsOnly() || Modifier.isPublic(method.getModifiers())) { // 只有public方法才会走到这里解析@Transactional } // 非public方法,直接返回null }所以,protected方法和package私有方法上的事务注解同样不会被处理。这一点和CGLIB能不能代理无关,是Spring在属性解析层面的默认策略。
我在实际项目里见过把@Transactional加在private方法上的人,问他为什么这么写,他说“这个方法只有同类在用,不想暴露出去,但又怕出错,加个事务保险”。结果可想而知,事务形同虚设。
正确做法是:事务方法必须是public,并且通过代理对象调用。
2.3 new出来的Bean:连代理都没有,何谈事务
还有一个很隐蔽的失效场景:对象根本不在Spring容器管理范围内。比如某个工具类里直接new了一个Service:
public class OrderHelper { public void doSomething() { OrderService orderService = new OrderService(); orderService.createOrder(dto); // 这个Service实例没有任何代理 } }Spring的AOP增强是在Bean初始化阶段通过BeanPostProcessor织入的。new出来的对象完全脱离了Spring容器生命周期,既没有被代理,也不会被注入依赖。这种情况下,@Transactional注解就是一行毫无意义的装饰。
排查方法其实很简单:在方法入口临时打个断点,看调用对象的实际类型。如果是com.example.OrderService而不是com.example.OrderService$$EnhancerBySpringCGLIB$$...,那就说明当前持有的是原生对象,不是代理。这时候应该去查是谁把它new出来的,或者是不是在静态方法里直接实例化了。
3. 异常处理里的回滚陷阱:吞掉异常与受检异常为何让事务“假装存在”
代理层没问题的情况下,事务依然可能不工作。我见过太多人把问题归结于“Spring事务失效”,实际上异常处理不当才是真凶。这类场景有个共同点:事务确实开启了,但拦截器没有收到回滚信号。
3.1 catch后没有抛出异常,回滚指令根本没触发
看这段代码,就是典型的“我以为会回滚,结果还是写进去了”:
@Transactional public void refund(String orderId) { try { // 1. 修改订单状态为已退款 orderMapper.updateStatus(orderId, REFUNDED); // 2. 调用外部支付渠道退回款项 payChannel.refund(orderId, amount); } catch (Exception e) { log.error("退款失败", e); // 注意:这里没有重新抛出异常 } }把异常捕获并记录下来之后,方法正常返回,TransactionInterceptor认为业务执行成功,执行了commit。实际上,第2步外部调用可能已经失败了,但第1步的数据库更新还是被提交了。
这种代码在业务层非常常见,尤其是团队里有人养成了一种“有异常就try-catch”的肌肉记忆。修复方式也很简单,看你是哪种场景:
- 场景A:异常确实需要中断业务,那就把异常重新抛出去:
@Transactional public void refund(String orderId) { try { orderMapper.updateStatus(orderId, REFUNDED); payChannel.refund(orderId, amount); } catch (Exception e) { log.error("退款失败", e); throw new RefundException("退款流程异常", e); } }- 场景B:异常只是记录日志,业务还可以继续,那么这些操作本来就不应该放在同一个事务方法里。如果不希望回滚,那就不要使用
@Transactional,或者至少明确noRollbackFor。
另外,还有一种折中方案:在catch块里手动将当前事务标记为回滚:
catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 业务继续,最终还是会回滚 }但这个方法会让后续代码“继续执行但最后被回滚”,容易出现逻辑混乱,比如你catch里又调了一个数据操作,结果也被回滚了。我建议能抛出异常就抛出异常,不要滥用setRollbackOnly。
3.2 RuntimeException以外:受检异常不在默认回滚范围
还有一个高频误解:很多人以为@Transactional在“任何异常”下都会回滚。官方默认行为可不是这样。
Spring的默认回滚策略是:只对RuntimeException和Error回滚,受检异常(checked exception)不回滚。至于为什么这样设计,其实是有历史原因的——Spring把“受检异常”理解为业务可以处理、不需要事务帮忙撤销的异常,比如文件不存在、网络超时等;而RuntimeException则被理解为系统级错误,意味着当前业务操作是失败的。这个设计思路上游没有错,但它坑了大量按直觉写代码的人。
举个例子:
@Transactional public void exportReport(String path) throws IOException { reportDao.insertReport(...); Files.write(Paths.get(path), data); // 如果抛IOException,事务不会回滚 reportDao.updateStatus(...); }如果这里抛出了IOException,异常被Spring捕获后,因为它是受检异常,Spring默认不触发回滚。数据库操作就“半提交”了。这经常是“数据看起来怎么都不对劲”的根源。
3.3 正确打开rollbackFor和noRollbackFor的方式
解决受检异常不回滚的办法很简单,显式指定回滚规则:
@Transactional(rollbackFor = Exception.class) public void exportReport(String path) throws IOException { // ... }rollbackFor的值是一个异常类型数组,表示“遇到这些异常时触发回滚”。大多数业务场景下,我建议直接写Exception.class,因为真实业务里并没有那么多需要区别对待的场景,与其去记忆Spring默认行为,不如把规则设置得更符合直觉。
反过来的需求也存在:有一些异常本身不应当触发事务回滚。比如发短信通知失败的异常,不应该让主流程的数据库操作一起回滚掉。这种场景用noRollbackFor:
@Transactional(rollbackFor = Exception.class, noRollbackFor = NotifyException.class) public void createOrder(...) { // 主业务逻辑 // 发通知,通知失败不回滚主事务 }这里要提一个细节:rollbackFor和noRollbackFor同时存在时,Spring的判断是“先看匹配noRollbackFor,不匹配再看rollbackFor”,它们的优先级关系要心里有数。官方文档强调过noRollbackFor优先于rollbackFor。
4. 传播行为和隔离级别被玩坏后,事务边界就失控了
如果说代理和异常是“事务有没有生效”的问题,那么传播行为和隔离级别就是“事务的边界是否符合预期”的问题。很多人把这类问题也归为“事务失效”,因为表现上同样是“操作没有按想象中那样回滚”。
4.1 REQUIRED和REQUIRES_NEW:事务边界被撑开/割裂的典型现场
我先放一张传播行为的速查表,这是事务边界判断的核心依据:
| 传播行为 | 含义 | 常见使用场景 |
|---|---|---|
| REQUIRED | 如果有事务则加入,没有则新建 | 默认,绝大多数业务 |
| REQUIRES_NEW | 无论有没有事务都新建独立事务 | 日志记录、审计、消息发送 |
| NESTED | 有事务则创建Savepoint嵌套,没有则新建 | 部分可回滚的批处理 |
| SUPPORTS | 有事务则加入,没有则在非事务环境执行 | 只读查询 |
| NOT_SUPPORTED | 有事务则挂起,以非事务方式执行 | 非关键查询 |
| MANDATORY | 必须有事务,否则抛异常 | 强制事务上下文 |
| NEVER | 必须没有事务,有则抛异常 | 测试或特殊场景 |
最容易被搞混的是REQUIRED和REQUIRES_NEW。REQUIRED意味着外层方法的异常会导致内层方法的所有操作一起回滚,因为它们是“同一个物理事务”。REQUIRES_NEW则完全不同,内层方法会暂停外层事务并新建一个独立事务,内层提交或回滚都不会直接影响外层。
举个例子,在一个下单流程里记录操作日志。如果日志方法和主业务方法都是REQUIRED,那么当主业务异常回滚时,日志也会被回滚掉。这会很反直觉——系统崩溃时你反而看不到日志了。
正确做法是让日志方法使用REQUIRES_NEW:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); auditLogService.record("CREATE_ORDER", dto.getOrderId()); // REQUIRES_NEW } } @Service public class AuditLogService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void record(String action, String orderId) { auditLogMapper.insert(action, orderId); } }这个模式下,即使createOrder因后续逻辑报错而回滚,auditLog也已经独立提交了,便于追踪问题。
但也有一个经典坑:如果一个REQUIRED方法内部调用了REQUIRES_NEW方法,而REQUIRES_NEW方法内部又访问了外部事务中还没提交的数据,它会因为隔离级别问题读不到那些数据。这种“读不到自己刚写的数据”的现象,最容易让人误以为事务失效了。
4.2 NESTED的保存点机制和REQUIRES_NEW有什么本质不同
NESTED是一个容易被忽略但极其好用的传播行为,它和REQUIRES_NEW有本质区别:
- REQUIRES_NEW:新开一个独立的物理事务,有自己的Commit/Rollback,与外层事务完全隔离。
- NESTED:基于Savepoint(保存点)实现,内层事务实际上是外层事务里的一个标记点。内层回滚时,只回滚到保存点,外层事务可以继续;但如果外层事务最终回滚,内层的所有操作也会跟着回滚。
听上去很绕,用一个实际场景说明:批量导入商品,每导入一条记录就调用一次内部方法。如果希望“某一条失败不影响其他记录”,但整个批次如果有严重错误还是要整体回滚,NESTED就很适合。
@Transactional public void batchImport(List<Product> products) { for (Product p : products) { try { importOne(p); // NESTED } catch (Exception e) { log.error("导入失败: {}", p.getSku(), e); } } } @Transactional(propagation = Propagation.NESTED) public void importOne(Product p) { productMapper.insert(p); }在这个案例里,importOne失败只会影响自己那一条,不会破坏整个批次的已导入数据。数据库层面是依靠Savepoint实现的,没有额外占用一个物理连接,也不会死锁。这个方案在很多“部分成功部分失败”的业务里特别好用。
4.3 隔离级别配置错误:事务在跑,读到的数据却像“没生效”
隔离级别也是排查失效场景时容易忽略的一块。MySQL默认的隔离级别是REPEATABLE_READ(可重复读),Spring的@Transactional默认隔离级别是DEFAULT,也就是跟随数据库默认值。如果你对数据库设置没有概念,可以先看这张表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用,风险极高 |
| READ_COMMITTED | 不可能 | 可能 | 可能 | Oracle默认 |
| REPEATABLE_READ | 不可能 | 不可能 | 可能 | MySQL默认 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 性能最差,几乎不用 |
我遇到过一个“事务失效”的案例,表象是:一个事务方法里先查了订单金额,再进行金额更新,但更新完之后再次查询,拿到的还是旧值,于是后续判断全乱了。这不是事务没生效,而是隔离级别和连接绑定导致同一事务内的多次查询依赖了快照读。
在MySQL的REPEATABLE_READ下,事务内第一条普通的SELECT会建立一致性快照,后续不加锁的普通SELECT都会基于这个快照读取,即使数据已经被当前事务自己UPDATE了,再次普通SELECT也可能读到旧值。这个“旧值”看起来就像是事务操作没生效。
如果你确实需要实时读到最新数据,有两个方向可以选:
- 把查询改成
SELECT ... FOR UPDATE,走当前读而不是快照读。 - 拆分事务边界,让更新和后续校验放在不同的事务方法里,或者把相关操作设计成不依赖旧值的形式。
我个人的建议是尽量走方案二。长事务本身就有很多隐患,比如持有数据库连接过久、锁范围过大、undo log膨胀,为了“读取最新”而加FOR UPDATE属于饮鸩止渴。
5. 多线程和异步场景:事务跟着线程走,别指望跨线程继承
一旦业务里出现多线程、异步任务,事务问题就变得非常微妙。Spring把事务和线程绑定在一起,这是很多“无效事务”的根源。
5.1 事务与线程绑定:子线程中的操作不在主事务里
Spring的事务状态存在什么地方?答案是TransactionSynchronizationManager,它内部使用的是ThreadLocal。这个设计的含义非常直接:Spring事务只能在一个线程内部延续,它不会跨线程传播。
看这个例子:
@Transactional public void batchProcess(List<Long> ids) { ids.parallelStream().forEach(id -> { // 每个子线程里的操作,都在各自的新事务(甚至无事务)环境下执行 processOne(id); }); }外层方法的事务确实开启了,但parallelStream或者显式ExecutorService创建的线程,并不会继承当前线程的事务上下文。子线程里如果调用了数据库操作,要么没有事务,要么在Spring的默认机制下尝试重新开启事务(取决于有没有加@Transactional)。
这种场景之所以让人感到“事务失效”,是因为结果非常反直觉:主线程回滚了,但子线程里的数据一样写进去了。
解决思路有两个:
- 子线程任务必须单独加事务,并且接受“每个任务各自提交/回滚”的语义。
- 如果确实要求“子线程失败,整个批次回滚”,那就不要让任务拆到多线程里跑,或者采用编程式事务,在主线程统一控制。
我见过团队为了并发性能,把批处理拆成多线程,结果出错后才发现数据里留了一堆“半成品”,最后只能手动清理。这种代价往往比性能收益大得多。拆线程前先想清楚事务边界。
5.2 @Async + @Transactional组合下的新事务与失效变体
Spring的@Async是另一个和线程强相关的场景。当一个方法同时标注了@Async和@Transactional,执行逻辑是这样的:Spring的异步拦截器先把方法提交到线程池执行,线程池中的新线程再去走事务拦截器。所以最终事务确实存在,但它是在异步线程中开启和管理的,和调用方的旧事务不在一个线程里。
有些人的本意是“我在事务方法里异步发一条消息,发消息失败不影响主事务”。但如果@Async方法本身带了@Transactional,那消息发送相关操作也会被包进一个新事务。这里的“新事务”是谁的?是异步线程的,不是主线程的。它能否回滚,完全看异步方法自己有没有抛异常。
还有一种非常微妙的失效变体:同一个类内部使用this.asyncMethod()。即使asyncMethod标了@Async,也不会异步执行。这和第2.1节讲的原理一样——this调用绕过了代理对象,连异步拦截器都没经过,更别说事务拦截器了。所以只要看到“同类内部调用异步方法”,基本可以直接判定这段代码的两层注解都是摆设。
5.3 跨库跨服务后,本地事务的能力边界和分布式一致性方案
还有一个经常被当成“Spring事务失效”的场景,其实是分布式事务问题:一个业务操作跨了两个数据库,或者调用了另一个服务的接口,两边各自维护数据。Spring的DataSourceTransactionManager只能管理一个数据源,也就是一个本地事务。想让它同时保证两个库的原子性,是不可能的。
这种情况下,即使代码里层层加@Transactional,也无法保证跨库操作的原子性。我见过有人在一个事务方法里调用另一个服务,想着“对方出错了,我这边的数据库也回滚,那整体就一致了”,结果发现对方服务很可能已经提交了自己的事务,两边数据对不上,怎么查都查不出问题。
真要解决跨服务的数据一致性,需要从架构层面考虑:
- TCC模式:Try、Confirm、Cancel三个阶段,适合强一致性的金融类场景。
- 消息最终一致性:先发本地事务消息,通过MQ让下游消费,配合对账和补偿,适合订单、库存这类可延迟同步的数据。
- 分布式事务中间件:Seata的AT模式对业务侵入较小,但要注意性能开销和回滚日志表的设计。
在做方案选型时,我的建议是:能否把“跨库”改成“应用层最终一致”,就不要强行追求分布式强一致。分布式事务带来的复杂度、性能损耗和运维成本,通常比它解决的问题更大。这个认知本身就是“事务失效排查”的一部分:你不是在排查一行代码,而是在判断一个设计是否越过了事务的能力边界。
6. 容易被忽略的底层故障:final方法、不支持事务的存储引擎与缺失的配置
最后一类失效场景,代码逻辑和注解都没问题,但问题出在框架或数据库的底层。这一类排查起来更隐蔽,因为表面看配置都对。
6.1 final方法无法被CGLIB子类代理覆盖
Spring Boot 2.x之后默认使用CGLIB代理来生成目标类的子类,通过重写方法来完成拦截增强。但final方法是无法被重写的,因此CGLIB也就无法在final方法上插入事务逻辑。
@Service public class PaymentService { @Transactional public final void settle() { // 这里的事务一定不会生效 } }如果是JDK动态代理(基于接口),最终执行的实现类方法也可能有final修饰,代理对象在调用实现类方法时,理论上还是可以走拦截器,所以有些老代码里final方法配JDK动态代理反而“碰巧有效”。但既然Spring Boot 2.x+默认是CGLIB,这个细节就别指望了,规范很简单:事务方法不要用final修饰,包括不要通过“final+private”这种组合来“锁死”方法。
顺带说一句,@Transactional加在接口方法上和加在实现类方法上,Spring都能读取到事务属性,因为Spring会同时查找接口方法和实现类方法上的注解。但为了规避奇怪的行为,我一向建议把@Transactional写在实现类的public方法上,不要写在接口上。
6.2 存储引擎不支持事务,注解再齐全也白搭
数据库层面最容易被忽视的坑是:MySQL用了MyISAM存储引擎。MyISAM是不支持事务的,更不用说回滚了。Spring注解和数据源配置再正确,底层没有事务能力,一切都是空谈。
这种问题在老旧系统里很常见。表是由多年前的DBA建的,默认引擎就是MyISAM;后来项目接入了Spring Boot,加上了@Transactional,但数据依然不回滚。你怎么看代码都觉得没问题,最后用一条SQL查看引擎才发现真相:
SHOW TABLE STATUS WHERE Name = 'your_table';看Engine列,如果是MyISAM就说明问题了。修复方法也比较明确:先把表改成InnoDB,再验证业务是否不受影响。
ALTER TABLE your_table ENGINE = InnoDB;顺带提醒:ALTER TABLE在大表上会加锁,生产环境要安排在低峰期执行。改造后再确认一下连字符集和索引状态,避免隐形性能问题。
6.3 事务管理器缺失或配错,Spring启动和运行期的表现
还有一种场景是:启动日志里没有任何异常,代码也写得对,但事务就是没生效。这可能是因为整个项目根本没有配置TransactionManager,或者配置了但类型不对。
在纯Spring项目里,很多人只写了@EnableTransactionManagement,却忘了声明事务管理器Bean:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean>或者用Java Config:
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }Spring Boot则通常省心很多,只要classpath里存在spring-boot-starter-jdbc或mybatis-spring-boot-starter,它就会自动装配DataSourceTransactionManager。但也有翻车情况:当项目里同时存在多个数据源、多个事务管理器的时候,Spring无法自动决定应该用哪一个,你可能需要显式指定@Transactional(transactionManager = "xxxTransactionManager"),或者通过自定义注解来区分。
我在一个多数据源项目里就遇到过这种问题:主库的事务管理器叫primaryTransactionManager,从库的却叫secondaryTransactionManager,但业务代码里写注解时没有指定transactionManager,Spring从容器里挑了奇怪的那个,导致主库操作没有按预期回滚。后来排查到根因后,把所有事务注解都显式指定了事务管理器,问题才彻底解决。
另一个值得提的细节是:事务管理器最终操作的是Connection,如果DataSource本身配的是只读的、或者是一个关闭了自动提交的连接池连接,事务也会表现异常。这类底层问题排查难度更大,建议从连接池日志和数据库监控着手。
最后分享一点我自己的排查习惯
每次遇到事务失效,我都会先用一个非常简单的方法快速确认“当前到底有没有活动事务”:在疑似出问题的方法里临时加一行日志,把TransactionSynchronizationManager.isActualTransactionActive()的结果打出来。这个静态方法返回当前线程是否存在真实的数据库事务。
如果返回false,说明业务代码还没进入事务上下文,重点查代理和调用链路;如果返回true,但数据依然没回滚,那问题基本就在异常处理和传播行为上,不用再盯着代理翻来覆去看了。
这招在排查线上问题时特别省时间。你不需要读一大堆源码,先确认“事务有没有开起来”,再确认“回滚信号有没有传出去”,两个点定位完之后,绝大多数事务失效场景都能锁定到具体原因。记住一句话:Spring事务不玄学,它的生效链路就那么短,只是你还没把链路上的每个节点都检查完。