☰
Spring事务底层原理与@Transactional失效场景排查指南
2026/10/12 4:04:25 网站建设 项目流程

1. 先把“事务”这件事掰开揉碎

1.1 事务的四个老朋友:ACID

聊Spring事务之前,必须先说清楚“事务”本身是什么意思。大学教材上管它叫ACID,四个字母分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。我用一个最经常被拿来举例的业务场景拆开讲:

假设用户在你的系统里发起一笔转账,流程是:从A账户扣款1000元,往B账户加款1000元。这两步必须“要么都成功,要么都别做”。如果扣款成功但加款失败,这笔钱就凭空消失了,用户第二天就会打电话找你。原子性保证的正是这一点:事务内的所有操作看作一个不可分割的整体。

隔离性稍微绕一点。两个并发事务同时操作同一张表时,如果没有隔离机制,就可能出现你读到一半的数据被别人改掉的尴尬场面。隔离级别就是用来约束这种并发访问的“交通规则”,后文会展开讲。

为什么事务必须在框架层面被管理?因为数据库本身只认“开始事务”“提交”“回滚”这几条命令。拿MySQL来举例,一条连接上的执行顺序是:BEGIN或START TRANSACTION开启事务,中间执行一系列SQL,最后COMMIT提交或者ROLLBACK回滚。如果所有业务代码都直接跟这些底层命令打交道,你的Service层会变成一片混乱,到处都是重复的try-catch处理逻辑。

Spring做的事简单来说就是把这套流程封装成了“声明式”的开关。你只需要在方法上标注一个注解,框架就帮你把开启、提交、回滚全部处理掉,不用每写一个业务方法都自己拼一遍事务命令。这种优雅背后,实际上是AOP(面向切面编程)在默默干活。

1.2 从JDBC手动事务到Spring自动管理

说清楚Spring事务之前,值得回头看两眼“原始时代”。假设你现在只用裸JDBC:

Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 关掉自动提交 // 执行扣款SQL statement.execute("update account set balance = balance - 1000 where id = 'A'"); // 执行加款SQL statement.execute("update account set balance = balance + 1000 where id = 'B'"); conn.commit(); // 都执行成功才提交 } catch (SQLException e) { if (conn != null) { conn.rollback(); // 出错了整体回滚 } } finally { if (conn != null) { conn.close(); } }

这段代码有几个非常让人头疼的地方。第一,事务边界必须手动维护,业务方法一多,这些模板代码散落得到处都是;第二,每条业务逻辑都要自己写try-catch和rollback分支,而且非常容易漏掉连接关闭;第三,如果业务里嵌入了多个DAO操作,你得小心翼翼地保证它们用的是同一个连接,否则事务会“断开”。

Spring最初想解决的,正是这三个问题。编程式事务由TransactionTemplate来处理模板代码,声明式事务用AOP把事务逻辑彻底从业务逻辑中剥离出去。这两种方式各自有适用场景,下面拆开讲。

2. Spring事务的设计哲学:把“复杂”留给框架

2.1 编程式事务的救赎:TransactionTemplate

在Spring的早期体系中,你不需要再手写裸JDBC的样板代码,框架提供了PlatformTransactionManager接口来统一管理事务。最常见的实现是DataSourceTransactionManager,它是围绕着DataSource设计的事务管理器,专门用于JDBC和MyBatis这类走java.sql.Connection的数据访问方式。

手动事务最标准的姿势是这样:

