ARTICLE DETAIL

资讯详情

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

Spring事务失效的8种常见原因与排查指南

Spring事务失效的8种常见原因与排查指南 1. 先理解Spring事务的正确姿势Spring的Transactional注解可以说是Java后端开发中使用频率最高、也最容易被误用的注解之一。很多人在项目里加了这个注解就以为事务一定生效了直到某天线上数据出现半截写入才意识到问题没那么简单。在展开聊“事务失效”之前先建立一个共识Transactional本身不实现事务它只是给Spring一个信号由Spring的AOP代理在目标方法前后插入事务管理逻辑。默认情况下Spring对带有Transactional注解的Bean生成代理对象调用方法时先开启事务方法执行完毕后再提交或回滚。这里有一个关键点事务是通过代理对象生效的。也就是说外部调用走的必须是Spring容器中的代理Bean如果绕过了代理事务逻辑根本不会执行。这个前提条件贯穿了整个失效问题的排查主线后面提到的多数失效场景本质上都能追溯到“代理没生效”或者“代理被绕过”这两个根源。另外为了让事务上下文能在同一个数据库连接中执行Spring底层使用ThreadLocal把数据库连接绑定到当前线程。这也解释了为什么多线程环境下事务经常“莫名其妙”失效——线程之间的连接和事务信息是不共享的子线程里跑的SQL根本不在父线程的事务范围内。理解了这两层核心机制后面排查具体原因时就能做到“知其然更知其所以然”。下面我把实际开发中最容易踩的失效场景一个一个拆开来讲。2. 事务失效的8种常见原因与定位思路2.1 方法自调用导致事务失效这是最经典、也最常见的一种失效场景。场景描述大致是这样的在同一个类中A方法调用同类B方法B方法上标注了Transactional结果事务没生效。Service public class OrderService { public void createOrder() { // 一些业务逻辑 this.deductStock(); } Transactional public void deductStock() { // 扣减库存需要事务保护 } }上面的写法从外部看调的是createOrder而createOrder没有事务注解deductStock虽然加了Transactional但因为它是被this直接调用的走的是原始对象而不是Spring生成的代理对象因此事务逻辑根本不会被拦截。这个场景可以用一个生活类比来理解你请了个保安代理对象帮你守门但你自己直接翻窗户进屋了保安自然管不到你。Spring的AOP拦截器干的就是保安的活儿它只能在代理对象的方法入口处拦下事务逻辑而this调用恰恰绕过了代理入口。解决思路也很直接把两个方法拆到不同的Bean里或者注入自身的代理对象Autowired加上Lazy避免循环依赖也可以用AopContext.currentProxy()获取当前代理后调用但这种方式需要在启动类上开启exposeProxytrue。我个人的建议是优先考虑拆Bean的方式结构越清晰越好排查AopContext带来的隐式依赖会增加代码阅读成本。2.2 方法非public导致事务失效Transactional用在private、protected或包可见方法上事务是静默不生效的。Service public class UserService { Transactional private void updateUser() { // 业务逻辑 } }为什么会有这个限制Spring官方文档里明确说明Transactional只能标注在public方法上。底层原理在于AbstractFallbackTransactionAttributeSource在解析注解时会先判断方法可见性非public方法直接返回null表示“此方法不需要事务”自然也就没有后续的拦截逻辑。很多初学者会在private方法上加事务注解然后半天排查不出问题。这种情况不报任何异常代码正常执行只是回滚不生效。定位时有个小技巧如果怀疑这里出了问题去看日志里有没有Creating new transaction相关的输出如果完全没有打印说明事务拦截器压根没有介入。2.3 异常被catch吞掉导致事务失效这种情况在真实项目里最为普遍也最坑。很多人写代码习惯在Service方法内部手动try-catch觉得把异常处理掉就算“业务兜底”了但这对事务来说是大忌。Transactional public void batchProcess() { try { // 插入A表 insertA(); // 模拟异常 int i 1 / 0; // 插入B表 insertB(); } catch (Exception e) { log.error(处理失败, e); } }上面的代码中异常被捕获后方法的正常流程继续走完Spring的事务拦截器看到的返回值是“方法正常执行完毕”于是执行commit()而不是rollback()。A表的数据已经插入但B表没插数据库就停在了一个中间状态。真正需要做的是要么不捕获异常让它直接抛出去交给事务拦截器处理要么在catch块中显式抛出RuntimeException注意要抛出去不能吞掉。Transactional public void batchProcess() { try { insertA(); int i 1 / 0; insertB(); } catch (Exception e) { log.error(处理失败, e); throw new RuntimeException(批量处理失败事务回滚, e); } }有同学会问那业务上确实需要对某些异常做降级处理比如某个非关键步骤失败不影响主流程该怎么办我的做法是把事务边界缩小把需要整体回滚的操作放在独立的事务方法里降级逻辑放在事务方法外面。这样事务的边界和业务的“恢复策略”就解耦了不会相互干扰。2.4 抛出的异常类型不触发回滚Spring事务默认的回滚策略是只有抛出RuntimeException或Error时才会回滚而Exception受检异常checked exception不会触发回滚。Transactional public void transfer() throws Exception { // 扣款 deduct(); // 抛出一个受检异常 throw new Exception(转账失败); }这个设计源于Spring早期对EJB事务的兼容但放在今天容易坑人。很多人凭直觉认为“抛了异常就应该回滚”事实上如果你的方法声明throws Exception抛出去之后事务照样提交。解决方案有两种在Transactional注解上指定rollbackFor Exception.class在方法内把受检异常包装成RuntimeException再抛出我强烈建议在项目里统一规范凡是标注Transactional的方法一律显式声明Transactional(rollbackFor Exception.class)。不要嫌麻烦这是一种“防御性编程”的长期习惯。虽然Spring Boot下默认只会拦截RuntimeException但显式声明后无论是受检异常还是运行时异常都会回滚杜绝掉一半以上的“事务静默失效”问题。2.5 数据库引擎不支持事务这个坑多见于老项目尤其是把MySQL从MyISAM迁移到InnoDB的项目或者有人在建表时没指定引擎继承了数据库默认参数。MyISAM引擎本身不支持事务BEGIN和COMMIT语句发过去也不会报错但就是不会真的回滚。如果访问量大还好数据量大的表一锁就是全表锁性能和可靠性双双拉胯。排查方式很简单SHOW TABLE STATUS WHERE Name your_table;检查Engine字段是否是InnoDB或者执行SHOW CREATE TABLE your_table;查看建表语句中的ENGINE配置。如果发现是MyISAM需要走表迁移操作ALTER TABLE your_table ENGINE InnoDB;这里有个经验点旧表转换时耗时比较长可能锁表建议放在业务低峰期做并且提前确认新旧引擎的索引、外键约束是否需要额外调整。数据量大时用pt-online-schema-change这类工具可以做到在线变更避免直接卡死线上业务。2.6 事务管理器配置错误或未生效项目引入了Transactional但事务没有生效还有一个隐藏很深的原因是事务管理器没有注册到Spring容器或者说注册的不是对应的数据源事务管理器。比如在Spring Boot项目中默认会自动配置DataSourceTransactionManager这没问题。但如果项目里手动配置了多个数据源事务管理器的绑定关系就乱了套Transactional很有可能管错了数据源。Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }这段代码看起来没问题但如果项目里有两个DataSource你没有用Primary标注主数据源Spring会因找不到唯一候选而报错或者装配了错误的数据源让事务操作落到错误的数据库上结果就是“该回滚的地方没回滚”。多数据源场景下还必须指定事务管理器名称Transactional(transactionManager orderTransactionManager) public void createOrderTx() { // 业务逻辑 }另外配置了EnableTransactionManagement的XML版本老项目一定要检查Spring配置文件中是否真的开启了事务管理功能。有时候手滑把tx:annotation-driven的配置注释掉了或者order配错了属性Transactional也会静默失效。2.7 多线程下事务传播失效Transactional的事务上下文通过ThreadLocal绑定在当前线程上这意味着子线程、异步线程里的数据库操作完全感知不到父线程的事务。很多人在项目里遇到“Async注解异步方法加Transactional没效果”的问题本质上就是这里的坑。父方法开启了事务但异步子线程执行数据库操作时拿到的连接是独立的提交/回滚也各走各的。Transactional public void parentMethod() { // 主线程写库 orderMapper.insert(order); // 异步子线程写库 logService.saveLog(order.getId()); }如果saveLog是在异步线程中执行的即使它本身标了Transactional它的回滚范围也只覆盖子线程里的操作。更严重的是父事务回滚时子线程里的数据可能已经成功提交了最终形成数据不一致。正确做法是不要在事务方法里开异步任务依赖事务回滚。异步任务要么等主事务提交后再执行比如监听TransactionSynchronizationManager的afterCommit事件要么在主事务回滚后做对应的补偿操作。就风险程度来说异步操作里不应该假设事务上下文可用事务边界要收得非常清楚。2.8 传播行为设置不当Transactional注解支持配置propagation属性控制事务的传播行为。如果设置成Propagation.NOT_SUPPORTED或者Propagation.NEVER事务会被挂起或直接抛异常。Transactional(propagation Propagation.NOT_SUPPORTED) public void notSupportTx() { // 即使外部有事务这里也不会开启新事务 }NOT_SUPPORTED的含义是如果当前存在事务则挂起当前事务以非事务方式执行。这通常用在一些不需要事务的只读查询上但如果你对写操作加了这个传播级别误以为有事务保护那结果就是只有“裸SQL”所有操作即时提交回滚无从谈起。还有一种情况是Propagation.REQUIRES_NEW它会挂起当前事务开启一个全新的事务。很多人以为这样“内外事务都安全”但内层事务独立提交后如果外层事务回滚内层的提交不会被撤销。等发现的时候数据已经写进去了。所以传播行为的选择必须基于业务语义别图省事统一用REQUIRED也别盲目用REQUIRES_NEW。3. 如何快速定位事务是否真的生效3.1 打开事务日志百闻不如一见。遇到可疑的事务失效第一件事就是把Spring的事务日志级别打开看拦截器是否真的创建了事务。在application.yml或application.properties中加入logging.level.org.springframework.transaction.interceptorDEBUG logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG重启后观察日志如果能正常看到类似下面的输出说明Transactional是被拦截到了的org.springframework.transaction.interceptor.TransactionInterceptor: Getting transaction for [com.example.service.OrderService.createOrder] org.springframework.jdbc.datasource.DataSourceTransactionManager: Creating new transaction with name [com.example.service.OrderService.createOrder]如果日志里完全没有“Getting transaction”或“Creating new transaction”这类关键字那就直接说明问题出在“代理没生效之前”应该把排查重点放在方法可见性、自调用、Bean定义这些环节不要再死磕SQL层面了。3.2 通过当前代理状态辅助判断还有一种调试方法在方法内打印AopContext.currentProxy()是否抛出异常或者判断this.getClass()是否带有$$EnhancerBySpringCGLIB$$之类的动态代理标记。Transactional public void debugTx() { System.out.println(AopContext.currentProxy() instanceof OrderService); }这里有一个前提需要开启spring.aop.proxy-target-classtrueSpring Boot 2.x之后默认开启并且必须配置exposeProxytrue才能拿到可用的代理对象。如果打印输出false说明当前对象不是代理对象事务逻辑注定不会执行。不过这种调试方式有侵入性临时加进去可以上线前要记得删掉。我更建议大家不要依赖运行时调试而是通过阅读日志来定位问题。3.3 写一个最小复现测试真实项目里事务失效的原因往往是多因素的组合与其在庞大的业务代码里大海捞针不如找一个空的Service方法加上Transactional在方法里故意抛RuntimeException然后观察数据库有没有回滚。Service public class TxDebugService { Autowired private JdbcTemplate jdbcTemplate; Transactional(rollbackFor Exception.class) public void testRollback() { jdbcTemplate.execute(INSERT INTO debug_tx (id, name) VALUES (1, before)); throw new RuntimeException(test rollback); } }调用这个方法后查询debug_tx表如果表里没有数据说明事务功能是好的如果数据存在说明事务链路有问题。这样一个最小化复现案例能帮忙极大缩小排查范围比在业务代码里反复试验高效得多。4. 事务失效场景速查与避坑清单把上面所有场景汇总成一张速查表方便对照排查后面还会附上我这些年实际踩坑后沉淀下来的几个经验。失效场景直接原因排查手段解决方案方法自调用走this调用绕过代理打断点看当前对象是否是代理类拆Bean、注入自身代理、AopContext非public方法Spring不解析非public方法注解查方法可见性看事务日志改成publiccatch吞掉异常事务拦截器感知不到异常看方法有没有向外抛异常捕获后显式抛出RuntimeException受检异常不回滚默认仅回滚RuntimeException/Error检查异常类型指定rollbackFor数据库引擎不支持MyISAM无事务能力查表引擎迁移至InnoDB事务管理器未生效未绑定正确的TransactionManager查容器注册情况指定transactionManager配Primary多线程调用ThreadLocal事务上下文不共享看执行线程是否为同一线程事务内不开异步或用afterCommit回调传播行为不当配置了NOT_SUPPORTED等查注解配置按业务语义选择传播级别4.1 一段真实踩坑记录我之前在一个老项目里排查过一个特别邪门的问题接口偶尔会写脏数据但大多数时候正常的。查了很久发现原因竟然是某个Controller方法的调用链中Service的某个方法上加了Transactional但这个方法内部调用了另一个Bean的Async方法。异步方法里也操作了数据库而且它的执行时机刚好卡在事务提交之前。当主线程后面遇到异常回滚时异步线程里的数据早就提交了最终留下脏数据。这个案例给我们的教训是事务边界不要覆盖异步操作跨线程的数据一致性需要专门的设计比如本地消息表、事务消息、对账补偿而不是靠Transactional声明一下就能一劳永逸。4.2 团队事务规范建议基于这些失效原因我在团队里定了三条硬性规范可以说直接砍掉了一大批潜在事务Bug第一所有Transactional方法必须显式声明rollbackFor Exception.class。这是默认值之外的防御性兜底不强迫大家背Spring的默认规则用显式声明来统一行为。第二Transactional禁止加在private方法、final方法上。加在类级别可以但方法级别必须为public。同时避免在类内部直接this调用带事务的方法必要时通过拆分Bean解决。第三事务方法内部不要try-catch后“静默吞掉”异常。如果业务需要捕获异常并继续执行那这段逻辑就不应该放在事务方法里。事务方法应该只负责“要么全成功要么全回滚”这一件事。这三条规范看着简单落地后对线上环境的影响是立竿见影的。每一次事务失效排查最后都能回归到这三点上找到根本原因。4.3 排查时的一个小技巧如果你不确定当前方法是否被Spring代理除了看日志还可以在IDE调试时查看this对象的class信息。如果是com.example.service.OrderService$$EnhancerBySpringCGLIB$$xxxx这种带$$的类名说明调用的是代理对象如果class就是原始类名那这个方法调用一定绕过了代理。另外判断一下当前线程名称也很有用。如果调用方线程和事务方法执行线程不是同一个名字说明代码里肯定有异步或者线程池事务上下文大概率已经断裂这时候就要顺着线程变更点继续查。搞清楚了这些原理和排查手段Transactional就不再是一个“玄学注解”了。任何一次失效都能被系统性地定位和解决。
返回列表