做后端开发这些年,Spring事务相关的坑我踩过不少,也帮团队排查过不少。最典型的一次是:一个订单接口明明加了@Transactional,结果库存扣减抛异常后,订单记录照样落库。代码反复看了好几遍都没发现问题,最后把代理调用链和事务同步器的行为完整捋了一遍才定位到根因。那次之后我就萌生了一个想法,把Spring事务的核心原理彻底梳理清楚,写成一篇既能当自己复盘笔记、又能直接给团队做培训的硬核文章。
Spring事务,本质上是对底层数据库事务能力的封装和增强,开发者不需要再手写setAutoCommit(false)、commit()、rollback()那套重复代码。但它远不止"简化写法"这么简单,背后涉及PlatformTransactionManager抽象、动态代理、事务同步器、传播行为、隔离级别、异常回滚策略等一系列复杂机制。这篇文章适合所有用Spring Boot做业务开发的同事,也适合准备面试、想从头把Spring事务搞明白的朋友。我会从最底层的JDBC事务说起,一直拆到Spring声明式事务的完整调用链,最后再分享几个实战中非常容易踩的坑。
1. 先把事务的本质讲透:从JDBC到Spring的封装逻辑
1.1 数据库事务到底在做什么
要理解Spring事务,先得搞清楚数据库事务本身是怎么回事。一次事务就是一组数据库操作的逻辑单元,这组操作要么全部成功,要么全部失败。经典的ACID四个特性——原子性、一致性、隔离性、持久性——是数据库层面承诺的,但其中隔离性是可以通过事务隔离级别调节的,原子性和持久性则依赖于数据库的日志机制。
很多人写业务代码的时候,把事务当成了"只要方法上标注解就能回滚"的开关。这个理解太简单了。事务真正的作用,是保证一组跨表、跨行操作的中间状态不被外部看到,并且任一步出错时能让数据回滚到操作前的状态。举个例子,转账操作需要先扣减A账户余额,再增加B账户余额,这两个更新必须在一个事务里完成,否则第一步成功、第二步失败时,钱就凭空消失了。
1.2 JDBC原生事务的体验与痛点
在没有Spring的日子里,Java开发者操作事务是这么写的:
Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 执行多个SQL操作 PreparedStatement ps1 = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE id = ?"); ps1.setBigDecimal(1, money); ps1.setLong(2, fromAccountId); ps1.executeUpdate(); PreparedStatement ps2 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE id = ?"); ps2.setBigDecimal(1, money); ps2.setLong(2, toAccountId); ps2.executeUpdate(); conn.commit(); } catch (Exception e) { if (conn != null) { conn.rollback(); } throw e; } finally { if (conn != null) { conn.close(); } }这段代码看着不复杂,但一旦放到真实业务里,问题立刻暴露:每个业务方法都要写一遍try-catch-finally,代码重复率极高;如果业务里还需要操作多个数据源,事务管理就会变得更加混乱;再叠加连接池、线程绑定等诉求,手写这套东西基本不可维护。Spring要解决的核心痛点,就是把这个重复的样板代码抽离出来,让开发者用声明式的方式就能获得事务能力。
2. Spring事务的抽象内核:PlatformTransactionManager与事务同步器
2.1 三大核心接口的设计思想
Spring事务抽象的核心是三个接口:PlatformTransactionManager、TransactionDefinition、TransactionStatus。理解这三个接口,基本就理解了Spring事务的设计骨架。
PlatformTransactionManager是事务管理器,它定义了三个方法:
public interface PlatformTransactionManager extends TransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }getTransaction负责根据配置获取一个事务(或者复用已有事务);commit负责提交;rollback负责回滚。实现类有很多,最常用的是管理单数据源的DataSourceTransactionManager,此外还有管理JPA的JpaTransactionManager、管理分布式事务的JtaTransactionManager。每种实现对应不同的底层事务模型,但上层代码感知不到这种差异,这正是抽象的魅力。
TransactionDefinition定义了事务的元信息:隔离级别、传播行为、超时时间、是否只读、事务名称。TransactionStatus则代表一次具体事务的运行状态,它持有当前事务的资源、是否是新事务、是否已完成等信息,是事务管理器操作具体事务的把手。
2.2 TransactionSynchronizationManager:事务资源的线程绑定
事务要真正作用于数据库连接,核心在于数据库连接和当前线程的绑定。Spring通过TransactionSynchronizationManager这个内部组件,把数据库连接(以及其他事务资源)保存到ThreadLocal里,确保同一个线程在事务期间拿到的都是同一个连接。
这个设计的直接效果是:@Transactional方法内部调用的DAO、Mapper操作,不用显式传递Connection,就能共享同一个数据库连接,进而共享同一个数据库事务。TransactionSynchronizationManager还维护了一个"事务同步回调"集合,允许业务代码在事务提交前、提交后、回滚后等时机注册回调逻辑,这是实现某些高级功能(比如事务提交后再发消息)的基础。
2.3 DataSourceTransactionManager的工作流程
以DataSourceTransactionManager为例,getTransaction方法执行时会做这些事:
- 从
TransactionSynchronizationManager中获取当前线程绑定的数据库连接资源。 - 如果当前没有绑定连接,就从数据源中拿一个新的连接,设置
autoCommit=false,把连接绑定到当前线程,并标记newTransaction=true。 - 如果当前已经绑定了连接,则根据传播行为决定是加入现有事务,还是挂起旧事务、开启新事务。
事务执行完毕后,commit方法会调用Connection.commit(),rollback方法会调用Connection.rollback(),最后把连接从ThreadLocal中清理掉并归还连接池。这个过程中,Spring还做了异常边界处理和资源释放的兜底,避免事务异常导致连接泄漏。
3. 声明式事务运行机制:@Transactional背后的代理链条
3.1 动态代理与事务拦截器的协作
@Transactional之所以能够生效,核心依赖Spring AOP。Spring容器在启动时,会为标注了@Transactional的Bean生成代理对象。调用方拿到的其实不是原始对象,而是代理对象;代理对象在调用真实方法之前,会先经过一系列拦截器链路。这里面最重要的就是TransactionInterceptor。
TransactionInterceptor继承了TransactionAspectSupport,它的核心逻辑在invoke方法里。它遵循一个大致的处理模板:根据方法上的事务配置调用getTransaction获得事务状态,然后执行被代理的业务方法,业务方法正常返回时提交事务,抛出异常时根据规则决定是否回滚。
public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { Class<?> targetClass = AopUtils.getTargetClass(invocation.getThis()); return invokeWithinTransaction(invocation.getMethod(), targetClass, new CoroutinesInvocationCallback() { // 执行业务方法 }); } }3.2 完整调用链拆解
假设我们有一个OrderService.createOrder()方法,标注了@Transactional。一次完整的调用过程是这样的:
- 外部调用
orderService.createOrder(dto),实际上调用的是Spring生成的代理对象的方法。 - 代理对象经过
TransactionInterceptor,拦截器解析@Transactional注解的配置信息。 - 调用
TransactionManager.getTransaction(definition),此刻才会真正决定是开启新事务,还是加入已有事务。 - 执行业务方法,这个过程中所有的数据库操作都通过当前线程绑定的Connection生效。
- 如果方法正常返回,拦截器调用
transactionManager.commit(status)提交事务。 - 如果方法抛出异常,拦截器判断该异常是否需要回滚,需要则调用
rollback,不需要则照样提交。
这个调用链设计的关键点在于:事务是围绕方法调用边界展开的,而不是围绕单个SQL展开的。这也意味着,同一个方法里多个DAO操作天然处于同一个事务中。
3.3 @EnableTransactionManagement做了什么
在Spring Boot项目中,事务默认是开启的,因为TransactionAutoConfiguration会自动配置好事务管理器。但在传统Spring项目中,需要手动使用@EnableTransactionManagement注解来激活事务管理功能。
@EnableTransactionManagement的核心作用是向容器中注册一个BeanFactoryTransactionAttributeSourceAdvisor,这个Advisor会扫描所有Bean的公共方法上是否有@Transactional注解,如果有,就为这个Bean生成事务代理。它还会引入TransactionInterceptor到Spring容器中,负责实际的事务拦截逻辑。
4. 传播行为原理精讲:七种传播级别背后的机制
4.1 REQUIRED与REQUIRES_NEW的本质区别
REQUIRED是Spring默认的传播行为,也是绝大多数业务场景的最佳选择。它的语义是:如果当前线程已经存在事务,就加入这个事务;如果不存在,就新建一个事务。
REQUIRES_NEW则不同,它永远开启一个新事务。如果当前已经存在事务,当前事务会被挂起(suspend),新事务执行完后再恢复旧事务。
这两者的本质区别,体现在数据库连接的使用方式上。REQUIRED传播时,所有方法共享同一个Connection,属于同一个数据库事务,任何一个操作导致回滚,整个事务都会回滚。REQUIRES_NEW传播时,内层方法使用的是另一个独立的Connection,对应独立的数据库事务,内层事务提交或回滚不会影响外层事务的最终状态。
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder() { orderDao.insert(); userService.updateBalance(); // 加入同一个事务 logService.saveLog(); // REQUIRES_NEW,独立事务 } }注意,当使用REQUIRES_NEW时,如果内层事务提交了,即使外层事务后续回滚,内层已经提交的数据也不会被回滚。这在"操作日志必须保存成功,但业务失败时日志不清理"的场景下是有用的,但也可能造成数据不一致,使用时要想清楚。
4.2 七种传播方式速览
| 传播级别 | 当前无事务时 | 当前有事务时 | 适用场景 |
|---|---|---|---|
| REQUIRED | 新建事务 | 加入当前事务 | 绝大多数业务方法 |
| SUPPORTS | 不创建事务 | 加入当前事务 | 查询方法,有事务则参与 |
| MANDATORY | 抛异常 | 加入当前事务 | 强制要求调用方开启事务 |
| REQUIRES_NEW | 新建事务 | 挂起旧事务,新建事务 | 独立日志记录、异步操作 |
| NOT_SUPPORTED | 不创建事务 | 挂起旧事务,非事务方式运行 | 特殊查询,避免锁竞争 |
| NEVER | 不创建事务 | 抛异常 | 明确不允许事务参与 |
| NESTED | 新建事务 | 创建嵌套事务(保存点) | 局部回滚场景 |
NESTED是一个容易被混淆的传播级别。它和REQUIRES_NEW不同,NESTED是在当前事务内部建立一个保存点(Savepoint),内层代码回滚时只回滚到保存点位置,不影响外层事务的其他操作。这依赖于底层数据库对Savepoint的支持,例如MySQL的InnoDB引擎是支持的,但某些数据库可能不支持。
4.3 传播行为选择的实战建议
我个人在实战中的经验是:默认全用REQUIRED,除非有强烈的隔离诉求。一个比较典型的场景是"主业务+审计日志"。主业务失败时,审计日志可能也一起回滚掉了,但如果你想保留审计记录,可以考虑REQUIRES_NEW。然而更稳妥的做法是:不要用同一个事务来处理强一致性和最终一致性混杂的需求,把日志发送放到事务提交后的TransactionSynchronizationManager.registerSynchronization回调里,或者直接用消息队列异步处理。
5. 隔离级别、回滚规则与经典失效场景
5.1 隔离级别如何映射到数据库标准
@Transactional的isolation属性用于设置事务的隔离级别。TransactionDefinition定义了五个常量:ISOLATION_DEFAULT、ISOLATION_READ_UNCOMMITTED、ISOLATION_READ_COMMITTED、ISOLATION_REPEATABLE_READ、ISOLATION_SERIALIZABLE。
每个隔离级别解决的问题不同:
- 读未提交:可能读到其他事务未提交的数据,存在脏读。
- 读已提交:不会脏读,但存在不可重复读,即同一事务内两次读取结果可能不同。
- 可重复读:同一事务内多次读取结果一致,但存在幻读风险。
- 串行化:所有事务串行执行,性能最低但最安全。
MySQL默认的隔离级别是REPEATABLE_READ,而很多互联网公司实际把业务数据库的隔离级别调成了READ_COMMITTED,以便获得更高的并发性能,同时减少间隙锁导致的死锁问题。Spring的ISOLATION_DEFAULT会在事务开启时,使用数据库默认的隔离级别。
5.2 rollbackFor:回滚规则的一个大坑
@Transactional注解默认的回滚规则是:只有运行时异常(RuntimeException)和错误(Error)才触发回滚,受检异常(Exception的子类)不会触发回滚。这是Spring的设计选择,因为受检异常通常表示业务预期内的异常,比如"余额不足"、"商品下架"等,这些情况不应该导致整个事务回滚。
很多人在代码里抛出Exception,发现事务没有回滚,就是这个原因。解决办法是在注解上显式声明回滚规则:
@Transactional(rollbackFor = Exception.class) public void createOrder() throws Exception { // 业务逻辑 }或者按需指定回滚和noRollbackFor:
@Transactional(rollbackFor = BusinessException.class, noRollbackFor = NotEnoughStockException.class) public void createOrder() { // 业务逻辑 }5.3 自调用事务失效问题
这是所有Spring事务问题中最高频的一个。场景如下:
@Service public class OrderService { @Transactional public void outerMethod() { // 业务代码 this.innerMethod(); // 自调用 } @Transactional(propagation = Propagation.REQUIRES_NEW) public void innerMethod() { // 内部逻辑 } }这段代码的问题在于:this.innerMethod()调用的是当前对象的原始方法,没有经过Spring代理对象,因此innerMethod()上的事务注解完全不起作用。这本质上是Spring AOP的代理机制决定的——只有通过代理对象调用方法时,拦截器链才会生效。
解决办法有几种:把innerMethod()移到另一个独立的Bean中,注入后再调用;或者在当前类中注入ApplicationContext,通过容器获取代理对象来调用;还可以使用AopContext.currentProxy(),但这需要设置@EnableAspectJAutoProxy(exposeProxy = true)。
6. 提交与回滚的完整链路:结合源码级别的执行顺序
6.1 正常提交的执行顺序
当一个@Transactional方法正常返回时,TransactionInterceptor会调用commitTransactionAfterReturning。这里有一个容易忽略的细节:Spring在真正提交数据库事务之前,会先触发TransactionSynchronization的beforeCommit和beforeCompletion回调;提交之后,再触发afterCommit和afterCompletion回调。
这个顺序在业务上非常有用。比如你想在事务提交成功后再发一条消息,就应该注册afterCommit回调。如果在业务方法里直接发消息,事务还没提交,消息消费者可能读取不到最新数据。
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { // 事务提交成功后,再发送消息 messageSender.send(event); } });6.2 异常回滚的执行流程
当事务方法抛出标记为回滚的异常时,Spring会执行如下流程:
completeTransactionAfterThrowing捕获异常。- 判断异常是否匹配回滚规则,不匹配则正常提交。
- 匹配则调用
rollback,此时会触发beforeCompletion回调。 - 执行
Connection.rollback(),真正回滚数据库。 - 清理
TransactionSynchronizationManager中绑定的资源,触发afterCompletion回调,最后把数据库连接归还连接池。
这里还有一个"只读事务"的细节。@Transactional(readOnly = true)并不会强制数据库执行只读操作,它的主要作用是给底层事务管理器一个优化提示。例如Hibernate会在只读模式下跳过一级缓存的脏检查,从而减少刷新操作;在MySQL层面,JDBC驱动的readOnly提示也可能影响连接的设置,但并不是严格禁止写入。
6.3 事务失败标记对提交的影响
TransactionStatus内部有一个rollbackOnly标记。当内层方法标记了整个事务为rollbackOnly(例如在REQUIRED传播下的内层方法抛出了需要回滚的异常),即使外层方法把这个异常捕获了,事务最终依然会回滚。这也是一个典型的隐蔽问题:外层方法用try-catch捕获了内层异常后,以为自己处理了,但事务已经被标记为rollbackOnly,等到外层方法执行完,Spring提交时发现rollbackOnly标记,直接回滚。
这种"静默回滚"在日志不明显时非常难以排查,尤其是配合REQUIRED传播的时候。理解这个机制后,排查事务回滚问题时就能更快定位到具体是哪个内层调用标记了回滚。
7. 实战经验:事务问题的排查思路与工具技巧
7.1 一眼识别"事务没生效"的排查清单
面对一个事务失效问题,我通常按这个顺序排查:
- 方法是否是public的?Spring事务代理默认只拦截public方法,private和protected方法上标注
@Transactional不会生效。 - 调用是否经过了代理对象?自调用、同类内部调用都会绕过代理。
- 事务管理器是否配置正确?数据源和事务管理器是否指向同一个
DataSource? - 方法是否被final修饰?
@Transactional依赖CGLIB动态代理,final方法无法被重写增强。 - 数据库表引擎是否支持事务?MyISAM引擎就不支持事务,即使代码层面完全正确也会失效。
7.2 利用日志与断点验证事务状态
排查事务问题时,建议开启Spring事务日志,在application.yml中配置:
logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG开启日志后,你会看到类似Getting transaction for OrderService.createOrder和Completing transaction for OrderService.createOrder的输出。通过这些日志,可以快速判断一个方法是否真正进入了事务拦截器,以及最终是提交还是回滚。
7.3 关于分布式事务的个人提醒
很多团队在做微服务拆分后,开始纠结订单与库存的分布式事务该怎么实现。我的个人体会是:Spring事务解决的是"单体应用内、单数据源"的事务问题,它无法直接跨越多个微服务或者多个数据库实现全局事务。虽然存在JtaTransactionManager、Seata、TCC等方案,但它们都带来了复杂的协调成本和性能损耗。
所以在实际规划时,优先考虑能否通过业务设计避免跨服务事务,例如将库存操作与订单写入放在同一个服务、同一个数据库内;实在无法避免时,再考虑可靠消息最终一致性的方案。不要把Spring事务当作分布式场景下的银弹,这一点想清楚了,能帮你少走很多弯路。
最后再说一个技巧:如果你在排查一个诡异的事务回滚问题,不要只盯着@Transactional注解。先确认数据库连接是否真的被绑定到了当前线程,再看异常类型是否匹配rollback规则,最后检查是否有人把rollbackOnly标记设置上了。按照"连接是否复用、事务是否标记、异常是否匹配"这三个维度去排查,大多数事务问题都能在半小时内定位。这里面的很多坑,都是不深入源码就无法理解的,希望这篇把原理讲透的文章,能帮你少踩几个坑。