ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring事务失效全解析:@Transactional不生效的六种场景与修复

Spring事务失效全解析:@Transactional不生效的六种场景与修复 在 Spring 项目中事务管理几乎是每个后端开发者都会接触的功能。但很多人遇到的问题是代码里明明加了Transactional数据库表也建好了异常抛出后数据却还是被写进去了或者部分 SQL 提交了、部分回滚了数据状态变得不可控。这类问题在测试环境往往很难复现通常要在特定调用链、特定异常类型或特定方法访问方式下才会暴露。它不像空指针那样报错明显而是表现为“事务没生效”或“事务部分生效”排查起来非常隐蔽。这篇文章围绕Transactional的失效场景展开先讲清楚 Spring 事务代理的基本原理再列出最常踩的几种坑每一条都给出现象、原因、检查方式和修复方案最后提供一个可直接照着做的事务排查链路。如果你正在调试“扣款成功但订单没生成”“发了消息但数据库回滚了”“同一次请求里两个写操作只提交了一个”这类问题这篇内容可以直接拿来对照排查。1. 先理解 Spring 事务代理的工作方式1.1 事务不是 SQL 自动完成的而是由代理对象统一管理在 Spring 中Transactional并不是直接给你提供一套“自动保证数据一致性”的魔法机制它背后依赖的是 AOP 代理。当一个 Bean 被 Spring 容器创建时如果检测到类或方法上标注了TransactionalSpring 会生成一个代理对象把事务的开启、提交、回滚逻辑包裹在目标方法外面。实际执行流程大致如下外部调用方拿到的是代理对象不是目标对象。调用进入代理后事务拦截器根据传播行为决定是新建事务、加入已有事务还是挂起当前事务。代理调用真正的目标方法。目标方法正常返回代理提交事务。目标方法抛出异常代理执行回滚逻辑。这里最容易忽略的一点是Spring 的Transactional默认只在运行时异常触发回滚即RuntimeException和Error而Exception的 checked 异常不会触发回滚必须通过rollbackFor显式指定。1.2 为什么代理机制会直接导致事务失效事务代理机制成立的一个前提是外部调用必须通过代理对象进入方法。如果同一个类内部的方法直接调用另一个带Transactional的方法调用路径是从this指向内部方法的根本没有经过 Spring 容器生成的代理对象事务拦截器不会执行事务自然就不会创建或提交。这也就是为什么“自调用事务失效”会成为最高频问题之一。理解了代理机制再去看各种失效场景基本都能归因到三类原因上代理对象没有参与调用链路。异常没有传到事务拦截器。事务边界与你的预期不一致。下文所有排查思路本质都是围绕这三个点展开的。2. 常见的六种事务失效场景2.1 同一个类内部方法调用场景代码如下Service public class OrderService { public void createOrder(OrderDTO dto) { // 这里是普通方法 this.updateStock(dto.getSkuId(), dto.getQuantity()); } Transactional(rollbackFor Exception.class) public void updateStock(Long skuId, Integer quantity) { stockMapper.deduct(skuId, quantity); throw new RuntimeException(库存不足强制回滚); } }从外部调用createOrder后updateStock里的Transactional不会生效。因为this.updateStock()是目标对象内部调用Spring 的 AOP 代理拦截不到这次调用。修复方案有三种// 方案一把事务方法单独放到另一个 Service 里由新 Service 调用 Service public class StockService { Transactional(rollbackFor Exception.class) public void updateStock(Long skuId, Integer quantity) { stockMapper.deduct(skuId, quantity); } }// 方案二通过代理对象调用 Service public class OrderService { Autowired private TransactionTemplate transactionTemplate; public void createOrder(OrderDTO dto) { transactionTemplate.execute(status - { stockMapper.deduct(dto.getSkuId(), dto.getQuantity()); throw new RuntimeException(库存不足强制回滚); }); } }// 方案三用编程式事务 Service public class OrderService { Autowired private PlatformTransactionManager transactionManager; public void createOrder(OrderDTO dto) { DefaultTransactionDefinition definition new DefaultTransactionDefinition(); TransactionStatus status transactionManager.getTransaction(definition); try { stockMapper.deduct(dto.getSkuId(), dto.getQuantity()); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } }在实际项目中方案一最直观也最符合“一个事务方法就是一个独立业务入口”的划分思路。方案二和方案三适合事务逻辑比较复杂、需要环绕处理或需要覆盖非Transactional所支持的动态分支时使用。注意自调用问题不会报任何异常日志里也看不到错误。这恰恰是它最难排查的地方。如果你怀疑某个事务标注没生效先在方法进入位置打断点并观察this的实际类型。如果是 CGLIB 代理对象类型名里通常带$$EnhancerBySpringCGLIB如果是 JDK 动态代理则是$Proxy。2.2 异常被 catch 吞掉这是另一种隐蔽的失效方式。外层方法捕获了异常并继续执行Spring 的事务拦截器根本看不到异常自然也不会有回滚。Transactional(rollbackFor Exception.class) public void batchUpdate() { try { orderMapper.updateStatus(1L, PAID); throw new IllegalArgumentException(模拟业务异常); } catch (Exception e) { // 不对外抛出事务不会回滚 log.error(更新失败, e); } }虽然异常被记录到了日志但你会发现订单状态已经被改成PAID因为没有异常穿越事务边界Spring 认为方法正常返回。正确的处理方式是事务回滚要求异常必须传播到事务拦截器所以在 catch 之后要么重新抛出要么在 catch 块内标记回滚Transactional(rollbackFor Exception.class) public void batchUpdate() { try { orderMapper.updateStatus(1L, PAID); throw new IllegalArgumentException(模拟业务异常); } catch (Exception e) { // 记录日志后重新抛出让事务感知异常 log.error(更新失败, e); throw e; } }如果业务上必须在当前方法内处理异常又要让事务回滚可以通过TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记当前事务为只回滚Transactional(rollbackFor Exception.class) public void batchUpdate() { try { orderMapper.updateStatus(1L, PAID); throw new IllegalArgumentException(模拟业务异常); } catch (Exception e) { log.error(更新失败, e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } }注意setRollbackOnly只标记回滚不会终止后续代码执行。如果后面还有别的 SQL它们仍会继续执行只是在最终提交时统一回滚。所以不要依赖这种方式跳过某些业务逻辑该用 return 的地方还是要 return。2.3 非 public 方法上标注Transactional标注在private、protected、default方法上时Spring 默认无法应用事务拦截器。具体原因和代理方式有关JDK 动态代理只代理接口方法。CGLIB 代理对protected方法不一定生效且 Spring 官方默认不对非 public 方法做事务增强。即使你把事务注解写在 private 方法上代码也能正常启动不会报错但方法执行时根本没有事务包裹。正确的做法是事务方法必须是public的。// 错误写法 Transactional(rollbackFor Exception.class) private void updateStock() { stockMapper.deduct(1L, 10); } // 正确写法 Transactional(rollbackFor Exception.class) public void updateStock() { stockMapper.deduct(1L, 10); }还要特别注意 Lombok 的场景。有时候你觉得写的是 public 方法但同事后续给它加上了Transactional注释方法访问修饰符被改成protected或default这类改动很容易在 code review 中被漏掉。2.4 异常类型设置错误Transactional不显式指定rollbackFor时Spring 默认只对运行时异常和Error回滚。如果方法里抛出的是 checked 异常比如IOException、SQLException或者你自定义了一个继承自Exception的业务异常事务不会回滚。示例public class BizException extends Exception { public BizException(String message) { super(message); } } Transactional public void pay() throws BizException { paymentMapper.deduct(1L); throw new BizException(余额不足); }这段代码的问题在于BizException是 checked 异常Spring 默认不回滚。调用方虽然收到了异常数据库里余额却被扣了。修复方式是在注解上明确rollbackForTransactional(rollbackFor Exception.class) public void pay() throws BizException { paymentMapper.deduct(1L); throw new BizException(余额不足); }关于rollbackFor的选择还有一个常见误解rollbackFor Exception.class会不会太宽泛导致把所有异常都回滚了实际上事务回滚并不是异常类型越多越安全。合理的做法是如果业务上约定所有异常都必须回滚就统一用Exception.class如果只希望运行时异常回滚就保持默认配置或显式写RuntimeException.class。关键是团队要有一致约定不要在同一个项目里混用。2.5 传播行为导致事务边界不符合预期Spring 提供了多种传播行为平时用得最多的是REQUIRED和REQUIRES_NEW。事务失效或事务边界错位很多情况下都是因为误选了传播行为。先看一个典型场景Service public class OrderService { Autowired private PaymentService paymentService; Transactional(rollbackFor Exception.class) public void createOrder() { orderMapper.insertOrder(); paymentService.pay(); throw new RuntimeException(整体回滚); } } Service public class PaymentService { Transactional(rollbackFor Exception.class, propagation Propagation.REQUIRES_NEW) public void pay() { paymentMapper.insertPayment(); } }这里pay()使用了REQUIRES_NEW传播行为表示“无论当前是否有事务都新建一个独立事务并挂起当前事务”。所以主方法createOrder()抛异常回滚时pay()已经在新事务里提交了支付记录不会被回滚。实际业务中这种设计有时是有意的比如“订单失败但支付流水需要保留”的冲正场景有时则是无意的是开发者在某个方法上随意加了REQUIRES_NEW导致数据状态不一致。排查事务问题时一定要把传播行为纳入检查范围。常见传播行为对比传播行为当前存在事务当前无事务典型使用场景REQUIRED加入当前事务新建事务默认值绝大多数业务场景适用REQUIRES_NEW挂起当前事务新建独立事务新建事务记录支付流水、审计日志等不允许随主事务回滚的场景NESTED创建 Savepoint内层回滚不影响外层已执行部分新建事务大事务中嵌套易失败的子流程部分回滚使用SUPPORTS加入当前事务以非事务方式执行查询类操作NOT_SUPPORTED挂起当前事务以非事务方式执行非事务方式发送消息、调用第三方接口耗时操作MANDATORY加入当前事务直接抛异常强制要求调用方提供事务上下文NEVER抛异常以非事务方式执行不允许在事务内执行的耗时操作如果你不确定当前项目里传播行为使用是否正确最快的验证方式是打开事务日志观察打印出的Participating transaction failed或Suspending current transaction关键字。2.6 多线程导致事务不共享Spring 事务默认绑定到单个线程。Transactional方法里如果新开了线程去执行 SQL子线程的事务上下文与主线程不会自动共享。Transactional(rollbackFor Exception.class) public void createOrderAndNotify() { orderMapper.insertOrder(); new Thread(() - { notifyMapper.insertNotifyRecord(); throw new RuntimeException(子线程异常); }).start(); // 主线程继续执行其他操作 }上面的代码里即使子线程抛了异常主线程事务也不会因此回滚。反过来也一样主线程回滚时子线程里已经提交的数据也不会跟着回滚。正确的做法是尽量避免在事务方法内开多线程写数据库。如果必须要多线程处理把子线程的数据操作放到主事务提交之后执行。通过TransactionSynchronizationManager注册afterCommit回调把消息发送、通知、异步写库等操作放在事务提交后执行。示例Transactional(rollbackFor Exception.class) public void createOrder() { orderMapper.insertOrder(); // 事务提交后再执行异步通知 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { notifyService.sendNotification(); } }); }3. 事务失效的核心排查链路3.1 排查顺序遇到“事务没回滚”或“数据被部分写入”的问题不要直接去改代码乱试。按照下面的顺序排查基本都能定位到根因。排查步骤可以整理成一张检查单检查步骤检查内容判断依据1. 确认注解是否作用在 public 方法上方法访问修饰符private/protected/default 都不可靠2. 确认是否通过代理对象调用断点观察this对象类型如果是目标类自身类型而不是代理类型就是自调用3. 确认异常是否穿过事务边界在方法外层加打点日志catch 后未重新抛出是最常见的问题4. 确认 rollbackFor 是否覆盖当前异常类型异常继承链路checked 异常必须显式指定 rollbackFor5. 确认传播行为是否符合预期检查Transactional的 propagation 属性REQUIRES_NEW 会提前提交6. 确认是否有多线程写入检查事务方法内是否有 new Thread、ExecutorService子线程事务不会自动共享7. 确认数据源和事务管理器是否配置正确Spring Boot 自动配置或自定义配置数据源未被纳入 Spring 事务管理器时Transactional 完全不生效3.2 通过日志和断点验证事务是否创建最直接的验证方式是在事务方法内、在第一条 SQL 执行前加一段日志输出当前是否有事务Transactional(rollbackFor Exception.class) public void testTransaction() { boolean active TransactionSynchronizationManager.isActualTransactionActive(); System.out.println(当前是否有真实事务: active); orderMapper.insertOrder(); }如果active是false说明事务根本没有创建。此时优先检查方法修饰符、调用来源和注解是否生效。也可以在 Spring Boot 配置中开启事务日志logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction: DEBUG开启后事务的创建、提交、回滚、挂起都会打印日志。3.3 用数据库状态做最终验证排查的最后一步一定要用真实数据状态验证而不是只靠代码判断。比如造一个必失败的测试数据调用接口后检查数据表记录-- 执行接口前先记录数量 SELECT COUNT(*) FROM order_info WHERE order_no TEST20250101; -- 调用接口预期接口抛异常后再检查 SELECT COUNT(*) FROM order_info WHERE order_no TEST20250101;如果接口抛异常后记录数仍然变为 1说明事务没有回滚。此时再根据排查清单一层层检查。4. 实际项目中的事务最佳实践4.1 事务方法要短、要单一事务方法内不要塞入太多无关操作尤其是网络请求、文件读写、消息发送这类耗时长又不需要跟随数据库一起提交的逻辑。事务持有数据库连接的时间越长连接池被占用的风险越高数据库锁等待的概率也越大。推荐做法事务内只做必要的 SQL 操作。消息发送、缓存更新、审计日志、外部接口调用放到事务提交后的回调中。超大事务拆分为多个小事务或者用REQUIRES_NEW、NESTED控制边界。4.2 不要在事务里调用远程接口一个常见的错误是在事务方法里调用第三方支付、短信服务或 Feign 接口。如果远程接口响应慢事务会一直持有连接导致连接池耗尽。更严重的是如果主事务准备提交但远程调用其实已经成功而本地事务最终回滚两边数据就不一致了。正确顺序是先做本地数据变更并提交事务。事务提交成功后再调远程接口。远程失败时通过补偿任务、本地消息表或定时对账去处理。4.3 事务粒度要与锁粒度匹配在事务中对同一行数据加锁时要注意事务提交或回滚后锁才释放。如果在事务内先SELECT ... FOR UPDATE然后又做了长时间外部调用锁会被持有很久。这种情况下即使事务逻辑正确也容易引发数据库死锁或锁等待超时。排查数据库锁问题时要优先看事务范围和 SQL 顺序而不是只看 SQL 本身的执行计划。4.4 团队统一事务异常约定一个容易出现争议的问题是rollbackFor到底写什么。有的团队统一写Exception.class有的团队只用默认配置。如果项目里两种风格混用同一个业务异常在不同方法上的回滚行为就不一致排查时容易误导。实际建议是新建项目时在团队规范里明确一种默认规则。如果统一使用Exception.class那就所有事务方法都显式写上rollbackFor Exception.class保持风格一致。如果使用默认规则则业务异常要设计为RuntimeException的子类这样无需每次标注。更稳妥的做法是不让事务方法捕获到被业务层吞掉的自定义异常而是让异常直接冒泡。对需要记录已处理异常的场景使用setRollbackOnly()并明确注释。4.5 事务优化清单发布代码前可以按下面清单快速检查一遍检查项是否通过事务方法都是 public是 / 否没有同类内部 this 调用事务方法是 / 否异常没有被 catch 后吞掉或已显式标记回滚是 / 否checked 异常已配置 rollbackFor是 / 否传播行为与业务预期一致是 / 否事务方法内没有开新线程写库是 / 否事务方法内没有调用远程接口是 / 否事务方法没有持有锁做长时间外部操作是 / 否事务方法命名或注解风格与团队约定一致是 / 否数据源已被 Spring 事务管理器管理是 / 否5. 留下一张可直接使用的事务排查对照表事务出错时第一时间对照下表可以省去大量翻日志的时间现象最可能原因优先检查快速处理方法抛异常但数据已写入异常被 catch 吞掉catch 块是否重新抛出改为抛出或 setRollbackOnly事务注解写了但完全无事务自调用 / 非 publicthis 类型、方法修饰符拆 Service 或改 public主事务回滚但子表数据已提交REQUIRES_NEWpropagation 属性去掉 REQUIRES_NEW 或抽离为独立业务checked 异常没有回滚未指定 rollbackFor异常继承关系、注解属性显式加 rollbackFor多线程写入数据不一致事务边界跨线程new Thread / ExecutorService移到 afterCommit 或单独事务事务反复提交回滚日志难以定位事务方法过复杂是否包含外部调用/循环 SQL简化事务方法减少事务范围这套对照表和排查链路适用于绝大多数 Spring Boot 项目。即使换了 ORM 框架、换了数据库核心原理仍然一致只要Transactional是通过 Spring 代理生效的就要从代理对象、异常传播、事务边界三个方向去找问题。
返回列表