Spring事务(Transaction)实战笔记:从注解到源码,把事务机制一次讲透
做Java后端这几年,Spring事务可能是被问得最多、踩坑最多、也是最容易被“会用但不懂”的一个知识点。很多人天天写@Transactional,但真要问一句“为什么这个注解能让方法回滚?”或者“为什么有时候事务明明没生效?”就卡壳了。这篇文章我想把自己在实际项目中积累的对Spring事务的理解完整梳理一遍,从数据库事务的基础讲起,到注解的正确用法、失效场景、分布式事务方案,再到源码层面的代理与三级缓存机制,一次性把这条线打通。不管你是刚入门的新手,还是写了几年业务代码的老手,这篇文章都值得花二十分钟读完。
先说明白Spring事务到底解决了什么问题。在没有Spring之前,我们操作数据库,要手动去拿Connection,手动setAutoCommit(false),然后业务逻辑执行完手动commit,出异常了手动rollback。如果业务方法里调了好几个DAO,每个DAO都自己开连接、自己提交,那业务逻辑中途挂了,前面的操作已经提交了,后面的操作没执行,数据就处于半更新状态,问题非常大。Spring事务的核心价值就是把“事务边界”从业务代码里剥离出来,通过AOP机制,在方法执行前开启事务、方法正常返回后提交事务、方法抛出异常后回滚事务,让开发者只需要关注业务逻辑本身。
这篇文章适合所有正在用Spring或Spring Boot做开发的工程师。我会尽量把每一个关键概念都讲清楚,包括传播行为、隔离级别、回滚规则这些容易混淆的配置,也会把自调用、异常被吞、非public方法这些常见的失效场景一一拆解,最后还会聊聊分布式事务的几种主流方案,并带大家看一下Spring事务在源码层面的实现原理。
1. 事务整体设计与Spring解决方案拆解
1.1 数据库事务的ACID到底在保证什么
在深入Spring事务之前,我觉得有必要先统一一下对事务本身的认知。事务是一组数据库操作的执行单元,这组操作要么全部成功,要么全部失败回滚。它的四个核心特性就是我们常说的ACID:
- 原子性(Atomicity):事务中的操作不可分割,要么全部执行成功,要么全部不执行。这条靠数据库的undo日志机制保证,回滚时利用undo log把数据恢复到事务开始前的状态。
- 一致性(Consistency):事务执行前后,数据总量和业务规则保持一致。比如A转账给B,A扣了100块,B就必须多100块,不能出现只有一边变化的情况。
- 隔离性(Isolation):多个事务并发执行时,彼此之间不能互相干扰。这条靠锁机制和MVCC实现。隔离性的强弱直接决定了并发场景下数据的准确程度。
- 持久性(Durability):事务一旦提交,数据变更就是永久的,即使系统宕机也不会丢失。这条靠redo日志保证,系统重启后可以通过redo log重放恢复已提交的事务数据。
这四条里,和开发者日常纠结最多的就是隔离性。不同的隔离级别会带来脏读、不可重复读、幻读这些并发问题,后面我会专门展开。
1.2 Spring事务要解决的三个核心痛点
没有Spring事务的年代,写业务代码的痛苦我现在都还记得。总结下来主要是三个痛点:
第一,事务代码与业务代码严重耦合。每个方法里都要重复地写connection.setAutoCommit(false)、try-catch、connection.commit()、connection.rollback()这一套模板代码。一个复杂的Service里如果有五六个事务方法,光这些样板代码就能占掉一半行数。
第二,多数据源情况下事务难以统一管理。一个业务方法里可能同时操作了订单库和库存库,如果两个库用的是不同的连接,必须借助JTA(Java Transaction API)这种分布式事务中间件才能保证一致性,配置极其复杂。
第三,事务边界模糊,容易出错。手动管理事务时,经常出现“忘记提交导致连接一直占用”“异常被catch住导致没有回滚”“事务嵌套时提交顺序混乱”等等问题。我当时在项目里遇到过最诡异的一个Bug,就是某个方法在finally块里调用了connection.close(),结果连接池把这个连接回收了,但事务状态并没有重置,下一个人从连接池拿到这个连接,执行了一堆操作之后莫名其妙地被回滚了。
Spring通过AOP统一解决了这些问题。它把事务管理抽象成PlatformTransactionManager这个接口,不同的数据源接入不同的实现类,事务的开启、提交、回滚完全由框架控制,业务代码里不再需要处理任何事务相关的底层API。
1.3 编程式事务与声明式事务的抉择
Spring事务有两种使用方式,这两种方式在实际项目中的适用场景差别很大,我分开说。
编程式事务,就是在代码里通过TransactionTemplate或PlatformTransactionManager手动控制事务。这里写一个典型的示例:
@Service public class OrderService { @Autowired private TransactionTemplate transactionTemplate; @Autowired private OrderMapper orderMapper; @Autowired private AccountMapper accountMapper; public void createOrder(OrderDTO orderDTO) { transactionTemplate.execute(status -> { try { // 1. 保存订单 orderMapper.insert(orderDTO); // 2. 扣减账户余额 accountMapper.deductBalance(orderDTO.getUserId(), orderDTO.getAmount()); // 3. 如果余额不足会抛出业务异常,事务自动回滚 return Boolean.TRUE; } catch (Exception e) { // 手动标记回滚 status.setRollbackOnly(); throw e; } }); } }编程式事务的优点是非常灵活,事务边界精确控制到代码块级别,特别适合在一个方法里只有部分逻辑需要事务保护、或者需要根据运行时条件动态决定是否开启事务的场景。缺点是代码侵入性强,每个需要事务的地方都得写这么一段模板逻辑,维护成本高。
声明式事务,就是基于AOP的@Transactional注解方式。这也是绝大多数项目中的主流用法,核心优势是事务逻辑完全和业务逻辑解耦,你只需要在方法或类上加上注解,框架自动帮你完成事务的开启和提交回滚。我们日常开发中,90%以上的事务需求用声明式事务就够了。
这里说句实在话,除非你的业务确实有极其特殊的事务边界需求,否则我强烈建议优先使用@Transactional。编程式事务虽然在笔试题里经常出现,但实际项目里用得非常少。
1.4 Spring事务管理器的核心抽象
PlatformTransactionManager是Spring事务的最顶层抽象,定义就三个方法:
public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }getTransaction负责根据事务定义开启事务(或者加入已有事务),commit提交,rollback回滚。不同的持久化框架有不同的实现类,最常见的几个:
DataSourceTransactionManager:基于java.sql.Connection的单数据源事务管理器,配合MyBatis、JDBC使用,这也是绝大多数单体应用用的实现。JpaTransactionManager:配合Spring Data JPA / Hibernate使用。JtaTransactionManager:基于JTA的分布式事务管理器,适合多数据源场景。
Spring Boot中,只要引入了spring-boot-starter-jdbc或spring-boot-starter-web,并且在application.yml里配置了数据源,DataSourceTransactionManager就会被自动配置。大多数情况下,我们并不需要手动配置事务管理器,Spring Boot的自动配置已经搞定了。
2. @Transactional注解的核心参数与配置要点
2.1 注解的属性你真的都理解了吗
@Transactional注解上有几个重要属性,每一个都可以说是“配置错了就出大问题”的级别,我逐个拆解。
value / transactionManager:指定使用哪个事务管理器。这个通常在项目里有多个数据源、配置了多个PlatformTransactionManager时才需要显式指定。单数据源项目里不需要管。
propagation:事务传播行为。这是Spring事务中最灵活、也最容易把人绕晕的一个属性,我会在2.2单独展开。
isolation:事务隔离级别。对应数据库的四种隔离级别,默认是Isolation.DEFAULT,即使用数据库默认的隔离级别。MySQL默认是可重复读(REPEATABLE_READ),Oracle和SQLServer默认是已提交读(READ_COMMITTED)。
timeout:事务超时时间,单位秒。如果事务执行时间超过该值,Spring会自动回滚事务。默认值TransactionDefinition.TIMEOUT_DEFAULT是-1,表示不超时。注意这个超时不是“方法执行超过N秒就回滚”,而是“事务内的SQL操作累计执行时间超过N秒就回滚”,而且它本质上依赖底层数据库的查询超时机制,有时候并不完全准确。
readOnly:是否只读事务。设置为true时,Spring会做一些优化,并且底层数据库连接也会被设置为只读模式,此时执行insert/update/delete会抛出异常。这个属性应该设置给那些只做查询的方法,作用是防止误操作,而不是提升性能。有个常见的误解认为readOnly=true能大幅提升查询性能,实际上在大多数数据库中,性能提升微乎其微,它主要是语义上的保护。
rollbackFor/rollbackForClassName:指定哪些异常需要回滚。默认情况下,只有RuntimeException和Error会触发回滚,受检异常(也就是checked exception)不会回滚。这个设计很多新手不理解,Spring这样做是因为受检异常通常代表“业务层面的可预期异常”,比如余额不足、库存不够,Spring认为这些情况业务代码本来就会处理,不需要强制回滚。
noRollbackFor/noRollbackForClassName:指定哪些异常不需要回滚。
我这里给一个最常用的完整配置示例:
@Transactional( propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ, timeout = 30, rollbackFor = Exception.class ) public void createOrder(OrderDTO dto) { // 业务逻辑 }2.2 七种事务传播行为,逐个说清楚
传播行为解决的核心问题是:当前方法被调用时,如果调用方已经存在一个事务,那么当前方法是加入这个事务?还是挂起当前事务、自己新建一个事务?七种传播行为对照着业务场景来看会好理解很多。
我用一个最经典的“下单+扣库存”场景来讲。假设有一个OrderService.createOrder()方法,它内部调用了StockService.deductStock()方法来扣减库存。
REQUIRED(默认值):如果当前存在事务,则加入这个事务;如果当前没有事务,则新建一个事务。这是最常用的一种。在“下单+扣库存”的场景中,下单和扣库存必须作为一个整体,要么都成功要么都失败,所以扣库存方法必须加入下单方法的事务。
REQUIRES_NEW:无论当前是否存在事务,都新建一个独立的新事务。如果当前已经存在事务,当前事务被挂起,直到新事务执行完毕后再恢复。这个用到的一个典型场景是操作日志记录。比如下单方法已经在事务里了,但你希望订单操作日志的写入不受业务事务回滚的影响——即使订单创建失败了,日志也要记录下来。如果把日志写入也放在同一事务里,那么订单回滚时日志也会跟着一起回滚,这就完全失去了审计的意义。
NESTED:如果当前存在事务,则创建一个嵌套事务。嵌套事务是当前事务的一个“子事务”,它有独立的保存点(savepoint)。如果嵌套事务回滚,不会影响外层事务;但如果外层事务最终回滚,嵌套事务的提交也一起回滚。这个特性在实际开发中用得不多,我印象里最典型的场景是“批量处理多条记录,每条记录独立校验,希望单条失败只回滚这一条”。
SUPPORTS:如果当前存在事务,则加入;如果当前没有事务,就以非事务方式执行。这个适合那些“有事务就一起,没事务也能跑”的辅助方法。
NOT_SUPPORTED:如果当前存在事务,则挂起当前事务,以非事务方式执行。适合某些内部包含大量查询、不想在长事务里执行的辅助方法。
MANDATORY:如果当前没有事务,直接抛出异常。要求调用方必须开启事务,适合那些脱离事务没有意义的方法。比如“余额扣减”方法,如果在一个非事务环境中被调用,扣减了一半突然报错,没有回滚机制就会产生数据错误,所以强制要求事务存在。
NEVER:如果当前存在事务,直接抛出异常。适合那些不允许在事务中执行的操作,比如某些外部接口调用,事务环境下的连接占用时间长了会影响性能。
我来写一个对比表,方便大家快速查阅:
| 传播行为 | 当前方法不存在事务 | 当前方法存在事务 | 典型场景 |
|---|---|---|---|
| REQUIRED | 新建事务 | 加入当前事务 | 默认选择,绝大多数业务方法 |
| REQUIRES_NEW | 新建事务 | 挂起当前事务,新建独立事务 | 操作日志、异步消息发送 |
| NESTED | 新建事务 | 创建嵌套事务(保存点) | 批量处理,单条失败仅回滚该条 |
| SUPPORTS | 非事务方式执行 | 加入当前事务 | 查询辅助方法 |
| NOT_SUPPORTED | 非事务方式执行 | 挂起当前事务,非事务执行 | 大查询、外部接口调用 |
| MANDATORY | 抛出异常 | 加入当前事务 | 必须依赖外部事务的方法 |
| NEVER | 非事务方式执行 | 抛出异常 | 不允许在事务中执行的逻辑 |
2.3 事务隔离级别的设置与常见并发问题
隔离级别解决的是并发事务之间的可见性问题。越低级别的隔离,并发性能越高,但数据正确性越差。我用表来总结:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 | 级别最低,几乎不用 |
| READ_COMMITTED | 不可能 | 可能 | 可能 | Oracle/SQLServer默认 |
| REPEATABLE_READ | 不可能 | 不可能 | 可能(MySQL InnoDB通过间隙锁解决了) | MySQL默认 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 完全串行,性能最差 |
这几个问题初学者容易混淆,我用人话解释一下。
脏读:事务A修改了一条数据但还没提交,事务B这个时候读到了这条修改后的数据。如果事务A后续回滚了,事务B就读到了一条“不存在”的数据。就好比你同事跟你说“这个需求甲方已经通过了”(其实他还没正式汇报),你按通过的状态开始干活了,结果第二天同事说“被甲方打回来了”,你昨天干的活全部白费。
不可重复读:事务A在同一个事务里第一次和第二次读取同一条数据,结果两次读到的值不一样。原因是事务B在中间修改并提交了这条数据。就好比你点了杯奶茶,第一次问店员“要多久”,店员说10分钟,过了5分钟再问,店员说还要20分钟——同一个问题,两次答案不一样。
幻读:事务A执行了一个范围查询,比如select * from orders where amount > 100,第一次查出来5条,事务B插入了2条同时满足条件的数据并提交,事务A再次执行同样的查询,发现变成了7条。这些多出来的记录就像幻影一样。MySQL的InnoDB存储引擎在可重复读级别下通过间隙锁机制解决了幻读问题,这点和其他数据库不同,面试时经常被问到。
实际项目中怎么选隔离级别?我的建议是:默认不动,用数据库的默认配置就行。MySQL默认的REPEATABLE_READ在InnoDB引擎下不会产生幻读,已经能满足绝大多数场景。如果你的项目用的Oracle和SQLServer,默认的READ_COMMITTED也基本够用。强行调高隔离级别到SERIALIZABLE意味着所有并发事务串行执行,性能下降严重,不是特别严格的数据一致性场景不需要这么做。
2.4 回滚规则的坑:为什么checkException不回滚
这一节我单独拎出来说,因为这个是新人最容易踩的坑,而且踩了还不自知。
@Transactional默认的回滚触发条件只有RuntimeException和Error。如果你在业务方法里抛出了一个自定义的受检异常(继承自Exception而非RuntimeException),Spring会认为这是业务预期内的异常,不会回滚事务。
我之前在一个支付项目中就出过这个事。当时有一个退款接口,代码大概是:
@Transactional public void refund(RefundDTO dto) throws RefundException { // 1. 更新退款单状态 refundMapper.updateStatus(dto.getRefundId(), RefundStatus.REFUNDING); // 2. 调用支付渠道退款接口 boolean success = payChannel.refund(dto); if (!success) { throw new RefundException("支付渠道退款失败"); } // 3. 更新退款单状态为已退款 refundMapper.updateStatus(dto.getRefundId(), RefundStatus.REFUNDED); }RefundException是我自定义的受检异常,继承自Exception。结果就是:支付渠道退款失败抛出RefundException后,事务没有回滚,第一步那个“退款中”的状态更新被提交了。后续这个退款单一直卡在“退款中”,需要人工介入修复。排查了半天才意识到是异常类型的问题。
正确的做法是在注解里显式指定回滚异常:
@Transactional(rollbackFor = Exception.class)或者把自定义异常改成继承RuntimeException。我个人建议统一用rollbackFor = Exception.class,这样不管什么异常都能回滚,最省心。不过要注意,如果你用的是声明式事务,rollbackFor只是指定了回滚的异常类型,真正的事务回滚还是依赖异常从被AOP代理的方法中传播出去。
3. 事务失效的经典场景与底层原理分析
3.1 自调用失效:this.method()为什么不行
这是Spring事务中最经典的一个失效场景,凡是面试问到“事务失效的场景”,基本第一个说的就是它。来看这段代码:
@Service public class OrderService { @Transactional public void createOrder() { System.out.println("createOrder"); this.deductStock(); // 自调用 } @Transactional public void deductStock() { System.out.println("deductStock"); // 扣减库存逻辑 } }从外部调用orderService.createOrder(),你可能会觉得createOrder()和deductStock()都有事务注解,应该都在事务保护内。但实际上,deductStock()的事务完全没有生效。
原因在于Spring的声明式事务是基于AOP动态代理实现的。在Spring容器中,真正注入到调用方的对象不是OrderService本身,而是它的一个代理对象(JDK动态代理或CGLIB代理)。外部调用者调用的是代理对象的方法,代理对象在方法执行前开启事务、方法执行后提交或回滚事务。
但内部调用时,this指向的是当前的目标对象,不是代理对象,所以this.deductStock()直接走的是目标对象的方法,绕过了代理逻辑,事务注解自然就失效了。
解决方式有几种。第一种最简单粗暴,把内部方法拆到另一个Service类中,通过注入的代理对象调用,例如注入一个StockService,调用stockService.deductStock()。第二种是在当前类里注入自身:
@Service public class OrderService { @Autowired private OrderService self; @Transactional public void createOrder() { self.deductStock(); } }注意Spring Boot 2.6版本之后默认不允许循环依赖了,如果项目里其他地方有循环依赖,用@Lazy注解可以绕开,或者直接用AopContext.currentProxy():
@Transactional public void createOrder() { OrderService proxy = (OrderService) AopContext.currentProxy(); proxy.deductStock(); }使用AopContext.currentProxy()需要先在启动类或配置类上加上@EnableAspectJAutoProxy(exposeProxy = true),否则会报“Cannot find current proxy”的异常。
3.2 非public方法导致的事务不生效
Spring的@Transactional注解只能应用于public方法上。如果注解加在private、protected或包内可见的方法上,事务不会生效,而且不会报错。
原理在于Spring AOP的代理机制,JDK动态代理要求目标方法实现某个接口,CGLIB代理虽然可以代理类,但它通过继承目标类并重写方法来实现,对于private方法无法重写,对于protected和包内可见的方法,在跨包的情况下也无法重写。Spring官方文档明确说,@Transactional应该只放在public方法上。
我在实际项目中还遇到过一种隐蔽的写法,就是在一个类内部有一个private方法加了@Transactional,然后public方法里调用这个private方法,问身边同事这个地方事务为什么不生效,对方都会答不上来。实际上,private方法上的注解在编译时就被Spring在解析元数据的过程中过滤掉了,根本不会生成事务拦截逻辑。
这里多说一句,Spring 6开始,@Transactional在非public方法上的表现有了一些变化,部分版本下不再只是“不生效”而是直接启动时校验失败,但这并不意味着可以随意在非public方法上用事务,最好的习惯还是严格保持public。
3.3 异常被捕获后,事务静悄悄失效
这个问题比自调用更隐蔽,因为它不报错、不改架构,只是行为不对。来看这个例子:
@Transactional public void batchProcess(List<Order> orders) { for (Order order : orders) { try { processOrder(order); } catch (Exception e) { log.error("处理订单{}失败", order.getId(), e); // 这里把异常吃了 } } }你的本意是“批量处理所有订单,单条失败不影响其他条”。但问题在于,事务边界是整个batchProcess方法,当processOrder(order)抛出异常后被catch住,异常不会继续往外抛,事务就不会回滚。那么前面已经执行成功的订单,以及当前这条失败前写了一半的数据,最终都会被提交。你得到的不是“部分成功”,而是“脏数据提交”。
解决方案有两种思路。如果希望单条失败统一回滚整个批次,那就别catch,让异常抛出去让事务整体回滚。如果希望单条失败不影响其他条,那需要把每个订单的处理逻辑单独设置事务,并且不让外层的事务接管,通常会用到REQUIRES_NEW传播行为,把处理单个订单的逻辑放到独立的Service方法中,声明为@Transactional(propagation = Propagation.REQUIRES_NEW),这样即使这一条事务回滚,外层事务(或非事务环境)不受影响。
这个实践在批处理、定时任务、消息消费场景中特别重要。我之前负责过一个对账系统,每天凌晨跑批,对账明细处理失败就catch住继续下一条,结果失败的那些记录既有部分字段更新了,又有部分关联记录写入了,排查起来非常痛苦。后来改成REQUIRES_NEW独立事务方案,每条对账记录一个独立事务,失败就回滚这一条,再通过日志和状态字段标记失败原因,整个数据链路一下就清晰了。
3.4 传播行为配置错误导致的事务边界混乱
传播行为的错误配置造成的问题往往要到压测或生产环境并发量上来之后才会暴露。我遇到过的一个典型例子是,某个AuditLogService.saveLog()方法上的事务传播行为被配置成了REQUIRES_NEW,但这个方法实际是在主业务方法内部被调用的,每次调用都会把当前事务挂起、开启新事务、提交新事务、恢复主事务。由于挂起和恢复涉及底层连接的占用和切换,在高并发场景下,数据库连接池被打满,大量请求阻塞在获取连接上。
这种问题不是事务本身失效,而是事务边界划分不合理。审计日志类的写入,要么让它在同一事务里(REQUIRED),要么根据业务需要单独成事务。REQUIRES_NEW不是不能用,但一定要明确:每次调用都会开启一个新事务、独立提交,这意味着它会占用额外的数据库连接,而且如果新事务执行时间长,会延长整体处理时间。
3.5 存储引擎不支持事务
这个失效场景最诡异,因为代码完全正确,但事务就是“没有声音”。如果在MySQL中使用了MyISAM存储引擎,那么@Transactional是没有任何效果的,因为MyISAM本身不支持事务,InnoDB才支持。
排查方法很简单,执行以下SQL查看表引擎:
SHOW TABLE STATUS FROM `your_database` WHERE Name = 'your_table';或者在创建表时指定:
CREATE TABLE `your_table` ( ... ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个坑在老旧系统迁移时最容易踩到。之前帮一个客户维护一套老系统,里面有一批表还是MyISAM,结果加了一堆事务注解,该回滚的还是没回滚,最后才发现是表引擎的问题。
3.6 多数据源事务配置错乱
如果项目里配置了多个数据源,比如主库和读库分离,那么@Transactional默认使用的DataSourceTransactionManager只会管理主数据源的事务,读写库的数据变更不受事务保护。如果业务逻辑里同时操作了主库和从库,而且主库事务回滚了,从库的写入已经提交,两个库的数据就不一致了。
解决这种问题,需要为不同数据源配置不同的事务管理器,并在使用事务的方法上显式指定:
@Transactional(transactionManager = "secondaryTransactionManager") public void writeToSecondary() { // 操作从库 }要么就引入JTA或Seata这类分布式事务方案来统一管理多数据源。需要注意的是,多数据源事务本身是一个分布式事务问题,单独靠@Transactional是解决不了的。
4. 分布式事务的现实困境与主流方案
4.1 为什么微服务架构下事务问题更复杂
单体应用时代,一个业务操作涉及的所有表都在同一个数据库里,用@Transactional就可以保证一致性。但微服务架构下,订单服务操作订单库,库存服务操作库存库,账户服务操作账户库,一个完整的业务链路跨越了多个服务、多个数据库,每个服务自己的本地事务都无法保证整体的一致性。
典型的例子就是下单流程。用户下单后,订单服务创建一条订单记录,库存服务扣减库存,账户服务扣减余额。如果库存扣减成功了,但余额扣减失败,订单服务、库存服务、账户服务各自提交了自己的事务,没有统一的回滚机制,整个系统就处于“订单已创建,库存已扣,但钱没扣到”的中间状态。
分布式事务要解决的核心问题就是跨服务、跨数据库的一致性。注意,这里说的“一致性”和数据库ACID里的一致性不一样,分布式事务领域通常强调的是“最终一致性”——在一段时间内允许数据不一致,但经过补偿机制后最终达到一致状态。
4.2 强一致性方案:2PC与3PC
2PC(两阶段提交)是最经典的强一致性方案,核心思想是引入一个协调者节点,把事务提交过程分为准备阶段和提交阶段。
准备阶段:协调者向所有参与者发送准备请求,各参与者执行事务操作但先不提交,把undo和redo日志写入磁盘,然后向协调者回复“可以提交”或“不能提交”。
提交阶段:如果所有参与者都回复“可以提交”,协调者广播“提交”指令,各参与者正式提交事务;如果任何一个参与者回复“不能提交”或超时未响应,协调者广播“回滚”指令,所有参与者回滚自己的本地事务。
2PC的优点是实现简单、强一致性;缺点也很明显,最核心的是同步阻塞问题:准备阶段所有参与者都在等待协调者的最终指令,期间数据库资源一直被锁定,高并发场景下性能非常差。另外还有一个协调者单点问题:如果协调者在提交阶段宕机了,所有参与者都会一直阻塞等待,无法决定是提交还是回滚。
3PC(三阶段提交)在2PC的基础上增加了一个“预提交阶段”,把准备阶段拆成了“CanCommit”和“PreCommit”两步,目的是尽量降低协调者单点故障对参与者的阻塞时间。但由于实现复杂,业界实际应用并不多。
2PC/3PC在实际业务中很少直接使用,通常由分布式数据库中间件或XA协议来承载。如果你用过Atomikos、Narayana这类JTA实现,底层就是基于2PC的。
4.3 柔性事务方案:TCC、本地消息表与MQ事务消息
由于2PC的阻塞问题,互联网大厂在实际落地时更多采用柔性事务方案,核心思想是“放弃强一致性,追求最终一致性”。
TCC(Try-Confirm-Cancel)是一种补偿型事务方案,需要业务方自己实现三个方法:
- Try阶段:完成资源预留。比如扣减库存时先冻结库存量,不实际扣减。
- Confirm阶段:确认执行。真正扣减冻结的库存,这一步如果失败会不断重试,直到成功。
- Cancel阶段:取消执行。释放Try阶段冻结的库存。
TCC的优点是控制粒度细、性能好,但实现成本很高,每个业务操作都要设计三套逻辑。目前主流的TCC框架有Seata、ByteTCC、tcc-transaction。
本地消息表的方案很有意思,思路是“事务操作和消息写入放在同一个本地事务中”。以订单创建为例:
- 订单服务在本地事务中同时完成两件事:创建订单、往本地消息表插入一条“创建订单”的消息。
- 本地事务提交后,消息服务会定时扫描消息表,把未发送的消息发送给消息队列。
- 库存服务消费消息,完成扣库存操作。
- 如果库存服务处理成功,反馈成功并删除消息;如果处理失败,消息一直存在,消息服务会一直重试。
- 为了避免无限重试,需要配套一个“对账系统”,对于重试多次失败的消息,触发告警并进入人工处理流程。
这个方案的优点是不依赖外部中间件、实现简单,缺点是消息表和数据表耦合在一起,会多一次数据库写入和定时扫描的操作,而且无法做到实时性。
MQ事务消息是目前比较推荐的方案,以RocketMQ的事务消息为例,核心是一个“两阶段”的半消息机制:
- 订单服务发送一条“半消息”给MQ,此时消息对消费者不可见。
- 订单服务执行本地事务(创建订单)。
- 本地事务执行成功后,向MQ发送“Commit”指令,消息才对消费者可见;如果本地事务失败,发送“Rollback”指令,MQ删除半消息。
- 如果MQ迟迟没有收到Commit/Rollback指令,会反向回查订单服务的事务执行状态,然后决定是提交还是回滚这条消息。
这个方案把最终一致性的通用逻辑下沉到了MQ中间件中,业务方的实现成本大幅度降低。如果你用了RabbitMQ,它没有原生的事务消息支持,需要手动设计一个“本地消息表+定时补偿”的方案。
4.4 Seata:目前生产环境最主流的分布式事务框架
Seata(Simple Extensible Autonomous Transaction Architecture)是阿里开源的一套分布式事务解决方案,目前已在生产环境有大量验证。它提供了三种模式:
AT模式是Seata最有特色的,对业务代码侵入最小。它的原理是在执行业务SQL之前,先解析SQL生成“前镜像”(数据修改前的快照),执行SQL后再生成“后镜像”(数据修改后的快照),这些镜像存放在Seata的undo_log表中。如果全局事务需要回滚,Seata根据前后镜像自动生成反向SQL来恢复数据。
AT模式的好处是业务方完全不需要感知分布式事务,只需要在业务方法上加@GlobalTransactional注解。但它的代价是性能损耗比较大,每个SQL语句都多了一次日志写入和解析操作。
TCC模式就是前面说的手动三阶段补偿,Seata只是提供了一个框架支持。
SAGA模式是长事务解决方案,适合业务流程特别长、包含多个服务调用的场景。它的核心思想是把一个大事务拆成多个本地事务,每两个本地事务之间有一个补偿操作。如果某个本地事务失败了,会反向依次调用之前所有事务的补偿操作来撤销。
从实践角度看,如果你的团队没有专门的基础设施团队,分布式事务的首选方案应该是MQ事务消息加本地消息表,这套方案足够简单、可控性强、容易排查问题。如果团队有能力和精力,Seata AT模式是体验最佳的,但一定要在压测环境充分验证性能。
5. Spring事务源码原理:代理机制与三级缓存
5.1 事务拦截器的工作链路
前面说了很多事务的使用和配置,现在进入源码层面,看看Spring到底是怎么把@Transactional变成一个代理逻辑的。
Spring事务的核心类是TransactionInterceptor,它实现了MethodInterceptor接口。当一个被@Transactional标注的方法被外部调用时,真正执行的顺序是这样的:
- 调用者拿到的是Spring容器中的代理对象。
- 代理对象调用
TransactionInterceptor的invoke()方法。 invoke()方法内部先根据@Transactional注解创建TransactionInfo,其中包含了事务管理器、事务属性等信息。- 调用被代理的目标方法。
- 目标方法正常返回后,调用事务管理器的
commit()提交事务。 - 目标方法抛出异常时,根据
rollbackFor配置判断是否需要回滚,如果需要则调用rollback()。
简化后的invoke方法核心逻辑大概是:
public Object invoke(MethodInvocation invocation) throws Throwable { TransactionInfo txInfo = createTransactionIfNecessary( (PlatformTransactionManager) transactionManager, txAttr, joinpointIdentification ); Object retVal; try { // 调用目标方法 retVal = invocation.proceed(); } catch (Throwable ex) { // 根据异常类型决定是否回滚 completeTransactionAfterThrowing(txInfo, ex); throw ex; } finally { cleanupTransactionInfo(txInfo); } // 方法正常返回,提交事务 commitTransactionAfterReturning(txInfo); return retVal; }从这段伪代码可以非常清楚地看到,为什么异常被catch住后事务不会回滚——因为异常根本没有传播到TransactionInterceptor的catch块中。整个事务链路是依赖于“异常不被吞掉”这个前提的。
5.2 Spring三级缓存与循环依赖下的代理创建时机
热词里提到了“Spring三级缓存原理”,这个和事务失效有非常微妙的关系。Spring解决循环依赖靠的是三级缓存:
- 第一级缓存(singletonObjects):存放完整的、已经初始化完成的单例Bean。
- 第二级缓存(earlySingletonObjects):存放被提前暴露出来的、尚未完全初始化完成的Bean。
- 第三级缓存(singletonFactories):存放Bean的工厂对象,用于生成早期引用。
循环依赖的解决流程大致是:A依赖B,B依赖A,Spring创建A时,发现A还没初始化完,就把A的ObjectFactory放入三级缓存,然后去创建B。B创建时依赖A,从三级缓存中获取A的早期引用,完成B的初始化,B回到A的创建流程中,A完成后,B和A都被放入了单例缓存。
这里的问题在于:如果A的最终完成版本应该是一个代理对象(比如A的方法上加了@Transactional),那么B持有的A的早期引用必须是代理,否则事务失效。Spring对这个问题的处理方式是:在三级缓存的ObjectFactory中,通过AbstractAutoProxyCreator的getEarlyBeanReference()方法提前创建代理对象。也就是说,如果Bean需要被代理(有@Transactional等AOP注解),那么它的早期引用就已经是代理对象了。
如果循环依赖产生了早期引用,且此时代理还没创建,后续正式初始化时会发现已经提前暴露了引用,那么Bean后续可能不会被再次代理。Spring通过wrapEarlyBeanReference标志来尽量保证一致性,但实际开发中最好还是避免循环依赖,因为它会导致很多隐蔽的代理问题。
5.3 手写一个简化版事务代理来理解原理
为了更直观地理解Spring事务的底层机制,我建议你可以动手写一个简化版的事务代理工具。核心思路是用Java动态代理,在方法执行前开启事务,正常返回提交,异常抛出则回滚。
public class TransactionProxyFactory { public static Object createProxy(Object target, DataSource dataSource) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) -> { Connection connection = dataSource.getConnection(); boolean originalAutoCommit = connection.getAutoCommit(); try { connection.setAutoCommit(false); Object result = method.invoke(target, args); connection.commit(); return result; } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(originalAutoCommit); connection.close(); } } ); } }这个例子当然远不如Spring的实现完整,但它展示了一个最核心的思想:事务管理的本质就是在目标方法的外面包一层“代理壳”,通过方法执行结果的异常与否来决定是提交还是回滚。Spring事务的巨大价值在于:把这层“壳”做成了通用的、可配置的、与业务代码解耦的框架,你只用加一个注解就能获得它。
6. 常见问题排查与调试技巧
6.1 如何确认当前方法是否真的在事务中
排查Spring事务相关问题,第一步永远是确认“事务到底有没有开启”。我分享几个实用的确认方法。
最直接的方法是在关键位置打印当前连接的事务状态。在MyBatis的Mapper接口方法中注入SqlSessionFactory,通过SqlSession拿到底层连接,检查连接的getAutoCommit()值:
@Autowired private SqlSessionFactory sqlSessionFactory; public boolean isInTransaction() { try (SqlSession session = sqlSessionFactory.openSession()) { Connection connection = session.getConnection(); return !connection.getAutoCommit(); } catch (Exception e) { log.error("检查事务状态失败", e); return false; } }还有一个小技巧,在application.yml中开启MyBatis的SQL日志,观察日志中是否出现Setting autocommit to false on JDBC Connection这条记录。Spring事务管理器在开启事务时会调用connection.setAutoCommit(false),如果日志里有这行,说明事务已经开启:
logging: level: com.example.mapper: debug如果你用的是Spring Boot 3.x和MyBatis,可以配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl6.2 SQLServer事务日志查看与诊断
热词里提到了“sqlserver事务日志查看”,这里补充说明一下。在SQLServer中,如果遇到事务无法提交、日志暴涨、回滚卡死这类问题,可以靠几条SQL快速定位。
查看当前活跃事务和会话信息:
SELECT s.session_id, s.status, s.login_name, t.transaction_id, t.name AS transaction_name, t.transaction_begin_time, DB_NAME(t.database_id) AS database_name FROM sys.dm_tran_active_transactions t INNER JOIN sys.dm_tran_session_transactions st ON t.transaction_id = st.transaction_id INNER JOIN sys.dm_exec_sessions s ON st.session_id = s.session_id WHERE t.database_id = DB_ID('your_database');查看事务对应的SQL语句和阻塞情况:
SELECT t.transaction_begin_time, t.name, s.status, es.text AS sql_text FROM sys.dm_tran_active_transactions t LEFT JOIN sys.dm_tran_session_transactions st ON t.transaction_id = st.transaction_id LEFT JOIN sys.dm_exec_sessions s ON st.session_id = s.session_id OUTER APPLY sys.dm_exec_sql_text(s.sql_handle) es WHERE t.database_id = DB_ID('your_database');如果发现事务长时间不结束,大概率是代码里的事务没有正确提交或回滚,比如占用连接但事务没关闭、循环体内开启事务但某个提前return绕过了提交逻辑、又或者是锁等待导致事务悬挂。这类问题在生产环境非常致命,会造成连接池耗尽。
6.3 通过SqlSession显式提交事务的场景
大多数时候我们依赖Spring声明式事务就够了,但有些渗透在框架底层的数据操作,比如用SqlSessionTemplate直接操作时,也需要手动控制事务边界。
@Autowired private SqlSessionTemplate sqlSessionTemplate; public void manualTx() { sqlSessionTemplate.getSqlSessionFactory().openSession(ExecutorType.BATCH, false); // 业务逻辑 sqlSessionTemplate.getSqlSession().commit(); // 显式提交 // sqlSessionTemplate.getSqlSession().rollback(); // 显式回滚 }不过在实际项目中,我更推荐直接在Service方法上使用@Transactional来管理事务,而非在SqlSession层面手动控制,因为前者会统一纳入Spring的事务管理体系中,连接资源、提交回滚时机都由框架协调,不容易出现连接泄漏的问题。
6.4 高频面试题速查表
最后整理一份Spring事务相关的高频面试题及答案,方便大家面试前快速过一遍:
| 面试题 | 核心要点 |
|---|---|
| Spring事务的传播行为有哪些 | REQUIRED、REQUIRES_NEW、NESTED、SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER |
| 事务失效的常见场景 | 自调用、非public方法、异常被捕获、rollbackFor未设置、数据库引擎不支持、多数据源未指定事务管理器 |
| 为什么注解默认不处理受检异常 | 受检异常代表业务预期内的异常,需要业务方自行决定是否回滚 |
| Spring事务底层原理 | 基于AOP动态代理,通过TransactionInterceptor拦截方法,方法执行前开启事务,异常抛出时决定回滚,正常返回时提交 |
| 循环依赖与事务失效的关系 | 循环依赖时提前暴露的Bean可能是早期引用,AOP代理可能无法正确创建,因此事务可能失效 |
| ReadOnly为什么不能提升性能 | 它主要是语义上的保护,实际性能提升非常有限 |
| MySQL默认隔离级别 | REPEATABLE_READ,InnoDB下靠间隙锁解决了幻读问题 |
| 分布式事务有哪些方案 | 2PC、3PC、TCC、本地消息表、MQ事务消息、SAGA,Seata框架实现 |
回到最初的问题:为什么@Transactional能帮我们管理事务?因为Spring把事务的开启、提交、回滚封装成了AOP拦截逻辑,你只需要在方法上声明注解,剩下的交给代理对象。理解了这一点,事务失效的每一类场景都有了解释:自调用绕过了代理,异常被吞没传到拦截器,非public方法无法被代理,数据库本身不支持事务。这些并不是玄学,而是代理机制、异常传播机制和底层存储引擎的必然结果。
我个人在实际项目中的体会是,Spring事务本身不难,难的是“判断当前方法是否真的处在一个预期的事务边界内”。很多线上事故的根源,不是开发不知道事务的用法,而是复杂调用链下,某个旁路方法的事务边界和主流程的事务边界不一致。所以我一直建议团队里定两条规则:第一,事务注解统一加rollbackFor = Exception.class;第二,涉及事务的Service方法之间用对象引用调用,不要用this直接调私有方法。这两条规则简单,但真的能挡掉大部分麻烦。
最后再分享一个排障小技巧:当你怀疑事务没生效时,不要盯着代码猜,先开SQL日志,看Setting autocommit to false on JDBC Connection这行日志有没有出现。有,说明事务已经开启,问题出在回滚条件或传播行为上;没有,说明事务压根没启动,直接从代理失效的方向排查。定位问题的思路对了,一个复杂的事故往往能在十分钟内找出根因。