@Service public class TransferService { private final TransactionTemplate transactionTemplate; public TransferService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void transfer(String fromAccountId, String toAccountId, BigDecimal amount) { transactionTemplate.execute(status -> { try { accountDao.deduct(fromAccountId, amount); accountDao.add(toAccountId, amount); return null; } catch (RuntimeException e) { status.setRollbackOnly(); // 标记需要回滚 throw e; } }); } }

TransactionTemplate把连接的获取、事务的开启、提交和回滚都包进去了,你只需要往回调里塞业务逻辑。status.setRollbackOnly()这句就是“手动判死刑”,告诉框架这个事务必须回滚,别再想着提交了。

这种写法比裸JDBC干净太多,但仍然有一个致命问题:它侵入到了业务代码里。如果你有几十个需要事务的方法,每个方法里都要包一层transactionTemplate.execute,这些样板代码依然存在,只是从“数据库连接层”换到了“业务方法层”。

2.2 编程式事务真的不够“香”吗

肯定有朋友问:既然编程式事务逻辑清晰、容易调试,为什么主流项目都改用@Transactional注解?

我的体会是,编程式事务适合两类场景。第一类是事务边界不好用方法切分的时候,你需要在同一个方法内部动态决定“这段要不要加进事务里”;第二类是事务逻辑需要随运行时状态变化,比如根据参数决定使用不同的事务管理器。

但更多情况下,你希望事务是“方法级别”的:方法进入时开事务,方法正常返回时提交,抛异常时回滚。这种横切逻辑用编程式事务写在每个方法里,本质上属于重复劳动,而且特别容易在改代码时漏掉一层。声明式事务把这种横切关注点通过AOP统一收编,一次配置,处处生效。

打个比方,编程式事务就像你每天上班自己手动开关办公室的灯,声明式事务就像给办公室装了感应灯:你踏进门灯自动亮,走出去灯自动灭。你根本不需要再操心“我今天进门有没有按开关”这件事。

2.3 核心接口:三件套

理解Spring事务管理底层,必须认识三个核心接口:PlatformTransactionManager、TransactionDefinition、TransactionStatus。

PlatformTransactionManager是所有事务管理器的总门户,接口上定义了三个方法:getTransaction(TransactionDefinition definition)负责获取事务状态,commit(TransactionStatus status)负责提交事务,rollback(TransactionStatus status)负责回滚事务。

TransactionDefinition定义事务的“行为特征”,包括传播行为(Propagation)、隔离级别(IsolationLevel)、超时时间(Timeout)、是否只读(ReadOnly)。它是一份配置档案,告诉事务管理器这个事务该按什么规则来跑。

TransactionStatus保存着当前事务的运行时状态。它封装了事务是否新建、是否存在保存点、是否已经被标记为rollback-only等信息,setRollbackOnly()方法就是上面的代码里用到的那个。

Spring的多个事务管理器实现,比如DataSourceTransactionManager、JpaTransactionManager、HibernateTransactionManager,分别对接不同数据访问技术。它们实现同一个接口,于是上层业务代码不用关心底层用的是JDBC、JPA还是Hibernate,迁移技术栈时事务逻辑不用大改。我实际做过一个老项目从Hibernate迁移到MyBatis的改造,事务相关代码几乎没有动,这就是接口抽象带来的直接好处。

注意:现代Spring Boot项目里,如果你只引入了一个数据源,框架会自动帮你配置好DataSourceTransactionManager,你不需要手动注册Bean。只有在多数据源、强制指定某种事务管理器时,才需要显式声明。

3. 声明式事务的底层真相:AOP在背后做了什么

3.1 一句话说清楚AOP与声明式事务的关系

声明式事务之所以能“一点注解就生效”,核心是Spring AOP做了一个“代理对象”。当你把一个Bean交给Spring容器管理后,容器在创建Bean的过程中发现目标类上有@Transactional注解,就会生成一个代理类。

这个代理类看起来和原来的类一样,但所有公开方法被调用时,都会先经过一个拦截器链。在事务场景下,这个拦截器就是TransactionInterceptor。业务方法本身完全不感知事务的存在,就像演员不关心摄影机的机位一样,代理负责了拍摄的所有协调工作。

如果JDK动态代理和CGLIB代理这个概念听着陌生,不需要太有压力。只需记住:Spring容器中你拿到的UserServiceBean,未必就是你写的那个类的直接实例,在这条背后站着一个隐形的代理。方法上的@Transactional能不能生效,完全取决于这个代理有没有被正确创建和调用。

3.2 事务拦截器的三个关键步骤

TransactionInterceptor的invoke方法干了三件事:

第一步,判断当前方法是否配置了事务属性(也就是从@Transactional里解析出来的传播行为、隔离级别、超时时间等)。如果没有配置,直接调用目标方法,跟没加注解一样;如果配置了,走第二步。

第二步,调用TransactionAspectSupport的核心逻辑,通过事务管理器获取事务状态。这里的getTransaction会根据传播行为决定是加入当前事务、新建事务,还是挂起当前事务。拿到事务状态之后,它会把事务信息绑定到当前线程上,这里用到的就是TransactionSynchronizationManager。

第三步,执行业务方法。方法正常返回就提交事务,方法抛出异常就根据rollbackFor的配置判断是否需要回滚。注意这里的“异常类型匹配”有讲究,默认情况下只有RuntimeException和Error触发回滚,受检异常(checked exception)不会触发回滚。这个默认行为和很多人直觉不一致,后文单独讲怎么避坑。

整个流程用代码描述大概是这样的伪代码:

public Object invoke(MethodInvocation invocation) throws Throwable { // 1. 解析事务属性 TransactionAttribute txAttr = getTransactionAttribute(invocation); // 2. 获取事务管理器并开启事务 PlatformTransactionManager tm = getTransactionManager(); TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification); Object retVal = null; try { // 3. 执行真正的业务逻辑 retVal = invocation.proceed(); } catch (Throwable ex) { // 4. 异常时回滚 completeTransactionAfterThrowing(txInfo, ex); throw ex; } finally { cleanupTransactionInfo(txInfo); } // 5. 正常返回时提交 commitTransactionAfterReturning(txInfo); return retVal; }

这段流程透露了一个很重要的细节:invocation.proceed()是在事务已经开启之后才调用的。所以你在业务方法里通过TransactionSynchronizationManager.isActualTransactionActive()判断当前是否存在事务,返回的一定是true。

3.3 一个从概念到代码的完整推演

把理论落在代码上,看一个常见的转账例子:

@Service public class AccountService { @Autowired private AccountDao accountDao; @Transactional(rollbackFor = Exception.class) public void transfer(String fromId, String toId, BigDecimal amount) { accountDao.deduct(fromId, amount); accountDao.add(toId, amount); } }

当外部调用方调用accountService.transfer(...)时,真实发生的过程是:调用方持有的是AccountService的代理对象,代理对象的方法入口触发TransactionInterceptor,拦截器解析出@Transactional的配置——传播行为默认REQUIRED、隔离级别默认使用数据库默认、需要回滚的异常是Exception及其子类。

拦截器随后调DataSourceTransactionManager.getTransaction(),从DataSource里拿一个连接,执行conn.setAutoCommit(false),把连接绑定到当前线程的ThreadLocal里。下面进入业务方法,accountDao.deduct()执行时,MyBatis从当前线程拿到那个还没提交的连接执行扣款SQL;accountDao.add()同理。两步都执行完,方法正常返回,拦截器执行commit,连接上的commit()被调用,数据落库,连接被释放。

中间任何一步抛了Exception,拦截器的completeTransactionAfterThrowing判断异常类型匹配后,走rollback逻辑,数据库撤销全部未提交的更改。业务代码里自始至终没有一行事务API调用,这就是“声明式”三个字的精髓所在。

4. @Transactional的核心参数:你手里握着哪些旋钮

4.1 七种传播行为,逐个过一遍

传播行为是声明式事务中最容易绕晕的配置项。我的建议是先重点搞懂最常用的两个:REQUIRED和REQUIRES_NEW,其他几个偶尔见到知道什么意思即可。

REQUIRED(默认值)表示“如果当前存在事务,就加入当前事务;如果当前没有事务,就新建一个事务”。这符合大多数业务场景。外层方法配置了事务,内层方法也标注了@Transactional(propagation = Propagation.REQUIRED),内层方法不会新开事务,而是直接复用外层的事务。如果内层方法抛了异常,整个外层方法的事务都会回滚。

REQUIRES_NEW表示“无论如何都新建一个事务;如果当前已经存在事务,则把当前事务挂起”。这个配置适合“记录日志、审计”等场景——即使主业务失败回滚,操作日志也需要写进数据库。注意这里挂起的含义:外层事务会暂停,等内层新事务结束后再恢复。两者之间互不影响,内层事务的回滚不会拖累外层事务已执行的操作。

其他几种传B播行为:SUPPORTS表示当前有事务就加入,没有就非事务执行;MANDATORY要求当前必须已经存在事务,否则直接抛异常;NOT_SUPPORTED强制以非事务方式执行,如果有事务先挂起;NEVER强制以非事务方式执行,如果有事务抛异常;NESTED是“嵌套事务”,有别于REQUIRES_NEW,它利用数据库的保存点(savepoint)实现部分回滚,只有在底层数据库支持保存点时才能用。

我见过不少团队把REQUIRES_NEW当成万能药,不管什么场景都加。实际上弄错传播行为很容易引发“大事务里套着小事务”的诡异问题,比如外层事务已经锁了某行数据,内层REQUIRES_NEW又去更新同一行,直接死锁。传播行为的选择依据应该是“事务的独立性要求”,而不是“感觉加一个更保险”。

传播行为当前有事务时当前无事务时常见使用场景
REQUIRED加入当前事务新建事务常规业务操作,默认选择
REQUIRES_NEW挂起当前,新开事务新建事务日志记录、消息推送等必须独立的操作
SUPPORTS加入当前事务非事务执行查询方法
MANDATORY加入当前事务抛异常强制要求调用方提供事务
NOT_SUPPORTED挂起当前非事务执行不需要事务的批量操作
NEVER抛异常非事务执行严禁事务环境
NESTED创建保存点,嵌套执行新建事务需要部分回滚的场景

4.2 隔离级别到底怎么选

隔离级别解决的问题是四个字:并发冲突。SQL标准定义了四种隔离级别,从宽松到严格排列。

READ_UNCOMMITTED最低,允许一个事务读到另一个事务尚未提交的数据,也就是脏读。实际项目里基本没人用,除非你对数据一致性完全无所谓。READ_COMMITTED只能读到已提交的数据,解决了脏读问题,但从一个事务内部两次相同查询可能得到不同结果,这就是不可重复读。REPEATABLE_READ在MySQL里是比较有意思的一档,它通过MVCC机制让一个事务内的重复读结果保持一致,解决了不可重复读问题,但依然可能产生幻读(指查询满足条件的记录集合发生了变化)。SERIALIZABLE最高,事务串行执行,各种并发问题都没有了,代价是并发性能极低。

Spring的@Transactional上配置隔离级别时,默认Isolation.DEFAULT,表示使用底层数据库的默认隔离级别。MySQL默认是REPEATABLE_READ,SQL Server和Oracle默认是READ_COMMITTED。

我特意提一句:很多人在@Transactional里写isolation = Isolation.SERIALIZABLE,结果压测时发现吞吐量断崖式下跌。隔离级别不是越高越好,要结合业务容忍度来权衡。读多写少的报表类查询可以放心用READ_COMMITTED,资金类强一致业务才需要SERIALIZABLE或悲观锁兜底。

4.3 readOnly、timeout与rollbackFor的配合技巧

readOnly = true是一个特别容易让人误解的属性。它并不是强制数据库“禁止写入”,而是给底层框架一个“优化提示”。对于MySQL和MyBatis体系,设置readOnly后Spring会把连接设置为只读模式,有些数据库驱动会跳过某些锁或者走只读路由,从而提升查询性能。但它不是万能护身符,如果你在只读事务里执行了更新操作,数据库会直接报错。

timeout参数控制事务的最大执行时间,单位是秒。一旦事务运行超过这个时间,框架会强制回滚。这个配置对线上问题非常有价值,避免一个慢SQL把数据库连接池拖垮。我处理过一个异常案例:一个后台报表导出方法没有设置超时时间,结果上游某个表被锁,事务长时间悬着,连接池被占满,整条链路被打挂。后来全局统一加了合理超时时间,问题立刻缓解。

rollbackFor是我最希望所有开发者都认真看一眼的参数。前面提到默认情况下只有RuntimeException和Error会触发回滚。如果你在业务里封装了统一的BizException extends Exception(受检异常),然后在一个事务方法里抛出它,事务是不会自动回滚的。要么把自定义异常改成继承RuntimeException,要么在注解里写明rollbackFor = Exception.class。这是线上事务“不回滚”的最常见原因之一。

关键提示:异常会被拦截器捕获,但这有个前提——异常必须从被代理的方法边界抛出。如果你在方法内部用try-catch把异常吞掉了,拦截器看到的是“方法正常返回”,它会直接提交事务,数据照样落库。

5. 事务不生效?十有八九踩了这几个坑

5.1 经典中的经典:自调用导致事务失效

这是一个排查频次极高的问题。同一类内部的直接方法调用,不经过代理对象,于是@Transactional形同虚设。

@Service public class OrderService { public void createOrder(Order order) { // 调用本类方法的内部事务方法 this.updateStock(order); } @Transactional public void updateStock(Order order) { // 更新库存 stockDao.decrease(order.getProductId(), order.getQuantity()); } }

createOrder()调this.updateStock()时,this是原始目标对象,而不是Spring生成的代理对象。注解配置的事务属性根本没机会被TransactionInterceptor读取到,事务自然不生效。如果updateStock执行失败,前面已经写过的订单数据不会回滚。

解决方案有几种。第一种,拆开类,把事务方法放到另一个Spring管理的Bean里,通过注入的Bean调用;第二种,从容器中获取代理对象调用,比如在类中注入ApplicationContext,用getBean(OrderService.class)获得代理;第三种,在当前类上@Autowired自己这个Bean,或者使用@Lazy注入来避免循环依赖。

用三种方案时我见过不少朋友踩循环依赖的坑。如果你在OrderService里注入了自己,记得加@Lazy,否则Spring初始化时可能出现代理创建顺序上的问题。

5.2 方法可见性与异常处理:看似无关实则致命

@Transactional标注方法的可见性也会影响事务生效。Spring的默认代理方式中,只有public方法上的事务注解会被识别。private、protected、package方法上的注解会被直接忽略,Spring启动时还不会给任何警告提示,这坑有多深只有踩过的人才知道。

Java注解对private方法在反射层面确实可以读取到,但Spring AOP要生成一个子类代理来拦截方法调用,private方法是不能被子类重写的,于是拦截器根本插不进去。如果你用的是CGLIB代理,对非public方法的处理更复杂,结论一样不生效。

异常被吞的问题我放在这节再强调一次:

@Transactional public void pay(Order order) { try { accountDao.deduct(order.getAmount()); // 假设这里抛了SQLException } catch (Exception e) { log.error("扣款失败", e); // 异常被吞,没有重新抛出 } }

方法看起来执行完毕了,事务被提交,扣款SQL如果被数据库回滚了也无所谓,但如果数据库层面没有抛错而业务后续逻辑失败了,就会出现资金账不平。正确的做法有两种:要么先记录日志然后重新抛出异常,要么在catch块里调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()强制标记回滚(这种方式对调用方不透明,我还是建议重新抛出异常)。

5.3 类未被Spring管理、多线程与异常类型匹配问题

@Transactional注解必须作用在Spring容器管理的Bean方法上。如果你直接用new关键字创建了一个Service对象,再调用它的事务方法,代理对象压根不存在,事务完全不生效。这种情况常在工具类、定时任务里出现,排查时看一眼调用链路就能发现。

多线程是另一个冷门陷阱。Spring事务基于ThreadLocal绑定资源,子线程默认拿不到父线程的事务绑定。比如你用一个ExecutorService异步执行某个事务方法,这个子线程里的“事务”实际上是独立的,甚至可能根本没有事务。要处理跨线程事务传播,要么用TransactionTemplate在这个子线程里手动开启,要么把需要在一个事务里完成的操作全部放在同一线程内执行。

异常类型匹配的默认行为上面已经提过,这里补充一个容易混淆的点:rollbackFor = Exception.class配上去之后,是不是所有异常都会回滚?答案是不一定,异常如果发生在代理方法执行之前(比如参数校验阶段抛出的异常),此时事务还没有开启,谈何回滚。另外如果异常被内层方法捕获并转换成了另一种异常类型抛出,最终能否触发回滚,要看最终抛出异常的类型是否匹配rollbackFor。

6. 排查事务问题的现场实录与经验清单

6.1 如何确定事务到底有没有生效

我排查线上问题时,第一步永远不是看代码,而是确认“这个事务真的开起来了吗”。最实用的方法是开启Spring的日志输出。

在application.yml里加上:

logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource: DEBUG

然后看控制台日志,事务提交或回滚时会出现类似这样的一行:

Participating transaction failed - marking existing transaction as rollback-only

出现rollback-only标记时,说明有内层事务把外层事务标记成了只能回滚。这种情况下即使外层方法正常return,提交时也会抛UnexpectedRollbackException。这个现象经常出现在内层方法自己catch了异常但没有重新抛出,事务管理器却把它标记为rollback-only的场景。这是Spring事务体系里最令人困惑的一个行为,没有之一。

第二个排查思路是直接代码里打探:

boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();

在方法里打印这个值,如果为false,说明当前方法根本没有运行在事务中。这个方法在调试验证时非常好用,能立刻定位“事务没开启”还是“事务开了但没按预期回滚”。

6.2 回滚失效的经典现场

我处理过一个非常典型的场景:某商家对账系统,settle()方法里调用了两个事务方法deductServiceCost()和calculateCommission(),第一个成功,第二个抛了一个受检异常CalcException。代码看起来很正常,两个事务方法都是@Transactional(propagation = Propagation.REQUIRED),settle()自身也标注了@Transactional。

结果线上发现:deductServiceCost()的扣款被提交了,但calculateCommission()的佣金计算没有入账。排查下来有两个问题叠加。第一,CalcException继承了Exception,默认不回滚;第二,calculateCommission()内部自己没有catch异常,由外层settle()方法吞掉并做了降级处理。

修复其实很快:统一让自定义业务异常继承RuntimeException,同时全局配置@Transactional(rollbackFor = Exception.class)。最关键的是宣告一个团队规范:事务方法内部禁止catch所有异常后不重新抛出,要么记录日志后重新抛出,要么让调用方处理。

另外还有一个高频问题:事务方法里顺序执行多次数据库操作,中间有一步是远程调用或消息发送。远程调用耗时很长,事务一直被拖着不提交,后续锁竞争激烈。业内常规做法是把远程调用移到事务方法外部,事务里只做纯粹的数据操作。以前遇到过一个接口P99延迟从200ms涨到2s,排查发现就是有一个事务方法里同步调了短信服务接口,对方响应慢导致数据库连接被长期占用。

6.3 长期实践沉淀下来的经验清单

先整理一份避坑速查表,方便各位朋友贴在工位上:

问题现象可能原因解决方案
事务方法没走代理(自调用)this调用方式绕过了代理拆分Bean、注入代理对象
事务没回滚受检异常默认不触发回滚配置rollbackFor或继承RuntimeException
事务没回滚异常被catch吞掉重新抛出或标记setRollbackOnly
事务没开启类不是Spring管理的Bean检查是否使用了new创建对象
子线程方法事务不生效ThreadLocal不跨线程传递子线程内手动开启事务
抛UnexpectedRollbackException内层事务标记rollback-only内层异常统一向上抛出
连接被长期占用事务方法里做远程调用把远程调用移出事务方法

再补充几条更细节的经验:

第一,事务方法尽量设计得短而快。事务粒度太大,所有并发操作都在抢最长时间的锁,后期优化无从下手。相反,事务粒度太小,一个业务逻辑拆成十几个事务方法,中间一旦失败,前面已完成的操作无法回滚,业务一致性就崩了。事务边界的划分能力,是区分资深开发和初级开发的重要标志之一。

第二,如果使用了@Transactional,尽量别再用编程式事务混搭。两种模式叠加时,容易出现在同一线程内重复获取事务导致行为不符合预期的情况。除非确定要用的场景,比如某些异常情况下需要“手动回滚”,否则保持编程方式统一,混用会让你在排障时心力交瘁。

第三,@Transactional尽量放在实现类的方法上,而不是接口方法上。Spring官方文档多年来一直在强调基于接口的代理存在“注解不被继承”的问题,如果配置了JDK动态代理,放在接口方法上的注解在某种边界条件下无法被正确解析。放在实现类上,无论代理用的是JDK还是CGLIB,都能稳定生效。

第四,事务超时时间需要结合实际SQL执行时间设置。我见过一个项目,全局统一配置了超时10秒,但管理后台有个统计接口正常就要跑15秒,上线后频繁抛超时回滚。事务超时应该精细化到不同接口上,而不是一刀切。

第五,对于大事务里嵌套调用REQUIRES_NEW的写法要格外小心。内层REQUIRES_NEW提交的事务不随外层一起回滚,如果外层后续失败,会出现“部分数据已提交、部分数据已回滚”的残局。设计阶段就该画清楚事务边界,而不是靠后期补丁。

最后再分享一个我常用的调试小技巧:在测试环境开启事务相关日志,写一个只有@Transactional和一条更新语句的最小化测试接口,跑一遍看日志。如果最小化场景能正常回滚,问题一定出在调用链的异常处理或方法嵌套上;如果不能正常回滚,问题出在框架配置或Bean代理创建上。分而治之,比“盯着代码猜”高效得多。

7. 最终章:事务设计的几个个人感悟

做后端这些年,事务相关的问题排过很多,给我的总体感受是:Spring的声明式事务把“开启、提交、回滚”这三个动作藏得很深,使用门槛低,但理解门槛不低。真正把一个系统的事务处理好,需要你同时理解数据库锁、AOP代理机制、异常传播路径三条线。任何一个环节掉链子,表现出来的都是“数据不对”。

我个人的建议是,团队里每个写@Transactional的人,都该花时间搞明白两件事:第一,访问代理对象的到底是什么;第二,自己抛出的异常最终会怎么被处理。这两点想清楚了,事务失效这类问题基本能防住大半。如果你愿意再进一步,看一遍TransactionInterceptor和TransactionAspectSupport的源码,会比任何博客文章都来得深刻。

有个习惯对我帮助很大,每次评审代码时,只要看到@Transactional,就条件反射般地问三个问题:这个事务的边界是什么,哪些异常会触发回滚,有没有远程调用被包在事务里。这三问用于代码自审查,也用于团队互审,能拦截掉大量线上事务隐患。

事务是业务系统数据一致性的最后一道水坝。这道坝能不能守得住,靠的是每个工程师对底层机制的理解,而不是靠框架替你兜底。如果你读到这里,至少已经把Spring事务的底细摸清楚了七成,剩下来的三成,交给真实的业务故障去打磨。

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

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

立即咨询