ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring事务管理核心接口详解:PlatformTransactionManager、TransactionDefinition与TransactionStatus

Spring事务管理核心接口详解:PlatformTransactionManager、TransactionDefinition与TransactionStatus

1. 从一次数据不一致的排查说起:为什么事务管理值得反复琢磨

很多开发者第一次接触Spring的事务管理,都是在某个“明明加了注解却没生效”的下午。我印象很深的一次,是帮朋友排查一个批量导入的功能:代码里明明写了@Transactional,日志也显示方法正常执行完了,但数据库里就是出现了“主表有记录、明细表缺数据”的脏情况。最后定位下来,问题出在方法内部自己try-catch把异常吞掉了,事务管理器根本没收到回滚信号。

这件事说明一个道理:Spring的事务管理看起来只是加个注解那么简单,但它背后是一整套接口协作、代理增强、传播行为控制的机制。你不理解这套机制,就很容易在“注解失效”“回滚不生效”“嵌套事务行为诡异”这些坑里反复摔跤。

这篇内容就是围绕Spring事务管理及其核心接口来展开的。我会把PlatformTransactionManager、TransactionDefinition、TransactionStatus这三个核心接口拆开讲清楚,再结合传播行为、隔离级别、回滚规则这些实际开发中绕不开的点,配上可复现的代码和排查思路。不管你是刚学Spring Boot的新手,还是已经用了几年但一直“凭感觉写注解”的老手,都能从中拿到能直接用的东西。

需要先说明的是,本文涉及的代码示例都是基于常见的Spring生态实践整理的,具体版本差异我会在关键位置标注,你实际落地时以自己项目的依赖版本为准。

2. 事务管理的本质:Spring到底帮我们封装了什么

2.1 没有Spring的时候,事务代码长什么样

要理解Spring事务管理的价值,得先看看“原始社会”是怎么写事务的。以JDBC为例,一个典型的手动事务流程大致是这样:

Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行多条SQL statement1.executeUpdate(); statement2.executeUpdate(); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }

这段代码有几个明显的问题。第一,事务边界和业务逻辑完全耦合在一起,业务代码里塞满了commit、rollback、close这些和业务无关的操作。第二,一旦业务逻辑变复杂,比如中间要调用别的服务、要处理多种异常,这套模板代码会迅速膨胀。第三,不同持久化技术(JDBC、Hibernate、MyBatis)的事务API各不相同,换一个ORM框架,事务代码就得重写一遍。

Spring事务管理要解决的核心问题,就是把这套“开启事务—执行—提交/回滚—释放资源”的模板逻辑抽出来,让业务代码只关心业务本身。它通过AOP代理在方法调用前后织入事务控制逻辑,开发者只需要声明“这个方法需要事务”,剩下的交给框架。

2.2 抽象层的三层结构

Spring的事务抽象大致可以分成三层来理解,这个分层思路对后面理解核心接口非常关键。

最底层是事务资源的抽象,也就是“事务到底作用在什么东西上”。对JDBC来说是Connection,对Hibernate来说是Session,对JTA来说是分布式事务上下文。Spring用TransactionDefinition来描述事务的“配置”,用TransactionStatus来描述事务的“运行时状态”。

中间层是事务管理器的抽象,也就是PlatformTransactionManager接口。它定义了getTransaction、commit、rollback这三个方法,把“怎么开启、怎么提交、怎么回滚”这件事标准化。不同的持久化技术提供不同的实现类,比如DataSourceTransactionManager、HibernateTransactionManager、JtaTransactionManager。

最上层是声明式事务的入口,也就是我们最熟悉的@Transactional注解,以及背后的TransactionInterceptor。它负责读取注解配置、组装TransactionDefinition、调用事务管理器、处理异常和回滚。

这三层各司其职,理解了这个结构,你就能明白为什么“换个数据源要换事务管理器”“为什么注解上的属性最终会变成TransactionDefinition里的字段”。

2.3 编程式事务与声明式事务的取舍

Spring同时支持编程式事务和声明式事务,这两者不是替代关系,而是各有适用场景。

编程式事务的典型写法是用TransactionTemplate:

