☰
Spring事务从原理到实践:传播行为、失效场景与隔离级别详解
2026/10/14 5:39:48 网站建设 项目流程

平时写Spring业务代码,@Transactional可能是你用得最顺手的注解之一,但也是翻车概率最高的一个。我见过很多工作了五六年的同事,面试时能把ACID背得滚瓜烂熟,可一旦遇到线上某个方法悄悄不回滚、某个嵌套调用莫名把整条链路都锁死,还是要排查半天。这篇内容,就是想结合我多年的实际踩坑经历,把Spring事务从原理到实践彻底讲透,适合刚接触事务的初级开发,也适合想系统梳理事务知识的进阶读者。

事务不是什么高深的东西,说白了就是一组操作要么全部成功、要么全部失败的保障机制。但Spring在这个基础上加了自己的抽象和代理逻辑,于是就有了一堆“看起来正常、跑起来诡异”的问题。这篇文章会先讲清楚Spring怎么实现事务,再逐个拆解注解里的关键参数、七种传播行为、四种隔离级别,最后专门列一节最常见的失效场景和排查思路,帮你以后遇到问题时能直接照着定位。

1. 事务管理这件事,Spring到底替你做了什么

1.1 先回答一个“为什么”

在没有Spring的时候,Java里操作数据库事务是很原始的。拿到一个Connection,先setAutoCommit(false),中间执行一堆SQL,全部成功就commit(),任何一个地方抛异常就rollback(),最后还得在finally里把连接关掉。这套模板代码本身不难,难的是嵌套场景。比如一个方法里调了另一个方法,两个方法各自开了事务,内层回滚会不会把外层也带崩?事务边界到底在哪里?这种问题靠手工管理连接,写起来非常痛苦,而且极容易漏掉某个分支没回滚。

Spring事务要解决的就是这个问题。它把“开启事务、提交、回滚”这些琐碎操作全部封装起来,让你在业务方法上标注一个注解或配置一段AOP切面,就能声明“这个方法需要事务”。你不需要关心底层连接怎么处理,只需要知道:方法正常返回就提交,方法抛出指定异常就回滚,其他逻辑全由Spring接管。

Spring管理事务的核心是PlatformTransactionManager这个抽象接口。它有三个关键方法:getTransaction获取事务状态、commit提交、rollback回滚。针对不同的数据源技术,Spring提供了不同的实现:操作JDBC用DataSourceTransactionManager,操作JPA用JpaTransactionManager,操作Hibernate用HibernateTransactionManager。日常Web项目里最常用的就是DataSourceTransactionManager,它把DataSource里拿到的连接和事务绑定在一起,很多所谓的“事务失效”问题,根源就是某个数据源没有配上对应的事务管理器。

1.2 事务的本质:AOP代理和线程绑定

Spring事务不是靠魔法实现的,底层就是AOP。当你给一个Bean方法加上@Transactional后,Spring会判断这个Bean是否需要被代理,然后生成一个代理对象。调用时先进入事务拦截器,由拦截器决定是开新事务、加入已有事务还是挂起事务。很多人以为事务是在被调用的方法“内部”生效的,实际上它是在代理层生效的。这一点特别关键,因为后续所有关于事务失效的坑,几乎都能在“代理”这两个字上找到线索。

事务与线程的关系也很值得注意。Spring把事务相关的连接资源放到了ThreadLocal里,也就是说每个线程有自己独立的事务上下文。这样设计的好处是,同一线程内多个数据库操作可以共享同一个连接,从而保证它们在同一事务里。但这也会带来一个限制:如果事务方法里新开了一个线程去执行数据库操作,新线程拿不到父线程绑定的连接,自然也就不在同一个事务里。这个我们后面在失效场景里还会再展开。

2. 声明式事务的正确打开方式

2.1 注解参数逐个拆解

声明式事务最常见的写法是在Service方法上加@Transactional。注解本身有几个重要参数,很多人几年都只用了默认值,遇到问题才回头一个一个查文档。

propagation是传播行为,默认REQUIRED,表示当前没有事务就新建一个,有了就加入。isolation是隔离级别,默认DEFAULT,跟着数据库走。timeout是超时时间,默认-1表示不超时,单位是秒。readOnly是只读标志,默认false,对查询方法设置为true可以提示底层进行优化。rollbackFor指定什么异常触发回滚,默认情况下只有RuntimeException和Error会回滚,受检异常(比如IOException)不会。

