ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

@Transactional(rollbackFor=Exception.class)为什么没能回滚?一文看懂Spring事务回滚规则

@Transactional(rollbackFor=Exception.class)为什么没能回滚?一文看懂Spring事务回滚规则 去年排查了一个线上订单数据错乱的问题最终定位到一行注解上Transactional(rollbackFor Exception.class)。好在数据最终手工恢复了但这次排查背后暴露出的问题值得认真聊一次Java异常体系究竟是怎么设计的Spring事务回滚到底依据什么规则判断rollbackFor这个参数看似简单为什么藏着这么多坑。这篇文章不绕弯子直接从Throwable顶层继承关系开始把异常分类和事务回滚的关系讲清楚然后用真实的故障案例走一遍排查过程。无论你是刚写Spring的老手还是正在准备Java面试的候选人只要你写过Transactional这篇文章都值得你花十分钟看完。1. Java异常体系从Throwable顶层开始梳理1.1 Throwable、Error、Exception三者关系很多人在初学Java时就看到过这张继承图所有异常类的根都是java.lang.Throwable它的直接子类只有两个Error和Exception。Error代表JVM层面的严重错误比如OutOfMemoryError、StackOverflowError、NoClassDefFoundError。这类问题通常是不可恢复的程序不应该去捕获处理也几乎不可能通过业务逻辑挽救。所以绝大多数框架包括Spring事务都对Error做了回滚处理因为虚拟机都快不行了数据一致性必须保住。Exception则是程序层面可以感知和处理的问题。它下面又分成两类一类是RuntimeException及其子类比如NullPointerException、IllegalArgumentException、ArrayIndexOutOfBoundsException这类异常在编译期不需要强制声明或捕获完全依赖运行时的检查另一类就是受检异常比如IOException、SQLException、FileNotFoundException这类异常编译期强制要求方法签名里要么throws丢出去要么在代码里try-catch处理。这个继承关系看起来是很基础的Java知识但它和事务回滚之间有直接绑定关系。Spring判断“这个异常要不要回滚”时本质上就是在判断这个异常对象属于Throwable继承链上的哪个分支是走了RuntimeException这条线还是走了Exception的受检分支再或者是Error分支。1.2 受检异常与运行时异常的分类意义Java当初把异常分成受检和运行时核心目的是让开发者区分两类问题的性质运行时异常通常代表程序逻辑错误比如空指针、参数非法、数组越界这些问题应该在编码阶段就避免掉一旦出现就说明代码有bug数据不应该继续提交受检异常则代表外部环境或预期内的问题比如文件不存在、网络连接失败、数据库连接超时这些问题调用方有机会通过重试、降级或其他方式恢复。这套分类在理论上是自洽的但放到真实业务系统里就会“水土不服”。举个例子在订单服务里执行insertOrder、updateStock时底层操作往往会抛SQLException从Java语法上看它是受检异常必须处理但业务上它就是一次数据库操作失败数据片段可能已经写入了一半。如果事务因为“受检异常不代表程序逻辑错误”而默认提交那就会留下半截脏数据。这就为后面rollbackFor的出现埋下了伏笔Java异常分类是为编译器设计的而Spring事务回滚是为数据一致性设计的两者标准不一样。Spring为了避免误伤那些确实不需要回滚的受检异常默认采取了保守策略但这恰恰导致了一系列线上事故。2. Spring事务回滚机制默认策略的底层逻辑2.1 事务管理器与AOP代理的工作流程要理解事务回滚首先要明确事务不是无条件工作的。Transactional注解真正生效靠的是Spring AOP动态代理。当你在一个Service方法上加了TransactionalSpring启动时会对这个Bean创建代理对象。外部调用方注入的其实是这个代理对象真正执行业务逻辑的是目标对象。代理对象在调用目标方法之前先通过TransactionInterceptor开启事务目标方法执行结束后如果方法正常返回就提交事务如果方法抛出了异常就进入异常处理分支根据异常类型决定回滚还是提交。这个过程中最关键的就是异常判断的入口源码在TransactionAspectSupport.completeTransactionAfterThrowing方法里。简单说事务拦截器会调用TransactionAttribute.rollbackOn(Throwable ex)方法把当前抛出的异常传进去返回true就执行rollback返回false就执行commit。我们需要重点关注的正是这个rollbackOn的默认实现。需要注意的是代理对象只有在外部调用时才生效。如果你在同一个类里写了方法A和方法BA内部直接调用了B这种this调用并不会经过代理对象B上的Transactional配置形同虚设。这是一个非常典型的陷阱后面还会展开。2.2 默认回滚规则为什么只认RuntimeException和ErrorDefaultTransactionAttribute.rollbackOn的默认逻辑非常简单public boolean rollbackOn(Throwable ex) { return (ex instanceof RuntimeException || ex instanceof Error); }也就是说默认情况下只有方法抛出RuntimeException或其子类以及Error时Spring才回滚事务如果方法抛出的是受检异常Exception的非RuntimeException分支Spring不会回滚而是会正常提交。为什么Spring要这样设计核心原因是框架层面的“平台中立”考量。Spring诞生于企业级Java时代当时的开发规范中受检异常通常代表业务规则可以处理的预期情况。比如一个业务方法发现用户余额不足抛出一个InsufficientBalanceException这种预期内的业务拒绝场景很多老式设计不希望强制回滚整个事务而是希望调用方捕获后做出分支处理。所以在Spring设计者眼中受检异常默认提交是有意为之的。但问题在于实际开发中很多受检异常并不是“可恢复的业务预期”而是真正的系统级错误。尤其是当你引入第三方库、对接老旧接口时它们抛出的异常可能就是受检的。这个时候如果方法里写了Transactional但没有主动配置rollbackFor事务就会静默提交造成数据不一致。而且这种问题在测试阶段很难发现因为单测里往往不会真的触发受检异常。3. rollbackFor的适用场景与陷阱排查3.1 哪些异常不会触发回滚受检异常与自定义异常基于前面的默认规则下面几类异常天生不会触发Spring事务回滚所有的受检异常包括IOException、SQLException、ClassNotFoundException等继承了Exception但没有继承RuntimeException的自定义异常你自己在方法里构造并抛出的new Exception(...)。这里有一个容易混淆的点很多业务团队会自定义一个BizException有人习惯让它继承RuntimeException有人习惯让它继承Exception。如果BizException继承的是Exception那么当业务校验失败抛出它时事务不会回滚。这在某些“前置校验失败不需要回滚”的场景里可能恰好符合需求但在“校验失败等同于整个操作失败必须撤销之前写入”的场景里就会导致脏数据。举个最直观的例子Service public class OrderService { Transactional public void createOrder(OrderDTO dto) throws BizException { orderMapper.insert(dto.getOrder()); // 模拟后置校验失败 if (dto.getAmount() 0) { throw new BizException(金额非法); } } }如果BizException直接继承Exception那么orderMapper.insert这行SQL已经执行在事务中处于未提交状态当方法抛出BizException后Spring默认不会回滚这个事务等到方法从拦截器中返回时事务被提交一条错误订单就落库了。3.2 rollbackFor与rollbackForClassName的使用细节rollbackFor的作用就是打破默认规则告诉Spring“只要看到这种类型的异常就给我回滚”。最常见的写法是Transactional(rollbackFor Exception.class)这表示所有Exception及其子类都触发回滚包括受检异常。这个写法在项目里出现频率极高但并不意味着它是万能的。明确两个细节第一rollbackFor是支持异常继承关系匹配的。如果写的是rollbackFor BizException.class那么方法里抛出BizException的子类也会回滚反过来如果方法抛出的是BizException的父类则不会匹配。例如配置rollbackFor Exception.class那么任何异常的实例都会匹配因为它默认匹配所有Exception分支。第二rollbackForClassName是指定异常类的全限定名属于字符串配置方式。它在早期XML配置时代比较常用因为可以引用尚未加载到JVM里的类。但这种方式容易写错包名、类名而且IDE无法帮助检查实际维护起来非常痛苦。在代码里更推荐使用rollbackFor直接指定Class类型。还有一个配套参数是noRollbackFor用来排除指定异常不回滚。比如你配置了rollbackFor Exception.class但又希望某个受检异常出现时事务照常提交就可以同时写Transactional( rollbackFor Exception.class, noRollbackFor BusinessCheckException.class )这组参数本身不难难的是决策到底该用rollbackFor Exception.class无脑回滚还是精确到某个异常我的判断是小规模项目为了省事可以用Exception.class但要有心理准备一旦业务复杂起来无脑全量回滚可能把原本只需要局部回滚的操作也一起撤销了甚至把外部接口调用状态也弄乱。更推荐按业务语义配置精确异常或者直接让自定义业务异常继承RuntimeException。3.3 陷阱梳理异常被吞、代理失效、自调用与事务边界光是弄清楚异常类型还不够还有一个比类型更常见的坑异常根本没有传达到事务拦截器。事务拦截器只能看到从目标方法“抛出去”的异常。如果你在方法内部把异常catch掉打了行日志然后正常返回那么代理层看到的是“方法正常结束”于是提交事务。哪怕你的Transactional上写了rollbackFor Exception.class也没有任何意义因为异常压根没出方法。另一个高频陷阱是自调用。刚才已经提到Spring AOP代理只能拦截外部对代理对象的调用。如果同一个Service里有一个方法A方法A内部调用了另一个带Transactional的方法B这个B的事务注解是不会生效的。常见解决办法是把B拆到另一个Service类里或者注入ApplicationContext拿到代理对象后再调用再极端一点可以用AopContext.currentProxy()但需要设置exposeProxy为true。事务边界也有讲究。比如方法A定义了事务调用了方法B方法B也定义了事务默认传播行为是REQUIREDB会加入到A的事务中。如果B抛出了运行时异常并回滚整条事务会被标记rollback-onlyA如果捕获了这个异常自己继续执行到最后A最终尝试提交时也会因为事务已经被标记为rollback-only而抛出UnexpectedRollbackException。很多团队一开始不理解这个异常以为是框架bug实际上是传播行为导致的回滚扩散问题。另外异常在独立线程中抛出基本不影响外层事务。Spring事务状态是绑定线程的你起一个Thread或者用线程池去执行数据库操作那个独立线程里的异常不会自动把外层事务标记为回滚。解决思路是不要在事务方法里开异步任务写重要数据异步操作要做独立的事务管理或补偿机制。4. 实战案例从一次线上故障看事务回滚失效4.1 现象与初步排查这个案例来自一次内部订单服务升级。线上出现一条重复又数据异常的状态记录用户支付时调起了下单接口前端显示支付失败后台却查到了订单记录和库存扣减记录。第一反应是下单逻辑有bug但翻日志之后发现业务代码里已经明确执行到了某一步校验该校验在参数不合法时抛出了异常按道理事务应该回滚但数据还是落了库。排查的第一步确认目标方法在Service类中且方法签名是public void createOrder(...)Transactional注解确实存在。第二步确认数据库表是InnoDB引擎事务本身能工作。第三步模拟同样的参数在本地环境调用发现抛出异常后数据库里依然有新的记录。这个现象说明事务要么没开启要么开了也没回滚。进一步检查后发现抛出的异常是一个自定义的BizException。打开这个类的定义问题马上清楚了public class BizException extends Exception { // 注意继承的是 Exception不是 RuntimeException }这个异常是受检异常并且方法上Transactional只写了注解没有设置rollbackFor。按照Spring默认规则受检异常不会触发回滚。所以方法抛出了异常但事务拦截器判定为“不需要回滚”事务正常提交。4.2 根因分析受检异常未配置rollbackFor这算是典型的“Java语法通过逻辑事务失败”问题。代码层面没有任何报错编译通过运行日志里也有异常堆栈但就是没有回滚。因为异常类型在Throwable继承链上走的是Exception - BizException这条分支没有走进RuntimeException子树的判定条件里。这里也要再强调一点即便你写了catch (Exception e)并把异常包成RuntimeException重新抛出去事务也不会受影响因为最终抛到事务拦截器的是RuntimeException。很多团队会用“捕获受检异常包装成RuntimeException并抛出”的方式这也是一种可行的补救手段但代价是原始异常信息容易被截断最好把原异常作为cause传入。这个场景里就算不配置rollbackFor只要把BizException改成继承RuntimeException问题也能解决。但这类改动影响面很大因为所有调用方如果之前显式捕获了BizException改成运行时异常后那些catch块依然可以捕获catch的类型不变但从语义上来讲它就不再强制调用方处理了是否要做这种调整需要结合项目规范来定。4.3 修复方案与验证结合这个事件的实际情况我给出的建议是在方法上精确配置rollbackFor BizException.class暂时不改异常父类。这样事务回滚策略明确且不影响其他调用方Transactional(rollbackFor BizException.class) public void createOrder(OrderDTO dto) throws BizException { // 业务代码... }修复后要做两层验证。第一层写一个简单的SpringBootTest故意触发BizException并模拟插入数据执行后查询数据库记录数确认没有新增。注意测试类里要加上Transactional或使用Rollback避免测试数据滞留在数据库里。不过这个项目是内部代码我们的习惯是每个核心事务方法都配套一个故障注入单测专门验证回滚行为是否符合预期。第二层排查仓库里还有哪些事务方法没有配置rollbackFor同时方法签名上声明了受检异常。这种组合是最危险的因为问题只会线上爆。我自己常用一个笨办法全局搜索Transactional把所有方法列出来再看方法签名里throws后面跟的异常类型凡是受检异常且没有rollbackFor的都要过一遍。5. 常见问题与排查技巧实录5.1 问题速查表整理一张速查表方便复查。现象根因解决方案受检异常抛出后数据仍然提交Spring默认只回滚RuntimeException和Error配置rollbackForException.class或精确指定异常类catch后不打日志也不抛出事务提交异常没有传到代理层确认是否需要回滚若需要则重新抛出或包成RuntimeException抛出同类内this调用事务注解不生效自调用不走代理对象拆到另一个Service或者注入代理对象非public方法加Transactional无效Spring AOP不拦截非public方法改为public重新设计事务边界异步线程内抛异常外层事务未回滚事务状态绑定线程异步线程不影响主线程异步操作单独管理事务或主线程等待结果配置了rollbackForException.class但未生效方法内部已在catch中把异常“消化”了检查异常是否被捕获是否在方法边界外抛出出现UnexpectedRollbackException内部事务回滚导致事务被标记rollback-only调整事务传播避免不必要的事务嵌套每一条都是我在项目里踩过或帮同事排查过的真实情况。这里额外提醒一句MySQL的MyISAM存储引擎不支持事务哪怕Spring配置再正确也一样不会回滚。排查时先用SHOW TABLE STATUS确认表引擎是InnoDB再做深入分析。5.2 独家避坑Tips根据我的经验有几点属于“平时想不到、出事很重要”的细节重点分享给读者。第一回滚并不会撤销所有状态。事务回滚针对的是数据库操作像Redis缓存的改写、MQ消息的发送、内存中对象字段的变化这些都不具备事务性。假设一个事务方法先改了Redis缓存然后数据库操作失败回滚Redis里的值已经变掉了这块不会自动还原。所以在事务方法里操作非数据库资源时要考虑补偿或延迟到事务提交后再执行。Spring提供了TransactionSynchronizationManager.registerSynchronization来在事务提交后执行回调这个机制比你自己硬编码合理得多。第二事务回滚后主键ID可能不会连续。有些团队看到自增主键跳号误以为连接被回滚了。这个不能用来判断事务是否执行了回滚。第三Transactional注解放在private方法上在Spring Boot 2.x默认设置下不会报错但也不会生效。这个现象很迷惑人因为日志里什么都看不到。建议你在团队规范里强调事务方法必须是public最好通过静态扫描或CR强制检查。第四在测试中验证回滚尽量使用支持嵌套回滚的测试事务。如果你在同一个SpringBootTest里手动往库里插数再断言容易把脏数据留下。标准做法是让测试方法自身也运行在事务里测试结束自动回滚这样不会污染线上环境。第五别忽视“事务内再调事务”的回滚扩散。REQUIRED传播级别下多个Service方法通常会共用一个事务只要某个方法抛出运行时异常整个事务都会被标记rollback-only即使外层方法捕获了异常最终提交时也会失败。所以设计事务边界时要非常小心不相关的操作不要硬塞进同一个事务链路里。最后我再把话说回开头的那个线上事故。修复一行注解很快但真正值得记住的是Java异常分类和Spring事务回滚规则之间存在一条很多教材没有点明的缝隙。架构师在定规范时最好把自定义业务异常的继承策略、事务注解的统一写法、异常抛出边界这些问题一起定下来。我在实际项目中踩过几次坑之后现在给自己定了一个规矩所有自定义业务异常默认继承RuntimeException只有明确“这个异常抛出去后事务照常提交”时才考虑使用受检异常。这样写出的Transactional通常不需要堆满rollbackFor系统行为也更一致。事务回滚这个话题越往深挖越有意思一个rollbackFor背后连接的是异常设计、代理机制、传播行为甚至数据库存储引擎。希望这篇内容能让你在以后排查问题时少走几步弯路。
返回列表