Spring Boot事务实战:隔离级别、传播特性与失效排查

发布时间:2026/9/13 6:08:39
Spring Boot事务实战:隔离级别、传播特性与失效排查 想象一个再常见不过的场景用户在App上下了一单后台先扣库存、再生成订单、最后给用户加积分。如果扣完库存正要写订单时数据库连接突然断开等排查完发现库存已经扣了订单却没有怎么办这就是事务存在的意义。Spring Boot作为现在Java后端最主流的框架提供了非常好用的事务抽象很多人都会用Transactional但真被问到“隔离级别怎么选”“传播特性到底干什么用”时往往就开始含糊了。这篇博客我不打算讲教科书式的定义。我会从实际业务场景出发把Spring Boot的事务机制、四种隔离级别、七种传播特性一一拆开来讲顺便把我在项目里踩过的事务失效的坑也一并交代清楚。标题里藏着的两个核心问题——“Spring中如何使用事务”和“不同隔离级别的区别”——我会在后文逐一展开。不管你是刚接触Spring Boot的新手还是写了两三年业务代码但一直对事务一知半解的同学这篇文章应该都能给你一些实在的东西。1. 从一次线上数据错乱说起事务到底在解决什么问题开始之前先把事务回归到最朴素的定义。事务是一组操作的集合要么全部成功要么全部失败不存在中间状态。数据库之所以要提供事务是为了保证数据在面对并发、异常时依然可靠也就是ACID原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。1.1 没有事务时一次转账操作可能发生什么我们用一个银行转账的例子来理解。A账户要向B账户转1000元在代码里就是两步操作第一步A账户扣减1000第二步B账户增加1000。如果没有事务第一步成功了第二步因为网络超时、数据库报错等原因失败A的钱少了B的钱没多这笔账怎么都对不上。这里的关键点在于数据库的每一条SQL执行本身是自动提交的。也就是说如果你不在代码里显式开启事务扣款SQL执行完就永久生效了。Spring中的Transactional做的事情本质上就是把一组SQL纳入同一个事务边界让它们要么一起提交要么一起回滚。这一点理解清楚了后面所有内容才有根基。1.2 Spring Boot里的事务到底由谁在执行很多初学Spring Boot的人会有一个误解以为Transactional是Spring容器在魔法般地管理事务。实际上Spring的事务管理建立在两个基础能力之上平台事务管理器PlatformTransactionManagerSpring Boot的自动配置会根据你引入的依赖自动装配。比如你引入了spring-boot-starter-jdbc或mybatis-spring-boot-starter默认注入的就是DataSourceTransactionManager如果你用的是Spring Data JPA则会装配JpaTransactionManager。动态代理AOP当你在某个方法上标注TransactionalSpring会在容器启动时对这个类的Bean生成代理对象所有外部调用都会先经过代理。代理负责开启事务、提交事务、回滚事务再执行业务代码。我们来看一个最简单的使用方式Service public class OrderService { Autowired private ProductStockMapper stockMapper; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { // 1. 扣减库存 stockMapper.deduct(dto.getProductId(), dto.getCount()); // 2. 创建订单 orderMapper.insert(dto); // 3. 积分服务调用等 } }这里需要注意rollbackFor Exception.class。Spring默认只对运行时异常RuntimeException和Error进行回滚如果你直接写Transactional而不指定rollbackFor当业务代码抛出IOException、SQLException这类受检异常时事务是不会回滚的。这一点我在第四节还会专门展开。1.3 事务边界、事务管理器、回滚规则三个必须先理清的概念理解Spring事务有三个概念绕不开事务边界指的是一个事务从哪行代码开始到哪行代码结束。Transactional标注在方法上时事务边界就是进入方法前开启方法正常返回时提交方法抛出异常时回滚。需要注意的是事务边界默认只在方法级别一个事务不会跨越两个不同方法的调用链除非你把Transactional同时标在多个方法上并且传播行为允许复用事务。事务管理器Spring提供的事务抽象层核心接口。DataSourceTransactionManager内部做的事情可以简化理解为从数据源getConnection()拿到数据库连接然后调用连接的setAutoCommit(false)最后执行connection.commit()或connection.rollback()。这里的重点是一个事务绑定的是一个数据库连接所有SQL都要在同一个连接上执行。回滚规则Spring的事务回滚由异常触发。默认情况下只有方法抛出的异常往外传播经过代理对象时代理才会决定回滚。如果你的代码内部把异常吞掉了Spring根本感知不到异常发生自然不会回滚。这是事务失效最常见的原因之一。这三件事理解了你再看网上各种关于事务的教程基本不会再有看不懂的情况。2. 四种隔离级别脏读、不可重复读、幻读是怎么被解决的隔离级别解决的其实是一类问题多个事务同时操作同一条数据时彼此之间能看到什么、不能看到什么。2.1 隔离级别不是在Spring层实现的理解这点很重要首先要说明的是Transactional注解上写的isolation Isolation.READ_COMMITTED并不会由Spring自己去实现什么逻辑。Spring只是通过JDBC连接向数据库发送一条类似SET TRANSACTION ISOLATION LEVEL READ COMMITTED的指令真正的隔离行为由数据库的锁机制和MVCC多版本并发控制来保证。所以同样的隔离级别在不同数据库上的实际表现可能不一样。MySQL的默认隔离级别是REPEATABLE_READ而Oracle、PostgreSQL默认是READ_COMMITTED。在项目中讨论隔离级别时一定要先确认底层数据库是谁。2.2 每种隔离级别解决什么牺牲什么数据库标准定义了四种隔离级别从宽松到严格依次是隔离级别脏读不可重复读幻读实现方式与代价READ_UNCOMMITTED读未提交可能发生可能发生可能发生基本不加锁性能最好但数据最不可靠READ_COMMITTED读已提交不会发生可能发生可能发生读时读取已提交的最新版本写时加行锁REPEATABLE_READ可重复读不会发生不会发生可能发生MySQL通过间隙锁解决读使用MVCC快照写加行锁间隙锁SERIALIZABLE串行化不会发生不会发生不会发生读加共享锁写加排他锁并发能力严重下降脏读一个事务读到了另一个事务尚未提交的数据。假设事务A把商品价格从100改成80还没有提交。事务B在这个时候读价格读到的是80。如果事务A最终回滚了那么B就基于一个不存在的数据做了决策。这显然不可接受。READ_UNCOMMITTED级别下B会读到A未提交的修改脏读因此产生。不可重复读一个事务内两次读取同一条记录结果却不一样。还是那个价格例子事务B第一次读价格是100在B还没结束时事务A把价格改成了80并提交B第二次再读价格变成了80。也就是说同一事务内读到的数据前后不一致。READ_COMMITTED解决了脏读但无法避免不可重复读因为它每次读到的都是最新已提交版本。幻读一个事务内两次执行同一条查询语句得到的结果集条数不一致。比如一个统计订单数量的查询第一次查出来是10条在事务未结束时另一个事务插入了一条新订单并提交第二次查询变成11条。多出来的那条就像幻觉一样。不可重复读关注的是同一条数据内容变化幻读关注的是一批数据的数量变化。MySQL默认的REPEATABLE_READ级别下快照读已经基本避免了幻读但如果使用当前读如SELECT ... FOR UPDATE依然可能发生MySQL主要依靠间隙锁来解决。2.3 我在实际项目中怎么选隔离级别真实项目里绝大多数场景用数据库默认级别就够了。换句话说MySQL项目你就用REPEATABLE_READPostgreSQL/Oracle项目你就用READ_COMMITTED。我对团队的建议从来是除非有明确的数据一致性需求否则不要轻易改全局隔离级别。什么情况下才需要手动指定隔离级别举一个真实例子。在某个对账系统中我们需要先查询一批账单数据再根据账单结果做后续处理。这个场景要求整个处理过程中查询出来的数据不能被其他事务修改否则前后计算对不上。这时候我们把事务隔离级别设置为SERIALIZABLE用并发换准确因为对账本身就是低频任务性能损失可以接受。Transactional(isolation Isolation.SERIALIZABLE) public void reconcile(ListLong billIds) { // 对账逻辑查询、比对、状态更新 }反过来如果是一个高并发的库存扣减场景我不会依赖隔离级别解决问题而是考虑用乐观锁例如UPDATE ... SET stock stock - #{count} WHERE id #{id} AND stock #{count}、分布式锁或者Redis原子操作。把隔离级别调到SERIALIZABLE在这种场景下往往是下策。2.4 一个容易忽略的问题并发锁和隔离级别是一对搭档隔离级别和数据库锁不是各自独立的东西。REPEATABLE_READ之所以能保证同一个事务多次读到的数据一致依赖的是MVCC的快照读机制但当你执行SELECT ... FOR UPDATE或UPDATE语句时走的是当前读会加行锁甚至间隙锁。当两个事务试图更新同一条记录时后执行的一方会被阻塞直到前一个事务提交或回滚。这里有个很隐蔽的坑在READ_COMMITTED级别下一个事务内的两条UPDATE语句之间如果该记录被其他事务修改并提交了当前事务的第二次更新会直接基于最新值执行而不会重新校验之前的条件。换句话说读已提交级别下你无法在一个事务内用两条UPDATE实现可重复读的效果。如果业务上需要先查后改且不允许中间被改最好的办法不是调隔离级别而是用SELECT ... FOR UPDATE主动加锁或者把隔离级别调到REPEATABLE_READ。3. 传播特性七个选项真正常用的其实就几个如果说隔离级别解决的是事务与事务之间相互隔离的程度传播特性解决的就是当两个带事务的方法相互调用时事务该如何合并、挂起、或各自独立。这里要强调传播特性的核心发生场景是同一个线程内、同一个Spring容器内、两个Transactional方法之间的调用关系。3.1 从一个子方法也要事务的调用场景说起假设有一个订单服务创建订单后需要给用户发送一条站内消息。订单创建和消息发送各自有独立的Service方法。问题是如果消息发送失败订单要不要一起回滚这就引出了Spring支持的七种传播行为。我先用一张表把全部角色列出来再挑重点逐一说传播行为含义典型场景REQUIRED有事务则加入没有则新建默认值绝大多数业务方法适合SUPPORTS有事务则加入没有就以非事务方式执行查询方法有没有事务都行MANDATORY必须已有事务否则抛异常不能被独立调用的内部方法REQUIRES_NEW总是新建一个独立事务挂起当前事务写日志、消息推送等独立提交场景NOT_SUPPORTED以非事务方式执行挂起当前事务某些不建议在事务里执行的长任务NEVER必须以非事务方式执行否则抛异常明确禁止事务的清理、批处理操作NESTED嵌套事务内部事务基于Savepoint回滚批量处理中的单条失败不影响整体3.2 REQUIRED默认值用对了没REQUIRED是Transactional的默认值。它的规则是如果当前线程已经存在一个事务就直接加入这个事务如果当前没有事务就新建一个。这也是我身边绝大多数人日常写的唯一一种传播级别。值得展开的是加入现有事务到底意味着什么。加入事务最直观的后果是内层方法的回滚会影响外层事务的整体状态。举例来说Service public class OrderService { Autowired private MessageService messageService; Transactional(rollbackFor Exception.class) public void createOrder() { // 订单创建逻辑 try { messageService.sendMessage(); } catch (Exception e) { // 什么都没做 } } } Service public class MessageService { Transactional(rollbackFor Exception.class) public void sendMessage() { // 如果这里抛异常 } }在上面代码中sendMessage()加了REQUIRED它加入的是createOrder()开启的事务。当sendMessage()抛出异常时这个异常默认会传递到外层方法标记整个事务为rollback-only。即使外层方法用try-catch把异常吞掉了事务最终提交时也会抛出UnexpectedRollbackException数据照样回滚。这个现象很多人在第一次遇到时都非常困惑——明明没让事务回滚为什么数据还是没写进去3.3 REQUIRES_NEW需要独立提交时的首选REQUIRES_NEW的语义很干净不管当前有没有事务都新建一个独立事务当前事务先挂起等新事务执行完再恢复。这个传播级别最常见的应用场景是操作日志。比如一个核心业务方法开启了一个长事务期间要记录每一步的操作日志。如果你把日志写入和业务逻辑放在同一个事务里一旦业务逻辑后续回滚日志也会被回滚掉这就失去了审计的意义。这时候日志方法就应该用REQUIRES_NEWTransactional(propagation Propagation.REQUIRES_NEW) public void writeLog(LogEntity log) { logMapper.insert(log); }用REQUIRES_NEW之后即使外层事务回滚日志数据也已经独立提交了。我最早从这个特性里尝到甜头是在处理定时任务时要记录每个批次任务的执行结果外层失败不影响任务日志的留存。不过要提醒一句REQUIRES_NEW不是免费的。它意味着一次额外的数据库连接获取和释放在高并发场景下可能放大数据库连接池的压力。一个方法调用链条上如果嵌套了三层REQUIRES_NEW那就同时有三条连接被占用。所以能用REQUIRED就不要图省事全用REQUIRES_NEW。3.4 NESTED部分成功也能接受的批量任务救星NESTED是用Savepoint机制实现的嵌套事务。它和REQUIRED的关键区别在于NESTED不是简单加入外层事务而是设置一个回滚保存点。当内层事务抛异常时外层事务可以只回滚到保存点不一定要整体回滚。这个特性在处理批量数据时非常好用。比如一个批量导入员工的场景需求是一条数据失败不影响其他数据导入成功Transactional(rollbackFor Exception.class) public void batchImport(ListEmployee employees) { for (Employee employee : employees) { try { importSingle(employee); } catch (Exception e) { // 记录失败原因继续处理下一条 } } } Transactional(propagation Propagation.NESTED) public void importSingle(Employee employee) { // 单条导入逻辑失败时只回滚自己这一条 }这里有个很关键的实现细节NESTED依赖数据库的Savepoint能力。在MySQL中使用的是SAVEPOINT语法在部分不支持Savepoint的数据库或某些连接池场景下NESTED可能退化为和REQUIRED一样的行为。使用前最好确认你的数据库和驱动支持情况。很多人会拿NESTED和REQUIRES_NEW做对比我简单做个区分REQUIRES_NEW是物理上的独立事务它的提交和回滚完全不受外层影响NESTED是逻辑上的子事务内层回滚只回滚到保存点外层事务最终如何结束还是由外层决定。如果你需要内层先独立提交选REQUIRES_NEW如果你需要内层失败不影响外层整体选NESTED。3.5 SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER各自为谁而生这几个传播级别用得相对少但工作中偶尔会遇到我分头说一下SUPPORTS有事务就加入没有就以非事务方式跑。最适合查询类方法。如果一个方法被事务方法调用查询就在事务中执行被非事务方法调用也不会强制开启事务减少无意义的连接占用。MANDATORY必须有事务才执行否则直接抛异常。这适合那些只能作为内部子方法被调用的方法。比如你在一个Service方法中需要强制在事务上下文里执行一个公共方法可以用它来防止有人直接绕过外层事务调用。NOT_SUPPORTED挂起当前事务以非事务方式执行。典型场景是事务内执行一段非常耗时的外部接口调用不想让数据库连接被长时间占用。不过说实话把耗时操作挪到事务外部或者用异步处理通常比在事务里挂起更合理。NEVER如果当前存在事务就抛异常。适合一些清理类、批量修正类的执行入口确保绝对不会有长事务包裹。这个级别现实中我几乎没用过更多是作为约束性设计存在。4. 事务失效的坑我一次性帮你排完这是一篇讲Spring事务的文章绕不开的部分。我在code review时最常见到的场景就是开发同学说我加了Transactional数据还是写进去了结果查下来全是下面这些原因之一。4.1 自调用同一个类里方法调用代理根本不经过这是Spring事务失效的头号原因。前面说了Transactional靠AOP代理实现。外部调用通过代理对象进入目标方法时代理才会开启事务但同一个类中的methodA()直接调用methodB()是this.methodB()这种形式走的是对象本身不是代理对象事务注解自然就不生效了。Service public class ProductService { public void updateProduct(Product product) { // 这里直接调用下面的事务方法事务不生效 this.updateStock(product); } Transactional(rollbackFor Exception.class) public void updateStock(Product product) { // 更新库存 } }解决方案有三种要么把updateStock拆分到另一个Service类中由外部Bean调用要么在类中注入自身代理Autowired private ProductService self然后通过self.updateStock()调用要么使用AopContext.currentProxy()强制走代理需要在启动类加EnableAspectJAutoProxy(exposeProxy true)。我个人最推荐的是拆分到另一个Service因为代码结构更清晰也不容易在后续维护中埋坑。4.2 异常被try-catch吞掉Spring根本不知道发生了异常一个事务方法里内部捕获了异常而没有重新抛出方法正常返回代理层看到的是方法成功结束于是执行提交。这是异常处理不当导致的事务失效非常隐蔽。Transactional(rollbackFor Exception.class) public void order() { try { orderMapper.insert(order); stockMapper.deduct(stock); } catch (Exception e) { log.error(下单失败, e); // 没有重新抛出异常 } }正确的做法要么是让异常继续往外抛要么在捕获后手动执行TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()强制标记回滚。不过从设计角度事务方法内尽量不要捕获可能影响整体逻辑的异常把异常交给事务边界去处理是更干净的方式。4.3 非public方法代理模式天然的限制Transactional标注在private、protected或包可见方法上事务也不生效。Spring的AOP默认基于代理机制private方法根本不会被代理拦截。类内部自调用省略了代理非public方法则连代理织入的入口都没有。这里要补充说明Transactional标注在public方法上是官方推荐的用法基于JDK动态代理或CGLIB都能正确处理。如果你确实需要在非public方法上使用事务可以先想想能否把这段逻辑提取到单独的公共方法中。4.4 rollbackFor设置不对受检异常默认不回滚前面已经反复提到。Spring默认只回滚RuntimeException和Error受检异常checked exception不会触发回滚。原因也好理解Spring的设计哲学是事务回滚应该由不可预期的错误触发受检异常往往代表一种可以处理的业务预期默认不该中断事务。如果你希望受检异常也回滚必须显式声明Transactional(rollbackFor Exception.class) public void importData() throws IOException { // 如果抛IOException也会回滚 }甚至在某些业务里你可以指定只对特定异常回滚比如Transactional(noRollbackFor BusinessException.class)允许某个已知的业务异常不影响数据提交。不过这种用法要克制否则排查问题时容易晕。4.5 多线程调用事务绑定的是线程不是方法事务和数据库连接的绑定关系基本是与线程绑定的。如果你在事务方法内自己new Thread(...)或者使用Async异步调用另一个数据库操作方法那么子线程里的操作不在当前事务的连接上执行自然也不会参与同一个事务的提交和回滚。Transactional(rollbackFor Exception.class) public void createOrder() { orderMapper.insert(order); asyncService.sendNotify(); // 如果sendNotify里的逻辑失败不影响createOrder的事务 }这种场景如果要求通知发送失败也必须回滚订单异步调用就不合适。需要的是同步执行或者在异步方法里独立开启新事务、接受两边数据可能存在不一致的结果。做架构设计时要提前想清楚异步操作和事务是一对天然的矛盾体。4.6 其他几个容易被忽略的开关还有几个低概率但真实存在的情况一是数据源没有配置事务管理器Spring Boot虽然会默认配置但如果你自定义了多个数据源或者用了奇怪的连接池组合可能覆盖了默认配置导致事务管理器没有正确绑定到Transactional上二是数据库表引擎问题比如MySQL的MyISAM引擎本身不支持事务换成InnoDB才行三是类没有被Spring容器管理也就是你直接new了一个Service对象来调用方法代理自然不存在。4.7 一个排查失效问题的标准链路遇到事务不生效我通常按下述顺序排查效率很高先确认Transactional标注的方法是public并且是被外部Bean调用的。在方法里打断点或者加日志确认是否经过了代理对象可以观察到当前sqlSession中的connection是否设置了autoCommitfalse。检查方法内部是否有catch住了异常没有继续抛。检查数据库表是否为事务引擎MySQL下是InnoDB。确认事务管理器是否被正确注入多个数据源时确认Transactional使用的transactionManager是哪一个。确认异常类型和rollbackFor的配置是否匹配。这套链路走完基本能定位95%以上的事务失效问题。5. 想验证隔离级别和传播特性自己动手搭一个最小实验理论讲再多不如自己跑一遍实验。我建议你在本地搭一个最简单的Spring Boot工程用实际数据来验证上面提到的隔离级别和传播特性。这个实验成本很低收获却非常大。5.1 准备一个简单的Spring Boot工程工程只需要三个依赖spring-boot-starter-web、spring-boot-starter-jdbc、mysql-connector-j或者你用H2内存数据库也可以但为了更贴近生产建议直接用本地MySQL。建一张简单的账户表CREATE TABLE account ( id bigint(20) NOT NULL AUTO_INCREMENT, balance decimal(10,2) DEFAULT 0.00, PRIMARY KEY (id) ) ENGINEInnoDB;插入一条测试数据id1balance1000。5.2 复现脏读/不可重复读的实验步骤写两个Service方法。方法A负责开启一个事务先更新balance为800然后Thread.sleep(5000)5秒后才提交方法B负责在另一个线程或另一个浏览器请求里在A未提交时去查询balance。Service public class TxTestService { Autowired private JdbcTemplate jdbcTemplate; Transactional(rollbackFor Exception.class) public void updateBalanceWithSleep() throws InterruptedException { jdbcTemplate.update(UPDATE account SET balance 800 WHERE id 1); System.out.println(事务A已更新未提交开始休眠...); Thread.sleep(5000); } Transactional public BigDecimal queryBalance() { BigDecimal balance jdbcTemplate.queryForObject( SELECT balance FROM account WHERE id 1, BigDecimal.class); System.out.println(事务B查询到的余额 balance); return balance; } }先设置事务隔离级别为READ_UNCOMMITTED同时发起更新和查询请求你会发现事务B能读到事务A未提交的800。再把隔离级别改成READ_COMMITTED同样的操作事务B在A提交前读到的仍然是1000。这个实验非常直观比我写一万字解释都有说服力。5.3 复现传播特性的实验步骤传播特性的实验也不用复杂。写三个方法outerMethod()Transactional先插入一条记录再调用innerMethod()最后抛出异常。innerMethodRequired()Transactional(propagation Propagation.REQUIRED)插入一条记录。innerMethodRequiresNew()Transactional(propagation Propagation.REQUIRES_NEW)插入一条记录。分别组合调用观察最终数据库里留下了哪些记录外层方法操作内层传播级别最终结果调用内层后抛异常回滚REQUIRED内层记录也回滚库里没有调用内层后抛异常回滚REQUIRES_NEW内层记录已提交库里保留这个实验一做你对加入事务和新建独立事务的理解就不是停留在概念层面了。5.4 观察控制台与数据库状态做实验时别光看结果注意控制台日志。你可以在application.yml中可以配置logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG开启事务管理器的日志后每次开启事务、提交、回滚都会输出日志。你可以清晰地看到传播行为是怎么改变事务边界的。这个细节对理解Spring事务内部机制特别有帮助。如果以后接手了分布式事务相关的工作你会发现分布式事务虽然技术上更复杂但核心要解决的还是事务的原子性和隔离性问题。你在单体事务上积累的清晰认知会给那个阶段打下很扎实的基础。有时间的话真的建议把上面的实验都跑一遍——纸上得来终觉浅事务这块尤其如此。