news 2026/9/10 18:55:21

Spring Boot事务全攻略:回滚机制、传播行为与失效排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot事务全攻略:回滚机制、传播行为与失效排查

做后端开发,特别是写业务接口的时候,事务是绕不开的东西。不管是订单、库存、支付,还是用户资产变动,但凡涉及多个表更新,你都得考虑“如果中间一步失败,前面已经改掉的数据怎么办”。Spring Boot 里用@Transactional做声明式事务确实很省事,但很多人用着用着就会遇到各种诡异情况:明明抛了异常,数据却提交了;明明捕获了异常,事务还是回滚了;想只回滚某一段逻辑,结果整个方法全回滚了。

这篇文章我就结合自己实际踩过的坑,把 Spring Boot 里的事务操作从头到尾捋一遍,重点讲自动回滚、手动回滚、部分回滚这三种最常用也最容易出问题的场景,顺带把事务失效的常见原因和事务传播行为也一并说清楚。内容偏实战,所有代码都是我验证过可以跑的,你直接照着抄也能用。

1. 先理解 Spring Boot 里的事务是怎么“管起来”的

1.1 事务不是 Spring 发明的,但 Spring 管理了它

数据库本身就有事务机制,MySQL 的 InnoDB 引擎支持事务,提供 ACID 特性。Spring 做的事情是让开发者不用手动写connection.setAutoCommit(false)connection.commit()connection.rollback()这一堆样板代码,而是通过@Transactional注解声明“这个方法需要事务”。

声明式事务的本质,是在方法执行前开启事务,在方法正常结束或抛异常时根据规则决定提交还是回滚。这个“开启”和“决定”的过程,不是方法自己完成的,而是由 Spring 的 AOP 机制在方法调用前后自动完成的。所以你要先有一个概念:@Transactional注解只是声明需求,真正干活的是 AOP 代理对象。

