Java

Spring 事务为什么总像没生效?别先背概念,先回到出事现场

事务没按预期回滚时,很多人第一反应是注解失效了。可真实项目里,更常见的是你以为在一个事务里,实际上调用没走代理、异常被吃掉,或者早就切到另一条边界了。

  • Spring
  • 事务
  • Java
  • 后端开发
14 分钟阅读

很多事务问题,团队里第一句都会先说成:@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 事务很少真的是“注解突然失灵”。更多时候,是你在项目里看到了一种很像事务失效的故障外观:调用没进代理、异常没传出去、边界被切开,或者资源上下文已经不是同一条线。

把现场先收回到这些更具体的问题上,排查会快很多,也更接近真实项目里的那种故障感,而不是停在一套标准事务教程里。