ARTICLE DETAIL

资讯详情

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

Spring事务失效的底层原理与排查实战:从代理机制到异常处理

Spring事务失效的底层原理与排查实战:从代理机制到异常处理 1. 从一次线上事故说起事务到底什么时候会失效如果你写过几年Java后端大概率遇到过这样的场景明明在Service方法上加了Transactional注解代码也层层检查了好几遍结果数据该回滚的没回滚一条错误记录就这么安安静静地落进了数据库。等你回头排查发现不是注解没加就是异常被吞了再不然就是方法被this调用了——然后一拍大腿原来是事务根本没生效。我去年接手过一个对账系统里面有一段批量更新账户余额的逻辑外层方法标了Transactional结果某个批次中途抛了业务异常前几百条却照样提交了。最后定位到原因更新方法被同类内部调用代理根本没能拦截住。那次排查花了我将近半天时间也让我意识到Spring事务失效这事儿真不是简单背几条规则就能完全规避的你得先把它的底层机制彻底吃透。这篇博文我打算从Spring事务的代理原理讲起把这几种常见的失效场景逐一拆开揉碎每一步都跟你说清楚“为什么失效”而不是光扔给你一张“什么情况会失效”的表格。最后我还会分享一套我平时用来排查事务问题的实战经验希望对你有实际帮助。不管你是刚接触Spring的初级开发还是已经踩过不少坑的资深工程师这篇文章都值得你花二十分钟从头到尾读一遍。尤其是那些自认为“事务肯定没问题”的人我敢说这里面至少有两条坑你多半也踩过。2. 为什么事务会失效先搞懂Spring事务的底层代理机制2.1 Transactional不是魔法它靠的是AOP动态代理很多人用Transactional用了好几年却未必真正理解它背后的运行原理。Spring声明式事务之所以能生效靠的不是它在方法上加了几行“魔法”而是AOP面向切面编程在运行时为你动态生成了一个代理对象。你可以把代理对象理解成中间商当你调用一个带有Transactional注解的方法时首先进入的是这个代理对象代理对象负责在方法执行之前帮你去数据库连接池里开启事务setAutoCommit(false)在方法正常执行结束后提交事务在方法抛出异常后执行回滚。而真正执行业务代码的是被这个代理对象包装起来的原始Bean对象。这个机制的关键点在于只有当你调用的是代理对象上的方法时事务拦截器才能插上手。如果你不小心绕过了代理对象、直接调用了原始Bean的方法那Transactional注解就形同虚设拦截器根本不会执行事务自然也就不会开启。注意默认情况下Spring的事务管理是基于JDK动态代理或CGLIB代理实现的。JDK动态代理要求目标类必须实现接口它代理的是接口CGLIB通过生成目标类的子类来代理不要求接口。无论哪种方式代理对象都是Spring容器装配时替你创建好的你在Controller里Autowired注入的那个Bean实际上注入的就是代理对象。2.2 一个关键前提事务管理器必须被正确配置除了代理机制事务失效还有一个经常被忽略的前提条件——Spring容器里必须有一个可用的PlatformTransactionManager或TransactionManagerBean。如果你用的是Spring BootDataSourceTransactionManager会被自动装配这通常不容易出问题。但如果你是传统SSM项目或者自己手动配置Spring MVC那么你必须在XML或者JavaConfig里显式声明事务管理器并且要记得开启tx:annotation-driven/或者加EnableTransactionManagement注解。少了这一步你在方法上写多少个Transactional都不会生效。我见过一个很典型的案例某个老项目是Dubbo Spring的架构所有Service接口和实现类都写好了Transactional也加得挺认真但就是事务不回滚。排查到最后发现Spring配置文件里只配了数据源和SqlSessionFactory根本没有配置事务管理器也没有开启注解驱动扫描。问题解决起来倒是很快加一段XML配置就好了但排查过程相当折磨人。所以判断事务是否生效第一步不是盯着业务代码看而是确认这三样东西齐不齐数据源是否有事务管理器注解驱动或EnableTransactionManagement是否开启Transactional所在的类是否被Spring容器扫描到这三样缺一样后面所有的失效场景分析都无从谈起。2.3 iOS 测试事务是否生效的简易方法在讲具体失效场景之前先教大家一个最省的验证方法。当你怀疑某个Transactional是否生效时不用急着查各种配置直接在方法里写一段代码Transactional public void testTransaction() { System.out.println(TransactionSynchronizationManager.isActualTransactionActive()); // 如果这里打印 true说明事务已经开启 // 如果打印 false说明事务根本没生效 }TransactionSynchronizationManager.isActualTransactionActive()是Spring提供的一个工具方法用来判断当前线程是否绑定了一个真实开启的事务。如果输出true说明代理生效、事务已开启如果输出false恭喜你找到了问题的方向——事务压根就没启动。这个方法我几乎每次排查事务问题都会先用一遍简单粗暴却能省掉大量弯路的排查时间。3. 失效场景一方法自调用 —— 最经典的“事务没走代理”坑3.1 同一个类的内部调用代理对象直接被绕过了假设你有一个UserService里面有两个方法Service public class UserService { Transactional public void createUserWithOrders(User user) { userMapper.insert(user); this.createOrder(user.getId()); // 自己调自己 } Transactional(propagation Propagation.REQUIRES_NEW) public void createOrder(Long userId) { orderMapper.insert(userId); } }上面这段代码看起来没什么毛病但事务是失效的。原因在于this.createOrder(...)这里的this是原始Bean对象不是Spring容器里那个代理对象。当你在外部通过Autowired拿到UserService时拿到的是代理对象但一旦进入createUserWithOrders方法内部this指向的就是目标类的真实实例后续的调用链就彻底脱离了代理的控制。事务拦截器连外层方法的拦截都还没完成呢——换句话说外层方法的Transactional也可能因为内部的调用方式而被影响更深层的REQUIRES_NEW更是压根不会触发。可以这么理解代理对象相当于一个前台接待员正常流程是你先跟前台打招呼前台再帮你转接给后面的主管原始Bean。但自调用相当于你已经进了主管办公室然后直接拿起电话打给隔壁同事全程没人经过前台。前台当然不知道你打电话这回事也就不会给你做事务增强。3.2 解决自调用问题的三种思路思路一把被调用方法拆到另一个独立的Service里。这是最彻底的方案也是Spring官方推荐的实践方向。你把createOrder拆到OrderService里让UserService注入OrderService调用时走的就是代理对象事务拦截器自然能正常拦截。思路二自己注入自己。如果实在不想拆类有一个偏门但有效的办法——在UserService里注入UserService自己Service public class UserService { Autowired private UserService self; Transactional public void createUserWithOrders(User user) { userMapper.insert(user); self.createOrder(user.getId()); } }这样self是代理对象内部调用就经由代理了。不过这种写法容易让人困惑而且不符合“依赖注入自引用”的常规设计能不用尽量不用。思路三从ApplicationContext里拿代理对象。通过ApplicationContext.getBean()手动获取当前类的代理对象再调用也能解决问题。但这种方式把容器对象硬编码到了业务逻辑中耦合度很高我个人不推荐生产环境用。注意自调用问题还有一个隐蔽的变种——子类调父类的Transactional方法。如果你的父类方法标注了Transactional子类在内部通过super.method()调用该方法同样不会走代理。道理是一样的super也是原始对象引用不是代理。4. 失效场景二方法修饰符限制 —— private、final、static 为什么不行4.1 代理无法覆盖private方法再来看一个很常见的坑有些人习惯把Transactional放在private方法上觉得“反正这个细节方法只有自己内部用加个注解应该也能用吧”。答案是不能。原因要从代理的实现方式说起。不管是JDK动态代理还是CGLIBSpring创建代理对象时都会对目标类的方法进行判断。private方法是不可被继承、不可被覆盖的。如果是JDK动态代理代理类按接口实现目标类的private方法根本不会被暴露在接口中如果是CGLIB它以生成子类的方式代理目标类而private方法无法被子类重写。两种代理方式都拿private方法毫无办法拦截器自然也无法植入事务逻辑。顺带一提Spring的Transactional注解标注在private方法上时Spring容器启动阶段一般都不会报错只是静默忽略。这导致很多开发者根本意识不到事务已经失效直到数据错乱后才开始排查尤其是那些在方法内部做了大批量写操作的情况后果往往会更严重。4.2 final方法同样无法被代理覆盖final修饰的方法也有类似问题。CGLIB通过生成目标类的子类来实现代理它需要重写父类的方法来插入拦截逻辑。一个被final修饰的方法子类是无法重写的所以CGLIB只能放弃对这个方法的增强。JDK动态代理针对接口如果final方法是接口定义的方法代理类一般仍然可以实现它但实践里我们很少在接口里定义final方法Java的接口方法默认public abstract即使标识为default也不能加final所以final和代理失效主要集中在CGLIB场景。另外如果整个目标类都被final修饰CGLIB直接无法生成子类Spring启动时通常会抛出异常这已经不只是事务失效的问题了。Spring Boot 2.x 默认开启了proxyTargetClasstrue用的就是CGLIB代理Spring 6 / Boot 3 的核心是CGLIB风格的代理。在这样的默认配置下public方法能正常走代理final方法就会被跳过。我曾在一个项目中看到一个工具类里的方法被打上final同时又标了Transactional启动时没有任何异常但实际调用时事务完全没有开启排查了很久才找到原因。4.3 static方法是彻底的“无代理”区域static方法不依赖于对象实例它属于类本身。代理对象是基于实例创建的拦截器也是在实例方法调用链上生效的。你调用静态方法根本不需要经过任何实例代理自然不可能拦截到你头上。所以Transactional标注在static方法上基本等同于无效标注。这里想多说一句事务方法的设计最佳实践永远是“public 方法 通过代理调用”。如果你确实需要在私有方法中执行数据库写操作那么应该让一个public方法作为入口私有方法只做逻辑拆分Transactional放在public入口上而不是放在private方法上。设计良好的事务边界通常都是整个业务操作的入口方法而不是内部细节方法。5. 失效场景三异常被吞了 —— 事务回滚的本质是“感知异常”5.1 为什么catch异常会导致事务不回滚这一节讲的内容可能是所有失效场景里最容易踩、也最隐蔽的。很多人总是疑惑我的方法明明加了Transactional数据库操作也报错抛异常了但数据还是提交了为什么这就要回到Spring事务回滚的实现机制了。Spring的事务拦截器在方法执行后会根据方法是否抛出异常来决定是commit还是rollback。它的逻辑大致是try { // 执行业务方法 Object result invocation.proceed(); // 如果没有异常提交事务 transactionManager.commit(); return result; } catch (RuntimeException | Error e) { // 如果抛出的是运行时异常或错误回滚事务 transactionManager.rollback(); throw e; } catch (Exception e) { // 如果是受检异常默认不回滚除非配置了rollbackFor transactionManager.commit(); throw e; }关键来了事务拦截器只能在方法抛出异常时感知到异常。如果你在业务方法内部自己try-catch把异常吞掉了方法正常返回拦截器一看“没有异常嘛”然后就直接提交了——数据自然就落库了。就像你给保安下了命令“只要房间里有人呼救你就冲进去叫医生。”但房间里面的人喊了两嗓子后自己把嘴捂住了深呼吸了两口气然后若无其事地走出门说“我没事”。保安当然不会叫医生因为他根本没有收到任何异常信号。5.2 正确姿势让异常继续往外抛正确做法是如果你在方法内部对某段代码做了try-catch处理要么在catch块中抛出RuntimeException或Error要么使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记事务回滚。Transactional public void createOrder(Order order) { try { orderMapper.insert(order); stockService.deductStock(order.getProductId()); } catch (StockNotEnoughException e) { log.error(库存不足, e); // 手动标记回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 或者直接抛出运行时异常 // throw new RuntimeException(库存不足, e); } }使用setRollbackOnly()可以让事务最终回滚但方法会正常返回。这种方式适合那些“需要记录错误信息但是又必须回滚”的业务场景。不过这种方式也算一种“半隐式”回滚维护起来容易被忽略我通常更倾向于直接抛出统一封装的BusinessException并在全局异常处理器里捕获处理。注意有些框架工具在抛出受检异常时事务默认不会回滚后面会详细讲。所以严谨起见回滚条件最好通过rollbackFor显式指定避免因为异常继承体系产生意外。5.3 受检异常默认不回滚Spring的“历史设计”与应对Spring事务默认只对RuntimeException和Error进行回滚对于受检异常Exception的子类但不包含RuntimeException默认不回滚。这就是为什么有些同学写的Transactional方法在业务层直接throw new Exception(xxx)但数据照样提交的根本原因。这一点源于Spring早期设计的理念受检异常通常被认为是可以处理的业务异常系统可以进行恢复所以事务不应该盲目回滚。但放到现实的业务里很多受检异常恰恰是不可恢复的比如外部接口调用失败、消息发送失败等等这个时候我们往往需要整条链路回滚。解决方案很简单给注解显式指定回滚异常类型Transactional(rollbackFor Exception.class)或者更精确一些只对某几种异常回滚Transactional(rollbackFor { BusinessException.class, RemoteCallException.class })我个人强烈建议在项目中凡是使用Transactional的地方直接在注解上写清楚rollbackFor。原因很实际团队成员的编码水平参差不齐有人喜欢抛自定义异常有人习惯直接抛Exception如果你依赖默认行为很可能别人写了个受检异常就悄悄提交了数据。显式声明回滚范围等于把事务边界的行为固定下来能减少很多隐性风险。6. 失效场景四传播行为配置错误 —— REQUIRES_NEW 反而让你“丢了事务”6.1 理解事务传播机制的核心概念事务传播行为描述的是当一个事务方法被另一个事务方法调用时这个被调用方法的事务该如何响应。Spring定义了多个传播级别我这里只重点讲两个在生产中最常用、也最容易出问题的REQUIRED和REQUIRES_NEW。REQUIRED是默认的传播行为如果当前存在事务则加入到当前事务中如果当前没有事务则新建一个事务。换句话说多个方法在同一个事务上下文中执行要么全成功要么全回滚。REQUIRES_NEW的字面意思是“总是开启一个新事务”如果当前存在事务则把当前事务挂起suspend创建一个全新的独立事务。新事务提交或回滚后再恢复之前挂起的事务。——听起来很合理但实际落地时很多人对这个“独立事务”的理解有偏差导致事务不如预期地生效。拿前面那个自调用的例子来说如果createOrder方法标了REQUIRES_NEW而且它是被外部代理正常调用的那么它确实会开启一个新事务独立于外层createUserWithOrders事务。好处是哪怕外层事务后来回滚了createOrder已经提交的数据也不会跟着回滚。这可以用来做事务间的隔离比如记录操作日志不管主业务成功失败日志都要写入。坏处是很多人以为REQUIRES_NEW会增强数据一致性其实恰恰相反——它把原本一个整体的事务拆成了两个独立事务部分失败时数据就可能出现中间状态。如果对这条理解不透彻代码里到处是REQUIRES_NEW最后的结果往往是莫名其妙丢了一部分数据写操作。6.2 开发中最常见的传播行为误用在同一个类内部使用propagation Propagation.REQUIRES_NEW时如果发生在自调用场景里那新事务根本不会开启。外层调用链直接走进了原始Bean的方法代理层的传播行为拦截器没机会执行。这就导致你费劲配置的“新事务”形同虚设数据操作还是在原来的事务里执行。还有一种很典型的误用场景在事务方法中调用REQUIRES_NEW方法但是外层事务在调用完新事务方法后抛出了异常。此时外层事务回滚而REQUIRES_NEW事务已经独立提交——如果操作的是同步数据就会留下“已提交新事务、外部主流程回滚”的不一致状态。所以当你决定使用REQUIRES_NEW时先问自己几个问题这个被调用的方法是否必须独立提交即使主流程失败新事务提交的数据是否会对外造成数据不一致是否还有更可靠的方案比如独立的消息队列、事件表如果答案不清晰建议放弃REQUIRES_NEW回到REQUIRED默认传播行为上让多个写操作保持同一事务边界。6.3 嵌套事务REQUIRED 自调用同样可能失效开头那个自调用的例子即使两个方法都是默认的REQUIRED自调用也会导致内层方法的事务注解形同虚设。很多人会误以为“外层方法有事务内层方法就跟着一个事务了”其实在自调用场景下内层方法事务压根没有被代理拦截。假如你在外层加了事务内层方法里的数据库操作确实会落在同一个事务里——但这只是因为内层方法被外层代理方法顺带执行了而已而不是因为内层方法自身的Transactional生效了。如果你指望通过内层方法单独控制事务边界比如某个内层方法需要REQUIRES_NEW那它必然不会按你的预期工作。7. 失效场景五多线程、数据库引擎与手动提交 —— 那些容易被忽视的隐藏场景7.1 Transactional 和 Spring 事务的线程绑定机制Spring事务的实现很大程度依赖ThreadLocal绑定资源。事务在某个线程上开启时数据库连接会被绑定到当前线程上后续同一个线程中的数据库操作会使用这个连接。如果事务方法内部又新开了子线程子线程通过Spring管理的Mapper执行数据库操作那它获取到的数据库连接就是另一条连接和主线程的事务连接根本不沾边。换句话说Transactional管理的是主线程上的事务而子线程的数据库操作是独立连接上的操作既不参与主线程事务的提交和回滚也不会被主线程回滚掉。我之前处理过一个并发导入的需求主线程开启事务分批提交给线程池处理数据导入等所有批次完成后主线程再提交。结果运行中线程池里的某个批次出现异常主线程虽然回滚了但线程池里已经成功执行的那些批次数据全都悄悄提交到数据库了。这个坑踩得我印象深刻从那以后我养成了一个基本准则跨线程的操作坚决不用声明式事务去管理。要么把每个子线程当成独立事务处理要么把所有数据汇总后由主线程串行写入。7.2 数据库存储引擎不支持事务这个场景在现在看起来有些“老古董”了但依然值得提一句。MySQL的MyISAM存储引擎是不支持事务的它只支持表级锁没有redo/undo日志自然也就没有回滚机制。如果你使用MyISAM表即使用Transactional把这方法包得再严实数据操作也都是立即生效无法回滚。随着InnoDB成为MySQL默认存储引擎遇到这个问题的概率大大降低了但一些老系统、历史遗留表还是有可能存在MyISAM。排查事务失效时如果你确认代码层面没问题不妨顺手查一下表的存储引擎SHOW TABLE STATUS WHERE Name your_table;看一眼Engine字段如果是MyISAM那答案就明了了。另外表结构中的ENGINE也可以在SHOW CREATE TABLE中看到。7.3 手动提交事务与自动提交的混用如果你使用Spring的TransactionTemplate或者DataSourceTransactionManager手动控制事务同时又调用了某个被Transactional标注的方法两者混用很容易产生“外部事务已提交内部方法却以为还处于事务中”的混乱局面。更常见的反模式是在Transactional方法中自己从DataSource里拿Connection然后调用connection.commit()或connection.rollback()这等于把Spring事务管理的连接给“劫持”了回滚行为基本就失控了。老实说我在实际工作中几乎不使用手动提交除非是做非常底层的批处理逻辑。Spring的声明式事务已经覆盖了绝大多数业务场景手动介入事务控制只会增加理解和维护成本。8. 失效场景六注解标错位置、代理方式与自动配置条件8.1 注解不能放在接口方法上在JDK代理下会有歧义有些老项目习惯把Transactional标在接口方法上实现类里不标。在JDK动态代理模式下Spring允许你通过接口方法上的注解来配置事务但如果项目切换成了CGLIB代理比如Spring Boot默认proxyTargetClasstrue注解标在接口上就可能不被代理目标类感知事务又会悄悄失效。更让新手困惑的是Spring文档建议注解应该放在实现类的方法上而不是接口方法上。原因是实现类上的注解更直观、更不易丢失而且无论代理方式如何变化基于实现类的注解都能被正确定位。如果你追求严谨不建议在接口和实现类上同时加注解因为一旦两边配置不一致会以哪个为准让人很头疼。8.2 代理对象能否被创建final类、非public类的问题CGLIB代理需要生成目标类的子类。如果目标类是final的CGLIB无法创建代理如果目标类不是public的比如包私有类有时候也会受限于类加载器的模块访问规则导致代理创建失败或者事务无法生效。Spring对这类问题往往在启动时就给出异常提示这是好事——你能尽早发现问题。但我还是提醒一句Service实现类尽量写成public的非final类这是在各种代理方案下都不会出错的稳妥选择。8.3 Spring Boot自动配置失效的隐晦坑使用Spring Boot时你可能会忘了EnableTransactionManagementBoot下默认开启但如果你手动创建了多个PlatformTransactionManager可能导致事务管理器选择错误。如果配置中同时存在多个PlatformTransactionManager又没有明确指定PrimarySpring在事务匹配时可能取到错误的事务管理器进而造成事务不生效。这个问题比较隐蔽因为它不报错——Spring按类型去找唯一的TransactionManager找到多个时可能直接启动失败但如果某些条件下的候选集不同可能静默地选择了一个不匹配数据源的管理器。排查思路是在配置里明确指定某个事务管理器为Primary或者在Transactional注解中通过transactionManager属性指定准确的事务管理器Bean名称Transactional(transactionManager orderTransactionManager, rollbackFor Exception.class)9. 事务失效排查手册三步快速定位问题9.1 第一步验证事务是否真的开启了不管怀疑哪一类原因我建议都先跑一次最简单的验证用TransactionSynchronizationManager.isActualTransactionActive()打印当前事务状态。如果打了false就别再纠结异常、传播行为之类的了先解决“事务压根没开”的问题重点检查代理、事务管理器配置、类扫描范围。如果打了true但数据依然没回滚那问题大概率在异常传播或事务边界上继续看rollbackFor和异常是否被吞。9.2 第二步检查调用链是否经过代理仔细检查Transactional方法是被谁调用的是从外部BeanController/另一个Service注入后调用的还是同类内部this调用的有没有把Transactional方法设为private/final/static有没有用new直接创建对象而不是通过Spring容器获取一个很快速的自检办法在Transactional方法内打印当前对象的Class信息。如果被代理了Class名称中通常会包含$$EnhancerBySpringCGLIB$$或$Proxy之类的字样如果打印出来是普通类名说明你拿到的不是代理对象。9.3 第三步确认异常和事务边界如果代理、配置都没问题事务状态也打出了true那重点检查异常链路业务代码是否catch了异常并吞掉异常类型是否是受检异常默认不回滚有没有在catch块里又执行了其他数据库写操作是否存在跨线程调用、外部接口调用后继续写库的情况按这三步排查绝大多数事务失效问题都能快速找到根源。我自己在团队里带新人时都是让他们先按这个流程走一遍基本能避掉80%的坑。10. 最后再总结几个事务使用的实战原则写了这么多年代码也踩过无数事务的坑我总结了几条自认为很管用的实践原则分享出来供你参考原则一Transactional只放在public方法上且尽量放在实现类方法上。这是最稳的配置方式能最大程度兼容不同代理方案。原则二永远显式声明rollbackFor Exception.class。除非你有特别明确的理由否则不要依赖默认行为。防止受检异常悄悄提交数据。原则三不要在同一类内部调用带事务的方法。如果确实需要事务传递把被调用方法拆分到独立的Service中通过注入代理对象来调用。这是最直观也最容易被接受的方式。原则四不要在事务方法里长时间占用外部资源。比如事务方法内不要执行RPC调用、HTTP请求、文件上传下载等耗时操作这些操作会持有数据库连接容易导致连接池耗尽。尽量先拿到数据提交事务后再调用外部服务。原则五多线程环境下不要指望事务能跨线程传播。要么把每个线程的任务视为独立事务要么重新设计数据写入策略避免在一个事务里开子线程写数据。原则六排查事务问题先验证是否真的开启了事务再考虑异常和传播行为。别一上来就背“事务失效八大场景”做无头苍蝇式排查。一个isActualTransactionActive()验证能帮你快速排除掉一半的问题。事务看似是一个很小的技术点但它直接影响数据的一致性和系统的可靠性。很多人直到生产环境出了数据错乱才回头痛苦地排查事务失效问题。希望这篇文章能帮你把这些坑提前都填平——至少下次再遇到“为什么我的数据没回滚”你心里能马上浮现出好几个排查方向而不是一脸懵地对着屏幕发呆。
返回列表