transactionTemplate.execute(status -> { // 业务逻辑 orderMapper.insert(order); orderItemMapper.insertBatch(items); return null; });

它的好处是事务边界非常明确,你能精确控制哪几行代码在事务里,也能在事务内部做更细粒度的判断。缺点是代码里会混入事务相关的模板逻辑,业务纯度下降。

声明式事务就是@Transactional,好处是业务代码干净,配置集中。缺点是边界由方法决定,遇到“方法内部调用”“异常被吞”“传播行为不符合预期”这些情况时,排查成本更高。

我的经验是:核心的、复杂的、需要精细控制的事务用编程式,常规的增删改查用声明式。很多团队一刀切全用注解,结果在复杂场景下反复踩坑,其实换个思路用TransactionTemplate反而更省心。

3. PlatformTransactionManager:事务操作的统一入口

3.1 接口定义与三个核心方法

PlatformTransactionManager是整个事务管理的顶层接口,它的定义非常简洁:

public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition); void commit(TransactionStatus status); void rollback(TransactionStatus status); }

三个方法对应事务生命周期的三个阶段。getTransaction负责“开启或加入”一个事务,返回的TransactionStatus代表当前事务的运行时状态。commit负责提交,rollback负责回滚。

这里有个容易被忽略的细节:getTransaction的语义不是单纯的“开启”,而是“根据传入的TransactionDefinition和当前线程已有的事务状态,决定是新建事务还是加入已有事务”。这个“决定”的过程,就是传播行为在起作用。所以传播行为不是注解层面的魔法,而是事务管理器在getTransaction里根据定义做的判断。

3.2 常见实现类的选择逻辑

不同持久化技术对应不同的事务管理器实现,选错了会出现“事务不生效”或者“事务管理器找不到”的问题。下面这张表是我整理的常见对应关系:

持久化技术推荐事务管理器说明
原生JDBC / MyBatisDataSourceTransactionManager基于DataSource的Connection控制事务
HibernateHibernateTransactionManager与SessionFactory绑定,能感知一级缓存
JPAJpaTransactionManager支持JPA的EntityManager生命周期
分布式多数据源JtaTransactionManager需要容器或独立事务协调器支持

选型时有个实用原则:事务管理器要和实际执行SQL的组件共享同一个连接来源。比如你用MyBatis,那DataSourceTransactionManager和SqlSessionFactory必须引用同一个DataSource,否则事务管理器管的是A连接,MyBatis用的是B连接,事务自然不生效。这个坑我在早期项目里踩过,排查了大半天才发现是两个数据源配置不一致。

3.3 自定义事务管理器的场景

大多数情况下用Spring提供的实现就够了,但有些场景需要自己扩展。比如你想在事务提交前后加一些审计日志,可以继承DataSourceTransactionManager并重写doCommit、doRollback这些模板方法。

还有一种情况是多数据源下的动态路由。这时候通常会自定义一个AbstractRoutingDataSource,再配合一个能感知路由key的事务管理器。需要注意的是,这种方案下事务的原子性只在单个数据源内成立,跨数据源的一致性要靠其他机制保证,不能指望一个本地事务管理器搞定。

4. TransactionDefinition:事务的配置说明书

4.1 传播行为:七种取值背后的真实语义

传播行为是TransactionDefinition里最核心也最容易搞混的属性。它回答的问题是:当前方法被调用时,如果已经存在一个事务,应该怎么办?

Spring定义了七种传播行为,我按“是否复用当前事务”分成三组来理解会更清晰。

第一组是“复用当前事务”的:REQUIRED和SUPPORTS。REQUIRED是默认值,有事务就加入,没有就新建。SUPPORTS是有事务就加入,没有就以非事务方式执行。

第二组是“不复用当前事务”的:REQUIRES_NEW、NOT_SUPPORTED、NEVER。REQUIRES_NEW是无论有没有事务都新建一个,原事务挂起。NOT_SUPPORTED是挂起当前事务,以非事务方式执行。NEVER是如果当前有事务就抛异常。

第三组是嵌套相关的:NESTED。它是在当前事务里创建一个保存点,回滚时只回滚到保存点,而不是整个事务。注意NESTED和REQUIRES_NEW的区别:前者是同一个物理事务里的子事务,后者是两个独立的物理事务。

