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()获取当前代理后调用,但这种方式需要在启动类上开启exposeProxy=true。我个人的建议是优先考虑拆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.interceptor=DEBUG logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG重启后观察日志,如果能正常看到类似下面的输出,说明@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-class=true(Spring Boot 2.x之后默认开启),并且必须配置exposeProxy=true才能拿到可用的代理对象。如果打印输出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方法注解 | 查方法可见性,看事务日志 | 改成public |
| catch吞掉异常 | 事务拦截器感知不到异常 | 看方法有没有向外抛异常 | 捕获后显式抛出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就不再是一个“玄学注解”了。任何一次失效,都能被系统性地定位和解决。