
做后端开发特别是写业务接口的时候事务是绕不开的东西。不管是订单、库存、支付还是用户资产变动但凡涉及多个表更新你都得考虑“如果中间一步失败前面已经改掉的数据怎么办”。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 调用方法 Bthis指向的是原始对象而不是代理对象。这样方法 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 默认只对RuntimeException和Error进行回滚遇到受检异常checked exception时事务会正常提交。这个设计在 Spring 官方文档里写得很明确但实际开发中被坑的人不在少数。什么叫受检异常就是编译器强制你要么 try-catch、要么 throws 声明的异常比如IOException、SQLException、自定义的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。还有两个特性noRollbackFor和rollbackForClassName。noRollbackFor用于“某些异常不要回滚”的场景比如方法内某段逻辑抛了一个轻微异常但你已经 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继承RuntimeException或Exception都可以取决于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(ListUserImportDTO 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-catchTransactional(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.insert和smsClient.send是同一个事务内的两个操作。如果短信服务返回失败但你不希望因为短信失败导致优惠券也没了就不要标记回滚让方法正常结束事务提交优惠券正常发放。如果业务要求“短信必须成功否则发券也要撤销”那就在失败分支里调用setRollbackOnly()或者直接抛异常自动回滚。我个人的建议是能用抛异常触发自动回滚就优先用自动回滚代码更简洁。手动回滚的真正价值在于你有多个步骤但只有某些特定失败条件需要回滚其他失败条件可以忽略这时用setRollbackOnly()可以做到“精确控制”。3.4 手动回滚的边界与坑用了这么多次手动回滚我再分享两个细节。第一setRollbackOnly()只能标记不能“立即回滚”。调用它之后方法还会继续执行。如果你希望在标记后立即终止方法需要自己加return或抛异常。写代码时注意别在标记之后又执行了一些不该执行的操作否则这些操作也会被回滚造成“不必要的浪费”。第二TransactionAspectSupport.currentTransactionStatus()没有事务时不能调用。如果你的方法没有Transactional或者事务还没开启比如通过自调用绕过了代理这里会直接抛NoTransactionException。所以使用手动回滚之前先确认方法确实处于事务环境中。4. 部分回滚主流程成功子流程单独处理4.1 业务背景需要部分回滚的场景部分回滚字面意思就是“我只回滚一部分操作另一部分不受影响”。这在业务里非常常见。举几个例子用户下单成功后要给用户发送站内信。下单是核心业务站内信是附加业务。如果站内信发送失败不能让订单也消失。订单创建成功后要同步数据到搜索引擎或缓存。同步失败不应该影响下单成功。主订单和子订单子订单校验失败时希望只回滚这一条子订单不影响其他子订单和主订单。这些场景的共同点核心业务必须成功附加业务失败了不能拖累核心业务。如果全部塞在同一个事务里附加业务一失败核心业务数据也跟着回滚用户会直接感知到异常。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_NEWSuspend 掉事务 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)这次操作外层事务里已经执行的其他插入操作比如主订单不受影响。等所有子订单都处理完外层事务统一提交。表面上看NESTED和REQUIRES_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()); } }解决办法有几种把writeLog抽到独立的 Service 类中注入后调用。在同一个类里注入自己的代理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()); } }使用AopContext.currentProxy()但要开启exposeProxyEnableAspectJAutoProxy(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配置错误也会导致回滚范围不符合预期。常见的是REQUIRED和REQUIRES_NEW的混用。如果你希望在某个子方法失败时不影响主方法但子方法却配的是REQUIRED它就会加入主事务异常一抛整个事务就回滚了。Transactional public void mainLogic() { subLogic(); // 如果 subLogic 抛异常整个事务回滚 } Transactional(propagation Propagation.REQUIRED) public void subLogic() { // 抛异常 }这里subLogic是REQUIRED它会直接加入外层事务异常会导致全部回滚。改REQUIRES_NEW或NESTED才能把失败隔离出去。所以配置传播行为前一定要先想清楚“这个事务失败后对上层事务应该造成什么影响”。5.6 排查技巧怎么快速定位事务是否生效我一般会开 DEBUG 日志来观察事务日志logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction.interceptor.TransactionInterceptor: DEBUGDataSourceTransactionManager会打印类似Acquired Connection [HikariProxyConnection123 wrapping com.mysql.cj.jdbc.ConnectionImpl456] 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会让事务边界变得独立但代价是外部事务的异常不会影响它它自己的异常也不会影响外部事务。这带来一个隐藏问题如果子事务成功提交了但外部事务后来失败回滚子事务的数据会“孤立”存在业务上需要给这种不一致留补偿方案。NESTED比REQUIRES_NEW温和它允许在外部事务中局部回滚到保存点且外部事务最终提交时子事务的修改才会一并提交。所以在“要么全成功要么按保存点回滚”的场景里NESTED更有优势。提示Spring 的默认事务传播行为是REQUIRED所以如果你不确定就用默认的不要乱加。传播行为配错排查起来比业务代码出错还要痛苦因为看起来代码逻辑完全正常但结果就是不对。7. 踩坑记录与个人实践建议7.1 连接池耗尽REQUIRES_NEW 用多了要命有一年我做秒杀活动下单方法里有主事务里面调用了两次REQUIRES_NEW子事务。压测时发现并发一上来连接池直接被打满请求全部阻塞。原因是主事务持有连接子事务需要从连接池再拿新连接如果连接池大小只有 10同时有 10 个主事务在跑连接就被全部占用了子事务永远拿不到新连接形成死锁般的等待。后来我把子事务的逻辑拆出去改成消息队列异步处理问题才解决。所以用REQUIRES_NEW前一定要评估它的并发开销。核心业务链路上能不用就不用。7.2 超时时间要人性化Transactional默认没有超时限制如果 SQL 卡住事务会一直占用连接。建议在关键业务上加timeoutTransactional(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); }这里查询和扣减之间有个时间窗口两个请求同时查到stock1都判断“库存足够”然后各自扣减一次结果库存变成了 -1。事务无法解决这个问题需要你在 SQL 层面做原子更新比如UPDATE stock SET quantity quantity - #{quantity} WHERE sku_id #{skuId} AND quantity #{quantity}利用行锁保证并发安全。7.5 本地事务解决不了的要靠分布式事务方案这篇文章聊的都是本地事务默认只操作一个数据库。如果你一个事务里同时操作订单库、库存库、用户库或者调用多个微服务本地事务就无能为力了。常见方案有几种XA两阶段提交强一致性能差用的少、TCCTry-Confirm-Cancel业务侵入大适合对一致性要求极高的场景、本地消息表最终一致实现简单、事务消息如 RocketMQ 的事务消息。还有Seata AT模式这个在当前国内互联网公司用得比较多它通过全局锁实现分布式事务对业务侵入相对较小。选型的时候不要一上来就上分布式事务。你先问自己业务真的需要强一致吗还是最终一致就够了很多场景可以通过“先发消息异步消费 失败重试 对账补偿”来解决比引入分布式事务框架简单得多也稳定得多。7.6 写在最后的一个习惯我写代码这么多年踩过无数事务的坑现在养成一个固定习惯每个Transactional方法都会明确标注rollbackFor并且检查是否有 catch 吞异常、是否有自调用、是否有新线程。这三条检查一遍90% 的事务问题都能避免。还有一个小技巧我会在 Service 方法的关键操作前后打印日志包括当前线程名、方法名、关键参数。一旦线上出现“数据对不上”的问题可以先通过日志判断这个方法到底有没有进入事务、有没有走代理、在哪一步抛的异常。日志真的是排查事务问题最有效的工具比看代码快多了。事务其实不难难的是把边界想清楚。希望这篇文章能让你在写Transactional的时候多一分底气少踩几个坑。