尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring事务传播机制与失效场景深度解析:七种传播行为一次说清

Spring事务传播机制与失效场景深度解析:七种传播行为一次说清 后端开发只要用到关系型数据库事务就是绕不开的话题。而凡是使用 Spring 搭业务事务的传播机制迟早要跟你正面碰一次这话一点不夸张。面试的时候Java 面试题里 Spring 事务、事务传播机制几乎已经是固定考点Java 八股文清单里十份有九份都会带上它。但比面试更实在的是日常写代码时“加了 Transactional 却失效”“同类方法调用事务不生效”“嵌套调用事务被意外合并”这些问题几乎每个后端都踩过。“Java 外功精要”写到第六篇这篇我们专门把 Spring 事务从头到尾捋一遍。我从真实业务场景出发把事务的底层逻辑、七种传播行为到底怎么用、隔离级别怎么选以及那些官方文档不会直接告诉你的坑一次性说清楚。1. 先把话说在前面事务到底解决了什么问题1.1 从一次转账看事务的四个核心特性假设你要给朋友转 100 块钱银行卡扣款是一步对方账户入账是另一步。如果扣款成功后、入账前系统突然崩了会出现什么情况你的钱没了对方却没收到。没有事务保护的话这种“半截操作”随时可能发生。而有了事务之后要么两步都成功要么两步都失败绝对不会出现“扣款成功但入账失败”的中间状态。事务这个概念来自数据库领域它把一组数据库操作打包成一个不可分割的执行单元单元内部的所有操作要么全部提交commit要么全部回滚rollback。这里说的回滚本质是把数据库状态恢复到事务开始之前的样子并不是简单地把改过的数据“改回去”而是利用数据库的日志机制把已执行的修改逆向还原。数据库凭什么能做到这一点靠的是四个特性也就是大家经常听到的 ACID原子性Atomicity事务内所有操作是一个整体要么全做要么全不做。一致性Consistency事务执行前后数据库的完整性约束不能被破坏。还拿转账举例转出和转入两个账户的余额总和必须保持不变。隔离性Isolation多个事务并发执行时彼此之间不能互相干扰一个事务还没提交的数据另一个事务不应看到中间状态。持久性Durability事务一旦提交修改就是永久的即使之后系统崩溃数据也不能丢。四个特性里面试官最常追问的是实现原理。原子性在 InnoDB 里靠什么实现答案是 undo log。事务执行过程中InnoDB 会把旧值写入 undo log如果需要回滚就根据 undo log 把数据还原回去。持久性靠什么实现答案是 redo log。事务提交前先把修改记录写到 redo log再刷新到数据文件即使数据库异常宕机重启后也能通过 redo log 把已提交的事务恢复出来。隔离性则是锁和 MVCC多版本并发控制配合实现的。这些细节虽然看起来偏底层但在面试场景里属于必考的加分项日常排查问题时也会经常用到。1.2 Spring 在事务这层到底做了什么事数据库本身已经提供了完整的事务能力为什么还需要 Spring 来处理这个问题值得想清楚。你可以直接通过 JDBC 操作事务代码大致长这样Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行 SQL 1 // 执行 SQL 2 conn.commit(); } catch (Exception e) { conn.rollback(); } finally { conn.close(); }这种写法有一个致命的问题事务控制和业务代码被强行耦合在一起。只要一个方法牵扯到事务就必须写一堆 getConnection、commit、rollback、close 的样板代码。业务方法一多代码会变得极其难维护而且这种重复劳动非常容易出错——最常见的就是 catch 里忘了 rollback或者 finally 里忘了关连接最终导致连接池被耗尽。Spring 的解决思路是 AOP也就是面向切面编程。用大白话说Spring 像一个事务代理在你不直接感知的情况下偷偷帮你把 Connection 准备好在方法开始前开启事务在方法正常结束后提交事务在方法抛出异常的时候回滚事务。你只需要在方法上写一个 Transactional 注解剩余的事情全交给框架。这就是“声明式事务”的由来——你声明需求框架执行动作。理解到这里一个问题就自然浮出来了Spring 是怎么知道哪个方法要开事务、开什么类型的事务答案就是事务属性和传播机制这是接下来要展开的核心内容。2. Spring 事务的实现骨架从注解到真实提交的完整链路2.1 声明式事务与编程式事务怎么选Spring 事务分为编程式事务和声明式事务两大类。编程式事务的代表是 TransactionTemplate写法如下Autowired private TransactionTemplate transactionTemplate; public void doSomething() { transactionTemplate.execute(status - { // 业务逻辑 return null; }); }这种方式的好处是控制权完全在自己手里想提交就提交想回滚就回滚也不存在代理失效的问题坏处是侵入性强每个需要事务的方法体里都要包一层模板代码业务代码看着不够干净。声明式事务则完全反过来靠 Transactional 注解声明需求由 Spring 在运行时通过代理切入事务逻辑。日常业务开发中声明式事务是绝对的主流理由很简单代码清爽、职责清晰、配置统一。但是声明式事务有一个暗坑——它依赖代理机制一旦代理不生效注解就形同虚设。这个“失效问题”会在第 4 节详细展开这里先留个悬念。2.2 Transactional 注解的参数每一个都值得搞清楚注解本身并不神奇关键在于 Spring 如何解析它。Transactional 可以放在类上也可以放在方法上方法上的配置优先于类上。它的主要参数如下参数作用默认值propagation事务传播机制REQUIREDisolation隔离级别DEFAULT跟随数据库默认timeout事务超时时间秒-1不超时readOnly是否只读事务falserollbackFor触发回滚的异常类型RuntimeException 和 ErrornoRollbackFor不触发回滚的异常类型无这几个参数里最容易被忽视也最致命的是 rollbackFor。如果不主动配置Spring 默认只对 RuntimeException 和 Error 回滚。如果你的方法里抛出的是 IOException 这类受检异常事务不但不会回滚反而会提交——这和你脑子里“出了异常就该回滚”的直觉完全是两回事。所以务实建议是在 Transactional 上显式写rollbackFor Exception.class把受检异常也纳入回滚范围。虽然从语义上讲某些受检异常可能并不需要回滚但绝大多数业务场景里宁可多回滚也不要让脏数据悄悄落地。这个习惯可以省掉后续大量排查线上问题的时间。2.3 事务管理器连接与事务之间的桥Spring 事务之所以能屏蔽底层细节核心在 PlatformTransactionManager 这个顶层接口它有三个最重要的方法getTransaction、commit、rollback。不同的持久层技术对应不同的实现类DataSourceTransactionManager基于 JDBC / MyBatis 的经典实现管理单个数据源的事务。JpaTransactionManager针对 JPA / Hibernate。JtaTransactionManager用于 JTA 分布式事务。还有 Atomikos、Seata 等分布式事务框架的适配管理器。对于大多数单体应用来说DataSourceTransactionManager 已经够用。它的内部持有 DataSource拿到连接后开启事务、提交、回滚本质还是 JDBC Connection 那一套只是把繁琐样板代码收敛到了框架内部。这里埋了一个很常见的坑如果项目配置了多个数据源又没有给每个数据源指定对应的事务管理器Spring 默认注入的事务管理器可能只管理着其中一个库另一个库的操作压根不在事务保护范围内。这个问题的排查思路我会在第 4 节结合场景细讲。3. 传播机制七种行为逐个拆开看3.1 为什么需要“传播”这个概念传播机制描述的场景是当前已经存在一个事务这时你又调用了另一个带事务的方法此时新方法应该怎么办是加入当前事务还是挂起当前事务另开一个还是干脆报错这个“行为决策”就是事务传播机制。用生活化的类比来说你正在一间会议室开会当前事务这时候有人进来说你有一个紧急电话要打新事务方法。你有两种处理方式一种是在会议室里小声接听事情并入当前会议另一种是离开会议室出去打完电话再回来。七种传播机制本质上就是不同的“进退规则”。理解传播机制必须先理解一个底层事实事务在 Spring 里本质上是绑定在数据库连接上的而连接会在线程栈中传递。所谓“加入当前事务”就是让多个方法共享同一个事务上下文和同一个数据库连接最后统一提交或统一回滚。3.2 REQUIRED默认值也是 90% 场景的答案REQUIRED 是默认传播级别也是实际开发里用得最多的一种。它的规则是如果当前已经存在事务就直接加入当前事务如果当前没有事务就新建一个事务。这里“加入”的含义值得仔细说。它不是开了两个事务再合并而是把新方法的操作全部放进同一个事务上下文里大家共享同一个 Connection 和事务边界。REQUIRED 的好处是直观多个业务方法可以无缝组合成一个完整事务保证整体原子性。举个例子订单服务里创建订单后再调用库存服务的方法扣减库存同一应用内不是微服务。两个方法都加了Transactional(propagation Propagation.REQUIRED)从外部调用 createOrder 时创建订单和扣库存都会在同一个事务里任何一步失败整体回滚。REQUIRED 有一个非常关键但容易忽略的特点它的“事务起点”其实取决于调用链上第一个被事务代理拦截的方法。如果是一个不带事务的普通方法循环调用多个带 Transactional 的方法那么每次调用都会各自开启新事务但如果这个普通方法自身也带事务所有子调用就会并入外层事务。理解这一点对分析“为什么两个方法都加了注解回滚范围却超出预期”非常重要。3.3 REQUIRES_NEW必须独立的场景才能用REQUIRES_NEW 的规则是不管当前有没有事务都新开一个独立事务当前事务挂起等新事务结束之后再恢复。这里的关键词是“挂起”。挂起不是结束当前事务而是暂时把它冻结等新事务提交或回滚后再继续。所以在执行序列上真实发生的事情是外层事务开启 - 内层事务开启 - 内层事务提交 - 外层事务继续 - 外层事务提交。如果外层事务最终回滚已经提交的内层事务不会跟着回滚。典型的应用场景是记录审计日志。比如核心业务操作在事务 A 中不管成功还是失败我都希望把操作日志记录下来。如果日志写入也在同一个事务里一旦核心操作失败日志也会被回滚这显然不符合审计需求。用 REQUIRES_NEW 把日志写入独立出来核心成功则日志记录成功核心失败则日志记录失败原因两边互不影响。但用 REQUIRES_NEW 是有代价的。第一它要额外获取一个数据库连接而连接池是有限的并发高的时候可能造成连接紧张第二它破坏了外层事务的原子性如果调用方想当然地认为“内层方法失败外层也会回滚”就会得到意料之外的结果。所以使用前一定要想清楚这个独立事务到底是“可以独立提交”还是“必须独立提交”。3.4 剩余五种传播行为速览SUPPORTS当前有事务就加入没有事务就以无事务方式执行。适合“可有可无”的查询方法。NOT_SUPPORTED当前有事务就把它挂起以无事务方式执行。适合日志导出等耗时、不想占用事务连接的场景。MANDATORY当前必须有事务否则抛出 IllegalTransactionStateException。适合必须依赖外层事务的内部方法。NEVER当前绝对不能有事务否则抛异常。用在禁止事务的调用链里比如某些特殊的数据清理任务。NESTED嵌套事务。它和 REQUIRES_NEW 有本质区别。NESTED 不是开启一个完全独立的新事务而是在当前事务里开一个“保存点”savepoint。内层回滚时只回滚到保存点不影响外层事务整体但外层回滚时内层一定会跟着回滚。非常适合“某个子步骤失败不影响主流程但主流程失败必须全部撤销”的场景。注意一点NESTED 不一定被所有数据库支持。MySQL 的 InnoDB 通过 savepoint 实现了它但某些数据库或某些连接池配置下可能无法按预期工作。如果要使用建议先在测试环境确认行为符合预期别在生产环境直接上。最后给一句掏心窝的建议真正写业务代码的时候七种传播机制里90% 的情况只需要 REQUIRED 和 REQUIRES_NEW最多再加一个 NESTED。剩下四种大多出现在框架内部实现或者极端场景。面试可以把七种全部背下来工程上要克制不要为了炫技而乱用。4. 事务失效的六大高频原因这些坑我是真踩过4.1 同类内部调用代理对象失效Transactional 基于 Spring AOP而 Spring AOP 的默认实现是动态代理JDK 动态代理或 CGLIB。事务逻辑是通过代理对象注入到方法调用链里的。这里的关键词是外部通过代理对象发起的调用。如果你在同一个类里一个普通方法调用了另一个带 Transactional 的方法比如Service public class OrderService { public void createOrder() { // 调用同类中的扣库存方法 deductStock(); } Transactional public void deductStock() { // ... } }deductStock() 的调用实际是 this.deductStock()this 是目标对象本身不是 Spring 生成的代理对象所以 Transactional 根本不会被解析。这就是“Spring 自己的类方法事务失效”问题。解决办法有三种把两个方法拆到两个不同的 Spring Bean 中通过注入其他 Bean 来调用。在类内部注入自己比如加一个Autowired private OrderService self;然后调用self.deductStock()。使用 TransactionTemplate 手动包裹需要事务的代码段绕开代理问题。第一种方式最推荐因为它顺带让类的职责更清晰。第三种方式则适合那种实在不想拆类、又想精确控制事务范围的场景。4.2 异常被吞掉或者异常类型不对事务失效问题里很大一部分不是 Spring 没开事务而是你在 catch 里把异常吞掉了事务压根不知道要回滚。Transactional public void doSomething() { try { // 业务操作 } catch (Exception e) { log.error(操作失败, e); // 没有抛出异常事务会把已执行的修改提交 } }这段代码的问题极其隐蔽。异常被 catch 住后方法正常返回Spring 认为事务方法执行成功于是提交。数据库里已经写入了一半数据而你毫无察觉。更麻烦的是这类问题的复现概率不稳定可能只在高并发或者特定数据下才出现排查起来相当费劲。正确做法是如果 catch 之后可以重新抛出异常就抛出 RuntimeException如果业务上确实不允许抛出异常那就不要用声明式事务改用 TransactionTemplate在 catch 块里手动调用status.setRollbackOnly()标记回滚。还有一种情况是抛了受检异常但没有配置 rollbackFor事务同样提交。这两类问题都非常常见强烈建议团队规范统一所有 Transactional 方法都显式指定rollbackFor Exception.class。4.3 方法不是 public 或者被 final 修饰Spring 的声明式事务对方法可见性是有要求的非 public 方法上的事务注解默认不生效。原因在于代理机制——JDK 动态代理基于接口只能代理 public 方法CGLIB 虽然能代理非 public 方法但 Spring 在解析事务注解时默认不会处理非 public 方法。Transactional 放在 private、protected 方法上不会报错但完全不生效属于典型的“静默失效”。final 方法的问题同样隐蔽。CGLIB 通过生成子类来代理如果方法是 final 的子类无法重写代理逻辑就插不进去注解自然被忽略。所以事务属性应放在 public 且非 final 的方法上。这一点最好写进团队开发规范因为 IDE 并不会给出任何警告全靠经验去避开。4.4 事务管理器没有覆盖目标数据源当你使用 MyBatis 或 JdbcTemplate 时默认事务管理器管理的是主数据源。如果某个操作走的是另一个数据源而这个数据源没有对应的事务管理器操作就不会被纳入同一个事务。常见于多数据源项目、读写分离场景或者接入了第三方数据源。遇到这类问题排查思路是先开事务日志。在配置里加上logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG观察事务是否真的被开启以及提交和回滚日志输出在哪里。如果事务没有开启问题多半出在代理、方法可见性或者传播机制理解上如果事务开启了但目标数据源没有出现在连接的日志里问题多半出在数据源或事务管理器配置。另外还有一个容易被忽略的问题如果底层表用的是 MyISAM 存储引擎那事务天然就是无效的因为 MyISAM 根本不支持事务。生产环境一定要用支持事务的引擎比如 InnoDB。5. 隔离级别并发场景下的取舍与配置5.1 三种并发异常先搞清楚隔离级别解决的是并发事务互相干扰的问题。理解隔离级别的起点是先把三个经典异常现象搞清楚脏读Dirty Read事务 A 读取了事务 B 尚未提交的数据。如果 B 随后回滚A 就读到了一条不存在的“脏数据”。不可重复读Non-Repeatable Read事务 A 两次读取同一行数据两次结果不同。原因是事务 B 在 A 两次读取之间修改并提交了这行数据。幻读Phantom Read事务 A 按某个条件查询数据第一次查到 N 条第二次再查却多出了几条。原因是事务 B 在两次查询之间插入或删除了符合条件的数据。这三个概念是面试高频点也是实际并发问题的根源。可以这么记脏读是看到了别人未落地的半成品不可重复读是同一行数据前后不一致幻读是范围数据前后数量不一致。5.2 四种隔离级别强度递增性能递减READ UNCOMMITTED读未提交允许读取未提交的数据并发能力最强但脏读、不可重复读、幻读都可能发生。基本没人用。READ COMMITTED读已提交只能读取已提交的数据解决了脏读但不可重复读和幻读仍可能发生。很多数据库的默认级别。REPEATABLE READ可重复读事务执行期间多次读取同一数据结果一致解决了不可重复读。MySQL InnoDB 的默认级别就是它。它通过 MVCC 实现一致性快照在特定条件下也能基本避免幻读但从严格定义上讲要彻底解决幻读仍然需要加锁。SERIALIZABLE串行化强制事务串行执行彻底解决三种异常但并发性能最低实践中很少使用。Spring 里可以用Transactional(isolation Isolation.REPEATABLE_READ)指定隔离级别默认值是 DEFAULT也就是跟随底层数据库的默认隔离级别。5.3 实际业务中的隔离级别怎么选我的建议是绝大多数业务系统直接用数据库默认隔离级别不要为了炫技乱调。MySQL 默认的 REPEATABLE READ 已经能挡住绝大多数并发问题Oracle / PostgreSQL 默认的 READ COMMITTED 在实际业务中也足够好用。只有在非常特殊的场景才需要动手调整比如对账系统中某些强一致校验环节可能需要 SERIALIZABLE。有两个细节容易忽略。第一隔离级别设置越高锁竞争越激烈性能下降越明显不要在没有并发压测的情况下贸然升级。第二事务里的查询行为大多依赖 MVCC 快照但如果你在一个事务里先写后读读到的一定是事务内已修改的数据这一点和隔离级别无关。很多人在这里理解出错排查时绕了不少弯子。还有一个更实际的经验事务中大多数并发问题单靠隔离级别并不能完全解决还需要借助数据库行锁、乐观锁版本号、分布式锁等手段配合。典型如秒杀减库存场景更稳妥的做法是使用 UPDATE 原子操作加条件判断而不是单纯依赖隔离级别。6. 事务边界之外从单体到分布式的一致性思考6.1 为什么分布式事务那么难做单个数据库实例可以用 ACID 保证一致性。但一旦微服务化订单服务写在订单库库存服务写在库存库本地事务根本无法跨库。这时候要保证数据一致就需要引入分布式事务方案。常见的方案方向有三类两阶段提交2PC/ XA强一致性但性能开销大、协调者容易成为瓶颈互联网核心链路很少直接使用。最终一致性 本地消息表 / 事务消息业务接受短暂的不一致通过消息补偿或对账机制最终达成一致。Seata 等开源方案AT、TCC 模式兼顾业务侵入性与一致性是当前微服务场景里的热门选择。这里不展开部署细节但有一点必须强调分布式事务不是银弹再好的框架都无法解决团队对数据一致性理解不到位的问题。设计微服务时要优先思考能不能减少跨库操作能不能用幂等、补偿、对账机制来容忍暂时的不一致。很多时候把“必然失败”变成一个“可重试的最终一致”比强行上强一致分布式事务更靠谱。6.2 一些个人沉淀下来的实用建议最后把我在实际项目里沉淀下来的经验打包出来。第一事务要短。事务方法里不要做远程调用、消息发送、耗时的循环计算。原因很直接事务会持有数据库连接和锁事务越长锁持有时间越久连接池被占满的概率越大系统吞吐量跟着断崖式下降。需要远程调用的部分放到事务外去做通过事件或消息补偿完成。第二事务方法要职责单一。一个 Transactional 方法里只做一个完整的业务闭环不要聚合太多无关操作。如果你已经在一个大事务里调了好几个内层方法一定要在头脑里画出完整的调用链确认回滚边界确实是你期望的那个。等到线上出问题再去看日志成本就高太多了。第三日志和异常要规范。事务方法里的异常要么直接抛要么通过注解声明的 rollbackFor 控制绝对不能吞。每一步关键操作都要打日志这样事务回滚时你能快速定位是哪一步出了问题。回滚是防御行为但如果在代码里连回滚的触发源都找不到排查起来会特别痛苦。回看这几年处理过的线上事故大多数事务问题不是 Spring 的锅而是使用姿势的问题。把基础原理吃透把失效根因记牢事务其实是后端里最稳的一块内容。下次再有人问你 Spring 事务的传播机制希望你看过这篇之后能给他讲出点不一样的东西。
返回列表