做Java后端开发的,Spring事务可以说是绕不开的一道坎。平时加个@Transactional注解好像很轻松,但真到了线上出问题,事务不生效、回滚没触发、死锁频繁,这些坑能让你排查到怀疑人生。我自己就踩过大坑:某转账服务里一个方法内部调用了同类中另一个被事务修饰的方法,结果自调用绕过了代理,整个事务静默失效,一次批量操作跑到一半数据全部错乱,最后花了一整个下午才定位到根因。那次之后我把Spring事务的原理和所有失效场景系统过了一遍,这篇就把整块内容完整拆开讲。不管你是刚入行的新手,还是已经写过几年业务的开发者,这里应该都有值得看的东西。
1. 为什么需要事务:从一次转账事故说起
1.1 没有事务时,数据是怎么悄悄变乱的
先想想最简单也最经典的场景:转账。A账户要给B账户转100块,在数据库里对应的就是两条UPDATE语句:
UPDATE account SET balance = balance - 100 WHERE id = 'A'; UPDATE account SET balance = balance + 100 WHERE id = 'B';如果第一条执行成功、第二条执行失败,账面上A少了100,B却没多出这100,钱凭空消失了。没有事务的情况下,这种数据不一致就是常态。有了事务,这两条操作就被绑定在同一个"原子操作"里:要么全部成功,要么全部失败。失败时数据库会回滚到执行前的状态,A账户的钱原封不动。
这听起来很基础,但很多人在实际写代码时恰恰会把这件事忽略掉。比如有些同事习惯在Service方法里一条条地操作数据库中间表,某个步骤失败就抛个异常,但前面已经UPDATE或者INSERT的数据并不会自动撤销——因为Spring不会因为你抛了个异常就神奇地帮你回滚,除非你正确地使用了事务。
从我接触过的项目来看,"事务没生效"很多时候根本不是事务框架的问题,而是代码从一开始就没把事务边界设计对。边界设置得太宽,无关操作全被裹进事务,性能变差;边界设置得太窄,真正该绑定的操作被拆成了两次独立提交,数据照样会乱。理解事务的第一步,是想清楚哪些操作必须绑定在一起。
1.2 ACID四个特性,到底保护了什么
事务的四大特性大家应该都背过:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),合起来就是ACID。这里不照着教科书念定义,而是用实际场景说清楚每个特性在保护什么。
原子性保护的是"要么全做,要么全不做";一致性保护的是"业务规则不被破坏",比如余额不能为负数、总账必须守恒;隔离性保护的是"多个事务同时跑时互不干扰",避免读到脏数据;持久性保护的是"事务一旦提交,数据就永久落盘",不会因为进程崩溃丢数据。
从开发者的角度看,Spring事务帮我们解决的核心是原子性和隔离性这两块的编排问题。持久性是数据库底层存储引擎负责的,一致性很大程度要靠业务代码自己保证,比如转账金额不能为负的校验逻辑。理解这一点很重要:很多人以为加了@Transactional就万事大吉,其实事务只能保证"操作要么全成功要么全失败"以及"并发隔离",你的业务校验逻辑该写还是得写,该加唯一约束还是得加,代码里的bug不会因为事务而消失。
我见过一个典型的反面案例:某新人以为有了事务就不再需要SQL层面的唯一索引,结果并发下单时同一商品被卖了两次。事务保证的是两条SQL要么都成功要么都失败,但两个并发事务各自"成功"并不会互相感知,唯一约束和锁才是防住这种问题的关键。事务不是万能药,它只是数据一致性拼图里的一块。
2. 编程式事务与声明式事务,到底怎么选
Spring事务管理提供了两种风格:编程式事务和声明式事务。很多人一上来就直接用@Transactional,但有时候编程式才是更合适的解法,有必要把两者的差异讲清楚。
2.1 编程式事务:TransactionTemplate的正确用法
编程式事务的核心是TransactionTemplate,这个类把事务管理的模板方法封装好了,你只需要在回调里写业务逻辑:
@Service public class TransferService { private final TransactionTemplate transactionTemplate; public TransferService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void transfer(String from, String to, BigDecimal amount) { transactionTemplate.execute(status -> { accountDao.decreaseBalance(from, amount); accountDao.increaseBalance(to, amount); return null; }); } }TransactionTemplate最有价值的地方在于可以精确控制事务边界。事务从execute方法开始,到回调执行完提交或抛出异常回滚,边界非常明确。你可以在一个循环里对每一批数据分别开启事务,也可以只把真正需要事务的两行代码包进去,而不是把整个方法都裹在事务里。
我在处理批量任务时经常遇到一种情况:一个方法要处理一万条数据,总不能一万条包在一个大事务里跑吧?要么频繁提交导致性能差,要么一个大事务占着数据库连接和锁,其他请求全被卡住。这时候用TransactionTemplate就特别灵活,每处理一条单独提交一次,或者每100条提交一次,完全由自己掌握。
2.2 声明式事务:@Transactional背后的代理原理
声明式事务就是最常见到的@Transactional注解。Spring通过AOP在目标方法执行前开启事务,方法正常返回时提交事务,方法抛出异常时回滚事务。开发者不需要关心事务开启和关闭的代码,只需要在方法或类上标注注解即可。
@Service public class OrderService { @Transactional public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); } }声明式事务的最大优点是侵入性低、代码简洁,业务代码里看不到任何事务管理的痕迹,可读性比编程式好太多。日常业务开发中,90%以上的场景用@Transactional就够了,这也是它成为主流选择的原因。
但理解声明式事务必须要理解它背后的代理机制。Spring容器启动时会为带有@Transactional的Bean生成一个代理对象,外部调用这个Bean的方法时,实际调的是代理对象的方法。代理对象先开启事务,再调用你写的真实方法,最后根据结果提交或回滚。这个机制解释了后面要讲的一系列失效场景:为什么私有方法不行、为什么自调用不行、为什么final方法不行——因为它们都无法让代理对象介入。
Spring的代理有两种实现方式。JDK动态代理要求目标类实现接口,只代理接口中声明的方法;CGLIB是通过生成目标类的子类来实现代理,不要求实现接口。Spring Boot 2.x之后默认使用CGLIB方式。理解了代理方式,你就明白了为什么被Spring管理的类如果用了final,事务就可能失效——CGLIB生成的子类无法覆盖final方法。
2.3 一张表看懂两种方式的适用边界
我把两者的差异整理成一张表,方便选型时对照:
| 维度 | 编程式事务 | 声明式事务 |
|---|---|---|
| 使用方式 | TransactionTemplate.execute() | @Transactional注解 |
| 事务边界 | 精确可控 | 方法级,颗粒度较粗 |
| 代码侵入 | 业务代码包在回调里,有一定侵入 | 无侵入,加注解即可 |
| 动态控制 | 可在逻辑中动态决定提交点 | 行为固定 |
| 适用场景 | 批量任务、循环内事务、复杂边界 | 常规单体接口、简单业务方法 |
这里有一个容易被忽略的点:@Transactional的事务边界是整个方法。如果方法里有循环、远程调用、文件IO,这些耗时的内容全都会被包在同一个事务里。而编程式事务可以把边界精确缩到最小,这正是大事务优化时的一个重要手段。所以别把@Transactional当成唯一的选择,该用TransactionTemplate的时候就要果断用。
3. 事务核心参数详解:传播、隔离、回滚
@Transactional注解有一堆属性,很多人从来没用过,但遇到并发和嵌套场景时,这些属性才是解决问题的关键。
3.1 传播行为:嵌套调用时的事务组织方式
事务传播行为解决的核心问题是:两个被@Transactional修饰的方法互相调用时,它们的事务该怎么组织。Spring在Propagation枚举里定义了7种行为,我挑最常用的三种重点讲。
REQUIRED是默认值,也是最常用的。规则是:如果当前已经有事务,就加入当前事务;如果没有,就新建一个事务。简单说就是"有就蹭,没有就开"。大部分业务场景下想要的就是这个效果:外层方法和内层方法在同一个事务里,任何一个环节出错,整个一起回滚。
REQUIRES_NEW的含义是"无论如何都开一个新事务"。即使外层已经有一个事务在运行,内层方法也会挂起外层事务、开启一个新事务,新事务独立提交或回滚。这个行为适合什么场景?比如下单成功后要写操作日志,日志写失败了不应该导致下单回滚,这时候就可以把写日志的方法标记为REQUIRES_NEW,让日志提交独立于主事务之外。
NESTED是嵌套事务,它利用数据库的savepoint(保存点)机制,在当前事务内部创建一个子事务。子事务回滚时只会回滚到保存点,而不影响外层事务的其余操作。效果有点像"部分回滚":外层事务里A操作成功、B操作失败只回滚B,A的结果可以保留,最终整个事务再决定是否提交。不过要注意,NESTED依赖数据库的savepoint支持,并不是所有数据库和所有隔离级别下行为都一致。
其他几个行为一句话带过:SUPPORTS是有事务就加入、没有就无所谓;MANDATORY是必须要有事务,没有就抛异常;NOT_SUPPORTED是当前方法以非事务方式执行,有事务就先挂起;NEVER是当前方法必须不在事务里执行,有事务就抛异常。这几个用得少,知道含义就行。
3.2 隔离级别:并发读写下的可见性选择
隔离级别解决的是"多个事务同时读写同一条数据时,能看到什么、不能看到什么"的问题。SQL标准定义了四个级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 |
| READ_COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE_READ | 不会 | 不会 | 可能 |
| SERIALIZABLE | 不会 | 不会 | 不会 |
先解释三个概念。脏读是读到另一个事务未提交的数据,别人回滚了你就读了个假的;不可重复读是同一个事务里两次读同一行,结果不一样,因为中间被别的事务改了;幻读是同一个事务里两次查询同一个范围,结果行数不一样,因为中间被别的事务插入或删除了行。
Spring的@Transactional里有一个isolation属性可以指定隔离级别:
@Transactional(isolation = Isolation.REPEATABLE_READ) public void someMethod() { // ... }但有一个关键点很多人都不知道:Spring默认的隔离级别是DEFAULT,也就是不主动设置,完全由底层数据库决定。比如MySQL默认是REPEATABLE_READ,Oracle默认是READ_COMMITTED。这意味着你在MySQL上写的代码如果假设了某种隔离级别,换到Oracle上行为就可能不一样。所以线上要追求严格的行为一致性,最好显式指定isolation属性。
从实际开发角度,我建议除非有明确需求,否则不要轻易上调隔离级别。SERIALIZABLE虽然最安全,但性能代价非常大,事务基本串行执行,并发量一大就是灾难。真正该做的是:通过业务设计减少锁冲突,通过索引减少锁范围,通过乐观锁(版本号)替代数据库悲观锁。举个例子,库存扣减如果每次都锁行,高并发下必然排队,改成"UPDATE stock SET count = count - ? WHERE id = ? AND count >= ?"这种条件更新,配合受影响行数判断,往往比单纯抬高隔离级别更高效。
3.3 回滚规则与readOnly、timeout等属性
@Transactional最容易被误解的地方就是回滚规则。默认情况下,Spring只对RuntimeException和Error进行回滚,受检异常不会触发回滚。
@Transactional public void createAccount(Account account) throws Exception { accountDao.insert(account); throw new Exception("something wrong"); }上面这段代码里,insert会正常执行并提交,异常不会导致回滚。很多新手在这里踩坑踩得莫名其妙:明明方法抛异常了,为什么数据还是写进去了?原因就是默认只回滚RuntimeException。
这个设计的原因在于,Spring认为受检异常通常是业务上可以预期并处理的情况,调用方捕获后可能还能继续完成业务流程,所以默认不回滚;而运行时异常通常表示程序出现了未预期的错误,继续提交数据很危险,所以默认回滚。
解决办法是显式指定rollbackFor:
@Transactional(rollbackFor = Exception.class) public void createAccount(Account account) throws Exception { accountDao.insert(account); throw new Exception("something wrong"); }还有一种反向场景:某个运行时异常你希望它不影响事务提交,比如业务上有"失败了也要把状态更新进去"的需求,这时候可以用noRollbackFor来排除特定异常。另外还有两个常用属性:readOnly和timeout。
readOnly = true表示当前事务为只读事务。它并不是严格禁止写入,而是一个优化提示,Spring和某些数据库(比如MySQL在特定配置下)可以根据这个提示做优化。比如Hibernate在只读事务下可以跳过脏检查,提升性能。但如果你的方法确实有写操作却标了readOnly,可能不会报错,只是事务表现和预期不一致,所以标注时要谨慎。
timeout属性用于设置事务的超时秒数,超过时间还没提交,事务会被强制回滚并抛出异常。这个参数可以在极端情况下避免事务长期悬挂,比如某个方法被慢SQL拖住时。我给超时设置的一个参考值:普通接口5到10秒,批量任务可以适当放宽,但一定不能不设。
关于回滚规则,我的建议是:所有写事务注解的地方,都要形成条件反射一样地问自己一句——这个方法会抛受检异常吗?会不会需要显式指定rollbackFor?养成这个习惯,可以帮你避开一大半的"事务不回滚"问题。
4. 从配置到代码:完整实操与失效场景清单
理论讲完了,接下来是实战。从Spring Boot下的基础配置开始,到一个完整的转账业务Demo,再给你一份排查事务失效的完整清单。
4.1 Spring Boot环境下的基础配置
在Spring Boot里,事务的基本配置几乎是零成本的。只要你引入了spring-boot-starter-data-jpa或者spring-boot-starter-jdbc,并且正确配置了数据源,Spring Boot的自动配置机制会自动帮你创建PlatformTransactionManager。对于单数据源场景,默认就是DataSourceTransactionManager。
如果你用的是Spring Boot之外的传统Spring MVC项目,需要显式配置事务管理器和注解驱动:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>而Spring Boot下你只需要在启动类所在的配置里让@EnableTransactionManagement生效——其实Spring Boot的自动配置已经帮你开启了声明式事务管理,一般情况下连这个注解都不用写,直接用@Transactional即可。
多数据源的情况要复杂一些。需要为每个数据源配各自的PlatformTransactionManager,然后在@Transactional注解里通过transactionManager属性指定用哪个事务管理器:
@Transactional(transactionManager = "orderTransactionManager") public void createOrder(Order order) { // ... }这个在多数据源项目里特别重要。我之前在某个项目里配了两个数据源,结果所有事务都默认用了第一个数据源的事务管理器,第二个库的操作根本不参与事务,排查了很久才发现是transactionManager没有指定。如果你接手一个老项目,看到一个方法里操作了两个库,先别急着怀疑事务框架,先看看事务管理器是不是配对了。
4.2 一个完整的转账业务实现
我们来实现一个完整的转账方法,把前面讲到的知识点都串起来。先看Account对应的Mapper层,这里以MyBatis为例:
@Mapper public interface AccountMapper { @Update("UPDATE account SET balance = balance - #{amount} WHERE id = #{fromId} AND balance >= #{amount}") int decreaseBalance(@Param("fromId") Long fromId, @Param("amount") BigDecimal amount); @Update("UPDATE account SET balance = balance + #{amount} WHERE id = #{toId}") int increaseBalance(@Param("toId") Long toId, @Param("amount") BigDecimal amount); }注意到decreaseBalance的SQL里带了AND balance >= #{amount}这个条件,这是防止余额不足扣成负数的最朴素做法。如果条件不满足,int返回值就是0,业务层根据返回值判断是否抛出余额不足的异常。
Service层的实现:
@Service public class TransferService { private final AccountMapper accountMapper; public TransferService(AccountMapper accountMapper) { this.accountMapper = accountMapper; } @Transactional(rollbackFor = Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("转账金额必须大于0"); } int affected = accountMapper.decreaseBalance(fromId, amount); if (affected != 1) { throw new IllegalStateException("扣款失败,余额可能不足"); } accountMapper.increaseBalance(toId, amount); } }几点说明。
第一,rollbackFor = Exception.class在这里是必要的,因为如果我把受检异常往上抛,默认情况下不会触发回滚。虽然这个例子里抛的都是运行时异常,但显式写出来是一种更好的防御习惯。
第二,扣款和入账中间没有远程调用,事务范围很小,这是理想的场景。如果真实业务里需要通知、发消息、调用外部接口,尽量不要把这些操作放在事务里,否则事务时间会被拉得很长,连接池和锁资源都会被拖垮。
第三,balance >= #{amount}的SQL条件加上受影响行数的判断,这既是并发安全的第一道防线,也是业务校验的一部分。事务能保证这两条SQL要么都成功要么都失败,但不能代替业务规则校验。
4.3 事务失效的九大典型场景
我把平时最容易踩的事务失效场景整理成一份清单,每条都标注了原因和解决方案。
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 方法不是public | Spring代理不会拦截非public方法 | 改为public |
| 类内部自调用 | this.method()绕过代理对象 | 注入自身代理或拆分Bean |
| try-catch吞掉异常 | 异常没传到代理层,无法触发回滚 | 别吞异常,必要时手动标记回滚 |
| 受检异常 | 默认只回滚RuntimeException | 指定rollbackFor = Exception.class |
| final类或final方法 | CGLIB无法代理final成员 | 去掉final |
| 跨线程调用 | 事务与线程绑定,子线程不继承 | 不在子线程里做事务逻辑 |
| 传播行为设置错误 | 内层用了不支持事务的行为 | 按业务场景选择传播行为 |
| 数据表不支持事务 | 比如MySQL的MyISAM引擎 | 改成InnoDB引擎 |
| 事务管理器指定错误 | 多数据源时默认指向错误的库 | 显式指定transactionManager |
这九条里,最容易犯的是前三条。自调用问题我见过太多次了,后面专门有一节讲解决方案;try-catch吞异常也是高发问题,很多人以为只在方法里打了日志就算处理了,实际上异常被自己catch住,Spring根本感知不到。
4.4 吞掉异常等于宣告放弃回滚
这里单独说一下try-catch吞异常的问题,因为它隐蔽性极强。看这段代码:
@Transactional public void batchUpdate() { try { dao.updateA(); dao.updateB(); } catch (Exception e) { log.error("更新失败", e); } }表面上看,updateA和updateB只要有异常就会被捕获并记录日志,方法正常返回,事务正常提交。问题在于:updateA成功、updateB失败时,updateA的修改已经被提交了——这恰恰是事务最不应该出现的结果。正确的做法是:
@Transactional public void batchUpdate() { dao.updateA(); dao.updateB(); }让异常自然抛出去,交给Spring触发回滚。如果确实需要在catch里做一些清理工作,记得在catch块里调用:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();把当前事务标记为需要回滚,这样方法即使正常返回,事务最终也会回滚。这种方式可以作为兜底手段,但最好的做法还是别吞异常。
5. 常见问题排查与避坑实录
这一节把我自己在实际项目中反复处理过的问题和排查思路完整记录下来,很多内容在官方文档里是看不到的。
5.1 自调用事务失效的三种解决方案
先复现一下这个经典问题:
@Service public class OrderService { public void outerMethod() { this.innerMethod(); // 自调用! } @Transactional public void innerMethod() { // 期望在事务中执行,实际没有 } }这里的innerMethod加上了@Transactional,但通过this调用的方式是直接调用目标对象的方法,根本没有经过Spring生成的代理对象,所以事务注解完全失效。Spring文档里把这种情况叫self-invocation。
我见过三种靠谱的解决方案。
方案一:把内部方法拆分到另一个Spring管理的Bean中。比如新建一个OrderInnerService,把innerMethod放到里面,然后在OrderService里注入这个新Bean并调用。这是最推荐的做法,干净、清晰、易测试。
方案二:通过AopContext获取当前代理对象。需要在配置里暴露代理:
@EnableAspectJAutoProxy(exposeProxy = true)然后代码里这样调用:
public void outerMethod() { OrderService proxy = (OrderService) AopContext.currentProxy(); proxy.innerMethod(); }这个方案能用,但AopContext.currentProxy()在异步线程里会取不到代理对象,存在一定限制。
方案三:注入ApplicationContext,从容器里重新获取Bean再调用。这个方法可行,但比方案一繁琐,不推荐。
还有一个变通的思路:把事务边界统一放在最外层方法上,内部方法不加@Transactional。如果你确定整个调用链就是一个事务,那直接把@Transactional加在outerMethod上就完了,内部方法根本不需要注解。很多时候自调用问题的出现,恰恰是外部方法没加事务、内部方法又期望单独事务,这时候才需要拆Bean。
5.2 事务没回滚时的标准排查路径
线上反馈"事务没回滚"的时候,我的排查路径基本是固定的。
第一步,确认异常类型。先定位抛出来的到底是不是受检异常。如果是受检异常且没有指定rollbackFor,那大概率就是它了。
第二步,确认异常有没有被catch。在业务代码里找一下方法调用链上有没有try-catch把异常吞掉。这一步最容易忽略,因为吞掉的异常在日志里可能只有一行warn甚至什么都没有。
第三步,确认调用方式。看看事务方法是不是被同类内部调用的、是通过this还是通过代理对象调的。最快的方式是在事务方法第一行打个断点或者加日志,观察方法调用栈,看有没有经过Spring AOP相关的拦截器。
第四步,确认事务管理器。多数据源项目里,看看当前数据源是不是配置了对应的事务管理器。
排查方向整理成表格会更直观:
| 现象 | 优先怀疑方向 | 验证手段 |
|---|---|---|
| 抛了异常但没回滚 | 受检异常、回滚规则 | 确认异常类型,检查rollbackFor |
| 方法没进事务 | 自调用、非public、代理失败 | 断点看调用栈,打印代理类名 |
| 数据部分更新 | try-catch吞异常 | 检查调用链上的catch块 |
| 多库操作部分生效 | 事务管理器指向错误 | 核对transactionManager配置 |
5.3 大事务和长事务的治理方法
大事务是性能问题的重灾区。一个事务里塞了太多操作,意味着:数据库连接被长时间占用,连接池容易被打满;行锁长时间不释放,其他事务全部阻塞;事务日志要记录大量变更,提交时压力巨大;如果中途失败回滚,回滚时间也长得可怕。
治理思路通常从这几个方向入手。
方向一:缩小事务范围。能用编程式事务的不用声明式,特别是循环内操作,用TransactionTemplate逐批提交。我自己写批处理代码时,一般每100条或每1000条提交一次,根据单行操作耗时和事务日志大小来调整。
方向二:把非DB操作移出事务。远程调用、消息发送、文件处理、HTTP请求这些IO操作,统统不要在事务里做。常见的做法是事务内只做DB操作,事务提交后再发消息、调远程。这样既保证数据落库,又不让IO拖住事务。
方向三:使用批量SQL。大量数据的更新尽量走批量语句,不要一条条循环更新。每条更新都要拿一次连接、开一次事务,性能和锁竞争的差别是数量级的。
方向四:设置合理的超时时间。@Transactional(timeout = 5)可以设置事务超时秒数,超时会抛出异常并回滚。这个参数可以在极端情况下避免事务长期悬挂。
5.4 调试事务的日志配置与判断技巧
最后分享一套我常用的排查配置。在Spring Boot的application.yml里:
logging: level: org.springframework.transaction: debug org.springframework.jdbc.datasource.DataSourceTransactionManager: debug com.example.mapper: trace其中org.springframework.transaction这个日志级别设为debug后,Spring会打印大量事务生命周期信息,比如"Creating new transaction with name"、"Initiating transaction commit"、"Rolling back transaction"。这些日志是排查事务是否生效的最有力证据。com.example.mapper(你的Mapper包路径)设为trace,可以打印MyBatis执行的完整SQL,用来看实际执行的语句和事务提交时机。
还有一个技巧:在事务方法里用TransactionSynchronizationManager.isActualTransactionActive()判断当前线程是否真的处于事务中。
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info("Current transaction active: {}", txActive);这个判断点在你调试自调用问题时尤其好用,一段代码就能确认事务到底开没开。返回false,说明你的事务注解压根没起作用,继续往代理方向排查;返回true但数据还是没回滚,再往异常逻辑和事务边界方向排查。
6. 写在最后的一些经验
最后再分享一点我的感受。Spring事务这套东西,看着就是一个个注解和配置,但真正用好的关键,是理解它背后那条代理链:你的业务方法不是直接被调用的,而是被Spring生成的代理对象包了一层,事务的开启、提交、回滚全在这一层完成。理解了这条链,你就能解释自调用为什么会失效、异常为什么要抛出去、private方法为什么没用,也能在遇到奇怪问题时快速定位问题出在哪一环。
另一个体会是,事务不是越多越好,也不是越少越好。它保护的是数据一致性,但每一层保护都有代价。日常开发里,我会先想清楚哪些操作必须在同一个事务里,哪些操作根本不该在事务里,再用声明式或编程式的方式把边界画准确。很多时候排查事务问题排查到天亮,回头一看,其实是当初画边界的时候太随意。
如果你在实际项目里遇到过更奇怪的事务问题,建议把当时的日志和代码保留下来,用上面提到的排查路径一步步走一遍。这类问题往往是越追越清楚的,希望这篇Spring事务的完整拆解能帮你少走一些弯路。