@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { // 插入订单 orderMapper.insert(dto.toOrder()); // 扣减库存 stockMapper.decrease(dto.getSkuId(), dto.getQuantity()); // 写日志 logMapper.insert(dto.toLog()); } }

这段代码看起来简单,但“谁能保证插入订单后,如果扣库存失败,订单也会跟着消失”?答案就是代理对象。

1.2 @Transactional 背后其实是 AOP 代理

Spring 容器在启动时,会扫描带有@Transactional的 Bean,并为其生成代理对象。当你从容器里拿到OrderService时,拿到的其实是一个代理,而不是你写的那个原始对象。代理对象在执行createOrder方法之前,会先调用事务拦截器(TransactionInterceptor)开启事务,方法执行完毕后,再由拦截器根据执行结果决定提交或回滚。

所以这里有一条非常关键的原则:事务是通过代理对象生效的,只有外部调用经过代理,注解才会起作用。

这解释了绝大多数“事务不生效”的问题。比如在同一个类中,方法 A 调用方法 B,this指向的是原始对象,而不是代理对象。这样方法 B 上的@Transactional根本不会被拦截器识别,事务自然就失效了。

1.3 什么时候代理会失效:自调用陷阱

@Service public class OrderService { // 这个方法有事务,但它的内部调用了 saveLog,而 saveLog 也被 @Transactional 标记了 @Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toOrder()); stockMapper.decrease(dto.getSkuId(), dto.getQuantity()); // 自调用:this.saveLog(...) 不会走代理 this.saveLog(dto); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveLog(OrderDTO dto) { logMapper.insert(dto.toLog()); } }

在这个例子中,createOrder本身的@Transactional是生效的(因为它被外部调用),但this.saveLog(dto)这行代码,调用的是当前对象的方法,没有经过代理,所以saveLog上的REQUIRES_NEW根本不会生效。日志操作会复用外层事务,而不是开启一个新事务。

这也是很多人“部分回滚”实现失败的根源。解决办法是把saveLog放到另一个 Service 类里,或者通过@Autowired注入自己(代理自身)来调用,或者用AopContext.currentProxy()(需要开启exposeProxy)。后面讲部分回滚的时候我会单独演示。

2. 自动回滚:默认规则与你必须知道的例外

2.1 默认只对 RuntimeException 和 Error 回滚

@Transactional注解的默认回滚策略并不像很多人想的那样“只要有异常就回滚”。Spring 默认只对RuntimeExceptionError进行回滚,遇到受检异常(checked exception)时,事务会正常提交。这个设计在 Spring 官方文档里写得很明确,但实际开发中被坑的人不在少数。

什么叫受检异常?就是编译器强制你要么 try-catch、要么 throws 声明的异常,比如IOExceptionSQLException、自定义的BizException extends Exception。如果你在 Service 方法里抛了一个受检异常,@Transactional不会回滚,数据该提交还是提交了。

@Transactional public void updateUser(UserDTO dto) throws Exception { userMapper.update(dto); // 比如这里调用了第三方接口,抛了 IOException if (dto.getPhone() == null) { throw new Exception("手机号不能为空"); // 受检异常,默认不回滚! } userLogMapper.insert(dto.toLog()); }

这段代码,userMapper.update(dto)执行完,如果throw new Exception()被触发,事务依然会提交。也就是说,用户数据已经改了,但日志没写进去——这显然不是你想要的。

2.2 为什么 Spring 默认这样做

Spring 这样设计并不是拍脑袋,而是早期的 EJB 规范就是这么定的:RuntimeException代表“运行时错误,程序无法自动恢复”,比如空指针、非法参数、数组越界;而checked exception属于“业务上可预期的异常”,比如文件不存在、网络超时,可能调用方稍后重试就能成功。所以在默认策略下,Spring 认为“受检异常是业务流程的一部分,不应该导致整个事务回滚,而应该由调用方去决定处理方式”。

这个设计思想在绝大多数场景下是合理的,但业务代码里自定义的业务异常往往都直接继承RuntimeException,或者虽然继承了Exception,但业务上就是希望它触发回滚。所以实际开发中,你几乎总是在@Transactional上显式指定rollbackFor

2.3 用 rollbackFor 覆盖默认策略

标准写法是:

@Transactional(rollbackFor = Exception.class) public void updateUser(UserDTO dto) { userMapper.update(dto); if (dto.getPhone() == null) { throw new BizException("手机号不能为空"); } userLogMapper.insert(dto.toLog()); }

这里把回滚条件扩大到了所有Exception,不管是受检异常还是非受检异常,只要方法内抛出,一律回滚。这是我在所有项目里的基本标配,几乎每个@Transactional都会带上rollbackFor = Exception.class

还有两个特性:noRollbackForrollbackForClassNamenoRollbackFor用于“某些异常不要回滚”的场景,比如方法内某段逻辑抛了一个轻微异常,但你已经 catch 住并处理了,不希望它影响事务,但如果你抛出去就不一样了。实际上noRollbackFor用得很少,更多是 catch 之后吞掉或标记。rollbackForClassName是字符串形式,不推荐,容易拼错不易发现。

2.4 自动回滚的完整示例

下面我用一个下单场景,把自动回滚的完整过程演示出来:

@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void placeOrder(OrderCommand cmd) { // 1. 校验库存 Integer stock = stockMapper.selectStock(cmd.getSkuId()); if (stock < cmd.getQuantity()) { throw new BizException("库存不足"); } // 2. 创建订单 Order order = Order.createFrom(cmd); orderMapper.insert(order); // 3. 扣减库存 int updated = stockMapper.deduct(cmd.getSkuId(), cmd.getQuantity()); if (updated == 0) { throw new BizException("扣减库存失败"); } } }

这里如果第 3 步抛了BizException(继承RuntimeExceptionException都可以,取决于rollbackFor的配置),那么第 2 步已经插入的订单会被自动回滚掉。BizException如果是RuntimeException的子类,即使不加rollbackFor也会回滚;但为了统一,我还是建议你一直加上rollbackFor = Exception.class

注意:自动回滚的前提是异常能“抛出到事务拦截器”。如果你在方法内部 try-catch 把异常吞掉了,事务拦截器根本看不到异常,自然就选择提交了。这是回滚失效最常见的坑,后面我会专门讲。

3. 手动回滚:掌控权交到代码手里

3.1 为什么需要手动回滚

自动回滚省事,但有个问题:它的决策方式比较“一刀切”,异常抛出就回滚整个事务,不抛出就提交。可实际业务里经常出现“我不希望抛异常出去,但确实需要回滚”的情况。

举一个我真实遇到过的场景:批量导入用户数据,一条一条插入,中间有一条数据校验失败,我想跳过这一条继续处理后面的,但前面已经成功插入的数据又需要撤销。这时候如果直接抛异常,整个批量任务就会中断;如果不抛异常,前面插入的数据就残留了。我的做法是在 catch 块里做标记,等批量循环结束后再根据标记决定是否回滚整个事务。

还有一种更常见的场景:调用了远程 RPC 或者消息队列发送,远程调用返回了失败结果,但并没有抛异常,只是返回了一个ResultCode.FAIL。这时候你需要在代码里判断这个失败结果,然后主动回滚。

3.2 TransactionAspectSupport 手动标记回滚

Spring 提供了手动回滚的入口:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()

@Transactional(rollbackFor = Exception.class) public void batchImport(List<UserImportDTO> list) { for (UserImportDTO dto : list) { try { userMapper.insert(dto.toUser()); } catch (DuplicateKeyException e) { // 这一条数据重复了,跳过,但不影响整体流程 log.warn("重复数据,跳过: {}", dto.getPhone()); // 标记整个事务回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); return; } } }

这段代码的执行效果是:当某一条数据触发DuplicateKeyException后,方法体内 catch 住了异常,循环终止,方法正常返回。但因为调用了setRollbackOnly(),事务拦截器拿到的是一个“被标记为 rollback-only 的事务”,所以最终整个事务回滚。所有已经插入的数据都会撤销。

这里有个很关键的细节:setRollbackOnly()只是给当前事务打上“只能回滚”的标记,方法正常返回时,事务拦截器尝试提交,发现事务状态已经是 rollback-only,就会抛出UnexpectedRollbackException,同时数据库事务回滚。

如果你没调用setRollbackOnly(),而是 catch 住异常后让方法正常返回,事务就会提交,前面插入的数据全部保留。

3.3 try-catch 与手动回滚的配合

手动回滚最常见的配合方式就是 try-catch:

@Transactional(rollbackFor = Exception.class) public void sendCouponAndNotify(UserCouponDTO dto) { try { // 1. 发券 couponMapper.insert(dto.toCoupon()); // 2. 调短信服务 SmsResult result = smsClient.send(dto.getPhone(), "恭喜获得优惠券"); if (result.getCode() != 0) { // 短信没发出去,但这不是致命错误,不影响优惠券发放 log.warn("短信发送失败: {}", result.getMsg()); // 如果业务要求短信失败也回滚,就加下面这行 // TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } } catch (Exception e) { log.error("发券过程中出现异常", e); // 如果这里 catch 住不往外抛,事务不会自动回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 业务上需要重新抛出一个可预期的异常 throw new BizException("发券失败,请稍后重试"); } }

在这个例子里,couponMapper.insertsmsClient.send是同一个事务内的两个操作。如果短信服务返回失败,但你不希望因为短信失败导致优惠券也没了,就不要标记回滚,让方法正常结束,事务提交,优惠券正常发放。如果业务要求“短信必须成功,否则发券也要撤销”,那就在失败分支里调用setRollbackOnly(),或者直接抛异常(自动回滚)。

我个人的建议是:能用抛异常触发自动回滚,就优先用自动回滚,代码更简洁。手动回滚的真正价值在于:你有多个步骤,但只有某些特定失败条件需要回滚,其他失败条件可以忽略,这时用setRollbackOnly()可以做到“精确控制”。

3.4 手动回滚的边界与坑

用了这么多次手动回滚,我再分享两个细节。

第一,setRollbackOnly()只能标记,不能“立即回滚”。调用它之后,方法还会继续执行。如果你希望在标记后立即终止方法,需要自己加return或抛异常。写代码时注意别在标记之后又执行了一些不该执行的操作,否则这些操作也会被回滚,造成“不必要的浪费”。

第二,TransactionAspectSupport.currentTransactionStatus()没有事务时不能调用。如果你的方法没有@Transactional,或者事务还没开启(比如通过自调用绕过了代理),这里会直接抛NoTransactionException。所以使用手动回滚之前,先确认方法确实处于事务环境中。

4. 部分回滚:主流程成功,子流程单独处理

4.1 业务背景:需要部分回滚的场景

部分回滚,字面意思就是“我只回滚一部分操作,另一部分不受影响”。这在业务里非常常见。举几个例子:

  1. 用户下单成功后,要给用户发送站内信。下单是核心业务,站内信是附加业务。如果站内信发送失败,不能让订单也消失。
  2. 订单创建成功后,要同步数据到搜索引擎或缓存。同步失败不应该影响下单成功。
  3. 主订单和子订单,子订单校验失败时,希望只回滚这一条子订单,不影响其他子订单和主订单。

这些场景的共同点:核心业务必须成功,附加业务失败了不能拖累核心业务。如果全部塞在同一个事务里,附加业务一失败,核心业务数据也跟着回滚,用户会直接感知到异常。

4.2 方案一:REQUIRES_NEW 独立事务

先看事务传播行为里的REQUIRES_NEW。它的语义是“无论当前有没有事务,都开启一个新事务,并暂停外层事务”。

@Service public class OrderService { @Autowired private NotifyService notifyService; @Transactional(rollbackFor = Exception.class) public void placeOrder(OrderCommand cmd) { orderMapper.insert(cmd.toOrder()); stockMapper.deduct(cmd.getSkuId(), cmd.getQuantity()); // 这里调用的是另一个 bean,会走到代理 try { notifyService.sendNotify(cmd.getUserId(), "下单成功"); } catch (Exception e) { log.error("通知失败,不影响下单", e); } } } @Service public class NotifyService { @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void sendNotify(Long userId, String content) { notifyMapper.insert(new Notify(userId, content)); // 模拟远程调用失败 if (content.contains("FAIL")) { throw new RuntimeException("notify fail"); } } }

执行流程:

  • placeOrder开启事务 A。
  • notifyService.sendNotify因为标记了REQUIRES_NEW,Suspend 掉事务 A,开启新事务 B。
  • 事务 B 内执行notifyMapper.insert,然后抛异常。
  • 事务 B 回滚,通知数据不落库。
  • 异常被placeOrder里的 try-catch 捕获,placeOrder正常完成,事务 A 提交,订单和库存变更保留。

这样,附加业务失败被隔离在了自己的事务里,不会拖垮主事务。前提是notifyService是从容器里注入的另一个 Bean,而不是this当前对象,否则REQUIRES_NEW不会生效,sendNotify会在外层事务 A 中执行,异常会导致事务 A 回滚,订单也没了。

4.3 方案二:NESTED 嵌套事务(保存点)

NESTED的语义是“如果当前存在事务,则在该事务内创建一个保存点(Savepoint),后续逻辑可以在保存点处回滚”。这句话有点绕,我用代码解释。

@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrderWithSubItems(OrderCommand cmd) { // 主订单插入 orderMapper.insert(cmd.toOrder()); // 子订单逐个插入,某个子订单失败只回滚它自己 for (SubOrderDTO sub : cmd.getSubOrders()) { try { insertSubOrder(sub); } catch (Exception e) { log.error("子订单 {} 插入失败,仅回滚该子订单", sub.getId(), e); } } } @Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class) public void insertSubOrder(SubOrderDTO sub) { subOrderMapper.insert(sub); } }

如果insertSubOrder抛异常,Spring 会在它的保存点位置回滚,只撤销subOrderMapper.insert(sub)这次操作,外层事务里已经执行的其他插入操作(比如主订单)不受影响。等所有子订单都处理完,外层事务统一提交。

表面上看,NESTEDREQUIRES_NEW都实现了“隔离失败”,但两者有本质区别:

  • REQUIRES_NEW是开启了一个全新的事务,与外部事务完全独立,提交和回滚互不干扰。
  • NESTED没有开启新事务,它只是在外层事务里设置了一个保存点。外层事务如果后续回滚,嵌套事务里已提交的操作也会跟着回滚。也就是说,NESTED是“受外部事务控制的局部回滚”。

用 MySQL 的 InnoDB 引擎,NESTED依赖的是数据库的 Savepoint 机制。Spring 的DataSourceTransactionManager默认支持嵌套事务,但需要把它设置为允许嵌套:

@Bean public DataSourceTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager tm = new DataSourceTransactionManager(dataSource); tm.setNestedTransactionAllowed(true); return tm; }

如果你的配置里没有这个设置,NESTED可能退化为“如果当前存在事务,则加入外层事务”,达不到局部回滚的效果。这一点务必注意。

4.4 两种方案怎么选

根据我的经验,选择原则很简单:

  • 附加业务完全独立,失败后不希望和主事务有任何关联,比如发短信、发消息、写日志,选REQUIRES_NEW。它最干净,但代价是多占一个数据库连接,高并发场景下要关注连接池大小。
  • 附加业务是主流程的一部分,但希望失败时只回滚自己,不影响其他部分,比如批量插入、多子订单校验,选NESTED。它不额外占连接,但会占用保存点资源,大批量循环时也要注意性能。

两种方案我都在生产环境里用过,REQUIRES_NEW用得更多,因为它语义最清晰:“我就是要独立事务,你管不着我”。但如果你在一个大事务里循环调用多个REQUIRES_NEW,每个子事务都会独立提交,一旦主事务后面出错了,之前已提交的子事务不会跟着回滚。这就是“部分成功”的状态,业务上需要额外处理。

提示:使用REQUIRES_NEW时,外层事务会暂停(suspend),数据库连接会被挂起。如果一个事务里同时开启多个REQUIRES_NEW子事务,外层连接一直没释放,而子事务又需要新连接,连接池过小可能出现连接等待。线上遇到过几次这种问题,排查起来很费劲,后来我都是把maximum-pool-size调大,并严格控制REQUIRES_NEW的使用范围。

5. 回滚失效排查实战:这些场景里事务会“静默失效”

5.1 自调用:同类方法互调用

前面已经说过了,this.xxx()不会经过代理。这是事务失效最经典的场景。不仅REQUIRES_NEW会失效,连最基本的@Transactional都会失效。

@Service public class UserService { @Transactional public void updateUserWithLog(UserDTO dto) { userMapper.update(dto); // 自调用,@Transactional 不生效 this.writeLog(dto); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void writeLog(UserDTO dto) { logMapper.insert(dto.toLog()); } }

解决办法有几种:

  1. writeLog抽到独立的 Service 类中,注入后调用。
  2. 在同一个类里注入自己的代理:
@Service public class UserService { @Autowired private UserService self; @Transactional public void updateUserWithLog(UserDTO dto) { userMapper.update(dto); // 通过代理对象调用,事务生效 self.writeLog(dto); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void writeLog(UserDTO dto) { logMapper.insert(dto.toLog()); } }
  1. 使用AopContext.currentProxy(),但要开启exposeProxy
@EnableAspectJAutoProxy(exposeProxy = true)

然后:

((UserService) AopContext.currentProxy()).writeLog(dto);

这几种方式第一种最推荐,因为它避免了循环依赖问题和代理暴露的安全隐患,而且代码结构也更清晰。

5.2 private、final、static 方法

@Transactional标注在private方法上,事务不会生效。原因是 Spring 的 AOP 默认使用 CGLIB 动态代理,CGLIB 通过生成子类来代理目标类,而private方法无法被子类重写,所以拦截器根本不会拦截到它。

final方法同理,CGLIB 生成子类时无法重写final方法,事务不会生效。static方法不属于实例方法,更不会被代理。

@Service public class PaymentService { @Transactional public void pay(PayDTO dto) { // 这里的逻辑生效 doPay(dto); } @Transactional private void doPay(PayDTO dto) { // 这里的 @Transactional 不生效 accountMapper.decrease(dto.getAccountId(), dto.getAmount()); } }

排查技巧:看日志中是否出现Creating new transaction,如果调用方法时没有这行日志,说明事务没开启。

5.3 try-catch 吞掉异常

这是平时遇到最多的“回滚失效”原因。代码里经常为了不让异常影响后续逻辑,把异常 catch 住再打日志,或者干脆 catch 后什么都不做。但一旦异常被捕获了,事务拦截器看不到异常,就不会回滚。

@Transactional public void transfer(TransferDTO dto) { try { accountMapper.decrease(dto.getFromId(), dto.getAmount()); accountMapper.increase(dto.getToId(), dto.getAmount()); } catch (Exception e) { log.error("转账失败", e); // 异常被捕获,事务不会回滚! } }

解决办法就是不要 catch,或者 catch 之后重新抛出:

@Transactional public void transfer(TransferDTO dto) { try { accountMapper.decrease(dto.getFromId(), dto.getAmount()); accountMapper.increase(dto.getToId(), dto.getAmount()); } catch (Exception e) { log.error("转账失败", e); throw new BizException("转账失败", e); } }

如果你真的想把异常吞掉,又想回滚,那就用前面介绍的setRollbackOnly()手动标记。

5.4 多线程和 @Async 异步方法

Spring 的事务是基于 ThreadLocal 实现的,数据库连接绑定在当前线程上。如果你在事务方法里开了新线程,子线程里执行的操作跟主线程的事务没有半点关系,即使子线程里抛了异常,也不会影响主线程事务。

@Transactional public void processOrder(OrderCommand cmd) { orderMapper.insert(cmd.toOrder()); new Thread(() -> { // 子线程里抛异常,主线程事务不回滚 stockMapper.deduct(cmd.getSkuId(), cmd.getQuantity()); }).start(); }

@Async注解标注的方法,如果在事务方法内部调用,同样会遇到这个问题。@Async本身就是另外一个线程执行的,它的事务是独立开启、独立提交的。所以只要涉及异步,事务边界就要重新思考:你到底想要谁和谁保持一致?如果要一致,就别异步;如果允许最终一致,就把异步方法设计成独立的事务。

5.5 传播行为配置错误

propagation配置错误也会导致回滚范围不符合预期。常见的是REQUIREDREQUIRES_NEW的混用。如果你希望在某个子方法失败时不影响主方法,但子方法却配的是REQUIRED,它就会加入主事务,异常一抛,整个事务就回滚了。

@Transactional public void mainLogic() { subLogic(); // 如果 subLogic 抛异常,整个事务回滚 } @Transactional(propagation = Propagation.REQUIRED) public void subLogic() { // 抛异常 }

这里subLogicREQUIRED,它会直接加入外层事务,异常会导致全部回滚。改REQUIRES_NEWNESTED才能把失败隔离出去。所以配置传播行为前,一定要先想清楚“这个事务失败后,对上层事务应该造成什么影响”。

5.6 排查技巧:怎么快速定位事务是否生效

我一般会开 DEBUG 日志来观察事务日志:

logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction.interceptor.TransactionInterceptor: DEBUG

DataSourceTransactionManager会打印类似:

Acquired Connection [HikariProxyConnection@123 wrapping com.mysql.cj.jdbc.ConnectionImpl@456] for JDBC transaction Releasing JDBC Connection after transaction Committing JDBC transaction on Connection ... Rolling back JDBC transaction on Connection ...

看到Rolling back JDBC transaction就说明回滚执行了;看到Committing JDBC transaction但你没有期望它提交,那就要检查是不是异常被吞了或者事务没被拦截。

还有一个排查思路:给方法加一个断点,看当前对象是不是代理类。如果不是(比如是原始类的实例,而不是$$EnhancerBySpringCGLIB这种类名),那事务一定不生效。

6. 传播行为速查:事务与事务之间怎么协作

@Transactional里最常配置的就是propagation属性。我用表格整理一下七种传播行为,方便你随时查阅。

传播行为当前无事务时当前有事务时典型应用场景
REQUIRED(默认)开启新事务加入当前事务绝大多数业务方法,保持同一个事务
REQUIRES_NEW开启新事务挂起当前事务,开启新事务日志、消息、短信等附加操作,失败不影响主流程
NESTED开启新事务在当前事务内创建保存点批量插入、子订单处理,失败可回滚局部
SUPPORTS不开启事务加入当前事务有事务就用,没事务也能跑,比如查询方法
NOT_SUPPORTED不开启事务挂起当前事务,以无事务方式执行事务内做耗时操作或非事务资源操作
MANDATORY抛异常(要求必须有事务)加入当前事务强制要求调用方必须处于事务中
NEVER不开启事务抛异常(要求不能有事务)明确禁止在事务内执行的方法

REQUIRED是最常用的,它的特点是“合并事务”。多个REQUIRED方法嵌套调用时,它们共享同一个物理事务,任何一个方法抛出异常,所有操作都会回滚。这保证了强一致性,但也意味着你没法轻易隔离失败。

REQUIRES_NEW会让事务边界变得独立,但代价是外部事务的异常不会影响它,它自己的异常也不会影响外部事务。这带来一个隐藏问题:如果子事务成功提交了,但外部事务后来失败回滚,子事务的数据会“孤立”存在,业务上需要给这种不一致留补偿方案。

NESTEDREQUIRES_NEW温和,它允许在外部事务中局部回滚到保存点,且外部事务最终提交时,子事务的修改才会一并提交。所以在“要么全成功,要么按保存点回滚”的场景里,NESTED更有优势。

提示:Spring 的默认事务传播行为是REQUIRED,所以如果你不确定,就用默认的,不要乱加。传播行为配错,排查起来比业务代码出错还要痛苦,因为看起来代码逻辑完全正常,但结果就是不对。

7. 踩坑记录与个人实践建议

7.1 连接池耗尽:REQUIRES_NEW 用多了要命

有一年我做秒杀活动,下单方法里有主事务,里面调用了两次REQUIRES_NEW子事务。压测时发现并发一上来,连接池直接被打满,请求全部阻塞。原因是主事务持有连接,子事务需要从连接池再拿新连接,如果连接池大小只有 10,同时有 10 个主事务在跑,连接就被全部占用了,子事务永远拿不到新连接,形成死锁般的等待。后来我把子事务的逻辑拆出去,改成消息队列异步处理,问题才解决。

所以用REQUIRES_NEW前,一定要评估它的并发开销。核心业务链路上,能不用就不用。

7.2 超时时间要人性化

@Transactional默认没有超时限制,如果 SQL 卡住,事务会一直占用连接。建议在关键业务上加timeout

@Transactional(timeout = 5, rollbackFor = Exception.class) public void payment(PayDTO dto) { // 超过5秒自动回滚并抛异常 }

timeout的单位是秒,执行时间超过阈值,事务会自动回滚。这个参数对接口防抖、数据库死锁排查都很有帮助。不过要注意,timeout是从事务开始到方法结束的总耗时,不只是单条 SQL 的耗时。

7.3 只读事务别忘了 readOnly

对于只做查询的方法,如果加@Transactional(readOnly = true),一方面告诉数据库这个事务只读,可以走优化路径;另一方面,Spring 会取消部分持久化上下文的刷新操作,减少不必要的写操作开销。

@Transactional(readOnly = true) public OrderVO getOrder(Long orderId) { return orderMapper.selectById(orderId); }

但千万别在readOnly事务里偷偷调用写操作,虽然有的数据库不强制拦截,但行为是不确定的,一旦后续接入严格校验的数据库或依赖,就会出问题。

7.4 事务和锁不是一回事

@Transactional管的是数据库事务,不是并发锁。多个线程同时执行同一个带事务的方法,如果方法里只做了“查-改”操作,没有加锁,还是会出现超卖、重复扣减等问题。

@Transactional public void deductStock(Long skuId, Integer quantity) { // 先查库存 Integer stock = stockMapper.selectStock(skuId); if (stock < quantity) { throw new BizException("库存不足"); } // 再扣减 stockMapper.deduct(skuId, quantity); }

这里查询和扣减之间有个时间窗口,两个请求同时查到stock=1,都判断“库存足够”,然后各自扣减一次,结果库存变成了 -1。事务无法解决这个问题,需要你在 SQL 层面做原子更新,比如UPDATE stock SET quantity = quantity - #{quantity} WHERE sku_id = #{skuId} AND quantity >= #{quantity},利用行锁保证并发安全。

7.5 本地事务解决不了的,要靠分布式事务方案

这篇文章聊的都是本地事务,默认只操作一个数据库。如果你一个事务里同时操作订单库、库存库、用户库,或者调用多个微服务,本地事务就无能为力了。

常见方案有几种:XA两阶段提交(强一致,性能差,用的少)、TCC(Try-Confirm-Cancel,业务侵入大,适合对一致性要求极高的场景)、本地消息表(最终一致,实现简单)、事务消息(如 RocketMQ 的事务消息)。还有Seata AT模式,这个在当前国内互联网公司用得比较多,它通过全局锁实现分布式事务,对业务侵入相对较小。

选型的时候不要一上来就上分布式事务。你先问自己:业务真的需要强一致吗?还是最终一致就够了?很多场景可以通过“先发消息,异步消费 + 失败重试 + 对账补偿”来解决,比引入分布式事务框架简单得多,也稳定得多。

7.6 写在最后的一个习惯

我写代码这么多年,踩过无数事务的坑,现在养成一个固定习惯:每个@Transactional方法,都会明确标注rollbackFor,并且检查是否有 catch 吞异常、是否有自调用、是否有新线程。这三条检查一遍,90% 的事务问题都能避免。

还有一个小技巧,我会在 Service 方法的关键操作前后打印日志,包括当前线程名、方法名、关键参数。一旦线上出现“数据对不上”的问题,可以先通过日志判断这个方法到底有没有进入事务、有没有走代理、在哪一步抛的异常。日志真的是排查事务问题最有效的工具,比看代码快多了。

事务其实不难,难的是把边界想清楚。希望这篇文章能让你在写@Transactional的时候多一分底气,少踩几个坑。

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

大厂Java面试实战:从支付场景拆解核心技术与高频考点

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

作者头像 李华
网站建设 2026/9/10 18:51:14

PyTorch分片模型文件损坏排查与修复指南

1. 问题现象与背景分析遇到"OSError: Model file pytorch_model-00001-of-00003.bin is corrupted or incomplete (unexpected"这类错误时&#xff0c;通常是在加载PyTorch分片模型文件时发生的。这个错误表明系统在尝试读取模型分片文件时&#xff0c;检测到文件结构…

作者头像 李华
网站建设 2026/9/10 18:50:08

HMM与LSTM混合模型:提升股票趋势预测准确率的实战方法

简介&#xff1a;一份基于 HMM-LSTM 融合的股票市场趋势分析 Python 源码项目&#xff0c;内置四种模型实现与配套说明文档&#xff0c;适合希望掌握时序预测与隐马尔可夫建模的研究者或量化学习者。资源包共61个文件&#xff0c;以28个 Python 脚本和26个编译后的 pyc 文件为主…

作者头像 李华