Spring 事务为什么总像没生效?别先背概念,先回到出事现场
事务没按预期回滚时,很多人第一反应是注解失效了。可真实项目里,更常见的是你以为在一个事务里,实际上调用没走代理、异常被吃掉,或者早就切到另一条边界了。
很多事务问题,团队里第一句都会先说成:@Transactional 失效了。
但我后来越来越少这样下判断。因为线上真正出问题时,现场通常不是“注解突然不工作”,而是这几种更具体的情况:
- 代码明明报错了,订单却还是落库了
- 外层方法回滚了,日志表、流水表反而留住了
- 本地一切正常,换到异步、线程池或者多数据源后,结果开始变得说不清
- 同样一段业务,开发以为是一个事务,数据库里看起来却像几段各自提交的操作
这些现象最后都可能被笼统地叫成“事务没生效”,可它们根本不是同一种问题。
我更习惯先把现场问清楚:到底是根本没进事务,还是进了事务但没有按你以为的方式回滚;到底是异常没传出去,还是事务边界早就被别的调用方式切开了。
如果你手里的问题已经长得像“有些数据回滚了,有些没回滚”“明明抛异常了却还是提交”“写在一个方法里但像不是一笔事务”,这篇就直接从这些真实现场往下拆。
先别急着谈原理,先看你碰到的是哪种现场
我自己排这类问题,通常先按现象分,不先按知识点分。
现场一:代码抛错了,但数据库还是提交了
这是最常见的一种,也是最容易让人一口咬定“事务失效”的一种。
但它背后经常不是一个原因。
有时是异常类型不对,比如你抛了受检异常,事务默认并不会替你回滚;有时是你在 catch 里把异常记了日志,然后方法正常返回;还有些场景里,真正抛错的地方已经不在当前事务边界里。
也就是说,看到报错,不等于事务已经收到“该回滚”的信号。
现场二:外层失败了,但中间某几步还是提交了
这种现场也很常见。
比如下单主流程最后失败了,但审计日志写进去了;主表没了,操作记录还在;库存没扣成功,某个通知状态却先改了。
这时很多人会说“怎么只回滚了一半”。
可更常见的真实情况是:这些动作本来就不在同一个事务里。可能有人在中间用了 REQUIRES_NEW,也可能其中一段已经跑到了别的线程、别的事务管理器,或者干脆是异步发出去的。
它不是“回滚回了一半”,而是你原先理解的一整块边界,在运行时其实早就被切开了。
现场三:方法上有 @Transactional,结果就是像没开事务
这种情况十有八九先看调用路径,不要先看数据库。
尤其是:
- 同类内部直接调自己
- 不是通过 Spring 注入的 Bean 去调用
- 某段逻辑是在你自己
new出来的对象里执行
这类问题的共同点很直接:你看见了注解,但 Spring 代理没有真正接住这次调用。
现场四:单线程下看着正常,一换异步或线程池就开始乱
这类问题最容易在项目后期出现。
业务一开始很简单,事务也都像是正常的。后来为了提速、解耦、削峰,开始往里塞:
@Async- 自己的线程池
- 事件监听
- 消息投递
- 多数据源切换
然后某天你会发现,同一段流程明明写在一个大方法里,结果数据库表现出来像几段互相独立的操作。
这时候别再执着于“同一个方法里怎么会不是一个事务”。方法写在一起,不代表执行时还在同一条边界里。
我排事务问题,第一眼先看调用是不是根本没走到代理
如果一个事务问题刚出现,我第一步通常不是翻传播行为,也不是先看数据库日志,而是先看:这次调用到底有没有经过 Spring 代理。
因为只要这一步没成立,后面所有“为什么没回滚”的讨论都容易跑偏。
最常见的坑,就是同类里自己调自己
像这种代码:
@Service
public class OrderService {
public void createOrder() {
saveOrder();
}
@Transactional
public void saveOrder() {
orderMapper.insert(order);
logMapper.insert(log);
}
}
很多人看到 saveOrder() 上有注解,就会下意识认为这里一定开了事务。
可实际执行时,createOrder() 是这个对象内部直接去调 saveOrder()。这次调用没有穿过 Spring 代理,而是对象自己调自己。那事务拦截器自然就接不住。
这种问题之所以高频,不是因为原理难,而是因为代码看起来太像“已经写对了”。
另一类常见现场,是对象根本不是 Spring 托管的
比如:
- 你在某个地方直接
new了一个 service - 老代码里混着一些工具式组件,不在容器里
- 测试代码没真的跑在 Spring 上下文里
这时 @Transactional 写得再工整,也只是写在一个普通方法上,Spring 根本无从介入。
如果你查到这里有问题,先别继续往异常、传播行为这些层面深挖。因为这类现场的根因还停留在更前面:调用就没进事务代理。
如果调用路径没问题,再看“异常为什么没把事务带回去”
另一类特别常见的现场是:日志里已经看到异常了,但数据还是提交了。
这种时候,很多人的排查动作会非常散:看数据库、看锁、看连接池、看 SQL。其实常常不用先走那么远,先把异常传播这件事看清楚就够了。
一种很典型的写法,是把异常吞掉了
@Transactional
public void createOrder() {
try {
orderMapper.insert(order);
logMapper.insert(log);
int x = 1 / 0;
} catch (Exception e) {
log.error("create order failed", e);
}
}
从日志看,异常明明发生了。
但对事务拦截器来说,这个方法最后是正常返回。既然你没有把失败继续往外抛,它自然会按“执行成功”去提交。
这类代码在老项目里特别多。开发当时可能只是想“先别让异常炸出去,日志留着再说”,结果副作用是把事务也一起安抚过去了。
还有一种现场,是抛错了,但抛的不是默认会回滚的那类异常
这类也很容易误判。
比如你抛的是受检异常,或者被上层包装成了别的返回值,表面上业务已经失败,可对 Spring 默认的事务规则来说,它未必应该回滚。
这里最容易把人带偏的地方在于:业务失败 和 事务感知到需要回滚,不是一回事。
业务上你可以认定“这次操作失败了”,但如果失败没有以 Spring 能识别的方式穿过事务边界,它依然可能提交。
所以这一步我通常只问两个问题:
- 异常到底有没有真正抛出方法边界
- 抛出去的那个类型,事务默认会不会回滚
大多数“报错了却没回滚”的问题,都能先在这两问里收住。
再往下,才轮到看事务边界是不是被你自己切开了
如果调用经过了代理,异常也确实往外抛了,可结果还是表现得“有的提交了,有的没提交”,那我通常会开始怀疑:这段业务是不是根本就不是一个事务。
最典型的例子,就是中间插了 REQUIRES_NEW。
@Transactional
public void createOrder() {
orderService.saveOrder();
logService.saveLog();
throw new RuntimeException("fail");
}
如果 saveLog() 是这样的:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog() {
logMapper.insert(log);
}
那最终结果就很容易变成:
- 外层下单流程回滚了
- 日志已经单独提交
很多人第一次看到这种结果,会说“怎么事务像坏了”。
其实恰恰相反,事务没有坏,它只是严格按照你配置出来的边界在执行。真正出错的,是人脑里以为“写在一个调用链里,就该一起成败”。
项目里还有很多类似的切边界方式:
- 内层服务单独开新事务
- 某段补偿逻辑为了确保落库,被故意写成独立提交
- 一部分写库走这个事务管理器,另一部分走另一个
- 同一个大流程里夹了异步或消息发送
从代码阅读视角看,它们像一条线;从运行时看,它们可能早就是几段关系松散的边界。
真正难排的,往往是“代码写在一起,但执行时已经不在一个上下文里”
很多事务问题,最绕人的地方并不是代理和异常,而是资源上下文。
比如你看到这样的代码:
@Transactional
public void createOrder() {
orderMapper.insert(order);
asyncService.sendMessage(order);
}
代码是连着写的,很多人就自然以为它们在一个事务里。
但只要 sendMessage() 进了异步线程,这个判断通常就不成立了。事务上下文不会因为你“逻辑上觉得还是一件事”,就自动跟着过去。
多数据源时也一样。
开发经常会有一种错觉:只要它们都写在一个 @Transactional 方法里,数据库动作就应该一起提交、一起回滚。可一旦底下实际接的是不同的数据源、不同的事务管理器,结果往往就不会如想象那样整齐。
所以遇到这种现场,我通常会提醒自己别再被代码版式欺骗:写在一起,只说明它们在一个方法里;不说明它们在同一个事务资源上下文里。
很多事务问题,最后已经不是“回不回滚”,而是把系统一起拖慢了
还有一类现场,最开始确实只是事务结果不对,后来却慢慢演变成别的问题:
- 连接池越来越紧
- 锁等待变多
- 接口 RT 被一起抬高
- 线上开始出现偶发超时
这时如果你还只盯着“为什么注解没生效”,通常已经有点晚了。
因为真正的问题已经放大成:
- 事务太大
- 事务持有时间太长
- 某些异常没及时终止流程,反而让长事务继续跑
- 独立事务、补偿事务和异步重试一起叠加,造成更多连接占用和锁冲突
也就是说,事务问题不一定终于“回滚语义”本身,它很可能只是更大运行态问题的起点。
我自己更信这条排查顺序
以后再遇到“Spring 事务怎么像没生效”的问题,我会建议先按这条线走,而不是一上来就背一套事务知识点:
1. 先看调用有没有真的经过 Spring 代理
优先排:
- 同类内部自调用
- 直接
new对象 - 非 Spring Bean
- 事务边界其实写在没被代理接住的位置
2. 再看异常有没有真的把失败带出方法边界
重点看:
- 有没有
catch后吞掉 - 抛出去的异常类型是否会触发默认回滚
- 业务失败和事务回滚信号是不是被你混成了一件事
3. 再看是不是你自己把事务切成了几段
重点排:
REQUIRES_NEW- 嵌套服务的独立事务
- 补偿式写法
- 看起来是一条链,实际是几段提交边界
4. 最后再确认资源上下文有没有变
比如:
- 切到异步线程
- 跨数据源
- 不同事务管理器
- 数据库操作和消息动作混在一起,但并没有统一事务语义
这条顺序的好处是,几乎每一步都能直接验证,不容易在“Spring 原理”里空转。
最容易把人带偏的几个误判
误判一:方法上有 @Transactional,就等于一定在事务里
不是。前提是调用得先经过 Spring 代理。
误判二:日志里打出异常了,就等于事务一定会回滚
也不是。异常可能被吞了,也可能抛出去的类型根本不在默认回滚范围里。
误判三:一个调用链里前后连着几个方法,就说明它们一定同生共死
项目里最容易出错的恰恰是这里。传播行为、独立事务、异步线程都会把这条线切开。
误判四:同一个方法里既写库又发消息,说明它们天然受同一事务保护
这通常只是代码看起来像。真到了运行时,尤其一旦异步或跨资源,边界往往不是你想的那样。
误判五:事务问题只是一层注解配置问题
很多时候不是。它后面可能连着长事务、锁等待、连接池紧张和接口变慢,是整条运行链的问题。
最后收一句
Spring 事务很少真的是“注解突然失灵”。更多时候,是你在项目里看到了一种很像事务失效的故障外观:调用没进代理、异常没传出去、边界被切开,或者资源上下文已经不是同一条线。
把现场先收回到这些更具体的问题上,排查会快很多,也更接近真实项目里的那种故障感,而不是停在一套标准事务教程里。