“线上上了个单据审核接口,第一次调就发现事务没生效,回滚没执行,脏数据都进去了。排查了好半天才发现问题根本不在数据库配置,而是出在一个特别基础但又特别容易踩的坑:Spring 事务在自调用场景下失效。”这是我最近处理的一个支付类核心模块的真实经历。项目的代号叫 JoyAI,里面有个订单创建服务,代码里一个方法调用另一个带事务注解的方法,用this.method()写的。当时第一反应是数据源没配对,后来脑子里过了一遍 Spring AOP 代理的原理,才意识到this.method()调用的是目标对象自身的方法,根本不会经过 AOP 代理,事务切面自然就插不上手。这个问题非常适合所有在 Spring 生态里写业务代码的朋友参考,尤其是用了@Transactional但一直没深究过它到底是怎么生效的人。这篇文章我想把整个排查过程、底层原理、实测验证和最后采用的解决方案完整写出来,顺便帮大家梳理清楚:什么情况下事务会失效、为什么this.method()会绕过代理、以及有哪些稳妥的修复手段。1. 问题复盘:从一次线上数据异常说起先说项目背景。JoyAI 这套系统里我负责一个对账模块,负责从第三方接口拉取结算数据,然后批量写入本地库。写入过程需要保证“要么全部成功,要么全部回滚”,所以很自然地在外层加上了@Transactional。线上第一次跑完,发现明细表和汇总表的数据对不上,少了好几条,但程序没报任何异常。这不对劲,因为代码逻辑里如果某条数据校验失败,会直接抛出RuntimeException,理论上整个批次都应该回滚才对。可实际情况是,部分数据写进去了,部分没有,出现了“半截事务”的典型现场。1.1 现象:日志里没有任何回滚痕迹第一件事就是看日志。正常情况下事务回滚时,Spring 会打印类似Rolling back transaction或Initiating transaction commit这类逻辑,但我翻遍了本批次日志,发现连Creating new transaction都没有出现过,也没有任何事务管理器的参与记录。再仔细看调用链,发现负责写入明细的方法是被同一个类的内部方法调用过来的,用的还是最朴素的this.insertDetail()写法。看到这里,我心里已经大概有数了,但还是决定把理论再验证一遍再动手改。顺带提一嘴,排查这类问题有一个好习惯:先确认异常有没有被吞掉,再确认事务管理器有没有参与,最后才是检查代理有没有生效。日志顺序往往能告诉你一切。1.2 初步怀疑:把锅甩给数据源配置按照常规排查思路,事务不生效最容易被怀疑的就是数据源没配置好,或者@EnableTransactionManagement没加。于是我先检查了启动类,确认@EnableTransactionManagement存在,数据库连接池配置也正常,事务管理器明确指向了主数据源。排除掉这一层之后,才把目光放回到调用方式上。这里有个小经验分享:遇到事务失效,最好先在心里过一遍“三步曲”——配置对不对、异常类型对不对、调用链路有没有经过代理。绝大多数事务失效问题都逃不出这三类原因,而自调用就属于典型的第三种。1.3 定位:this.method()是问题核心定位到具体代码时,看到的结构大概是这样的:@Service public class SettleOrderService { public void batchCreate(List<OrderDTO> orders) { for (OrderDTO order : orders) { this.createSingle(order); } } @Transactional(rollbackFor = Exception.class) public void createSingle(OrderDTO order) { // 写主表 // 写明细表 // 校验不通过则抛异常 } }batchCreate内部循环调用了this.createSingle(),而createSingle上面的@Transactional注解并没有生效。原因就是 Spring 的事务功能是通过 AOP 代理实现的,this拿到的是原始对象,不是代理对象。只有从外部容器里获取到的 Bean,或者通过@Autowired注入到其他类里的 Bean,才是被切面包裹后的代理对象。这一步确认之后,问题定位就算完成了。接下来需要对 Spring 的代理机制做深入分析,把“为什么自调用会绕过代理”这个问题彻底讲透。2. 深入原理:Spring AOP 代理与事务生效的前提条件要用大白话解释清楚自调用为什么会失效,得先搞清楚 Spring 创建 Bean 的时候到底做了什么。默认情况下,Spring 容器中存放的并不是你写的那个普通业务对象,而是一个代理对象。这个代理对象会“包裹”原始对象,在方法调用前、后插入额外逻辑。当容器把一个 bean 注入到另一个 bean 中时,注入的其实是代理对象。所以,外部调用带@Transactional的方法时,实际上是先走代理,再走到原始方法。事务就是在这个“额外逻辑”里开启和提交回滚的。2.1 JDK 动态代理与 CGLIB 的区别Spring 的代理机制有两种实现方式:代理方式适用条件特点JDK 动态代理目标类实现了至少一个接口基于接口代理,生成代理类实现相同接口CGLIB 代理目标类未实现接口,或配置强制使用生成目标类的子类,重写非 final 方法在 JoyAI 项目里,SettleOrderService是纯类,没有实现任何接口,所以 Spring 默认会用 CGLIB 生成一个子类代理。这个代理子类重写了createSingle()方法,在方法前后挂载事务拦截器。你可以把this理解成“站在对象内部看自己”,它指向的是那个未经过任何包装的原始实例。而外部持有的 bean 引用指向的是 CGLIB 生成的子类,两者根本不是同一个对象。用this调用方法,等于绕过了代理子类,直接执行原始逻辑,事务拦截器完全不会被触发。2.2 为什么外部调用和内部调用行为完全不同为了让自己彻底理解,我做了一个极简的类比:假设你请了一个助理(代理对象),所有外部访客都必须经过助理登记才能见到你。但你自己早上出门时,难道还要助理给你自己登记一遍吗?this.method()就是这个道理,你自己调用自己,根本不会找助理。Spring 容器中的 Bean 相当于“你本人 + 助理”的合体,外部依赖注入的是助理这个角色,但从类内部看,this永远都只代表你本人。所以即使createSingle()标注了@Transactional,在没有代理的环境下直接调用,它就是一个普普通通的原生方法。2.3 验证:如何确认目标对象确实被代理了我写了一段验证代码来确认自己的判断:@Autowired private SettleOrderService orderService; public void checkProxy() { System.out.println(orderService.getClass().getName()); // 输出类似: com.xxx.SettleOrderService$$EnhancerBySpringCGLIB$$abc123 System.out.println(orderService instanceof SettleOrderService); // 输出: true,因为 CGLIB 子类属于 SettleOrderService 类型 }如果类名里带着$$EnhancerBySpringCGLIB$$,就说明容器里装的确实是代理对象。外部通过orderService.createSingle()调用时,走的确实是代理方法,@Transactional才能生效。反之,在类内部用this.createSingle()调用时,this指向的类名应该就是原始的SettleOrderService,没有增强痕迹,问题一目了然。这里补充一个知识点:如果项目使用了@EnableTransactionManagement,但类本身没有接口,Spring 会优先选择 CGLIB。而如果配置了proxyTargetClass = false且类没有接口,就无法生成代理,事务同样会静默失效。这类配置问题比自调用还要隐蔽,我建议大家在项目初始化时先加一个代理状况检查。3. 事务切面的完整原理拆解:@Transactional 到底在什么时机起作用搞懂了代理机制,还需要再进一步理解事务切面本身的执行流程,这样你才能在设计接口时主动避开各种坑。3.1 TransactionInterceptor 的工作流程在 Spring 中,真正干活的事务切面是TransactionInterceptor。它实现了 AOP 的方法拦截逻辑,核心流程大致是这样:检查当前方法是否配置了事务属性。检查当前线程是否已经存在活动事务,如果没有,就创建一个新的。执行业务方法。如果方法正常返回,则提交事务。如果方法抛出异常,则根据rollbackFor配置决定是否回滚。这整个流程都是在代理方法中完成的。也就是说,业务方法只是被“包裹”的中间环节,事务的开启和结束完全由代理控制。如果this.method()绕过代理,那么这个流程中的“开启事务”、“提交/回滚”就永远不会执行,相当于事务这块逻辑被完全跳过了。3.2 连接获取与“假装有事务”的陷阱这里还要提一个容易混淆的场景:假设自调用方法内部执行了insertDetail(),且执行过程中没有抛出任何异常,那数据确实能写进数据库,因为数据库认为每个单独的 SQL 操作本身就是一个隐式事务。但如果你在自调用方法内部先执行了插入,再把连接归还到连接池,整个方法执行期间就是一个“无事务包裹”的原生状态。如果业务方法中途抛了异常,注意这个时候数据库已经完成了前面几步操作,而 Spring 没有任何能力去回滚已经提交的语句。因为 Spring 只有在开启事务的情况下,才会把连接绑定到当前线程的事务资源中,并持有这个连接直到事务结束。自调用场景下,每次 SQL 操作都是独立获取和释放连接,没有全局协调者,脏数据就这么出现了。3.3 异常处理对回滚行为的影响即使代理正常生效,异常类型不对也会导致事务不生效。@Transactional默认只在遇到RuntimeException和Error时回滚,如果业务方法抛出的是受检异常(比如Exception的子类),默认是不会回滚的。所以在 JoyAI 的项目里,我给事务注解特意加上了rollbackFor = Exception.class,确保所有异常都能触发回滚。在自调用失效的问题上,需要额外注意的是:如果内部方法自己捕获了异常,并且没有重新抛出,那即使代理正常,事务拦截器也会认为方法执行成功,从而提交事务。这种情况更隐蔽,因为代码不会报错,线上数据却会大规模出现不一致。我的习惯是写业务代码时,对需要事务的批量操作,尽量不在方法内部吞异常,而是向外层抛出。3.4 编程式事务与声明式事务的本质差异@Transactional属于声明式事务,优点是无侵入,缺点是过度依赖代理机制。而编程式事务直接通过TransactionTemplate控制事务边界,不依赖代理,适用于自调用或者设计比较复杂的场景。两者各有优劣,我在后文解决方案部分会给出 JoyAI 项目中最终采用的办法。4. 实测验证:复现自调用失效并对比三种修复方案为了确保结论准确,我在本地搭了个最小化项目原型,模拟 JoyAI 的结算场景,分别测试了几种方案的实际效果。4.1 复现代码设计我建立了一张settle_record表,设计了两条插入语句,第一条成功,第二条故意制造主键冲突,验证第一条数据是否会被回滚。业务接口与实现代码大致如下:@Service public class SettleRecordService { public void selfInvoke() { insert("A001", 100); insert("A001", 200); // 故意造成主键冲突 } @Transactional(rollbackFor = Exception.class) public void insert(String recordNo, BigDecimal amount) { settleRecordDao.insert(new SettleRecord(recordNo, amount)); } }测试入口:@Component public class Runner implements ApplicationRunner { @Autowired SettleRecordService service; public void run(ApplicationArguments args) { service.selfInvoke(); // 检查 settle_record 表是否有数据 } }运行之后查询表,发现A001那条数据竟然留在表里了。按正常事务逻辑,第二条插入触发主键冲突后,第一条应该被回滚,但此处没有。这说明自调用场景导致@Transactional完全没有触发。4.2 方案一:注入自身代理这个方案的核心思路是让类内部持有代理对象来调用。最常见写法是通过@Lazy注入自己:@Service public class SettleRecordService { @Autowired @Lazy private SettleRecordService self; public void selfInvoke() { self.insert("A001", 100); self.insert("A001", 200); } }@Lazy加在这里是为了避免循环依赖问题。当类内部注入自己的代理时,Spring 需要先创建代理对象,再注入到原始对象中,如果不加@Lazy,在某些情况下会造成初始化顺序问题。实测结果:事务正常开启,第二条插入异常后第一条回滚了。这种方案改动非常小,最容易落地,但缺点是有一种“绕弯子”的感觉,而且每一处自调用方法都要用self去调用,容易遗忘。4.3 方案二:使用 TransactionTemplate 编程式事务如果既不想注入代理,又想让事务边界清晰可控,可以在方法内部直接使用TransactionTemplate:@Service public class SettleRecordService { private final TransactionTemplate transactionTemplate; public SettleRecordService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void selfInvoke() { transactionTemplate.execute(status -> { insert("A001", 100); insert("A001", 200); return null; }); } }这种方案完全不依赖 AOP,也不需要代理,直接由TransactionTemplate开启事务并绑定连接。实测效果同样正常,数据回滚干净利落。好处是逻辑直白,不会因为调用方式而失效。缺点是需要手动编写事务边界代码,如果一个类里有大量事务场景,代码会逐步显得冗余。我个人在 JoyAI 项目中大量使用了这个方案,尤其是在需要“事务包事务”场景下,TransactionTemplate的传播行为控制反而比注解更直观,而且没有任何代理心智负担。4.4 方案三:将需要事务的方法拆分到其他 Bean另一种常见的办法是把带@Transactional的方法挪到单独的 Spring Bean 中,然后在原类中注入这个新 Bean:@Service public class SettleRecordService { @Autowired private SettleRecordWriter settleRecordWriter; public void selfInvoke() { settleRecordWriter.insertWithTransaction("A001", 100); settleRecordWriter.insertWithTransaction("A001", 200); } } @Component public class SettleRecordWriter { @Transactional(rollbackFor = Exception.class) public void insertWithTransaction(String recordNo, BigDecimal amount) { // 插入逻辑 } }这个方案语义最清晰:事务方法本身定义在一个干净的业务组件中,任何外部调用者想触发事务都直接走代理,没有this的问题。缺点是为了事务强行拆类,类数量会变多,尤其当系统已经很庞大的时候容易造成结构碎片化。4.5 方案横向对比与选型结论方案是否需要代理代码侵入性事务边界清晰度适用场景注入自身代理需要低一般小范围修改,快速修复TransactionTemplate不需要中高复杂事务场景拆分事务 Bean需要低高系统架构分层明确时在 JoyAI 项目中,我最终选择了“拆分事务 Bean + 部分复杂场景用 TransactionTemplate”的组合方案。原因主要是结算模块对事务边界要求高,代码量又大,拆分后每个事务的职责一目了然,后续维护成本低。而自身代理方案作为快速修复手段,用在了几个应急场景中,确实帮了大忙。这里要特别提醒:不管用哪种方案,事务本身的默认传播行为都是REQUIRED。也就是说,如果外层已经存在事务,内层加入已有事务;如果不存在,则新建。拆分 Bean 后,多个带事务的方法相互调用,它们的传播行为也需要认真考虑,否则可能出现“事务过长”或“锁持有时间过久”的问题。5. 避开自调用之外的其他事务失效场景前面主要聊的是自调用带来的问题,但排查过程中我也把 JoyAI 整个结算链路里所有跟事务相关的“坑”统一梳理了一遍,很多问题从表面看是事务失效,实际上各有各的原因。5.1 异常被吞掉导致“假提交”最常见的一种:内部方法抛了异常,但是调用方把异常 catch 住并记录日志,继续执行后续流程。此时事务拦截器认为方法正常完成,直接提交。从数据库结果看,数据出现了“部分写入”,却没有任何报错。很多线上脏数据就是这么来的。规避方法只有一个:事务方法内部尽量不要自己捕获异常后不抛出。如果确实需要处理异常并继续执行后续逻辑,那就不要依赖声明式事务,而是改用编程式事务重画事务边界。5.2 多数据源下的事务管理器路由错乱如果项目引入了多个数据源,比如读写分离或分库分表,很容易出现“事务管理器没有指向实际使用的数据源”的情况。这样@Transactional虽然触发了,但是管理的是另一个数据源的连接,业务 SQL 依然不在事务控制范围内。我的排查经验是,在配置多数据源时,为每个数据源分别定义DataSourceTransactionManager,然后在@Transactional中显式指定transactionManager,比如@Transactional(transactionManager = "masterTransactionManager")。Java Config 配置的时候,最好给每个事务管理器起明确的名字,避免 Spring 自动装配时匹配错误。5.3 方法可见性与 final 方法限制CGLIB 代理通过子类继承来增强方法,private方法和final方法都无法被重写增强。因此,把@Transactional标在private方法上毫无意义,标在final方法上同样不会触发代理。Spring 对这些情况通常也不会有明显错误提示,属于静默失效。在 JoyAI 的代码规范里,我要求所有事务方法必须是public,且不允许final。另外,类本身也要保持可继承状态,不能因为某些性能优化习惯给 Service 类加上final修饰。5.4 同类 Bean 互相调用与接口代理的混淆如果使用了 JDK 动态代理,而事务方法所在类实现了接口,Spring 注入到其他类的是接口代理,类型是接口子类。在类内部自行调用时,如果通过实现类强转调用,同样会绕过代理。这种场景容易迷惑人,因为外部看代码是合法的接口调用,内部却偷偷用了this。解决方向和自调用一致,要么注入接口代理,要么用编程式事务,要么拆分 Bean。6. 最终落地:JoyAI 项目事务模块的重构记录问题定位清楚,方案也验证完毕之后,我对 JoyAI 的结算模块做了一次系统性的重构。这里把关键动作写出来,大家可以直接参考。6.1 统一事务边界设计按照业务行为,我将结算相关的写操作划分为三种粒度:粒度事务范围执行方式单条明细写入仅一条记录普通 DAO 插入,不单独开事务单批次插入一批记录使用单独事务 Bean 方法调用跨多批次汇总多个批次整体最外层使用 TransactionTemplate 控制拆分后,每个事务方法只负责自己的边界,不再出现“一个 Service 内部调用一堆方法靠注解撑大局”的混乱结构。6.2 代码迁移样例原始实现里SettleOrderService内部自调用逻辑很多,重构后我把涉及事务的写操作统一迁移到SettleWriter:@Component public class SettleWriter { private final SettleRecordDao dao; public SettleWriter(SettleRecordDao dao) { this.dao = dao; } @Transactional(rollbackFor = Exception.class) public void writeBatch(List<SettleRecord> records) { for (SettleRecord record : records) { dao.insert(record); // 可能包含其他业务校验 // 校验失败会抛出 RuntimeException } } }原服务类只负责业务编排和调度,不做底层写库。这样从架构上讲,职责也更清晰。6.3 启动校验与回归验证重构完成后,我添加了一个简单的启动校验组件,用来扫描所有标记了@Transactional的方法是否满足以下条件:方法是public。方法所在类不是final。方法没有被final修饰。方法不在同一个类中被this调用。这种静态扫描无法 100% 覆盖所有情况,但至少能在开发早期就拦住一部分低级问题,比上线后靠脏数据反馈要高效得多。回归验证方面,我重新跑了最初的最小化测试用例:两条插入、第二个故意失败、确认数据库没有残留数据。测试通过之后,又补了并发场景的压测,确认在高并发下事务边界没有因为连接池竞争而变宽或变窄。7. 避坑守则:这些经验比代码本身更值钱这个项目从头到尾踩了不少坑,有些是自身设计问题,有些是框架理解不够深入。我把最值得记住的经验整理成几条,供大家参考。第一点,永远不要信任@Transactional的默认行为。默认只回滚RuntimeException和Error,对受检异常不会回滚。如果项目里大量使用受检异常做业务控制流,务必在注解上显式声明rollbackFor = Exception.class。第二点,排查事务问题时,先看日志再改代码。重点找Creating new transaction、Participating in existing transaction、Rolling back这几类关键输出,能快速判断事务到底有没有参与。没有这些日志,就算代码写得再完美,事务也是空中楼阁。第三点,设计批量写入逻辑时,尽量做到“一个事务对应一个有明确业务含义的入口”。不要把一堆细粒度方法都加上事务注解然后把它们串在一起,这种做法非常容易在调用链中埋下自调用或者传播行为混乱的雷。先定义好事务边界,再写实现代码,次序不能反。第四点,如果团队里新人较多,强烈建议在项目里加入简单的 AOP 使用规范文档。我在 JoyAI 的工程文档里专门写了一节“事务方法调用注意事项”,把this.method()的问题用示例代码写清楚,每个新接手模块的人都会被要求先读这一节。别看内容简单,它省下来的排查时间非常可观。第五点,用TransactionTemplate时注意不要在一个大循环里反复开启新事务,这样会造成连接频繁获取和释放,性能极差。正确做法是把整批操作放一个execute回调中,或者根据业务量合理分组。8. 个人经验与最后补充写到这里,关于 Spring 自调用导致事务失效这件事,其实核心就一句话:@Transactional是通过 AOP 代理生效的,而this.method()绕过了代理。理解了这句话,剩下的问题都能顺着推理出来。我在 JoyAI 项目的实际操作中,深刻体会到一点:对一个框架,知其然很重要,但知其所以然更重要。很多人抱怨 Spring 的事务“时灵时不灵”,其实就是因为对代理机制缺乏完整认知。每次遇到事务问题,不要急着搜代码片段,先静下来想一想:我这行代码到底是在调用代理对象,还是在调用原始对象?如果能把这个问题想明白,后续遇到类似的缓存失效、异步注解失效、权限注解失效等问题时,都能触类旁通。最后再分享一个小技巧:排查代理问题时,可以在代码里临时输出当前对象类型,看类名是否带有EnhancerBySpringCGLIB或$Proxy字样。这不是生产环境的常规操作,但作为本地调试手段却非常有效,能让你在 30 秒内就确认代理到底有没有生效。希望这篇实录能帮你在 Spring 事务的深水里少踩几个暗礁。
“线上上了个单据审核接口,第一次调就发现事务没生效,回滚没执行,脏数据都进去了。排查了好半天才发现问题根本不在数据库配置,而是出在一个特别基础但又特别容易踩的坑:Spring 事务在自调用场景下失效。”这是我最近处理的一个支付类核心模块的真实经历。项目的代号叫 JoyAI,里面有个订单创建服务,代码里一个方法调用另一个带事务注解的方法,用this.method()写的。当时第一反应是数据源没配对,后来脑子里过了一遍 Spring AOP 代理的原理,才意识到this.method()调用的是目标对象自身的方法,根本不会经过 AOP 代理,事务切面自然就插不上手。这个问题非常适合所有在 Spring 生态里写业务代码的朋友参考,尤其是用了@Transactional但一直没深究过它到底是怎么生效的人。这篇文章我想把整个排查过程、底层原理、实测验证和最后采用的解决方案完整写出来,顺便帮大家梳理清楚:什么情况下事务会失效、为什么this.method()会绕过代理、以及有哪些稳妥的修复手段。1. 问题复盘:从一次线上数据异常说起先说项目背景。JoyAI 这套系统里我负责一个对账模块,负责从第三方接口拉取结算数据,然后批量写入本地库。写入过程需要保证“要么全部成功,要么全部回滚”,所以很自然地在外层加上了@Transactional。线上第一次跑完,发现明细表和汇总表的数据对不上,少了好几条,但程序没报任何异常。这不对劲,因为代码逻辑里如果某条数据校验失败,会直接抛出RuntimeException,理论上整个批次都应该回滚才对。可实际情况是,部分数据写进去了,部分没有,出现了“半截事务”的典型现场。1.1 现象:日志里没有任何回滚痕迹第一件事就是看日志。正常情况下事务回滚时,Spring 会打印类似Rolling back transaction或Initiating transaction commit这类逻辑,但我翻遍了本批次日志,发现连Creating new transaction都没有出现过,也没有任何事务管理器的参与记录。再仔细看调用链,发现负责写入明细的方法是被同一个类的内部方法调用过来的,用的还是最朴素的this.insertDetail()写法。看到这里,我心里已经大概有数了,但还是决定把理论再验证一遍再动手改。顺带提一嘴,排查这类问题有一个好习惯:先确认异常有没有被吞掉,再确认事务管理器有没有参与,最后才是检查代理有没有生效。日志顺序往往能告诉你一切。1.2 初步怀疑:把锅甩给数据源配置按照常规排查思路,事务不生效最容易被怀疑的就是数据源没配置好,或者@EnableTransactionManagement没加。于是我先检查了启动类,确认@EnableTransactionManagement存在,数据库连接池配置也正常,事务管理器明确指向了主数据源。排除掉这一层之后,才把目光放回到调用方式上。这里有个小经验分享:遇到事务失效,最好先在心里过一遍“三步曲”——配置对不对、异常类型对不对、调用链路有没有经过代理。绝大多数事务失效问题都逃不出这三类原因,而自调用就属于典型的第三种。1.3 定位:this.method()是问题核心定位到具体代码时,看到的结构大概是这样的:@Service public class SettleOrderService { public void batchCreate(List<OrderDTO> orders) { for (OrderDTO order : orders) { this.createSingle(order); } } @Transactional(rollbackFor = Exception.class) public void createSingle(OrderDTO order) { // 写主表 // 写明细表 // 校验不通过则抛异常 } }batchCreate内部循环调用了this.createSingle(),而createSingle上面的@Transactional注解并没有生效。原因就是 Spring 的事务功能是通过 AOP 代理实现的,this拿到的是原始对象,不是代理对象。只有从外部容器里获取到的 Bean,或者通过@Autowired注入到其他类里的 Bean,才是被切面包裹后的代理对象。这一步确认之后,问题定位就算完成了。接下来需要对 Spring 的代理机制做深入分析,把“为什么自调用会绕过代理”这个问题彻底讲透。2. 深入原理:Spring AOP 代理与事务生效的前提条件要用大白话解释清楚自调用为什么会失效,得先搞清楚 Spring 创建 Bean 的时候到底做了什么。默认情况下,Spring 容器中存放的并不是你写的那个普通业务对象,而是一个代理对象。这个代理对象会“包裹”原始对象,在方法调用前、后插入额外逻辑。当容器把一个 bean 注入到另一个 bean 中时,注入的其实是代理对象。所以,外部调用带@Transactional的方法时,实际上是先走代理,再走到原始方法。事务就是在这个“额外逻辑”里开启和提交回滚的。2.1 JDK 动态代理与 CGLIB 的区别Spring 的代理机制有两种实现方式:代理方式适用条件特点JDK 动态代理目标类实现了至少一个接口基于接口代理,生成代理类实现相同接口CGLIB 代理目标类未实现接口,或配置强制使用生成目标类的子类,重写非 final 方法在 JoyAI 项目里,SettleOrderService是纯类,没有实现任何接口,所以 Spring 默认会用 CGLIB 生成一个子类代理。这个代理子类重写了createSingle()方法,在方法前后挂载事务拦截器。你可以把this理解成“站在对象内部看自己”,它指向的是那个未经过任何包装的原始实例。而外部持有的 bean 引用指向的是 CGLIB 生成的子类,两者根本不是同一个对象。用this调用方法,等于绕过了代理子类,直接执行原始逻辑,事务拦截器完全不会被触发。2.2 为什么外部调用和内部调用行为完全不同为了让自己彻底理解,我做了一个极简的类比:假设你请了一个助理(代理对象),所有外部访客都必须经过助理登记才能见到你。但你自己早上出门时,难道还要助理给你自己登记一遍吗?this.method()就是这个道理,你自己调用自己,根本不会找助理。Spring 容器中的 Bean 相当于“你本人 + 助理”的合体,外部依赖注入的是助理这个角色,但从类内部看,this永远都只代表你本人。所以即使createSingle()标注了@Transactional,在没有代理的环境下直接调用,它就是一个普普通通的原生方法。2.3 验证:如何确认目标对象确实被代理了我写了一段验证代码来确认自己的判断:@Autowired private SettleOrderService orderService; public void checkProxy() { System.out.println(orderService.getClass().getName()); // 输出类似: com.xxx.SettleOrderService$$EnhancerBySpringCGLIB$$abc123 System.out.println(orderService instanceof SettleOrderService); // 输出: true,因为 CGLIB 子类属于 SettleOrderService 类型 }如果类名里带着$$EnhancerBySpringCGLIB$$,就说明容器里装的确实是代理对象。外部通过orderService.createSingle()调用时,走的确实是代理方法,@Transactional才能生效。反之,在类内部用this.createSingle()调用时,this指向的类名应该就是原始的SettleOrderService,没有增强痕迹,问题一目了然。这里补充一个知识点:如果项目使用了@EnableTransactionManagement,但类本身没有接口,Spring 会优先选择 CGLIB。而如果配置了proxyTargetClass = false且类没有接口,就无法生成代理,事务同样会静默失效。这类配置问题比自调用还要隐蔽,我建议大家在项目初始化时先加一个代理状况检查。3. 事务切面的完整原理拆解:@Transactional 到底在什么时机起作用搞懂了代理机制,还需要再进一步理解事务切面本身的执行流程,这样你才能在设计接口时主动避开各种坑。3.1 TransactionInterceptor 的工作流程在 Spring 中,真正干活的事务切面是TransactionInterceptor。它实现了 AOP 的方法拦截逻辑,核心流程大致是这样:检查当前方法是否配置了事务属性。检查当前线程是否已经存在活动事务,如果没有,就创建一个新的。执行业务方法。如果方法正常返回,则提交事务。如果方法抛出异常,则根据rollbackFor配置决定是否回滚。这整个流程都是在代理方法中完成的。也就是说,业务方法只是被“包裹”的中间环节,事务的开启和结束完全由代理控制。如果this.method()绕过代理,那么这个流程中的“开启事务”、“提交/回滚”就永远不会执行,相当于事务这块逻辑被完全跳过了。3.2 连接获取与“假装有事务”的陷阱这里还要提一个容易混淆的场景:假设自调用方法内部执行了insertDetail(),且执行过程中没有抛出任何异常,那数据确实能写进数据库,因为数据库认为每个单独的 SQL 操作本身就是一个隐式事务。但如果你在自调用方法内部先执行了插入,再把连接归还到连接池,整个方法执行期间就是一个“无事务包裹”的原生状态。如果业务方法中途抛了异常,注意这个时候数据库已经完成了前面几步操作,而 Spring 没有任何能力去回滚已经提交的语句。因为 Spring 只有在开启事务的情况下,才会把连接绑定到当前线程的事务资源中,并持有这个连接直到事务结束。自调用场景下,每次 SQL 操作都是独立获取和释放连接,没有全局协调者,脏数据就这么出现了。3.3 异常处理对回滚行为的影响即使代理正常生效,异常类型不对也会导致事务不生效。@Transactional默认只在遇到RuntimeException和Error时回滚,如果业务方法抛出的是受检异常(比如Exception的子类),默认是不会回滚的。所以在 JoyAI 的项目里,我给事务注解特意加上了rollbackFor = Exception.class,确保所有异常都能触发回滚。在自调用失效的问题上,需要额外注意的是:如果内部方法自己捕获了异常,并且没有重新抛出,那即使代理正常,事务拦截器也会认为方法执行成功,从而提交事务。这种情况更隐蔽,因为代码不会报错,线上数据却会大规模出现不一致。我的习惯是写业务代码时,对需要事务的批量操作,尽量不在方法内部吞异常,而是向外层抛出。3.4 编程式事务与声明式事务的本质差异@Transactional属于声明式事务,优点是无侵入,缺点是过度依赖代理机制。而编程式事务直接通过TransactionTemplate控制事务边界,不依赖代理,适用于自调用或者设计比较复杂的场景。两者各有优劣,我在后文解决方案部分会给出 JoyAI 项目中最终采用的办法。4. 实测验证:复现自调用失效并对比三种修复方案为了确保结论准确,我在本地搭了个最小化项目原型,模拟 JoyAI 的结算场景,分别测试了几种方案的实际效果。4.1 复现代码设计我建立了一张settle_record表,设计了两条插入语句,第一条成功,第二条故意制造主键冲突,验证第一条数据是否会被回滚。业务接口与实现代码大致如下:@Service public class SettleRecordService { public void selfInvoke() { insert("A001", 100); insert("A001", 200); // 故意造成主键冲突 } @Transactional(rollbackFor = Exception.class) public void insert(String recordNo, BigDecimal amount) { settleRecordDao.insert(new SettleRecord(recordNo, amount)); } }测试入口:@Component public class Runner implements ApplicationRunner { @Autowired SettleRecordService service; public void run(ApplicationArguments args) { service.selfInvoke(); // 检查 settle_record 表是否有数据 } }运行之后查询表,发现A001那条数据竟然留在表里了。按正常事务逻辑,第二条插入触发主键冲突后,第一条应该被回滚,但此处没有。这说明自调用场景导致@Transactional完全没有触发。4.2 方案一:注入自身代理这个方案的核心思路是让类内部持有代理对象来调用。最常见写法是通过@Lazy注入自己:@Service public class SettleRecordService { @Autowired @Lazy private SettleRecordService self; public void selfInvoke() { self.insert("A001", 100); self.insert("A001", 200); } }@Lazy加在这里是为了避免循环依赖问题。当类内部注入自己的代理时,Spring 需要先创建代理对象,再注入到原始对象中,如果不加@Lazy,在某些情况下会造成初始化顺序问题。实测结果:事务正常开启,第二条插入异常后第一条回滚了。这种方案改动非常小,最容易落地,但缺点是有一种“绕弯子”的感觉,而且每一处自调用方法都要用self去调用,容易遗忘。4.3 方案二:使用 TransactionTemplate 编程式事务如果既不想注入代理,又想让事务边界清晰可控,可以在方法内部直接使用TransactionTemplate:@Service public class SettleRecordService { private final TransactionTemplate transactionTemplate; public SettleRecordService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void selfInvoke() { transactionTemplate.execute(status -> { insert("A001", 100); insert("A001", 200); return null; }); } }这种方案完全不依赖 AOP,也不需要代理,直接由TransactionTemplate开启事务并绑定连接。实测效果同样正常,数据回滚干净利落。好处是逻辑直白,不会因为调用方式而失效。缺点是需要手动编写事务边界代码,如果一个类里有大量事务场景,代码会逐步显得冗余。我个人在 JoyAI 项目中大量使用了这个方案,尤其是在需要“事务包事务”场景下,TransactionTemplate的传播行为控制反而比注解更直观,而且没有任何代理心智负担。4.4 方案三:将需要事务的方法拆分到其他 Bean另一种常见的办法是把带@Transactional的方法挪到单独的 Spring Bean 中,然后在原类中注入这个新 Bean:@Service public class SettleRecordService { @Autowired private SettleRecordWriter settleRecordWriter; public void selfInvoke() { settleRecordWriter.insertWithTransaction("A001", 100); settleRecordWriter.insertWithTransaction("A001", 200); } } @Component public class SettleRecordWriter { @Transactional(rollbackFor = Exception.class) public void insertWithTransaction(String recordNo, BigDecimal amount) { // 插入逻辑 } }这个方案语义最清晰:事务方法本身定义在一个干净的业务组件中,任何外部调用者想触发事务都直接走代理,没有this的问题。缺点是为了事务强行拆类,类数量会变多,尤其当系统已经很庞大的时候容易造成结构碎片化。4.5 方案横向对比与选型结论方案是否需要代理代码侵入性事务边界清晰度适用场景注入自身代理需要低一般小范围修改,快速修复TransactionTemplate不需要中高复杂事务场景拆分事务 Bean需要低高系统架构分层明确时在 JoyAI 项目中,我最终选择了“拆分事务 Bean + 部分复杂场景用 TransactionTemplate”的组合方案。原因主要是结算模块对事务边界要求高,代码量又大,拆分后每个事务的职责一目了然,后续维护成本低。而自身代理方案作为快速修复手段,用在了几个应急场景中,确实帮了大忙。这里要特别提醒:不管用哪种方案,事务本身的默认传播行为都是REQUIRED。也就是说,如果外层已经存在事务,内层加入已有事务;如果不存在,则新建。拆分 Bean 后,多个带事务的方法相互调用,它们的传播行为也需要认真考虑,否则可能出现“事务过长”或“锁持有时间过久”的问题。5. 避开自调用之外的其他事务失效场景前面主要聊的是自调用带来的问题,但排查过程中我也把 JoyAI 整个结算链路里所有跟事务相关的“坑”统一梳理了一遍,很多问题从表面看是事务失效,实际上各有各的原因。5.1 异常被吞掉导致“假提交”最常见的一种:内部方法抛了异常,但是调用方把异常 catch 住并记录日志,继续执行后续流程。此时事务拦截器认为方法正常完成,直接提交。从数据库结果看,数据出现了“部分写入”,却没有任何报错。很多线上脏数据就是这么来的。规避方法只有一个:事务方法内部尽量不要自己捕获异常后不抛出。如果确实需要处理异常并继续执行后续逻辑,那就不要依赖声明式事务,而是改用编程式事务重画事务边界。5.2 多数据源下的事务管理器路由错乱如果项目引入了多个数据源,比如读写分离或分库分表,很容易出现“事务管理器没有指向实际使用的数据源”的情况。这样@Transactional虽然触发了,但是管理的是另一个数据源的连接,业务 SQL 依然不在事务控制范围内。我的排查经验是,在配置多数据源时,为每个数据源分别定义DataSourceTransactionManager,然后在@Transactional中显式指定transactionManager,比如@Transactional(transactionManager = "masterTransactionManager")。Java Config 配置的时候,最好给每个事务管理器起明确的名字,避免 Spring 自动装配时匹配错误。5.3 方法可见性与 final 方法限制CGLIB 代理通过子类继承来增强方法,private方法和final方法都无法被重写增强。因此,把@Transactional标在private方法上毫无意义,标在final方法上同样不会触发代理。Spring 对这些情况通常也不会有明显错误提示,属于静默失效。在 JoyAI 的代码规范里,我要求所有事务方法必须是public,且不允许final。另外,类本身也要保持可继承状态,不能因为某些性能优化习惯给 Service 类加上final修饰。5.4 同类 Bean 互相调用与接口代理的混淆如果使用了 JDK 动态代理,而事务方法所在类实现了接口,Spring 注入到其他类的是接口代理,类型是接口子类。在类内部自行调用时,如果通过实现类强转调用,同样会绕过代理。这种场景容易迷惑人,因为外部看代码是合法的接口调用,内部却偷偷用了this。解决方向和自调用一致,要么注入接口代理,要么用编程式事务,要么拆分 Bean。6. 最终落地:JoyAI 项目事务模块的重构记录问题定位清楚,方案也验证完毕之后,我对 JoyAI 的结算模块做了一次系统性的重构。这里把关键动作写出来,大家可以直接参考。6.1 统一事务边界设计按照业务行为,我将结算相关的写操作划分为三种粒度:粒度事务范围执行方式单条明细写入仅一条记录普通 DAO 插入,不单独开事务单批次插入一批记录使用单独事务 Bean 方法调用跨多批次汇总多个批次整体最外层使用 TransactionTemplate 控制拆分后,每个事务方法只负责自己的边界,不再出现“一个 Service 内部调用一堆方法靠注解撑大局”的混乱结构。6.2 代码迁移样例原始实现里SettleOrderService内部自调用逻辑很多,重构后我把涉及事务的写操作统一迁移到SettleWriter:@Component public class SettleWriter { private final SettleRecordDao dao; public SettleWriter(SettleRecordDao dao) { this.dao = dao; } @Transactional(rollbackFor = Exception.class) public void writeBatch(List<SettleRecord> records) { for (SettleRecord record : records) { dao.insert(record); // 可能包含其他业务校验 // 校验失败会抛出 RuntimeException } } }原服务类只负责业务编排和调度,不做底层写库。这样从架构上讲,职责也更清晰。6.3 启动校验与回归验证重构完成后,我添加了一个简单的启动校验组件,用来扫描所有标记了@Transactional的方法是否满足以下条件:方法是public。方法所在类不是final。方法没有被final修饰。方法不在同一个类中被this调用。这种静态扫描无法 100% 覆盖所有情况,但至少能在开发早期就拦住一部分低级问题,比上线后靠脏数据反馈要高效得多。回归验证方面,我重新跑了最初的最小化测试用例:两条插入、第二个故意失败、确认数据库没有残留数据。测试通过之后,又补了并发场景的压测,确认在高并发下事务边界没有因为连接池竞争而变宽或变窄。7. 避坑守则:这些经验比代码本身更值钱这个项目从头到尾踩了不少坑,有些是自身设计问题,有些是框架理解不够深入。我把最值得记住的经验整理成几条,供大家参考。第一点,永远不要信任@Transactional的默认行为。默认只回滚RuntimeException和Error,对受检异常不会回滚。如果项目里大量使用受检异常做业务控制流,务必在注解上显式声明rollbackFor = Exception.class。第二点,排查事务问题时,先看日志再改代码。重点找Creating new transaction、Participating in existing transaction、Rolling back这几类关键输出,能快速判断事务到底有没有参与。没有这些日志,就算代码写得再完美,事务也是空中楼阁。第三点,设计批量写入逻辑时,尽量做到“一个事务对应一个有明确业务含义的入口”。不要把一堆细粒度方法都加上事务注解然后把它们串在一起,这种做法非常容易在调用链中埋下自调用或者传播行为混乱的雷。先定义好事务边界,再写实现代码,次序不能反。第四点,如果团队里新人较多,强烈建议在项目里加入简单的 AOP 使用规范文档。我在 JoyAI 的工程文档里专门写了一节“事务方法调用注意事项”,把this.method()的问题用示例代码写清楚,每个新接手模块的人都会被要求先读这一节。别看内容简单,它省下来的排查时间非常可观。第五点,用TransactionTemplate时注意不要在一个大循环里反复开启新事务,这样会造成连接频繁获取和释放,性能极差。正确做法是把整批操作放一个execute回调中,或者根据业务量合理分组。8. 个人经验与最后补充写到这里,关于 Spring 自调用导致事务失效这件事,其实核心就一句话:@Transactional是通过 AOP 代理生效的,而this.method()绕过了代理。理解了这句话,剩下的问题都能顺着推理出来。我在 JoyAI 项目的实际操作中,深刻体会到一点:对一个框架,知其然很重要,但知其所以然更重要。很多人抱怨 Spring 的事务“时灵时不灵”,其实就是因为对代理机制缺乏完整认知。每次遇到事务问题,不要急着搜代码片段,先静下来想一想:我这行代码到底是在调用代理对象,还是在调用原始对象?如果能把这个问题想明白,后续遇到类似的缓存失效、异步注解失效、权限注解失效等问题时,都能触类旁通。最后再分享一个小技巧:排查代理问题时,可以在代码里临时输出当前对象类型,看类名是否带有EnhancerBySpringCGLIB或$Proxy字样。这不是生产环境的常规操作,但作为本地调试手段却非常有效,能让你在 30 秒内就确认代理到底有没有生效。希望这篇实录能帮你在 Spring 事务的深水里少踩几个暗礁。