news 2026/9/13 6:51:45

Spring事务失效排查指南:从@Transactional底层原理到8大高频场景

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring事务失效排查指南:从@Transactional底层原理到8大高频场景

先问一个问题:你写的@Transactional,真的每次都生效吗?

我见过太多这样的场景:开发人员在 Service 方法上老老实实加了@Transactional,自认为已经给业务上了“保险”,结果接口调用后数据库里该有的数据一样没少,该回滚的却纹丝不动。查日志找半天都看不出来问题,最后发现要么是同类内部方法调用绕过了代理,要么是 catch 把异常吞了,要么是抛出的受检异常压根不在 Spring 默认的回滚范围里。这类问题在代码评审里反复出现,但网上讲得系统的不多,大部分都是零散知识点。

这篇文章就把@Transactional从底层机制到实际使用讲透,内容包括代理与事务管理器的关系、8 个高频失效场景、rollbackFor 的正确姿势、传播行为的实际行为,以及生产环境里事务超时、长事务、多数据源等工程化实践。适合正在用 Spring Boot 做业务开发的 Java 工程师,也适合准备系统梳理事务知识的同学。看完你至少能排查掉 80% 的“事务不生效”问题。

1. 先弄清楚两件事:代理机制与事务管理器

1.1 @Transactional 到底是怎么让事务自动生效的

很多人以为@Transactional是 Spring 通过“拦截”方法实现的,这个方向是对的,但不够准确。严格来说,Spring 的声明式事务是基于AOP(面向切面编程)动态代理实现的。Spring 启动的时候会扫描 Bean,看到带有@Transactional注解的 Bean,就会为目标对象生成一个代理对象,然后把这个代理对象放进容器里。外部调用者(比如 Controller 调 Service)拿到手的其实是这个代理对象,而不是原始对象。

被代理之后,方法调用的链路大致是这样:

// 外部调用者拿到的实际是代理对象 proxy.orderService.methodA() // 代理对象在实际目标方法执行前做事务开启 // -> 真正业务方法执行 // -> 事务提交/回滚

这也就从根上解释了一个常见问题:为什么同类内部this.xxx()调用会失效?因为你调的是原始对象的methodA,没有穿过代理,Spring 压根没有机会在调用前后做事务处理。这个点我后面会详细展开,先说结论:@Transactional的作用范围是“代理可见的调用”,不是“类内部所有方法”。

1.2 三层结构:代理、事务管理器、数据库连接

要理解@Transactional,你需要知道它背后站着三个角色:

层次角色职责
注解层@Transactional声明事务边界,提供传播行为、隔离级别、超时时间等配置
事务管理器PlatformTransactionManagerSpring 抽象出来的事务管理入口,负责开启、提交、回滚事务
底层资源DataSource/Connection真正持有数据库连接,执行setAutoCommit(false)commit()rollback()

Spring Boot 默认在引入spring-boot-starter-jdbcspring-boot-starter-data-jpa后,会自动配置一个DataSourceTransactionManager。它是PlatformTransactionManager的实现类,专门管理基于 JDBC 的事务。

我之前排查过一个老项目,发现事务始终不生效,最后一看配置,项目里自定义了一个TransactionManager,但类型写错了或者没有交给 Spring 管理。代码层面用的是@Transactional,底层却没有任何事务管理器在处理,自然就没有事务。所以排查事务问题时,先确认容器里到底有没有可用的PlatformTransactionManager,这是第一步。

2. 事务失效的 8 个高频场景与排查思路

2.1 同类内部调用:最常见的“没走代理”坑

这个问题排在第一位,因为出现频率实在太高。看下面的代码:

@Service public class OrderService { @Transactional public void createOrder() { // 业务逻辑 this.updateStock(); } @Transactional public void updateStock() { // 库存扣减 } }

如果createOrder()执行到一半抛出异常,你希望updateStock()里的数据库操作也一起回滚。但实际上this.updateStock()是从原始对象直接发起的方法调用,没有经过 Spring 生成的代理对象,所以updateStock上的@Transactional根本没有意义——它不会被增强。

解决这个问题的常用方案有几种:

