Spring事务失效这个话题,说它是Java后端面试的“必考题”一点都不夸张,我在实际项目里排查过的事务问题,十个里至少有六七个都能归到下面这几个经典陷阱上。最讽刺的是,很多人在面试时能把八股文背得滚瓜烂熟,结果自己写的代码里事务静默失效了却毫无察觉,直到线上出现数据不一致才反应过来。这篇文章我会把这8个最常见的失效场景一次性掰开揉碎,每一条都配上代码、原理和解决方案,看完你就能在自己的项目里快速定位类似问题。
1. 先搞懂事务失效的总根源:代理机制这一层窗户纸
任何讨论@Transactional失效的问题,如果绕开Spring AOP的代理机制,都是在耍流氓。你得先理解一件事:@Transactional本身不干活,它只是一个标记,真正负责开启、提交、回滚事务的是TransactionInterceptor这个增强器。Spring在启动时会扫描带有@Transactional的Bean,然后为这个Bean生成一个代理对象——JDK动态代理或者CGLIB代理——所有外部调用者拿到的其实是这个代理对象,不是你的原始对象。
关键点就在这里。代理对象在调用你的目标方法之前,会先检查方法上有没有事务注解,有就开启事务,方法执行成功后提交,异常时回滚。但如果调用链绕过了代理对象,比如在同一个类的另一个方法里直接调用这个带@Transactional的方法,走的是this.method(),是原始对象自己的方法调用,压根不会经过代理,那么事务拦截逻辑自然不会执行。
我举个最直观的例子。你写了一个OrderService,里面有一个createOrder方法,它内部调用了saveOrder和deductStock两个方法,这两个方法上都标了@Transactional。如果createOrder本身没有事务注解,那么saveOrder和deductStock上的事务注解是“隐身”的,因为这是this调用,事务拦截器根本没机会介入。这个问题在面试里经常被问到,叫“同类内部自调用导致事务失效”。
理解了代理机制,后续所有陷阱都有了落脚点。你可以把Spring事务想象成一个门卫,代理对象是门的守卫,你的方法是否能进入事务房间,取决于这个守卫有没有被触发。任何能让守卫“看不见你”的调用方式,都会让事务静默失效。接下来我要说的8个陷阱,本质上都是“守卫看不见你”的某种具体表现形式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 陷阱一:同类内部方法自调用,事务直接被“内部消化”了
这个问题出现的频率极高,我几乎每隔一段时间就会在代码评审里看到一次。典型的情景是这样:
java复制@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
// 第一步:保存订单
saveOrder(dto);
// 第二步:扣减库存
deductStock(dto.getSkuId());
}
@Transactional
public void saveOrder(OrderDTO dto) {
orderMapper.insert(dto);
}
@Transactional
public void deductStock(Long skuId) {
stockMapper.deduct(skuId);
}
}
这段代码表面上看起来没什么问题,saveOrder和deductStock都加了@Transactional,但你从外部调用createOrder时,它内部是this.saveOrder()和this.deductStock(),也就是原始对象的直接调用,不是代理对象的调用。结果就是:如果saveOrder成功、deductStock失败抛出异常,saveOrder插入的数据不会回滚,订单库里会留下一堆“半成品”数据。
这类问题的隐蔽性在于,代码不报错、日志也正常,只有你去看数据库时发现数据不对,才意识到事务根本没生效。我在一次线上问题排查中遇到过一个类似的场景,用户反馈下单后偶尔会出现库存扣了但订单没生成的情况,查了半天,最后发现是服务里有个状态同步方法被同类内部方法调用,@Transactional形同虚设。
解决这个问题的方案有三种,按推荐顺序排序:
第一种是最推荐的——把需要事务的方法拆到另一个独立的Service中。Spring的代理机制天然支持跨Bean调用,你从A Service调用B Service的方法,走的就是B的代理对象。这个方案最清晰,也最符合单一职责,重构时也不容易出错。
java复制@Service
public class OrderService {
private final StockService stockService;
public void createOrder(OrderDTO dto) {
saveOrder(dto);
stockService.deductStock(dto.getSkuId());
}
@Transactional
public void saveOrder(OrderDTO dto) {
orderMapper.insert(dto);
}
}
@Service
public class StockService {
@Transactional
public void deductStock(Long skuId) {
stockMapper.deduct(skuId);
}
}
第二种方案——在类内部注入自身的代理。Spring允许你对自身进行依赖注入,虽然有点绕,但在某些场景下确实能用。
java复制@Service
public class OrderService {
@Autowired
private OrderService self;
public void createOrder(OrderDTO dto) {
self.saveOrder(dto);
self.deductStock(dto.getSkuId());
}
}
注意使用这个方案时,你要确保Spring的循环依赖没有被关闭。从Spring Boot 2.6版本开始,循环依赖默认被禁止了,如果项目升级到了这个版本以上,这种写法可能会直接启动报错。
第三种方案——使用AopContext.currentProxy()获取当前代理对象。这个方案需要额外配置exposeProxy=true才能生效,而且要写强转,代码上不够优雅,但在某些无法拆分类的场景可以救急。
java复制@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
OrderService proxy = (OrderService) AopContext.currentProxy();
proxy.saveOrder(dto);
proxy.deductStock(dto.getSkuId());
}
}
提示:内部自调用遇到REQUIRES_NEW传播行为时失效得更彻底。你以为新开了一个事务,实际上因为根本没走代理,连接还是同一个,事务还是同一个,完全没有“新开”的效果。
3. 陷阱二和陷阱三:private方法与final方法,Java语法把事务拦截在门外
private方法的@Transactional失效,也是很多人容易踩的坑。我见过不少代码,把事务方法写成private,然后在同类其他地方调用,结果事务根本不生效。原因很简单,无论JDK动态代理还是CGLIB,代理机制都要求目标方法能被外部访问。JDK动态代理基于接口,private方法本来就不在接口里;CGLIB基于继承生成子类,但private方法是final的,子类无法重写。Spring在解析事务方法时,也会直接跳过非public方法,压根不会为它创建事务拦截。
这里有个容易忽略的细节——如果@Transactional加在了接口方法上,而实现类的方法没有加,那么基于JDK动态代理时注解能被识别;但如果你用了CGLIB代理,或者实现类方法上又自己加了注解且没加@Transactional,就会出现各种“换了个姿势失效”的情况。最稳妥的做法是:事务注解直接加在实现类的方法上,且方法必须是public。
final方法的问题和private本质类似。CGLIB通过生成目标类的子类来创建代理对象,然后重写父类的方法来完成事务增强。final方法不允许子类重写,所以当Spring尝试在子类中织入事务逻辑时,发现这个方法根本没法覆盖,事务逻辑自然就加不上去。static方法也是同理,static方法属于类而不属于实例,代理机制压根不覆盖。
我之前在排查一个老项目时就遇到过一个诡异的问题:同一个Service里,有些方法事务生效,有些不生效,而且不生效的方法恰好都是private的。查代码时发现这些private方法上全都标了@Transactional,写代码的人大概是从其他地方复制模板过来的,没想过修饰符的问题。这个坑不会导致编译报错,也不会在启动时抛异常,唯一的警示就是日志里的“Skipping transactional execution because method is not public”之类的记录,但很多人根本没看过这个日志。
提示:在IDE中编写代码时,IntelliJ IDEA会对private方法上的@Transactional给出提示,大意是“@Transactional is not allowed on private methods”。看到这个提示千万别忽略,它在帮你规避一个线上才能发现的隐患。
4. 陷阱四和陷阱五:异常处理不对路,要么被吞要么抛错类型不对
异常处理是事务失效的高发区,而且这两个陷阱经常一起出现。先说异常被吞——这个场景太常见了,你在事务方法里写了一个try-catch,捕获了所有异常,打了日志,然后什么都没做。从Spring事务拦截器的视角看,方法正常返回了,没有异常抛出,于是它做出了一个“合情合理”的决定:提交事务。但实际上,你的业务逻辑已经执行失败了,数据被部分写入了。
java复制@Transactional
public void createOrder(OrderDTO dto) {
try {
orderMapper.insert(dto);
stockMapper.deduct(dto.getSkuId());
} catch (Exception e) {
log.error("创建订单失败", e);
// 注意:异常没有重新抛出,事务拦截器以为方法执行成功了
}
}
我在排查线上问题时见过一个特别典型的案例:一个同步接口,同步成功后要更新状态,更新状态时偶尔会抛异常,但开发者用try-catch包住后只打了日志,导致每次同步成功后状态字段永远是旧的,下游系统重复推数据,整个系统都乱了。排查了半天,最后发现事务方法里吞了异常。
解决方案是:catch到异常后,要么直接抛出RuntimeException,要么在catch块里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。第一种最推荐,代码最直观。
java复制@Transactional
public void createOrder(OrderDTO dto) {
try {
orderMapper.insert(dto);
stockMapper.deduct(dto.getSkuId());
} catch (Exception e) {
log.error("创建订单失败", e);
throw new RuntimeException("创建订单失败", e);
}
}
再说抛错类型不对。Spring默认的rollback规则是:只对RuntimeException和Error回滚,对checked exception(受检异常)默认不回滚。这其实是Spring的设计选择——它认为受检异常代表的是“可预期的业务分支”,比如商品下架、余额不足,这类情况不一定要把整个事务回滚掉,可能是想保留部分数据。但问题是,大多数业务场景里,你希望任何异常都能触发回滚,那就得显式指定rollbackFor。
java复制@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) throws Exception {
// 业务逻辑
throw new Exception("订单保存失败");
}
有人可能会问:为什么不把默认行为改成对所有异常回滚?答案在Spring的历史设计里——它从EJB时代继承了这种约定,后来成了事实标准。但你在实际开发中,只要涉及checked exception,最好一律在注解上写明rollbackFor = Exception.class,避免依赖这个隐晦的默认行为。另外还要注意,如果方法里抛出了Error(比如OutOfMemoryError),Spring默认是会回滚的,但这类情况基本没法靠事务来解决,属于另一层面的问题。
提示:有一个更隐蔽的异常相关失效——你的事务方法本身签名声明了throws Exception,但你在方法内部把这个异常包装成了RuntimeException抛出去,那就会触发回滚;可如果你在catch块里先写了return,再想抛异常就已经来不及了,代码根本执行不到。这种逻辑顺序的问题,最好在Code Review阶段就盯住。
5. 陷阱六:传播行为设置不当,事务被静默“降级”成非事务
传播行为这概念,面试时人人都会背,但实际配置时经常出错。Spring的传播行为一堆枚举值,什么REQUIRED、REQUIRES_NEW、NESTED、NOT_SUPPORTED、NEVER……真正开发中用到最多的其实是REQUIRED(默认),少部分场景会用REQUIRES_NEW和NESTED。但有些人因为参照了某段网上代码,或者想当然地设置了NOT_SUPPORTED,结果事务静默失效。
NOT_SUPPORTED的意思是:如果当前存在事务,则挂起当前事务,以非事务方式执行。很多人设置这个传播行为是出于“某个方法不需要事务”的考虑,但设置完之后,这个方法里所有的数据库操作都不在事务保护范围内了,一旦中途失败,前面已经执行的SQL全部留在数据库里。我见过一个案例,同步数据的方法设置了NOT_SUPPORTED,说是想避免长事务锁表,结果同步过程中有一批数据格式校验失败,但已经写入的数据没有回滚,最后只能用脚本手工清理。
还有一种常见的误用是REQUIRES_NEW。REQUIRES_NEW会挂起当前事务,开启一个全新的事务。如果你对A方法设置了REQUIRED,A内部调用设置了REQUIRES_NEW的B方法,那么B一旦提交成功,后续A即使回滚,B的数据也回不去了。这在有些场景是刻意为之(比如记录操作日志,日志不应该随主事务回滚),但如果你本意是想让B和A“同生共死”,这个配置就是灾难。
这里还要提一下自调用和传播行为的叠加效应——如果REQUIRES_NEW方法是在同类内部被this调用的,那它根本不会新开事务,而是直接沿用当前事务(因为压根没走代理)。前面已经说过这个坑,这里再强调一次是因为它太容易被人忽略。
| 传播行为 | 效果 | 常见误用场景 |
|---|---|---|
| REQUIRED(默认) | 有事务则加入,无则新建 | 大多数业务方法 |
| REQUIRES_NEW | 挂起当前事务,新建独立事务 | 把应该共享一个事务的操作拆成两个独立事务 |
| NESTED | 嵌套事务,依赖JDBC savepoint | 想要部分回滚但没理解savepoint的作用范围 |
| NOT_SUPPORTED | 挂起当前事务,非事务执行 | 本意减轻锁压力,结果整个方法失去事务保护 |
提示:配置传播行为前,先问自己一个问题——这个方法失败时,前面已经执行的数据库操作该不该回滚?如果你答不上来,就用默认的REQUIRED。多数业务场景需要的都是它。
6. 陷阱七:数据库引擎不支持事务,配置再好也白搭
这个问题在面试中出现的频率不算高,但在实际业务里一旦踩中就是大坑。MySQL最常用的引擎是InnoDB,它是支持事务的;但还有一个老牌引擎MyISAM,它压根就不支持事务。如果你的表用了MyISAM,那么Spring这边配置再完美、@Transactional写得多规范,也没用——数据库层面就没有事务能力,commit和rollback对它来说都是无效操作。
我记得有一次排查一个报表统计功能的数据异常,统计结果时多时少,刚开始所有人都怀疑是Spring事务的问题,代码翻了个底朝天,没找到毛病。后来DBA无意中说了一句“这张表是MyISAM的吧”,我们查了一下,果然是。这个表是很多年前建的老表,一直没迁移引擎,平时只做查询也没出过问题,但后来业务改造后开始往里写数据,事务失效的问题就暴露了。
排查方法很简单:
sql复制-- 查看表的存储引擎
SHOW TABLE STATUS LIKE 't_order';
-- 或者查看建表语句
SHOW CREATE TABLE t_order;
如果是MyISAM,解决方案是迁移到InnoDB:
sql复制ALTER TABLE t_order ENGINE = InnoDB;
注意迁移的时候要评估锁表时间——如果表很大,ALTER TABLE操作会锁表,建议在业务低峰期操作,或者使用pt-online-schema-change这类工具。另外迁移之后要验证一下已有数据有没有损坏,一般执行一下CHECK TABLE即可。还有一个相关的坑:即使引擎是InnoDB,如果服务配置了多个数据源,事务管理器可能没有正确绑定到你实际操作的那个数据源上,这时@Transactional照样失效。这种情况会在启动时或运行时抛出“No qualifying bean of type PlatformTransactionManager”之类的异常,但如果你配置了多个事务管理器又没指定primary,事务注解就容易“选错对象”。
提示:新项目建表时直接用InnoDB基本是标配,但老项目改造时别忽略历史表。我曾经在代码评审时见过一个老系统,几十张表全是MyISAM,后面接了个订单接口,事务在代码层面查不出任何问题,最后问题定位到表引擎上,一行ALTER语句就解决了。
7. 陷阱八:多线程环境,事务与线程绑定,new Thread就是新世界
这个坑在架构稍微复杂一点的项目里几乎必踩。Spring事务和数据库连接是绑定在当前线程上的——事务上下文存放在ThreadLocal里,同一个事务里的所有操作必须发生在同一个线程。一旦你在事务方法里手动new了一个Thread,或者用了线程池、@Async注解,那么新线程里的数据库操作走的是完全不同的数据库连接,和主线程的事务毫无关系。
看一个最常见的问题代码:
java复制@Transactional
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto);
// 异步发消息,或者异步记录日志
asyncService.sendMessage(dto);
stockMapper.deduct(dto.getSkuId());
}
@Service
public class AsyncService {
@Async
public void sendMessage(OrderDTO dto) {
// 这个操作不在当前事务里
messageMapper.insert(dto);
}
}
这段代码里,如果createOrder后续抛出异常导致事务回滚,那么sendMessage里插入的消息记录是不会回滚的。因为sendMessage在另一个线程执行,它拿到的是新的数据库连接,新连接上默认是自动提交模式,每条SQL执行完就立即提交了。更麻烦的是,由于子线程的执行时机不确定,可能出现主线程事务还没提交,子线程已经读到旧数据的情况,导致数据不一致。
那多线程业务到底怎么保证事务一致?我给出几条实际可行的心得:
第一,如果异步操作和主事务必须保持一致,就不要做成异步。很多团队把“发消息”和“写订单”强行放进一个事务,还追求异步化,这本身就是矛盾的。好的做法是主事务里只做核心数据变更,事务提交成功后,通过Spring的TransactionSynchronizationManager注册一个afterCommit回调,在回调里发消息。
java复制@Transactional
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto);
stockMapper.deduct(dto.getSkuId());
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 事务提交后再发消息,保证消息一定在数据落库之后才发送
asyncService.sendMessage(dto);
}
});
}
第二,如果子线程里的操作也要具备事务能力,不要试图共享主线程的事务,而是给子线程的方法单独加@Transactional,让它自己开一个独立事务。但你要清楚,这意味着主线程和子线程的操作是不同事务,天然存在“某个成功某个失败”的可能性,想要最终一致性,需要引入本地消息表、MQ事务消息或者分布式事务方案。
第三,尽量避免在主线程事务里直接操作线程池等待子线程结果。这会拉长数据库连接占用时间,也可能因为子线程中的异常没有被正确捕获而导致主线程事务状态混乱。
提示:@Async和事务注解同时出现在同一个方法上时,有一个经典误区——很多人以为这样写就有“异步+事务”双重效果,实际上Spring的@Async代理和事务代理是两个不同维度的代理,它们叠加时,事务是加在异步方法内部的,也就是说,外层调用方的事务根本管不到这个异步方法的事务。理解这一点,才能设计出合理的异步事务边界。
8. 用一张排查清单,30秒定位事务为什么失效
前7个陷阱讲完之后,我给你整理一份我在实际项目中最常用的事务失效排查清单。遇到事务不生效的问题,按照顺序走一遍,基本能定位到90%以上的原因。
| 排查步骤 | 检查内容 | 常见问题 |
|---|---|---|
| 第一步 | 断点查看当前对象是否为代理对象 | this.getClass()显示的是带$$EnhancerBySpringCGLIB的类,说明是代理;如果直接是原始类,说明压根没被代理 |
| 第二步 | 确认调用入口是否绕过代理 | 查是不是同类内部this调用,或者是否通过new的方式创建了Bean实例 |
| 第三步 | 检查方法修饰符 | 是否public,是否final,是否static |
| 第四步 | 检查异常是否被拦截 | 方法里有没有try-catch吞掉异常,catch后有没有重新抛出 |
| 第五步 | 检查异常类型 | 抛出的异常是否是RuntimeException,如果不是,注解上有没有配rollbackFor |
| 第六步 | 检查传播行为 | 是否设置了NOT_SUPPORTED或NEVER之类的事务降级行为 |
| 第七步 | 检查数据库表引擎 | MyISAM还是InnoDB,事务管理器是否绑定到了正确的数据源 |
| 第八步 | 检查是否跨线程 | 是否在事务方法里开启新线程执行数据库操作 |
这个清单为什么按这个顺序排?因为前几步排查成本最低、覆盖范围最大。第一步一看就知道有没有代理,第二步能解释绝大多数内部调用问题,第三、第四、第五步能覆盖至少三成失效场景。后面几步是需要结合具体业务代码来判断的,但也值得一一排查。
实际操作时我还有一个习惯:开启Spring事务的调试日志,观察日志里有没有“Getting transaction for”和“Completing transaction”之类的记录。如果事务生效,日志中会明确出现这两类关键字;如果完全没有,说明事务拦截器压根没有执行——问题出在代理层面;如果只有“Getting transaction”没有“Completing transaction”,说明事务开启后可能一直没有正常结束,这种情况往往和异常被吞有关,或者事务嵌套导致事务状态异常。
yaml复制# application.yml 中开启事务相关调试日志
logging:
level:
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
org.springframework.transaction.interceptor.TransactionInterceptor: DEBUG
根据我自己的经验,事务失效的问题绝大多数不是“配置写错了”,而是“调用链上出了问题”。代理机制、异常处理、线程边界这三个维度,几乎包含了所有常见失效场景。遇到问题时,先别急着怀疑框架,用日志和断点确认事务拦截器到底有没有介入,往往比盲目改配置更快。
说实话,这8个坑我基本都踩过,最让我印象深刻的不是某个高深的技术问题,而是一个特别简单的小细节——某个事务方法被另一个事务方法调用后,内部的REQUIRES_NEW子事务因为代理问题而没有生效。排查过程花了大半天,最后的解决方案就是加一个自注入,三行代码搞定。所以如果你在项目中遇到类似问题,不妨先回到代理机制这个根本点,把调用链捋一遍,问题通常就有了答案。
