ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot事务回滚失效排查:从代理到隔离级别

SpringBoot事务回滚失效排查:从代理到隔离级别 简介面向Spring Boot开发者的中文技术笔记系统讲解事务管理与回滚功能重点解决Transactional不生效、异常未回滚等高频问题。文档为单份docx文件包体仅136KB文字精炼且配有可直接运行的代码片段。目前已吸引400人学习/下载。内容从EnableTransactionManagement开启事务支持讲起明确Transactional只能作用于public方法的限制随后分析controller-service调用链中事务边界对回滚范围的影响。回滚策略方面既说明默认仅对RuntimeException与Error自动回滚也演示了rollbackForException.class强制任意异常回滚的自定义配置。针对实际开发中的易错点还整理了手动调用setRollbackOnly()、try-catch吞掉异常后需手动回滚、finally中return覆盖catch异常等场景并简要介绍PROPAGATION_REQUIRED等事务传播行为。读者可将其作为日常编码时的事务排错清单快速定位事务失效原因。1. 事务注解在 SpringBoot 项目里为什么总在回滚上出问题Transactional是 SpringBoot 项目里出现频率最高、也最容易被误用的注解之一。很多人加上注解后以为异常一抛数据就会自动还原等线上出了半截数据才发现事务根本没生效回滚完全没有触发。问题通常不在 MySQL 本身而在代理生效边界、异常类型匹配、传播行为和自调用这些 Spring 层面的细节上。这篇文章把从注解到回滚的完整链路拆开讲覆盖最小可复现工程的写法、事务失效的排查路径以及手动回滚点的兜底方案。适合已经跑通 SpringBoot 增删改查、却被回滚坑过的开发SpringBoot 面试前拿来当一轮系统复习也能把事务注解和隔离级别这类高频问题一次理清楚。2. 回滚功能生效的底层机制自动配置、代理与事务传播2.1 SpringBoot 自动配置给事务管理带来了什么SpringBoot 工程里不需要手动声明事务管理器原因是 spring-boot-starter-jdbc 或 spring-boot-starter-data-jpa 会触发事务管理相关的自动配置核心类是 spring-boot-autoconfigure 里的 TransactionAutoConfiguration。它会根据当前数据源自动注册 DataSourceTransactionManager并开启 Spring 对Transactional注解的解析能力。理解自动装配原理的关键点在于这套自动配置是有条件的如果工程里自定义了 PlatformTransactionManager 类型的 BeanSpringBoot 会优先采用你的实现并跳过默认逻辑如果同时存在多个数据源只靠自动配置就不够用了必须显式指定每个事务管理器对应的数据源。Transactional之所以能回滚依赖的是 AOP 代理。Spring 会给被注解的 Bean 生成代理对象调用进入代理后由事务拦截器 TransactionInterceptor 负责开启事务、提交或回滚。换句话说只有从 Spring 容器里拿到 Bean 再调用才会经过代理自己 new 出来的对象或者类内部的 this 调用都不会进入事务逻辑。这是后面所有事务失效问题的总根源排查回滚问题时第一步永远先确认调用链路上有没有经过代理。MySQL 侧的机制也要对齐InnoDB 是支持事务的存储引擎MyISAM 不支持建表时引擎必须写成 InnoDB。如果一张表是 MyISAMSpring 层事务管理做得再对异常后数据照样不会回滚这类问题最容易在接手老工程时遇到。2.2 传播行为决定事务边界REQUIRED 与 REQUIRES_NEW 的取舍事务的传播行为描述的是多个事务方法互相调用时如何共享事务。默认值 REQUIRED 表示当前存在事务就加入不存在则新建。实际业务里最典型的边界问题出现在「内部方法想独立提交」的场景比如订单创建成功后要写一条审计日志日志写入失败不能影响订单主流程。如果内部方法用默认传播它会加入外层事务日志一异常整个订单也跟着回滚。Service public class OrderService { private final OrderMapper orderMapper; private final OperationLogService logService; public OrderService(OrderMapper orderMapper, OperationLogService logService) { this.orderMapper orderMapper; this.logService logService; } Transactional public void createOrder(Order order) { orderMapper.insert(order); // 日志写入失败不应导致订单回滚 logService.writeLog(create order: order.getId()); } } Service public class OperationLogService { Transactional(propagation Propagation.REQUIRES_NEW) public void writeLog(String content) { // 独立事务即使失败也只回滚日志记录 } }这段代码里 writeLog 使用 REQUIRES_NEW会挂起外层事务并开启新事务。writeLog 抛出异常时只有日志这个新事务回滚外层订单插入仍然可以提交。代价是数据库连接占用时间变长因为外层事务要等内部事务结束后才能完成提交连接池较小的系统里要控制这种写法的并发量。传播行为事务边界表现典型用途REQUIRED有事务则加入无则新建默认值绝大多数业务REQUIRES_NEW挂起当前事务强制新建独立子任务日志、审计NESTED在当前事务内创建保存点局部回滚到嵌套点MANDATORY不存在事务直接抛异常必须在事务内执行的内部方法NOT_SUPPORTED挂起当前事务非事务执行大查询、异步导出NEVER存在事务就抛异常只允许非事务执行的逻辑SUPPORTS有则加入无则非事务执行只读短查询REQUIRES_NEW 和 NESTED 的区别值得单独记一下NESTED 在 MySQL 里基于 SAVEPOINT 实现内层回滚后外层还能继续提交REQUIRES_NEW 则完全独立内层提交或回滚都影响不到外层。还有一个边界要清楚这些传播行为只解决单体应用内部的事务边界跨数据源或跨服务的场景不在本地事务管理器控制范围内那是分布式事务一致性的领域比如订单与库存分属两个服务时本地回滚只能保证单侧一致需要引入分布式事务方案来处理那套机制的复杂度和这里完全不是一个量级。2.3 默认回滚条件为什么受检异常不会触发回滚Transactional默认只对 RuntimeException 和 Error 回滚受检异常checked exception默认不回滚而是走提交。这是回滚功能最容易踩的坑。看下面这个例子表面上逻辑完整实际转账金额不会还原Transactional public void transfer(String fromId, String toId, BigDecimal amount) throws Exception { accountMapper.decrease(fromId, amount); accountMapper.increase(toId, amount); // 远程通知失败抛出 IOException属于受检异常 remoteService.notify(toId, amount); }IOException 是受检异常方法虽然抛出去了但事务拦截器判断异常类型不在回滚清单里最终结果就是扣款成功、入账成功唯一失败的是通知数据没有回滚。想让所有异常都触发回滚要在注解上明确指定Transactional(rollbackFor Exception.class) public void transfer(String fromId, String toId, BigDecimal amount) { // 业务逻辑 }rollbackFor 里填的是异常类的 Class 对象表示「抛出该异常及其子类时回滚」。我一般会统一写rollbackFor Exception.class避免某个受检异常因为漏写而静默提交。配套的还有 noRollbackFor用于在统一回滚策略下放行特定异常比如某个业务异常你希望以编译错误形式返回而不是回滚数据。timeout 参数默认是 -1表示使用底层数据库的事务超时设置。线上转账、库存扣减这类长时间持锁的操作建议显式设置 timeout比如 3 到 5 秒防止某个慢 SQL 拖住连接池里的事务连接把其他请求都压垮。只读方法上建议加Transactional(readOnly true)它不会带来性能提升但会向底层驱动传递只读语义配合 MySQL 驱动做必要的优化提示也能让代码意图更清晰。3. 在最小工程里把回滚跑通建表、Service 与测试验证3.1 建表与依赖配置最小可复现工程的准备用账户转账作载体最直观因为转账天然要求「扣减成功、入账失败时整体回滚」。建表语句如下CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL UNIQUE COMMENT 账号, balance DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT 余额, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; INSERT INTO t_account (account_no, balance) VALUES (A001, 1000.00), (B001, 0.00);SpringBoot 工程只需要 JDBC 相关的 starter不需要引入 ORM 就能验证事务dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyspring-boot-starter-jdbc 会引入 spring-jdbc其中的 DataSourceTransactionManager 就是本地事务的管理器。注意这里不需要额外声明事务管理器 BeanSpringBoot 的自动装配会基于数据源完成。application.yml 里配置数据源再加一行事务相关的日志spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver sql: init: mode: always schema-locations: classpath:schema.sql logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUGsql.init 的配置作用是在启动时执行 classpath 下的 schema.sql方便测试环境自动建表。最后把事务管理器日志级别开到 DEBUG后面验证回滚时会直接看到事务开启、提交还是回滚的日志行。3.2 Service 层事务写法与关键参数说明转账 Service 用 JdbcTemplate 操作两张账户表整个方法放在一个事务里Service public class TransferService { private final JdbcTemplate jdbcTemplate; public TransferService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Transactional(rollbackFor Exception.class, timeout 5) public void transfer(String fromAccount, String toAccount, BigDecimal amount) { // 扣减源账户where 条件里带余额校验防止并发超扣 int affected jdbcTemplate.update( UPDATE t_account SET balance balance - ? WHERE account_no ? AND balance ?, amount, fromAccount, amount); if (affected 0) { throw new IllegalArgumentException(余额不足或账户不存在); } // 目标账户入账 jdbcTemplate.update( UPDATE t_account SET balance balance ? WHERE account_no ?, amount, toAccount); // 模拟入账后的下游业务校验失败必须触发整笔回滚 if (amount.compareTo(new BigDecimal(100)) 0) { throw new RuntimeException(单笔转账超过限额触发回滚); } } }这里用 JdbcTemplate 是为了不依赖 ORM 框架把关注点完全放在事务行为上。第一个 update 的 where 条件里带balance ?用更新影响行数判断余额是否足够这比「先查询再判断再更新」更稳能避免并发场景下两个请求同时通过余额检查造成超扣。timeout 5 表示事务整体执行超过 5 秒就强制回滚单位是秒这里故意设短方便观察超时行为。最后一个 if 是模拟下游校验失败余额大于 100 的转账会走到这条分支把异常抛给事务拦截器。3.3 用单元测试观察余额是否真正回滚写一个带 SpringBootTest 的测试类验证异常发生后数据库余额没有变化SpringBootTest class TransferServiceTest { Autowired private TransferService transferService; Autowired private JdbcTemplate jdbcTemplate; Test void transferShouldRollbackWhenExceptionThrown() { assertThrows(RuntimeException.class, () - transferService.transfer(A001, B001, new BigDecimal(200))); BigDecimal fromBalance jdbcTemplate.queryForObject( SELECT balance FROM t_account WHERE account_no A001, BigDecimal.class); BigDecimal toBalance jdbcTemplate.queryForObject( SELECT balance FROM t_account WHERE account_no B001, BigDecimal.class); assertEquals(new BigDecimal(1000.00), fromBalance); assertEquals(new BigDecimal(0.00), toBalance); } }测试分三段第一段调用转账方法并断言抛出 RuntimeException第二段在异常后用 JdbcTemplate 重新查询两个账户的余额第三段断言余额没有变化。如果事务没有回滚A001 的余额会变成 800.00B001 变成 200.00assertEquals 直接失败。跑测试时注意观察日志DataSourceTransactionManager 会输出 Initiating transaction rollback 和 Rolling back JDBC transaction 两行这是回滚真正发生的最直接证据。注意生产环境不要把事务管理器日志开在 DEBUG 级别日志量会明显放大只在排查回滚问题时临时开启比较合适。把Transactional注解去掉再跑一次同样的测试断言会失败A001 被扣成 800B001 变成 200。这个对照实验能帮你确认当前环境里代理和事务管理器确实正常工作也能加深对「注解才是回滚开关」的理解。4. 事务失效的排查路径自调用、try-catch 与隔离级别4.1 自调用绕过代理事务注解形同虚设类内部方法直接调用是事务失效最高频的原因。当类里方法 A 调用方法 B且两者在同一个类中时调用走的是 this 引用没有经过 Spring 生成的代理对象B 上的Transactional完全不生效。Service public class AccountService { Transactional public void outerMethod(String accountNo) { // 这里走的是 this.innerMethod()事务代理被绕过 innerMethod(accountNo); } Transactional(propagation Propagation.REQUIRES_NEW) public void innerMethod(String accountNo) { // 期望独立事务实际根本没进入事务 } }三种常见解决办法把 innerMethod 拆到另一个 Service Bean 里通过 Spring 注入后再调用调用链经过代理事务正常生效或者注入自身代理Autowired 注入本类或者用 AopContext.currentProxy() 并配置 exposeProxy true最保守的做法是不拆独立事务让外层方法用默认传播统一管理整段逻辑。判断是否真的绕过代理可以在内层方法里打印当前事务是否活跃TransactionSynchronizationManager.isActualTransactionActive()返回 false 基本可以断定事务代理没有介入这是排查这类问题最快的手段。4.2 try-catch 吞掉异常后回滚条件永远不满足另一种常见失效是把异常捕获后又没有重新抛出。事务拦截器只在方法向外抛出匹配的异常时才触发回滚异常一旦被吞掉方法正常返回事务走的是提交分支。Transactional public void saveOrders(ListOrder orders) { for (Order order : orders) { try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 记录日志后继续循环事务拦截器看到的是正常返回 log.warn(订单已存在: {}, order.getId()); } } }这上面这段如果订单列表里有重复主键前面插入成功的记录不会回滚最终结果是部分数据落库。按需求不同有两种处理方向希望「任何一条失败就整体回滚」把异常重新抛出即可希望「单条失败不影响其他条」则要把每条插入放到独立事务里用 REQUIRES_NEW 拆开或者换用编程式事务逐条提交。还有折中方案保留 try-catch但在 catch 块里调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记事务为只回滚方法正常返回后事务依然回滚这个做法下一章展开讲。4.3 隔离级别在 MySQL 下的实际表现与参数设置事务的隔离级别控制并发读写时能看到什么数据。Spring 通过Transactional的 isolation 属性映射到底层数据库的隔离级别不设置时使用数据库默认值MySQL InnoDB 默认是 REPEATABLE_READ。隔离级别脏读不可重复读幻读锁开销READ_UNCOMMITTED可能可能可能最低READ_COMMITTED不会可能可能低REPEATABLE_READ不会不会InnoDB 下通常不会中SERIALIZABLE不会不会不会最高脏读指读到其他事务未提交的数据不可重复读指同一行记录在一个事务内两次查询结果不一样幻读指同一个查询条件下两次查询返回的行数不同。MySQL InnoDB 的 REPEATABLE_READ 通过 MVCC 和间隙锁解决了大部分幻读问题所以线上 MySQL 项目保持默认即可。SpringBoot 里显式指定隔离级别的写法如下Transactional(isolation Isolation.READ_COMMITTED) public ListOrder queryRecentOrders() { return orderMapper.selectRecent(); }把隔离级别提升到 SERIALIZABLE 会显著放大锁竞争高并发场景要谨慎不要靠全局提升隔离级别来解决问题优先用唯一约束、乐观锁版本号或行锁来兜底。面试里常问的「MySQL 事务隔离级别为什么和 SQL 标准不完全一样」关键就在 InnoDB 对 REPEATABLE_READ 的额外处理上回答时能点出 MVCC 和间隙锁这两点就够区分度了。5. 回滚进阶setRollbackOnly 手动标记与编程式事务兜底5.1 用 setRollbackOnly 在无异常场景显式回滚业务里经常出现「没有异常但数据状态不符合预期」的情况比如计算结果为负、余额校验失败但又不适合抛异常。此时可以在代码里直接标记当前事务为只回滚Transactional(rollbackFor Exception.class) public void settle(Account account) { BigDecimal result calculate(account); if (result.compareTo(BigDecimal.ZERO) 0) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); return; } accountMapper.updateBalance(account.getId(), result); }setRollbackOnly 会把当前事务标记为 rollback-only方法正常 return 后事务管理器仍然执行回滚。注意两个限制这个调用必须在事务线程内执行异步线程里拿不到当前事务状态标记后事务内的后续数据库操作可以继续执行但最终全部回滚不要指望部分提交生效。5.2 用 TransactionTemplate 做编程式回滚兜底需要精细控制单条记录的提交粒度时TransactionTemplate 比注解更直观适合批量导入这类「每条一个事务」的场景Service public class BatchImportService { private final TransactionTemplate transactionTemplate; public BatchImportService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); this.transactionTemplate.setTimeout(10); } public void importRows(ListRow rows) { for (Row row : rows) { transactionTemplate.execute(status - { importMapper.insert(row); // 返回 null 表示提交抛异常自动回滚 return null; }); } } }5.3 验证回滚是否生效的三步检查法第一步看日志把logging.level.org.springframework.transaction调到 DEBUG观察是否出现 transaction rollback 相关输出第二步直接查库像第三章节的测试那样在异常后重新查询受影响行比对前后数据第三步用断点卡在异常抛出之前检查当前连接是否处于事务活跃状态以及 autocommit 是否为 false。把这套检查链路固定下来线上遇到回滚不生效的问题时按顺序排查代理链路、异常类型和日志输出通常比对着业务代码瞎猜效率高得多。本文还有配套的精品资源点击获取
返回列表