Spring事务从注解到原理:传播行为、隔离级别与事务失效的完整剖析

发布时间:2026/10/16 13:04:12
Spring事务从注解到原理:传播行为、隔离级别与事务失效的完整剖析
Spring事务Transaction这个话题看起来谁都会用但真正出了生产事故能把责任分清楚的人永远是那些把原理吃透的人。我在一线写了快十年的业务系统见过太多线上数据错乱、订单状态对不上、用户被重复扣款的Case最后排查下来十个有七个是事务边界画错了。这篇文章不打算只把ACID那套理论背一遍而是把Spring事务从注解到底层、从传播行为到隔离级别、从正常场景到踩坑现场全部拆开讲。无论你是刚接触Transactional的新人还是已经在项目里“能用但不敢改配置”的老手这篇文章都会给你一些实际操作层面的参考。我先把话放在前面事务这个东西代码写对了是业务逻辑写错了就是线上事故。它能解决“一次操作要么全成、要么全没”的问题Spring做的是把传统编程式事务这层皮给剥掉让开发者在方法上贴个注解就能获得事务能力。可恰恰是这种简单让很多人忽略了背后那一整套机制。所以下面这几章我会从为什么要用事务、Spring事务是怎么设计出来的一路讲到我在运维和排障时遇到的那些教科书没写的坑。1. 项目里的事务痛点为什么很多线上事故都栽在事务边界上1.1 事务要解决的是哪类问题说个最常见的场景。用户下单支付你做了一个方法里面干三件事插入订单记录、扣减用户余额、更新商品库存。如果这三步之间任何一步抛了异常而你又没有事务就会出现“订单已生成但是余额没扣”或“库存已经减了但订单没建”这种脏数据。用户端感知到的就是钱少了但没买到东西或者买到了东西但余额没动。这种问题一旦上了生产恢复成本极高因为脏数据可能已经随着后续业务被放大复制了。这时候事务的作用就很直观把三件事包成一个原子性的操作要么全部成功提交要么全部回滚。Spring给Java生态提供的是一个通用的事务抽象层不管是单机数据库、多数据源还是消息队列和数据库混用你都能在同一个事务框架里管理。1.2 Spring事务相比裸用JDBC事务高明在哪在Spring还没普及的年代开发者在业务代码里写事务通常是这样Connection conn null; try { conn DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); // 执行若干SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { if (conn ! null) conn.close(); }这段代码的问题你能立刻感受到事务逻辑和业务逻辑高度耦合。每个需要事务的业务方法都要重复写一遍try-catch-rollback而且事务边界藏在方法内部同事之间review代码时很难一眼看出哪些操作在同一个事务里。更麻烦的是一旦多了连接池、多数据源、嵌套事务这套原生写法很快就会失控。Spring的核心设计思想就是“横切关注点分离”。它通过AOP把事务这个横切逻辑从业务方法里抽离出去开发者只需要在方法上声明“这个方法需要事务”具体的事务开启、提交、回滚、异常处理全部由Spring和底层的PlatformTransactionManager去完成。这种声明式方案不仅让代码干净也让事务边界从“代码细节”提升到了“配置级/注解级”的可见维度。2. Spring事务的第一层骨架事务管理器与声明式注解是怎么配合的2.1 先认识事务管理器Spring事务这条链路里最核心的角色不是Transactional本身而是PlatformTransactionManager。这是一个顶层接口定义了三个方法getTransaction(TransactionDefinition definition) —— 开启或获取事务commit(TransactionStatus status) —— 提交事务rollback(TransactionStatus status) —— 回滚事务Spring之所以设计成这样一个抽象接口原因很务实底层资源种类繁多。你用JDBC连MySQL时事务是靠Connection管理的你用MyBatis时本质上还是JDBC的Connection你用JPA/Hibernate时事务要交给EntityManager去管理你连接消息队列时事务语义又完全不同。今天把代码写在数据库上明天切到消息队列事务相关代码不能跟着重写一遍这就需要统一抽象。对应到具体实现上Spring提供了一堆事务管理器类DataSourceTransactionManager、JpaTransactionManager、HibernateTransactionManager、JmsTransactionManager、JtaTransactionManager等。多数常规业务项目用到的就是DataSourceTransactionManager它会从DataSource里拿一个连接关闭自动提交然后在commit时做提交在rollback时做回滚。你只需要在配置里声明好这个管理器Spring底层就自动把事务逻辑接上了。2.2 Transactional注解的秘密Transactional大概是Spring家族里最常见也最容易被低估的注解。它的生效分为两个阶段第一步Spring在启动时扫描到某个Bean上有Transactional就为这个Bean生成一个代理对象。代理对象会在目标方法执行之前拦截获取事务管理器根据注解上的属性创建对应的事务执行目标方法方法正常返回执行提交方法抛出异常时根据回滚规则判断是否回滚。第二步方法执行时Spring通过事务同步管理器TransactionSynchronizationManager把当前事务的资源绑定到当前线程上。也就是说同一个线程里后续执行的SQL拿到的都是同一个Connection这样它们才会被包在同一个数据库事务里。这里有个所有开发者都应该记住的结论Transactional是基于Spring动态代理实现的。所以任何绕过代理的调用方式都会让注解失效。这一点我在第4章会重点展开。Transactional注解上的主要配置项包括属性作用默认值value/transactionManager指定用哪个事务管理器默认按类型匹配propagation事务传播行为Propagation.REQUIREDisolation事务隔离级别Isolation.DEFAULT跟随数据库timeout事务超时秒数跟随底层默认通常-1readOnly是否为只读事务falserollbackFor哪些异常触发回滚RuntimeException和ErrornoRollbackFor哪些异常不触发回滚无rollbackForClassName/noRollbackForClassName按类名配置无2.3 声明式事务和编程式事务怎么选前面强调声明式事务的好处是因为它简单、清晰、侵入低。但是写多了你会发现声明式事务有两个天生的短板粒度不够细灵活性不足。某些场景下你只想让一个大方法的部分代码处于事务中另一部分不需要事务或者事务中间必须穿插外部API调用、等待用户输入而这种行为用声明式事务表达非常别扭。这时候就该用编程式事务。Spring里最常用的是TransactionTemplateService public class OrderService { private final TransactionTemplate transactionTemplate; public void createOrderWithMultipleSteps(OrderDTO dto) { transactionTemplate.execute(status - { // 事务内的业务逻辑 orderMapper.insert(dto.toOrder()); accountMapper.deduct(dto.getUserId(), dto.getTotalAmount()); return null; }); } }transactionTemplate.execute()的底层同样走了PlatformTransactionManager异常时自动回滚正常时自动提交。优点是事务边界精确到代码块缺点是事务逻辑嵌在业务逻辑里代码复用性差。我的建议是90%的普通业务用声明式事务剩下的批处理、组合任务、流程控制类场景才用编程式事务。不要因为听了“TransactionTemplate更灵活”就全部切过去代码可读性一定会下降。3. 传播行为和隔离级别八成开发者在发布之前没想清楚的两个配置3.1 事务传播行为事务之间怎么嵌套传播行为解决的是“一个带事务的方法调用了另一个带事务的方法此时这个事务该怎么处理”。这是Spring事务里最贴近实际业务又最容易被误解的一块。传播行为含义典型使用场景REQUIRED有事务就加入当前事务没有则新建事务绝大多数普通业务方法REQUIRES_NEW无论如何都新建一个独立事务外层事务会被挂起操作日志、异步通知、外部接口调用需要独立提交NESTED有事务则创建嵌套事务利用Savepoint没有则新建批量处理某条失败不影响整体SUPPORTS有事务就加入没有就以非事务方式执行查询类方法NOT_SUPPORTED始终以非事务方式运行有事务则挂起大查询、报表统计MANDATORY必须有事务否则抛异常内部校验型逻辑NEVER必须不能有事务否则抛异常特殊场景极少用需要重点解释的是REQUIRED。默认的REQUIRED行为是当两个service方法互相调用时它们用的是同一个事务。这个“同一个事务”不是“两条SQL在同一个连接里这么简单”而是指这两个方法共享一个事务状态任何一个方法抛异常导致整体回滚另一个方法前面已经做的修改也一起回滚。举个例子方法A插入一条订单方法B扣减库存A调用了B。如果B抛了运行时异常A中已经插入的订单也跟着回滚。很多开发者在这里犯迷糊总以为“B自己会回滚A应该自己控制自己的事务”。事实是REQUIRED传播方式下这俩方法根本就在一个事务里谁都不能局部提交。如果你确实想要B独立提交哪怕B失败也不影响A的插入那就用REQUIRES_NEW。REQUIRES_NEW的问题也很明显它会挂起当前事务先开一个新事务两个事务之间互不感知。如果两个事务同时操作同一行数据就存在锁竞争和死锁风险。所以它的使用要克制一般只用于日志记录、消息投递这类即便主业务失败也要成功执行的场景。NESTED和REQUIRES_NEW的区别也容易被混淆。NESTED不创建物理意义上的新事务它是利用数据库的Savepoint在同一个物理事务里做一个嵌套点。嵌套事务的回滚只回滚到Savepoint而不是整个事务。如果你的数据库不支持SavepointNESTED会退化成REQUIRED。这个行为在MySQL和PostgreSQL上通常表现良好但要注意在类似早期版本或特殊中间件上不是全都支持。3.2 隔离级别并发场景下的事务屏障隔离级别解决的是“多个事务同时读写同一行数据时允许谁看到什么”。Spring通过TransactionDefinition里的isolation属性来配置默认值是Isolation.DEFAULT表示跟随所连接的数据库默认级别。拿到代码层面大家最常纠结的就是下面几个隔离级别脏读不可重复读幻读READ_UNCOMMITTED可能可能可能READ_COMMITTED避免可能可能REPEATABLE_READ避免避免可能SERIALIZABLE避免避免避免先解释下三个风险是什么意思。脏读事务A改了数据但还没提交事务B读到了这个未提交的数据然后A回滚B就把一条从不存在的“脏”数据当真了。不可重复读事务A两次读取同一行第一次读到值为1第二次读到值为2因为中间有事务B提交了修改。幻读事务A按条件范围查询第一次查出10条第二次查出11条中间事务B插入了一条符合条件的新数据。实际项目里MySQL默认的REPEATABLE_READ加上InnoDB的MVCC机制已经能在多数情况下把幻读降到很低Oracle和PostgreSQL默认是READ_COMMITTED。Spring里配置的isolation最终都会转化为数据库层面的SET TRANSACTION ISOLATION LEVEL语句所以如果你在注解上设置了一个数据库不支持或者意料之外的隔离级别轻则性能下降重则直接报错。我见过不少同学一遇到并发问题就把隔离级别调到SERIALIZABLE这绝对不是一个好习惯。SERIALIZABLE意味着事务串行执行锁粒度大、并发吞吐断崖式下降。更合理的思路是先分析业务场景是“覆盖写”还是“校验读”再决定用乐观锁版本号、悲观锁SELECT ... FOR UPDATE还是合理的事务隔离级别。事务注解只是第一道防线真正扛并发的是你的SQL写法和锁策略。3.3 回滚规则异常类型决定回滚范围Spring事务的回滚规则默认是“只有RuntimeException和Error触发回滚”。这是一个让无数人踩坑的设计。如果你在业务代码里try-catch捕获异常或者方法抛出的是受检异常默认情况下事务不会回滚。举一个真实案例某扣款服务中方法里调了一个第三方支付接口接口返回状态后用受检异常方式包装失败结果。由于代码没有显式配置rollbackFor Exception.class扣款记录已经更新了但第三方支付实际未成功等到人工对账时才发现账不平。正确写法是Transactional(rollbackFor Exception.class) public void deductBalance(Long userId, BigDecimal amount) throws PayException { // 业务逻辑... }还有一个误区是“我只要抛个异常Spring就会帮我回滚”。不是的只有异常真正从被代理的方法中抛出来AOP拦截器才能感知到异常并执行回滚。你如果自己catch掉了异常Spring看到的是方法正常返回于是执行提交脏数据就被写进去了。4. 事故复盘与排查实录事务失效、异常吞没、数据源混用4.1 自调用导致事务失效自调用是我见过出现频率最高的事务失效原因。什么叫自调用就是一个类内部方法A调用另一个方法B而且两者在同一个类中。Service public class OrderService { public void createOrder(OrderDTO dto) { saveOrder(dto); // 同类中直接调用 deductStock(dto); } Transactional public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } Transactional public void deductStock(OrderDTO dto) { stockMapper.deduct(dto.getSkuId(), dto.getCount()); } }表面上看saveOrder和deductStock都加了Transactional事务应该生效。但问题在于Spring的代理在调用saveOrder时必须经过OrderService的代理对象。而createOrder内部用this.saveOrder()去调用走的还是原生对象不是代理对象所以Transactional根本没被AOP拦截到注解形同虚设。解决办法通常有几种拆成两个独立的Bean把需要加事务的方法放到另一个Service里由Spring注入后调用代理。在类内部通过AopContext.currentProxy()获取当前代理对象再调用。使用TransactionTemplate编程式事务直接把事务包在代码块内彻底绕开代理问题。第三种办法在复杂业务里反而是最靠谱的。它能让你明确看到事务边界不必操心代理关系。4.2 异常被吞导致回滚失败回滚失败第一大元凶就是异常被吞。看下面的代码Transactional(rollbackFor Exception.class) public void pay(OrderDTO dto) { try { orderMapper.insert(dto); accountMapper.deduct(dto.getUserId(), dto.getAmount()); } catch (Exception e) { log.error(支付失败, e); // 没有抛出异常Spring认为方法成功 } }这段代码里如果insert成功而deduct失败catch把异常打日志吞掉了方法最终正常返回Spring就会执行提交。此时订单有了余额没扣数据不一致。所以事务方法里的异常处理要遵循一个铁律事务边界内不要随意catch异常更不要在catch里只打日志不抛出。如果你确实需要记录日志请在catch后重新抛出原异常并且确保抛出时间在事务方法结束之前。或者你的需求就是“局部失败也要保存主流程数据”那你需要考虑的是事务传播行为和嵌套事务而不是吞异常。4.3 多数据源下的事务治理多数据源场景介绍现在项目很少只有一个数据源。最常见的是业务数据库和报表/日志/历史库分离或者分库分表后主库和从库甚至一个Service会同时操作MySQL和Redis甚至ES。Spring事务怎么管直接说结论Transactional默认只能管一个DataSource对应的DataSourceTransactionManager。如果你在一个事务方法里同时操作了两个数据源后一个数据源的操作不会自动纳入同一事务跨数据源原子性根本无法保证。多数据源时常规做法是明确指定事务管理器Transactional(transactionManager orderTransactionManager, rollbackFor Exception.class) public void syncOrderData(OrderDTO dto) { orderMapper.insertOrder(dto); // 走业务库 reportMapper.insertReport(dto); // 走报表库 }但即便指定了管理器你也要清楚这个配置只保证业务库这边的操作有事务报表库那条SQL照样没有事务保护。如果报表库插入失败业务库会跟着回滚但报表库自身已经执行的修改不会回滚。若两个库都需要原子性那就得引入分布式事务方案比如基于消息最终一致性、TCC、两阶段提交2PC等。这些方案成本都不低不是一个小项目能随便驾驭的。所以我在实际项目里的建议是跨界操作时不要追求强事务而是设计“本地事务 事务消息 对账补偿”的最终一致方案。这也正是很多金融平台的核心做法。先在本地事务里写业务表和消息表再异步投递消息去更新另一个数据源失败则走重试和对账。体系复杂但从运行稳定性来看值得投入。4.4 事务标记与只读事务的误解readOnly这个属性很多人以为它是“只让事务执行SELECT禁止UPDATE”。实际上readOnly只是给底层数据库或ORM一个提示这个事务没有写操作可以少做一些锁相关的记录操作你的SQL里写UPDATE也不会被拦截最多在某些框架下会报错。如果你是在一个高并发读多写少的场景下做报表查询用Transactional(readOnly true)确实能减轻部分数据库压力比如MySQL会跳过对undo log的部分维护或对二级索引的维护。但它不是银弹别指望加个readOnly就解决所有慢查询问题。还有一个常见的误解是“只要方法前面加了Transactional数据库连接就能省钱复用”。这句话不完全对。Spring的事务绑定是发生在当前线程上的即使是同一个Service里连续调用两个带事务的方法在REQUIRED传播下确实会复用同一事务。但如果涉及了线程池异步执行新线程里没有绑定原线程的事务资源事务会失效或另起新事务。这也是为什么事务方法里不能随意用Async的原因之一。5. 进阶经验事务日志、性能优化与监控思路5.1 如何用日志实时观察事务状态排查事务问题第一件事就是打开事务日志。Spring Boot项目里在application.yml中配置logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.jdbc.datasource.JdbcTransactionManager: DEBUG开启后你会在日志里看到类似这样的记录Getting transaction for [xxxService.createOrder]Participating in existing transactionCreating new transaction with name [xxxService.deductStock]Initiating transaction commitInitiating transaction rollback这些日志能在第一时间告诉你事务到底有没有被创建是加入现有事务还是新建事务最终提交了还是回滚了我们排障时最常用到的就是“Participating in existing transaction”和“Creating new transaction”这两行。前者说明REQUIRED传播生效了后者说明REQUIRES_NEW或新事务被创建了。如果你发现方法确实加了Transactional但日志里根本没出现“Getting transaction”字样那大概率是代理没生效也就是第4.1节里说的自调用或者被“Spring不管理”的对象调用了。5.2 长事务和慢SQL的相互埋雷长事务是个危害极其隐蔽的问题。一个事务从开启到提交时间越长它持有的数据库连接越久锁的范围也越大。如果这个事务里还混了远程HTTP调用、缓存写入、文件处理那简直是在给数据库上刑。我自己踩过的最惨的一次一个同步订单的方法里事务内调用了外部物流查询接口接口超时设了30秒。结果在促销活动高峰期大量线程都卡在那个事务里数据库连接池被打满整个应用全部服务不可用最后只能重启节点止血。所以事务方法要严格遵循“快进快出”原则任何跟数据库无关的外部操作尽量挪到事务外面。比如先做远程调用拿数据再在事务里只做数据库写操作。也可以把耗时操作放到事务提交之后用TransactionSynchronizationManager的afterCommit回调去执行或者干脆用消息队列异步处理。事务超时配置也要重视。Transactional(timeout 5)设置的超时时间底层实现是控制JDBC Statement的查询超时或连接空闲时间超过了就会抛TransactionTimedOutException。日常开发中大部分场景跟不上这个配置但高并发核心链路上还是建议设置一个合理超时防止慢SQL把整个连接池拖死。5.3 事务与连接池参数如何协同Spring事务和连接池的关系特别像“筷子和碗”。事务从连接池里借走一个连接到事务提交/回滚时才归还。所以连接池大小直接决定了系统能同时开启多少个事务。常见的配置思路是连接池上限maximumPoolSize要大于业务高峰时期并发事务数否则事务会在getConnection()阶段排队。有些人数值拍脑袋定结果一到压测就发现大量“HikariPool-1 - Connection is not available, request timed out after 30000ms”的错误这就是连接池被并发事务吃光了。一个经验值是核心写库连接池设置在CPU核数×2再加一点余量读多写少可以对只读查询走读库用独立的连接池。实际还要看SQL吞吐量、事务耗时和目标响应时间没有万能公式但你可以用监控指标“active连接数”“等待获取连接耗时”来判断是否够用。另外事务中尽量避免使用长生命周期对象比如加载一个大List在内存里处理完后一次性插入这种操作如果包在事务里很容易触发大批量SQL和锁竞争。适当的做法是先准备好批量数据再按批拆开小事务或者用分批提交来降低单事务压力。5.4 分布式事务的替代思路最后聊一下分布式事务。很多团队一听到“跨库一致性”就往上套Seata、TCC、2PC结果项目复杂度暴增运维成本翻倍。从我个人的经验看多数所谓“分布式事务需求”真正的核心是本地数据不丢、外部结果可查、失败可补偿。所以我们按这个思路来拆解本地事务先保证业务表 消息表写入同一个数据库事务这一步是基石。消息一致通过事务消息/本地消息表把需要外发的数据可靠投递重试、幂等、死信告警都配上。对账补偿定期核对两边的数据发现不一致时自动或人工补偿。这套方案虽然没有2PC那么强的即时一致性但它把故障范围控制在单库内部故障恢复也容易。对于绝大多数业务系统这就是性价比最高的选择。真到了“必须即时强一致”的程度那要先想想是不是架构本身出了问题而不是盯着事务方案不放。6. 写在最后的一点项目经验说实话Spring事务写了十年最深的感受是框架帮我们省掉了很多低层事务代码但也把不少隐患藏在了看不见的地方。注解用得太顺手反而容易让人忽略它背后的代理机制、传播规则和回滚条件。我个人建议每个项目组都把下面几条当作事务开发的默认约定有事务的方法参数尽量是简单的DTO或ID事务方法内部不要在循环里做远程调用异常捕获后要么按业务要求重新抛出要么记录后继续但绝不能不抛凡是涉及金额、库存、状态变更的写操作方法全部显式配置rollbackFor Exception.class事务日志默认开启到DEBUG级别方便线上定位问题。事务配置没有银弹只有把你的业务场景、数据库特性和Spring机制三者对齐才能在出问题时稳得住。最后再送大家一个小技巧上线前花半小时把核心链路上每个事务方法的入口、出口、异常分支画一遍这张图比任何框架配置都更能守护你的数据安全。