这里有个我最想强调的坑:很多人以为@Transactional声明了“所有异常都回滚”,其实不是。默认策略是运行时异常才回滚。如果你在方法里catch住了一个异常,没有往外抛,Spring根本感知不到异常发生,自然也不回滚。如果你抛的是自定义受检异常,又没有指定rollbackFor,事务照样提交。这个坑造成的后果往往是数据写入了但业务其实是失败的,排查起来还特别隐蔽。

2.2 事务管理器配置与常见框架搭配

用Spring Boot的时候,事务几乎是零配置的,因为自动配置会帮你创建DataSourceTransactionManager。但如果你用的是传统Spring MVC项目,要自己配置,下面这段是标准配置:

@Configuration @EnableTransactionManagement public class TransactionConfig { @Bean public DataSource dataSource() { // 配置数据源 return new HikariDataSource(); } @Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

只要容器里存在PlatformTransactionManager的Bean,@Transactional就会生效。多个数据源的时候需要指定transactionManager属性,比如@Transactional(transactionManager = "orderTransactionManager"),否则Spring不知道该用哪个事务管理器,很可能就抛异常或是不声不响地不生效。

还有一个实践细节:事务管理器的setNestedTransactionAllowed属性,默认是true,允许嵌套事务时使用Savepoint机制。如果某些特殊场景把它关掉了,NESTED传播行为就会退化。多数情况下不用动它,但排查异常时知道有这个东西能少走弯路。

3. 事务传播机制:七种行为的内在逻辑和应用场景

3.1 先理解“传播”到底在传播什么

传播行为听起来玄乎,其实就是在问一个问题:当A方法调B方法,A已经开了事务,B的@Transactional该怎么处理?是直接加入A的事务,还是挂起A的事务开一个新的,或者干脆报错?这就是传播行为要回答的。

理解传播行为的关键在于“事务边界”这个词。方法调用链越长,每个方法各自的事务边界怎么划分,直接影响锁的范围和回滚范围。比如下单操作里,减库存和扣余额如果放在同一个事务,任何一个失败都全部回滚;但如果减库存失败不影响扣余额,就需要把扣余额放在另一个事务里,用REQUIRES_NEW把事务边界隔开。

3.2 七种传播行为逐个拆解

传播行为当前无事务时的行为当前有事务时的行为典型场景
REQUIRED新建事务加入当前事务默认值,90%的常规业务用它
SUPPORTS非事务方式执行加入当前事务查询方法,有事务就参与,没有也能跑
MANDATORY抛异常加入当前事务要求调用方必须开启事务
REQUIRES_NEW新建事务挂起当前事务,新建独立事务日志记录、审计,不希望受主事务回滚影响
NOT_SUPPORTED非事务方式执行挂起当前事务,以非事务执行某些特殊操作,比如大文件导入,不想占用数据库连接
NEVER非事务方式执行抛异常明确不允许在事务中执行的操作
NESTED新建事务创建Savepoint嵌套事务部分回滚场景,比如批处理中跳过某条记录

REQUIRED和REQUIRES_NEW是最容易混淆的两个。用一个生活例子来说明:你出门办事(外层事务),中途接了个电话需要记笔记(内层事务)。REQUIRED的意思是,你本来就在出门,接电话就是顺手记在同一个本子上,后面这趟行程失败了,笔记也一起作废。REQUIRES_NEW则是,接电话时专门掏出另一个本子记,即使行程取消了,那个笔记依然保留。

NESTED很多人容易和REQUIRES_NEW搞混。NESTED不新建独立事务,而是利用数据库的Savepoint做嵌套,它仍然属于外层事务一部分,外层回滚它会跟着回滚,但内层回滚不影响外层已提交的部分。REQUIRES_NEW则完全独立,内层提交了就是提交了,外层回滚也管不到它。MySQL的InnoDB是支持Savepoint的,所以NESTED在MySQL上表现正常,但有些数据库不支持,这一点要提前确认。

3.3 实际项目里怎么选

传播行为的选择有两条朴素的准则。一是默认用REQUIRED,不要瞎换,因为大多数业务操作本来就该在同一个事务边界内完成,拆出多个事务只会增加不一致的风险。二是只有“确实需要独立提交或部分回滚”时才用REQUIRES_NEW或NESTED,比如记录操作日志、发送消息等场景,日志失败了不能影响业务数据的主事务。

有一个我在生产环境踩过的坑:某个统计服务在事务里调用了外部接口,外部接口超时导致整个统计事务回滚。后来改成REQUIRES_NEW把外部调用独立出去,但发现外部调用内部还有数据库操作,结果又出现外部接口部分数据已提交、主事务数据没提交的情况。最后方案是外部调用前先把需要的数据查好,真正的外部调用放到事务方法外面去做,根本不需要REQUIRES_NEW。这说明传播行为只是工具,代码结构才是治本的关键。

4. 事务失效:十有八九是这几个原因

4.1 自调用导致事务失效,当代第一大坑

这是我在各种项目里遇到频率最高的问题。同一个类里,A方法没加@Transactional,B方法加了,A内部调用了B,结果B的事务完全不生效。原因其实从第一节就能推导出来:事务要靠Spring生成的代理对象才有效,你调this.b()时,this是原始对象,不经过代理,事务拦截器根本没机会执行。

举个例子:

@Service public class OrderService { public void handleOrder() { // 自己的业务逻辑 this.updateStatus(); // 自调用,事务不生效 } @Transactional public void updateStatus() { // 更新订单状态 } }

修复方式有三种。第一是拆开,把updateStatus移到另一个Service里,注入后通过代理对象调用。第二是注入自身代理,比如@Autowired或构造器注入OrderService,然后调用self.updateStatus()。第三是用AopContext.currentProxy()获取当前代理对象。三种方案里我最推荐第一种,不仅解决事务问题,代码结构也更清晰,避免出现循环依赖的隐患。

4.2 异常被吞、异常类型不对、多线程、private方法

事务失效的常见场景可以列一张速查表:

失效场景原因解决方案
方法内用try-catch捕获异常且不抛出事务拦截器看不到异常,无法回滚捕获后重新抛出,或改用TransactionTemplate
抛受检异常但未指定rollbackFor默认只回滚RuntimeException和Error@Transactional(rollbackFor = Exception.class)
private方法加注解Spring默认不代理private方法方法改成public,或拆到外部
final方法加注解CGLIB无法代理final方法去掉final修饰
新开线程执行数据库操作事务资源存在ThreadLocal,子线程拿不到把子线程操作移到事务外或独立事务
同类自调用不经过代理对象拆分、注入自身或AopContext
数据库表不支持事务比如MyISAM引擎换InnoDB引擎
没配事务管理器@Transactional无法生效配置PlatformTransactionManager

private方法这个坑也很经典。你给一个private方法加了@Transactional(rollbackFor = Exception.class),Spring不会报任何错,也不会给你任何提示,就是静悄悄不生效。因为CGLIB生成的子类无法覆写private方法,JDK动态代理更是只能代理接口方法,所以规则就是:@Transactional只对public方法有意义。

4.3 快速定位事务是否生效的三个办法

排查事务失效,我有一套自己的固定流程。

第一个办法是检查当前拿到的Bean是不是代理对象。在代码里临时加一行打印:System.out.println(orderService.getClass())。如果class名里有EnhancerBySpringCGLIB或JdkDynamicAopProxy字样,说明是代理,事务注解理论上会生效。如果打印出来就是普通类,那说明这个Bean根本没有被代理,问题出在AOP配置上。

第二个办法是看日志。开启动态代理和事务日志之后,每次调用事务方法时控制台会输出类似Creating new transaction with name或Participating in existing transaction的日志。通过日志能直观看到方法到底有没有创建事务,有没有加入已有事务。

第三个办法是在数据库层面看状态。事务执行过程中,去数据库查information_schema.innodb_trx,能看到当前活跃的事务记录。如果方法执行期间有事务记录,说明事务开了;如果方法执行完了但没有commit的记录,说明事务中途可能被异常打断后回滚了。

5. 隔离级别与锁:并发场景下的事务思考

5.1 四种隔离级别能防住什么

事务隔离级别解决的是并发读写时的一致性冲突。三个经典问题是脏读、不可重复读、幻读。脏读是读到别人还没提交的数据,不可重复读是同一个事务里两次读同一条数据结果不一样,幻读是同一个事务里两次查询返回的记录条数不一样。

四种隔离级别从低到高分别是读未提交、读已提交、可重复读、串行化。读未提交完全不管这三个问题,性能最好但风险最大。读已提交解决了脏读,但不可重复读和幻读依然存在。可重复读解决了脏读和不可重复读,但理论上幻读问题依然存在。串行化把事务强制排队,三个问题全部解决,代价是并发性能骤降。

Spring里通过@Transactional(isolation = Isolation.READ_COMMITTED)这样的方式来指定隔离级别,Isolation.DEFAULT则是用数据库自带的默认级别。MySQL InnoDB默认是可重复读,Oracle等很多数据库默认是读已提交。

5.2 MySQL默认级别下的真实情况

MySQL的InnoDB在可重复读级别下,其实通过MVCC和next-key lock把幻读的大部分场景也解决了,所以不能说MySQL的可重复读就一定会有幻读。对于普通快照读,比如普通的SELECT,MVCC让事务看到的是自己开始时的一致性快照,不会出现幻读。但对于当前读,比如SELECT ... FOR UPDATE、UPDATE、DELETE这些语句,InnoDB用间隙锁和Next-Key锁来阻止其他事务插入间隙,从而避免幻读。

这里就引出一个在事务里非常需要注意的点:事务里加了锁,并发能力就会下降。比如在事务里执行SELECT ... FOR UPDATE锁定一行,另一个事务想改这行就必须等前一个事务提交。如果前一个事务里还有外部调用、远程请求这些耗时操作,锁的持有时间会很长,线上很容易出现堆积。

5.3 实际项目中如何选择

实际项目的选择逻辑很朴素。查询多的系统优先考虑读已提交,因为可重复读会额外增加锁和MVCC负担。需要保证账目一致性的系统,比如电商的下单扣库存,用可重复读比较稳妥。串行化一般只在极少数对一致性要求极高、并发量又极低的场景用,日常业务基本见不到。

我再补充一个实践细节:readOnly属性很多人以为只是给数据库一个“只读”提示,其实它还有实际作用。在Spring里设置了readOnly=true后,事务管理器会做优化,对某些数据库会把事务设置为只读模式,底层连接禁止写操作,提前帮你发现意外的更新语句。当然,这只是一种防御手段,不能依赖它阻止所有写入。

6. 事务模板TransactionTemplate:编程式事务的急救包

6.1 什么时候该用编程式事务

声明式事务适合绝大多数场景,但有几类情况我强烈建议用编程式事务。

第一类是同一个方法里要多次控制事务边界。比如循环处理一万条数据,每条数据独立提交,出了错只回滚当前这一条。这种场景用@Transactional整包在一个事务里,十条出错会导致后面几千条都回滚,性能差还容易锁表。第二类是事务里需要根据某个条件在中间决定是提交还是回滚,代码里用TransactionTemplate直接控制要比注解灵活得多。第三类是要在一个非事务方法里部分代码开启事务执行,比如定时任务处理数据时,单独把某一步包在事务里。

6.2 TransactionTemplate的具体用法

TransactionTemplate本质上就是用代码控制事务边界。先注入PlatformTransactionManager,然后在需要的代码位置调用execute方法,整个lambda表达式的执行过程就是一个事务。看个例子:

@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void processOrders(List<Order> orders) { for (Order order : orders) { try { transactionTemplate.executeWithoutResult(status -> { // 执行单条更新操作 updateOrder(order); // 根据条件主动回滚 if (order.getAmount() < 0) { status.setRollbackOnly(); } }); } catch (Exception e) { log.error("处理订单失败, id={}", order.getId(), e); // 捕获后继续处理下一条 } } } }

这个模式下,每条订单的更新都在独立事务里,一条失败不会影响其他条。需要注意的是,execute方法返回的是方法内最后一步的结果,方便你从事务里带数据出来,比如插入后返回自增ID。这样写比@Transactional长一点,但逻辑边界清楚,尤其在复杂批处理里非常好用。

我还有一个小技巧:TransactionTemplate也可以设置setPropagationBehavior、setIsolationLevel这些属性,所以它不仅能做编程式事务,还能做到单次执行的传播行为控制。有些场景需要“在现有事务里,单独开一个小事务执行”,直接用TransactionTemplate配合PROPAGATION_REQUIRES_NEW就能实现,不用为了这个单独拆Service。

7. 多数据源与分布式事务的边界

7.1 一个事务管理器只能管一个数据源

Spring的本地事务是通过事务管理器绑定一个数据源实现的,这意味着一个事务管理器管不了两个数据库。如果你的业务同时操作了orderDataSource和userDataSource,事务管理器只能保证其中一个数据源的事务一致性,另一个要么单独开事务,要么靠分布式事务方案。

多数据源场景下有一个常见的错误配置:多个数据源共用一个DataSourceTransactionManager,Spring根本分不清当前操作走哪个数据源,事务套错地方,结果就是看起来代码都写了,实际回滚时只回滚了一个库。多数据源时要给每个数据源单独配置事务管理器,并在@Transactional注解上用transactionManager属性指定。

7.2 分布式事务的几种常见思路

涉及到多个微服务或多个数据库的一致性,本地事务就解决不了了。业界方案比较成熟的有两阶段提交、可靠消息最终一致性、TCC、以及基于Seata这类框架的全局事务。两阶段提交强一致但有性能瓶颈,可靠消息最终一致性是大多数订单、支付类场景的默认选择,因为它不锁资源,靠消息表加定时任务把事务对账做成最终一致。

我对分布式事务的建议是:能不用就不用了。很多业务根本不需要全局强一致,把一个大事务拆成多个小事务,中间通过状态机、对账任务、消息补偿来兜底,反而更稳。硬上分布式事务框架,部署复杂度、运维成本、排障难度都会直线上升。

8. 几个务实建议和配置清单

8.1 日常开发中我给自己定的几个规矩

我给自己定了三条规矩,分享出来供参考。第一条,事务方法体尽量短小,里面不要放远程调用、消息发送、文件上传这类耗时操作。理由很简单:事务没提交,数据库连接和可能的行锁都还占着,你等外部服务返回一秒钟,线上可能就多积累几十个等待锁的请求。实在要调外部,先把事务提交,再处理外部调用,配合消息队列或者本地消息表解决数据一致问题。

第二条,所有事务方法都要显式指定rollbackFor,至少写成@Transactional(rollbackFor = Exception.class)。这样能规避受检异常不回滚的坑,也方便后来接手的人一眼就知道你的回滚策略。

第三条,涉及更新操作时尽量用@Transactional,查询时不要滥用。只读方法上如果加了事务,也会占用连接、参与快照读,白白浪费资源。查询简单直接返回就好,确实需要保证多表查询一致性时再加。

8.2 一个可复用的基础配置模板

最后给一个我在项目初期搭建时常用的配置模板,基本可以覆盖多数单体应用:

# application.yml spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000
@Configuration @EnableTransactionManagement public class DatabaseConfig { @Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

数据库连接池大小和事务的关系也是一门学问。连接池不是越大越好,因为数据库并发处理能力有限,连接数过多反而容易造成资源争抢。我之前遇到过连接池从20改成200后事务等待时间反而变长的案例,后来把连接数降回50,配合合理的超时设置,整体吞吐才恢复正常。连接池参数一定要结合压测结果调整,不要盲目抄网上给的“标准配置”。

8.3 最后说一点个人体会

我做项目这么多年,踩过的事务方面的坑确实不少,从最早的自调用失效,到后来的锁等待超时,再到跨库数据对不上,每一个问题最后都能归结到“事务边界没有设计清楚”这一点上。事务是把双刃剑,用好了保证数据安全,用不好就成了性能和一致性的黑洞。

我的建议是,接手新项目或者写新模块的时候,先把调用链路梳理一遍,识别清楚哪些步骤必须在同一个事务里、哪些可以独立、哪些需要异步补偿,再去选择传播行为和隔离级别。先把代码结构理顺,比在注解上反复调参重要得多。事务不是万能的,但它能让你的数据少出问题,前提是你要真正理解它背后的那套代理和连接管理逻辑,而不是只会在方法上写一个@Transactional。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询