@Transactional(propagation = Propagation.REQUIRES_NEW) public void logOperation() { // 这个方法的事务独立于调用方 // 即使调用方回滚,这里的记录也会保留 }

实际开发中,REQUIRES_NEW常用来做“日志记录不受主业务回滚影响”这类需求,NESTED则适合“部分失败不影响整体”的批量场景。但要注意,NESTED需要底层支持保存点,DataSourceTransactionManager是支持的,某些其他实现不一定。

4.2 隔离级别与数据库实际的对应关系

隔离级别这块,Spring定义了DEFAULT、READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE五种。DEFAULT表示用数据库默认的隔离级别,具体是什么取决于你用的数据库。

这里有个关键认知:Spring只是把隔离级别传递给数据库,真正实现隔离的是数据库本身。所以你在注解上写ISOLATION_SERIALIZABLE,最终能不能生效、以什么方式生效,取决于底层数据库的支持情况。比如某些数据库对READ_UNCOMMITTED的支持并不完整,设置了也可能被忽略。

隔离级别脏读不可重复读幻读
READ_UNCOMMITTED可能可能可能
READ_COMMITTED避免可能可能
REPEATABLE_READ避免避免可能
SERIALIZABLE避免避免避免

选隔离级别时不要盲目追求最高级别,SERIALIZABLE会带来明显的并发性能下降。大多数业务用READ_COMMITTED就够了,只有在确实需要避免不可重复读的场景才考虑REPEATABLE_READ。

4.3 超时、只读与回滚规则

TransactionDefinition还有三个实用属性:超时时间、是否只读、以及回滚规则。

超时时间单位是秒,指的是事务允许执行的最长时间,超时后会自动回滚。这个属性在防止慢查询拖垮连接池时很有用,但要注意它依赖底层实现的支持,不是所有数据库驱动都能精确控制。

只读属性readOnly是一个优化提示,告诉底层“这个事务只读”。对于某些数据库和ORM框架,开启只读能带来性能优化,比如Hibernate会跳过脏检查。但要注意,只读事务里如果执行了写操作,行为取决于具体实现,有的会报错,有的会静默忽略。

回滚规则默认是“遇到RuntimeException和Error回滚,遇到受检异常不回滚”。这个默认行为经常让人困惑:为什么我的方法抛了异常却没回滚?很可能是因为抛的是受检异常。你可以通过rollbackFor和noRollbackFor来调整。

@Transactional(rollbackFor = Exception.class) public void businessMethod() throws Exception { // 这样配置后,受检异常也会触发回滚 }

5. TransactionStatus:事务运行时的状态快照

5.1 它到底保存了哪些信息

TransactionStatus是getTransaction返回的对象,它代表当前事务在运行时的状态。它继承自SavepointManager,所以还具备管理保存点的能力。

它主要保存这几类信息:当前事务是否是新创建的、是否已经完成、是否只读、是否启用了保存点。这些信息在commit和rollback时会被事务管理器用来判断该怎么处理。

一个常见的误区是认为TransactionStatus可以跨线程传递。实际上它和线程绑定,因为Spring的事务上下文默认存在ThreadLocal里。你在一个线程里开启的事务,在另一个线程里是拿不到状态的,这也是为什么异步方法里的事务行为往往和预期不符。

5.2 保存点机制与NESTED的实现

保存点是理解NESTED传播行为的关键。当传播行为是NESTED时,事务管理器会在当前事务里创建一个保存点,并把这个保存点信息记录在TransactionStatus里。

如果嵌套的方法执行失败,回滚时会回滚到保存点,而不是整个事务。这样外层事务可以选择继续执行或者整体回滚。

// 外层事务 @Transactional public void outer() { // 一些操作 try { innerService.inner(); // NESTED传播 } catch (Exception e) { // 内层回滚到保存点,外层可以选择继续 } // 继续其他操作 }

需要注意的是,保存点的回滚能力依赖底层数据库支持。而且如果内层方法把异常吞掉了,外层事务管理器不会感知到内层失败,这时候保存点回滚是否发生就取决于内层是否主动调用了回滚。

5.3 在编程式事务中如何用好TransactionStatus

编程式事务里,TransactionStatus是你手动控制回滚的抓手:

transactionTemplate.execute(status -> { try { // 业务逻辑 return result; } catch (BusinessException e) { status.setRollbackOnly(); // 标记回滚 return null; } });

setRollbackOnly的作用是标记当前事务只能回滚,即使后续代码正常执行完,提交时也会变成回滚。这个机制在“捕获异常后不想立即抛出,但又需要回滚”的场景下非常实用。

还有一个细节:status.isNewTransaction()可以判断当前事务是不是新建的。在REQUIRED传播下,如果外层已经有事务,内层拿到的isNewTransaction就是false。这个判断在写一些通用组件时很有用,可以避免在已有事务里重复开启。

6. 声明式事务的生效链路:从注解到代理

6.1 @Transactional是怎么被解析的

@Transactional本身只是一个元数据注解,它不会自动生效。真正让它生效的是Spring的AOP基础设施加上TransactionInterceptor。

整个链路大致是这样的:Spring容器启动时,@EnableTransactionManagement(Spring Boot里由自动配置代劳)会注册一个InfrastructureAdvisorAutoProxyCreator,它会扫描所有带有@Transactional的bean,为它们创建代理。代理对象在方法调用时,会先经过TransactionInterceptor,由它读取注解配置、组装TransactionDefinition、调用事务管理器。

理解这条链路的意义在于:任何绕过代理的调用,事务都不会生效。这就是为什么“同类内部方法调用@Transactional方法不生效”——因为内部调用走的是this,不是代理对象。

6.2 代理方式的选择:JDK动态代理与CGLIB

Spring创建事务代理有两种方式:如果目标类实现了接口,默认用JDK动态代理;如果没有接口,用CGLIB生成子类代理。

这个选择会带来一些实际影响。JDK动态代理下,你注入的必须是接口类型,不能是具体实现类,否则会报类型转换错误。CGLIB代理下,目标类不能是final的,目标方法也不能是final或private的,否则无法增强。

Spring Boot从2.x开始默认使用CGLIB代理,所以大多数情况下你不需要太担心接口问题。但如果你在配置里显式设置了proxyTargetClass=false,就要注意注入类型了。

6.3 事务失效的几种典型场景

结合前面的链路分析,事务失效的场景可以归纳成几类,我按排查频率从高到低排列:

第一类是方法不是public。Spring的事务代理默认只增强public方法,protected、private、包级方法上的@Transactional会被忽略。

第二类是同类内部调用。前面说过,内部调用不走代理,注解形同虚设。解决办法是把方法拆到另一个bean里,或者注入自身代理。

第三类是异常被吞掉。如果方法内部try-catch了异常但没有重新抛出,事务管理器收不到异常信号,自然不会回滚。

第四类是异常类型不匹配。默认只对RuntimeException和Error回滚,受检异常需要显式配置rollbackFor。

第五类是事务管理器配置错误。多数据源场景下,如果@Transactional没有指定transactionManager,可能用错了管理器。

第六类是数据库引擎不支持事务。比如MySQL的MyISAM引擎就不支持事务,这种情况下注解再正确也没用。

7. 多数据源与嵌套调用下的事务行为实测

7.1 多数据源下的事务管理器绑定

多数据源场景是事务问题的高发区。假设你有两个数据源A和B,各自配了事务管理器。如果@Transactional不指定管理器,Spring会用默认的那个,操作B数据源的代码就可能在A的事务管理器下执行,导致事务不生效。

正确的做法是显式指定:

@Transactional(transactionManager = "dataSourceBTransactionManager") public void updateB() { // 操作数据源B }

更稳妥的方案是用自定义注解封装,比如定义@TransactionalB,在注解上固定transactionManager,避免每次手写字符串出错。

需要强调的是,这种方案下A和B的事务是独立的,不具备跨库原子性。如果业务要求两个库同时成功或同时失败,就需要引入分布式事务方案,这超出了本地事务管理器的能力范围。

7.2 REQUIRES_NEW与NESTED的实测差异

我做过一组对比测试,场景是外层方法插入一条主记录,内层方法插入一条明细,内层故意抛异常,观察不同传播行为下的结果。

传播行为外层结果内层结果说明
REQUIRED回滚回滚内层加入外层事务,一起回滚
REQUIRES_NEW回滚保留内层独立事务,已提交的不受影响
NESTED回滚回滚到保存点内层回滚,外层若继续则外层保留

这个测试结果解释了很多实际现象。比如你用REQUIRES_NEW记录操作日志,主业务回滚了,日志还在,这是符合预期的。但如果你用REQUIRED记日志,主业务回滚,日志也跟着没了,这往往不是你想要的效果。

7.3 异步方法里的事务陷阱

@Async和@Transactional一起用时,很容易出问题。因为@Async会把方法执行切换到另一个线程,而事务上下文是绑定线程的,新线程里没有事务上下文,@Transactional自然不生效。

如果你确实需要在异步方法里操作数据库,正确做法是在异步方法内部重新开启事务,而不是指望外层事务传递进来。同时要注意,异步方法里的事务和调用方的事务是完全独立的,不存在加入关系。

8. 几个我踩过的坑和对应的排查手法

8.1 自调用导致注解失效的定位过程

前面提到过同类内部调用的问题,我第一次遇到时排查了很久。当时的代码大概是这样:

@Service public class OrderService { public void createOrder() { this.saveOrder(); // 内部调用 } @Transactional public void saveOrder() { // 数据库操作 } }

现象是saveOrder里的操作没有事务保护。排查时我先确认了注解配置没问题,然后检查了方法是不是public,都正常。最后用断点调试发现,createOrder里调用saveOrder时,this指向的是原始对象而不是代理对象,所以根本没走事务拦截器。

解决办法有三种:把saveOrder拆到另一个bean里;注入自身代理@Autowired private OrderService self然后调self.saveOrder();或者用AopContext.currentProxy()。我一般推荐第一种,结构最清晰。

8.2 异常被catch后事务不回滚的修复

这个坑更隐蔽,因为代码看起来“处理了异常”,实际上把回滚信号也处理掉了:

@Transactional public void process() { try { // 数据库操作 } catch (Exception e) { log.error("处理失败", e); // 没有重新抛出 } }

这种情况下,事务管理器认为方法正常返回,于是执行提交。修复方式是在catch里重新抛出异常,或者调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。

我个人的习惯是:如果这个异常需要被上层感知,就重新抛出;如果只是记录日志不想中断流程,就手动标记回滚。两种方式都比“吞掉异常”要安全。

8.3 只读事务里执行写操作的意外结果

有一次在只读事务里执行了一个更新操作,结果没有报错,但数据也没更新。排查后发现是底层驱动对只读事务的处理方式导致的——它把连接设置成了只读模式,写操作被静默忽略了。

这件事给我的教训是:只读属性不是“安全锁”,而是一个优化提示。不要指望它来防止误写,该在代码层面控制的还是要控制。如果确实需要防止写操作,应该在业务逻辑或权限层面做校验。

9. 事务管理配置的取舍经验

9.1 注解属性该配多细

@Transactional支持很多属性,但实际项目里不需要每个方法都配得很细。我的经验是分层配置:

  • 全局默认:在配置类里通过TransactionInterceptor设置默认的传播行为、超时时间、回滚规则。
  • 类级别:在Service类上标注通用的配置,比如rollbackFor = Exception.class。
  • 方法级别:只对特殊方法覆盖,比如需要REQUIRES_NEW的日志方法。

这样配置的好处是,大部分方法不用写注解属性,只有例外情况才需要显式声明,代码更干净,也更容易发现“哪些地方是特殊的”。

9.2 事务粒度与性能的平衡

事务粒度太粗,会导致连接持有时间过长,并发能力下降。粒度太细,又可能破坏业务原子性。这个平衡点需要根据业务特点来找。

一个实用的判断标准是:事务里只放必须原子化的数据库操作,把远程调用、消息发送、文件IO这些耗时操作挪到事务外。比如“下单扣库存”这个场景,扣库存必须在事务里,但发送通知消息可以放到事务提交后异步做。

Spring提供了TransactionSynchronizationManager来注册事务回调,可以在事务提交后执行某些操作:

TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 事务提交后发送消息 } } );

这个机制在“数据库操作成功后才发消息”的场景下非常有用,能避免“消息发了但事务回滚了”的不一致问题。

9.3 监控与问题定位的辅助手段

事务问题往往在生产环境才暴露,所以提前做好监控很重要。我通常会在几个关键位置加日志:事务开启时记录传播行为和隔离级别,事务提交或回滚时记录结果和耗时。

Spring Boot Actuator提供了事务相关的端点,可以查看当前活跃事务的数量和状态。结合Micrometer还能把事务耗时做成指标,方便发现慢事务。

另外,数据库层面的事务日志也很有价值。比如MySQL的information_schema.innodb_trx表能查看当前运行的事务,排查锁等待和长事务时很有帮助。

10. 关于事务管理的一点个人体会

写了这么多,最后分享一个我自己的体会:事务管理的难点不在于记住那几个接口和注解属性,而在于理解“事务边界”这个概念。

很多人写代码时只关注“这个方法要不要加事务”,却忽略了“这个事务从哪里开始、到哪里结束、中间经过了哪些调用”。一旦调用链变长,事务边界就变得模糊,各种奇怪的现象就出来了。

我的建议是,在设计阶段就把事务边界画出来,明确哪些操作必须在同一个事务里,哪些可以独立。画清楚了再写代码,比写完再排查要省事得多。另外,遇到事务问题时,不要急着改注解属性,先用日志把事务的开启、提交、回滚点打出来,看清楚实际发生了什么,再对症下药。这套方法帮我解决过很多看似诡异的事务问题,希望对你也有用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进