ARTICLE DETAIL

资讯详情

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

Spring事务失效的常见场景与代理原理深度解析

Spring事务失效的常见场景与代理原理深度解析 做Java后端的大概率都经历过这种诡异时刻方法上明明白白加了Transactional数据库里该回滚的数据却一条不少地落了库或者代码review时看着逻辑没问题一到线上就出现订单状态和库存对不上的问题。我前些年排查过一个订单接口的故障下单、扣库存、写流水三个写操作在同一个方法里库存扣减抛了个受检异常结果前两步的脏数据全都提交了最后只能靠对账脚本去修。查了半天问题不是SQL写错、也不是数据库锁竞争而是事务压根就没开启。这类问题在Spring面试里属于高频考点网上能搜到各种事务失效场景清单但大部分只告诉你会失效没讲透为什么失效、以及现场怎么快速定位。这篇文章我从实际业务场景出发把Spring事务失效的几种典型情况、背后的AOP代理原理、以及从日志到断点的排查实操完整梳理一遍代码示例都来自我真实踩过的坑希望能帮你少走点弯路。1. 场景重现事务失效是怎么发生的1.1 最经典的自调用场景先看一段我当年写过的典型错误代码Service public class OrderService { public void createOrder(OrderDTO dto) { // 校验略 this.updateOrderStatus(dto.getOrderId(), CREATED); orderMapper.insert(dto); // 后续如果抛异常updateOrderStatus 的修改并不会回滚 } Transactional(rollbackFor Exception.class) public void updateOrderStatus(Long orderId, String status) { orderMapper.updateStatus(orderId, status); } }updateOrderStatus明明加了事务注解但它是被同一个类里的createOrder通过this调用的。this拿到的不是Spring生成的代理对象而是原始对象注解对这次调用来说完全没意义。结果就是updateOrderStatus里的SQL执行完就自动提交了后面任何一步炸了都没法把它捞回来。1.2 异常被try-catch吞掉之后还有一种业务里特别常见的写法我称为事务里的闷声吞异常Transactional(rollbackFor Exception.class) public void pay(PayDTO dto) { try { accountMapper.deduct(dto.getUserId(), dto.getAmount()); // 模拟业务异常 int result 1 / 0; } catch (Exception e) { log.error(扣款异常, e); // 这里捕获后没有继续抛出 // 事务认为方法正常返回于是提交了 } orderMapper.updateStatus(dto.getOrderId(), PAID); }业务方的本意是扣款失败也要把订单状态改成失败但代码写成这样后事务根本感知不到异常int result 1 / 0那一步抛出的异常被catch住了方法正常返回事务照常提交。最终结果就是钱没扣成订单状态却变成了已支付。这种问题的隐蔽性在于——日志里其实有异常堆栈但没人去翻。1.3 一个异常引发连锁脏数据更麻烦的是事务没生效往往不是单点问题而是一连串业务操作全部脱轨。比如你在事务方法里先更新了主表再更新明细表更新明细表时外键约束报错主表数据已经提交了用户重新发起重试又生成了一条新的主表数据。最终表里出现孤儿明细和重复主单对账的时候怎么都对不上这种数据问题才是事务失效真正的杀伤力所在。2. 事务失效背后的代理机制原理2.1 Spring事务的底层工作方式要彻底理解事务失效必须先明白Spring声明式事务的本质。Transactional不是魔法它靠的是AOP代理加TransactionInterceptor拦截器。方法调用经过代理对象时拦截器会做三件事开启事务、执行目标方法、根据异常情况决定提交还是回滚。整个过程可以用一个简化的时序来理解调用方 - 代理对象 - TransactionInterceptor - 真正的方法TransactionInterceptor内部通过TransactionManager去获取数据库连接把连接绑定到当前线程上再设置autoCommitfalse方法执行结束后根据异常决定commit()还是rollback()。2.2 为什么代理对象决定了事务的生死Spring容器启动时会给标注了事务注解的Bean生成代理对象。Spring Boot 2.x之后默认使用CGLIB代理通过继承生成子类早期版本或指定proxyTargetClassfalse时可能使用JDK动态代理基于接口。不管是哪种代理关键点都一样只有外部调用走进代理对象事务拦截逻辑才会生效。所以你会遇到这些情况的根源在于方法不是public的代理没法正常增强Spring的AbstractFallbackTransactionAttributeSource默认只处理public方法。方法被final修饰CGLIB是通过继承子类来重写方法的final方法无法重写增强自然失效。同一个类内部方法间this调用绕过了代理。类本身没被Spring管理没有Service、Component等注解压根没有代理。2.3 事务绑定线程理解连接是关键还有一个容易忽略的细节Spring事务是通过TransactionSynchronizationManager把数据库连接绑定到当前线程的ThreadLocal里的。换句话说一个事务的生命周期和当前线程强绑定。如果你在事务方法里开了新线程去执行SQL新线程拿不到主线程绑定的连接它执行的操作会在自己的连接上独立提交。这解释了为什么多线程环境下事务失效几乎是必然的——不是注解没用而是连接不在同一个线程的作用域里。3. 高频失效场景盘点与避坑方案3.1 非public方法上的TransactionalService public class AccountService { Transactional private void deduct(Long userId, BigDecimal amount) { accountMapper.deduct(userId, amount); } }Spring的AbstractFallbackTransactionAttributeSource对方法的事务属性解析默认只对public方法生效。private方法不能被子类重写CGLIB代理通过继承实现JDK动态代理又是基于接口的private方法根本不在接口里。所以private方法上的Transactional是无效标注。如果你看到一个事务方法被写成private不要犹豫直接改成public并确认它是从外部Bean调用的。3.2 同类内部方法自调用前面订单服务的例子已经演示过了。解决办法有三种把需要事务的方法拆到另一个Bean里由外部Bean调用。这也是最推荐的做法职责更清晰。注入自身代理通过注入的代理对象调用方法。Service public class OrderService { Autowired private OrderService self; public void createOrder(OrderDTO dto) { // 注意这里用的是 self不是 this self.updateOrderStatus(dto.getOrderId(), CREATED); } Transactional public void updateOrderStatus(Long orderId, String status) { orderMapper.updateStatus(orderId, status); } }使用AopContext.currentProxy()但需要在配置中开启exposeProxytrue代码里强转成当前类型再调用稍微有点绕不推荐作为首选方案。3.3 异常被吞或异常类型不对我已经数不清见过多少次Transactional public void transfer() { try { accountMapper.deduct(fromId, amount); accountMapper.add(toId, amount); } catch (Exception e) { log.error(转账失败, e); // 吞掉异常事务正常提交 } }事务回滚靠的是方法抛出异常这个信号。你catch住了异常但没抛出拦截器看到的返回值是正常的它会帮你commit。这个问题的解法很直白要么不catch要么catch之后重新抛出运行时异常要么使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。还有一种常见情况是异常类型不对Transactional public void transfer() throws Exception { accountMapper.deduct(fromId, amount); throw new Exception(业务校验失败); }Transactional默认只对RuntimeException和Error回滚受检异常Exception的子类默认不会触发回滚。这个设计初衷是让开发者显式声明受检异常是否需要回滚但实际业务里坑了太多人。建议在团队规范里统一约定事务方法一律写Transactional(rollbackFor Exception.class)或者直接写rollbackFor Throwable.class避免受检异常漏回滚。3.4 多线程环境下的事务分割Transactional public void batchProcess(ListLong ids) { ids.parallelStream().forEach(id - { itemMapper.update(id); // 这个调用跑在ForkJoinPool的线程里 }); }parallelStream、Async、手动new Thread都是典型的多线程场景。因为数据库连接绑定在当前线程的ThreadLocal里子线程拿不到主线程的事务连接子线程的每次SQL提交都是独立的。更坑的是子线程抛的异常也不会传到主线程父方法照常提交最后数据不一致。正确做法是要么去掉多线程把循环放到事务方法内部串行执行要么把子线程里的处理逻辑独立成事务方法各管各的事务再配合补偿/对账机制。记住一句话Spring事务不能跨线程传播这不是配置问题是设计约束。3.5 数据库表引擎不支持事务这个场景比较偏但一旦遇到非常隐蔽。MySQL里MyISAM引擎就不支持事务即使代码里事务配置得再完美底层引擎没有事务能力commit和rollback都是摆设。排查时如果确认代码没问题、代理也正常、日志也显示事务开启但数据就是不回滚记得看一眼建表语句SHOW TABLE STATUS LIKE your_table;重点看Engine字段。现在MySQL 8.0默认是InnoDB但如果是从老库迁移过来的表或者有人手工指定过ENGINEMyISAM就可能踩到。这类问题在代码层解决不了必须把表改成InnoDB。3.6 事务传播属性用错导致的部分提交事务的传播行为propagation也可能造成失效的假象。最典型的是Transactional public void outerMethod() { // 业务代码 innerService.innerMethod(); // 内层是REQUIRES_NEW // 后续代码抛异常 }外层的事务方法里调用了一个Transactional(propagation Propagation.REQUIRES_NEW)的内层方法。内层方法在外层提交之前就独立提交了。外层后续一旦抛异常回滚内层已经提交的数据不会跟着回滚。这在日志表和业务表混写的场景里尤其危险——如果你内层写的是审计日志外层失败时日志已经落库那可能正是你想要的但如果内层也在改业务数据就要想清楚是不是要用REQUIRED默认让内外层共用一个事务。还有Propagation.NOT_SUPPORTED它会挂起当前事务以非事务方式执行然后恢复原事务。如果你在事务方法里通过NOT_SUPPORTED去干临时操作那部分操作本身就不在事务保护范围内。3.7 其他容易被忽略的失效细节final方法CGLIB通过继承生成子类来完成增强final方法不能被子类重写事务注解被直接忽略没有报错没有日志就是静默失效。类没有交给Spring管理忘记加Service/Component或者通过new关键字手动创建对象Spring对它是完全透明的所有注解都不生效。接口注解被实现类覆盖如果事务注解标在接口方法上而实现类重写了方法却没有注解Spring在解析方法事务属性时优先看实现类方法接口上的注解可能被跳过。稳妥做法是注解直接写在实现类的方法上。方法内部分操作先走JdbcTemplate、后走MyBatis数据源不是同一个如果应用配置了多数据源Transactional只对指定的TransactionManager生效另一个数据源的操作不在同一个事务里。4. 排查事务失效的实战方法4.1 打开事务日志看关键信息遇到事务失效第一件事是打开日志不用猜。在application.yml里加上logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG然后复现一次请求重点看日志里有没有Getting transaction for [...methodName]Initiating transaction commitInitiating transaction rollback如果请求走到方法了但日志里完全没有Getting transaction说明事务压根没有进入。这时候基本可以锁定是代理或方法可见性的问题。如果看到commit但没有rollback说明异常没有被正确抛到拦截器层去检查异常是不是被吞了。4.2 确认当前对象是不是代理对象有时候日志没配好或者代理情况复杂可以临时在代码里打印对象信息System.out.println(bean.getClass().getName()); System.out.println(AopUtils.isAopProxy(bean));正常情况下被Spring管理的Bean打印出来的类名应该带着$$EnhancerBySpringCGLIB$$或者是一个Proxy对象。如果打印出来是原始类的全限定名说明这个Bean没有被代理事务注解百分百不生效。用这种方式还能排查出配置了AOP但切面没生效之类的问题属于万能试纸。4.3 常见失效场景速查表失效场景根因分类快速验证方法推荐解法private方法加注解代理无法增强改成public后测试方法改成public并走外部调用同类this自调用事务拦截未介入日志无Getting transaction拆分Bean或注入自身异常被catch吞掉异常信号丢失日志有error但事务commit忘掉catch或catch后重新抛出抛出受检异常默认回滚策略限制确认异常类型rollbackFor Exception.class多线程里执行SQL连接绑定当前线程子线程修改未回滚去掉多线程或独立事务补偿使用的表是MyISAM存储引擎不支持事务查SHOW TABLE STATUS改成InnoDB方法被final修饰CGLIB无法重写去掉final后测试去掉final类未被Spring管理无代理对象类无Service加注解或改为Bean注入这张表我建议直接存下来做code review的时候对照着看比死记硬背高效得多。5. 一些实操经验与后续建议5.1 事务注解放在哪一层更保险团队里经常争论Transactional到底放Service还是放DAO。我的观点很明确放在Service层的public方法上尽量让一个事务对应一个完整的业务用例。DAO层Mapper层的方法职责太细一个DAO方法只做一个写操作在DAO上加注解既覆盖不了完整的业务边界又容易在同一个事务里反复嵌套增加排查成本。事务的边界就是业务逻辑的边界这话说起来抽象实践里就是开启事务的粒度应该大到能覆盖一次业务的全部写操作又小到不把无关查询和远程调用包进来。5.2 编程式事务是最后的兜底声明式事务虽然方便但遇到自调用、多线程、动态分支这类场景时确实力不从心。我个人的经验是声明式事务搞不定的地方不要硬凑直接用TransactionTemplate做编程式事务代码可读性反而更好Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void complexBiz() { // 无事务的预处理 transactionTemplate.execute(status - { // 这里面的操作都在一个事务里 orderMapper.insert(order); itemMapper.update(item); return null; }); // 事务外的收尾 } }TransactionTemplate能明确地圈定事务边界也不用担心自调用、代理失效这些事先定义好的坑是解决疑难杂症的可靠兜底。5.3 几个值得养成的好习惯最后分享几个我踩坑踩出来的习惯算不上高深但确实能少出问题。第一事务方法里不要做耗时操作。远程RPC、外部HTTP调用、大量数据的循环处理都不要放在事务里。事务会长期持有数据库连接和行锁连接池一旦被打满整个服务跟着瘫痪那种事务没回滚其实是因为连接都没了的场面我经历过一次就不想再有第二次。第二团队统一事务注解规范。我经手的项目都会在规范里写死一条事务方法一律用Transactional(rollbackFor Exception.class)不允许无参版本。避免某个同事受检异常没回滚的问题上线之后才暴露。第三上线的改动多看一眼代理情况。凡是新增了Transactional的方法code review时顺手确认一下调用方是不是走的Spring Bean、方法是不是public、有没有内部自调用。这三个问题检查下来至少能挡掉九成的事务失效。Spring事务失效虽然坑多但根子上的原理就一条事务的一切都建立在代理对象的基础上绕过了代理注解就是摆设。把这个底层逻辑想通再复杂的失效现象都能快速定位到对应的环节。
返回列表