  • 把内部调用拆到另一个 Service 类里,让外部代理介入;
  • 注入自己的代理对象:@Autowired private OrderService self;,然后self.updateStock()
  • 使用AopContext.currentProxy()获取当前代理对象。

我自己比较推荐拆类的方式,因为拆类往往意味着职责更清晰,而且也不用处理自注入可能带来的循环依赖问题。如果是同一个类里的私有方法,把事务操作往后移一层,或者干脆把方法改成 public,同时注意代理入口,这才是根本解。

2.2 方法修饰符、异常捕获、代理方式:一网打尽失效清单

同类调用之外,还有很多隐蔽的失效场景。我把它们整理成一张速查表,每一条背后都是代码评审里实际踩过的坑。

失效场景根因解决方式
方法不是 publicCGLIB 和 JDK 动态代理对非 public 方法都不保证事务增强改成 public,或把事务逻辑放进 public 方法
类没有被 Spring 管理没有加@Service等注解,或者由new手动创建交给容器管理
方法内 try-catch 吞掉异常事务拦截器拿到的是正常返回,认为该方法成功捕获后重新抛出,或手动标记 rollback
抛出的异常是受检异常(checked exception)Spring 默认只对 RuntimeException 和 Error 回滚配置rollbackFor = Exception.class(见第 3 章)
方法在调用链中经过this调用代理不感知内部调用通过代理调用,或拆到其他 Bean
数据库表引擎不支持事务例如 MySQL 使用 MyISAM改用 InnoDB
多线程调用新建线程不在原事务上下文中不要在线程里直接操作事务资源,必要时用编程式事务
代理方式限制接口代理时,目标类没有实现接口使用 CGLIB 或确保接口存在

这里面“异常被吞”是最气的。有一次我带团队排查线上数据不一致,代码看起来加事务加得挺全,结果发现业务同学 catch 异常后只log.error()了一下,异常变量甚至没打印完整。事务拦截器在看到方法正常返回时,压根不知道里面出了事,直接提交了。所以如果你要 catch 异常,务必保证事务能感知到异常——要么原样抛出,要么手动设置回滚状态。

多线程这个场景我再多说一句:@Transactional绑定的是线程内的Connection资源。你新开的线程,拿到的Connection和主线程的Connection根本不是同一个,主线程的事务管不到子线程里的数据库操作。子线程报错回滚,也不会影响主线程事务的提交/回滚。所以网上“方法里 new Thread 去做数据同步”的做法,只要涉及事务,基本都是假的。

3. rollbackFor 的细节:为什么异常捕获后事务没有回滚

3.1 Spring 默认的回滚策略不是“全回滚”

很多人默认以为,只要方法抛了异常@Transactional就会回滚。实际不是。Spring 的默认规则是:只有抛出 RuntimeException(运行时异常)或 Error 时,事务才回滚;如果抛出的是受检异常(Checked Exception),事务不会回滚。

这个设计是有历史背景的——早期 EJB 的规范里就有类似约定,Spring 延续了这个思路。它认为受检异常是“可以预期的业务异常”,比如用户余额不足、库存不够,这类情况不应该把已经做了一半的操作全部回滚,而应该让调用层去处理。但在实际业务系统里,这个默认策略往往会带来困惑。

我举一个很实际的例子。你写了一个导入 Excel 的方法:

@Transactional public void importExcel(List<Row> rows) throws ParseException { for (Row row : rows) { // 解析每一行,写入数据库 } }

方法声明throws ParseException,某一行解析出错时,方法直接抛出这个受检异常。你以为数据回滚了,但实际上事务已经提交了,数据库里可能留了半截数据。这就是典型的“默认不回滚受检异常”埋下的坑。

3.2 受检异常到底怎么处理:正确配置 rollbackFor

最稳妥的写法,是在注解上明确指定对所有异常回滚:

@Transactional(rollbackFor = Exception.class) public void importExcel(List<Row> rows) throws ParseException { // ... }

也可以指定具体的异常类型:

@Transactional(rollbackFor = {ParseException.class, BusinessException.class})

还可以排除某些异常不回滚:

@Transactional(noRollbackFor = StatusException.class)

如果业务里有很多检查性异常,我的习惯是定义自己的业务异常基类,让它继承RuntimeException,这样默认策略就已经覆盖了大部分“出错即回滚”的需求,同时不需要在每个方法上都写rollbackFor。比如:

public class BizException extends RuntimeException { public BizException(String message) { super(message); } }

然后代码里该抛出业务异常就抛,事务默认就会回滚,不需要额外记忆 Spring 那套受检异常规则。如果确实存在某些“不需要回滚”的异常,再用noRollbackFor精确排除,这样语义更明确。

3.3 手动标记回滚与事务状态的控制

还有一种情况,你 catch 住异常后做了补充逻辑,但还是希望事务回滚。这时可以手动把当前事务标记为 rollback-only:

@Transactional public void businessMethod() { try { // 某些操作 } catch (Exception e) { log.error("业务处理异常", e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 其余补偿逻辑 } }

这个 API 在很多事务排查里非常有用。不过要提醒一句:setRollbackOnly()只是把当前事务标记为只回滚,它不会立刻中断方法执行,也不代表数据库马上回滚了,要等整个事务方法退出时才真正回滚。因此如果方法后续还有数据库操作,它们依然会执行到提交前的那一刻,然后被整体回滚。如果你希望后续代码不再执行,还是要显式 return 或 throw。

这里还需要理解一个“内外传播”下的特殊情况:如果外层事务调用了内层事务方法,内层事务被标记为 rollback-only 后,即使外层 catch 住了异常,外层事务在提交阶段也会抛出UnexpectedRollbackException,然后整体回滚。这个坑在事务嵌套里特别常见,这也是为什么很多生产环境“明明 catch 了异常还是会抛错”的原因。

4. 事务传播行为与嵌套事务的真实行为

4.1 7 种传播行为对比:一张表看懂

@Transactional上有一个propagation属性,用来控制事务的传播行为。初学者容易忽略它,但它实际影响了方法之间的调用关系。Spring 一共定义了 7 种:

传播行为含义使用场景
REQUIRED(默认)当前存在事务就加入,没有就新建大多数业务方法
REQUIRES_NEW当前存在事务就挂起,新建一个独立事务日志记录、审计、发送消息后独立提交
SUPPORTS当前存在事务就加入,没有就以非事务方式运行只读查询,有事务就参与
NOT_SUPPORTED当前存在事务就挂起,以非事务方式运行某些特殊场景,不希望事务参与
MANDATORY当前必须存在事务,否则抛异常强制要求调用方开启事务的方法
NEVER当前不能存在事务,存在就抛异常禁止事务参与的方法
NESTED当前存在事务则创建嵌套事务,没有则新建保存点机制,局部回滚

4.2 REQUIRED 与 REQUIRES_NEW:到底有什么区别

这两个在实际业务里是高频使用的。理解它们的关键,是明白“加入事务”和“新开事务”本质上的不同。

先看REQUIRED。外层方法 A 开启事务,中间调用 B,B 的传播行为是REQUIRED,那么 B 不会新开事务,而是加入 A 的事务。此时 A 和 B 底层用的是同一个数据库连接,共享同一份事务上下文。B 如果抛异常,A 也没有特殊处理,那事务必然回滚。

再看REQUIRES_NEW。B 声明的是REQUIRES_NEW,那么 A 调用 B 时,A 的事务会被挂起,B 在全新的连接上开启新事务。B 提交后,A 再接着跑,最后 A 提交或回滚。关键区别是:

  • B 出异常回滚时,A 可以 catch 住继续执行,A 的事务不受 B 回滚影响;
  • B 成功提交了,但 A 后面失败回滚,B 已经提交的部分不会被回滚。

用我当年做过的一个订单场景来举例:主流程创建订单(大事务),订单创建成功后要写一条敏感操作日志,这个日志我希望它一定能落库,不能因为主流程失败就连日志也没了。这时候就不能让日志写入加入主事务,而要给它标记REQUIRES_NEW,让它独立提交。

@Transactional public void createOrder(OrderDO order) { // 订单表插入等核心逻辑 sensitiveLogService.record(order); // 该方法使用 REQUIRES_NEW }

如果recordREQUIRED,那么主事务回滚时日志也跟着没;如果recordREQUIRES_NEW,日志先独立提交,主事务挂了也不影响日志的完整落库。要注意的是,REQUIRES_NEW意味着多占一个数据库连接,如果调用链特别深,要关注连接池是否够用。

4.3 NESTED 与保存点:一种容易被误解的“部分回滚”

NESTED传播行为很多文章提得少,但它和REQUIRES_NEW很容易搞混。NESTED不是开一个真正独立的事务,它利用数据库的保存点(Savepoint)机制实现部分回滚。外层事务还在,内层发生异常,可以只回滚到保存点位置,外层仍可选择继续提交。

但这里有个前提:底层数据库和 JDBC 驱动必须支持保存点。MySQL 的 InnoDB 支持,但某些国产数据库或老版本驱动不一定支持。如果驱动不支持保存点,NESTED会退化成和REQUIRED一样的行为——内层异常直接影响整个事务,这种情况下你想“局部回滚、外层继续提交”的愿望就落空了。

我在一个项目里踩过这个坑:当时让一个第三方接口的同步方法使用NESTED,认为内层失败不会影响外层,结果排查时发现数据库根本不支持保存点,回滚直接把外层也带崩了。后来换了REQUIRES_NEW,业务才符合预期。所以用NESTED之前先确认你的数据库支持情况,别只看 Spring 文档上的理论行为。

5. 生产环境的工程化实践:从参数配置到长事务治理

5.1 readOnly、timeout、rollbackFor 的推荐写法

很多项目中@Transactional就光秃秃一个注解,其他属性全靠默认值。日常低并发没问题,但到了核心交易链路,就值得把几个关键参数显式配置出来。

readOnly = true表示当前事务是只读事务。它的意义在于给事务管理器一个优化提示:对于 JDBC 层面,会把这个连接设置为只读模式,数据库也可以针对只读事务做一些优化。比如 Hibernate 会跳过脏检查,某些数据库对只读连接能省去部分锁开销。查询接口如果确实只有一个查询操作,加上readOnly = true是合理的。但你必须保证方法里没有写操作,不然会出现意料之外的行为——有些数据库会直接报错,有些则仍然能写进去。

timeout属性配置事务超时时间,单位是秒,默认值 -1 表示不超时。它的作用是当事务执行超过指定时间后,Spring 会尝试标记事务回滚。比较合理的做法是根据具体业务设置,比如一些批量处理方法给个 30 秒超时:

@Transactional(rollbackFor = Exception.class, timeout = 30) public void batchUpdate() { // ... }

不过要提醒一句,timeout的计时是从事务开始到事务结束,不是单个 SQL 的执行时间。如果某个 SQL 本身执行时间很长,你还需要结合数据库驱动的queryTimeout来设置。

推荐写法可以这样统一:

@Service public class UserService { // 查询场景 @Transactional(readOnly = true) public UserDO getById(Long id) { return userMapper.selectById(id); } // 写场景 @Transactional(rollbackFor = Exception.class) public void updateUser(UserDO user) { userMapper.updateById(user); } }

这里有一个容易忽略的点:查询场景里,即便只调了一个查询方法,Spring 的DataSourceTransactionManager默认也会获取一个数据库连接,开启事务。如果你的方法只是单条查询,完全可以不加@Transactional,减少一次事务开启和关闭的开销。但如果是多个查询,又希望它们在一个事务里保证数据快照一致,那加上readOnly = true更安全。

5.2 数据库引擎、连接池与长事务的隐形关联

@Transactional生效的前提,是底层数据库本身支持事务。这不是废话,我确实遇到过:项目从 MySQL 迁移到某些云数据库服务,或者有人建表时误用了老引擎,事务注解形同虚设。MySQL 里现在主流是 InnoDB,它支持事务,而 MyISAM 不支持。你可以在建表语句里明确指定:

CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, ... ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

检查已有表引擎可以用:

SHOW TABLE STATUS WHERE Name = 'order';

另一个更隐蔽、更影响生产稳定性的,是长事务问题。事务是持有数据库连接的,@Transactional方法从开启事务到方法结束,这个数据库连接就一直被占着。如果你在事务方法里做了远程 HTTP 调用、Redis 批量写入、消息队列发送、复杂计算这类耗时操作,连接就会被拖住很长时间。高并发下连接池很快被耗尽,新请求拿不到连接,直接把整个服务拖垮。

我之前诊断过一个线上卡顿:服务大面积超时,数据库 CPU 不高但活跃连接数持续满。最终定位到一个事务方法里调了外部短信平台的接口,外部服务超时重试 3 次,每次 5 秒,这一个请求就占着连接至少 15 秒。连接池 50 个连接,10 个这样的请求就能打满。后来我把远程调用挪到事务外面,连接释放快,问题立刻缓解。

所以这里有一条黄金法则:事务里只做数据库操作和必要的内存逻辑,凡是涉及外部 IO 的操作,尽量放到事务之外,要么先发事件,要么事务提交后再执行。如果确实有“事务提交后必须发送消息”的需求,可以考虑 Spring 的TransactionSynchronizationManager.registerSynchronization(),在事务提交后的回调里再发消息。

5.3 多数据源与分布式场景下的边界意识

@Transactional只对单个数据源的本地事务有效。如果项目里配置了多个数据源,比如一个 MySQL 一个 Oracle,或者一个库用于业务、一个库用于日志,你需要在配置层面明确每个DataSourceTransactionManager管理哪个数据源。同一个事务注解,默认只会绑定到主数据源的事务管理器上,另一个数据源的操作不在事务控制范围内。

在多数据源场景,常见做法是使用@DS这类动态数据源切换框架,再配合对应的事务管理器。但你心里要清楚:动态数据源切换本身无法保证跨库的一致性,它只是把当前线程的数据源上下文切换到目标库,事务依然局限在某一个库内部。

如果业务真正需要跨库一致性,@Transactional是解决不了的。这时候需要引入分布式事务方案,比如 Seata 的 AT 模式、TCC 模式,或者基于本地消息表与最终一致性的方案。这些方案各有取舍,不是银弹。我见过最糟糕的情况是团队想用一个注解包打天下,处理跨库转账,结果数据全乱了才回头排查。@Transactional的边界意识一定要有:它可以保证单个数据库连接的原子性,管不了多个资源之间的全局一致。

还有一点容易被忽略:@Transactional在有定时任务、MQ 消费的场景也会生效,前提是这些组件通过 Spring 容器调用你的事务 Bean。不过由于 MQ 消费天然是异步的,事务回滚后消息可能已经发出去了,需要考虑补偿机制。这类问题不是@Transactional本身失效,而是事务边界和消息投递的时机没有配合好。

最后分享一个我自己排查事务问题的固定套路:第一步确认@Transactional所在类是否由 Spring 管理;第二步确认调用入口是否穿过代理;第三步确认方法是否 public;第四步确认异常类型是否满足回滚条件;第五步确认数据库引擎和事务管理器。这五步走完,绝大多数“事务不生效”都能找到答案。

在我实际带项目的过程中,碰到事务相关的疑难杂症,最有效的不是反复看理论,而是把事务边界和业务边界画出来。先画正常路径,再画异常路径,看每个路径上事务的开启、提交、回滚时机是否合理。@Transactional本质是把原本要手动控制的begin / commit / rollback交给了 Spring 的代理,你只要把代理调用链理顺,把异常看住,它就不会给你惹麻烦。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 6:50:33

AR开发核心技术:空间计算与交互实现详解

1. AR技术中的空间计算基础解析 当我们在手机屏幕上看到虚拟恐龙在客厅里踱步&#xff0c;或是通过AR眼镜看到导航箭头直接投射在真实路面上时&#xff0c;背后都离不开一套精密的数学计算体系。作为AR开发的核心支撑&#xff0c;空间计算技术决定了虚拟内容能否准确"锚定…

作者头像 李华
网站建设 2026/9/13 6:45:57

二维差分数组详解:从矩形批量更新到前缀和的高效算法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 6:45:45

老旧安卓机也能跑30fps?AI美颜特效渲染优化实践拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华