Spring Boot 的 @Transactional 为什么会失效?从代理、自调用到异常回滚的排查实战

技术栈:Java 17 / Spring Boot 3.x / Spring Framework 6.x / Spring JDBC / H2

适合读者:已经在 Service 层使用 @Transactional,但还不太清楚事务拦截、回滚规则和传播行为的 Java 开发者

说明:文中使用 H2 演示核心行为;涉及 Spring Framework 6.2、7.x 的版本差异会单独注明。

前言:明明加了事务,数据为什么还是提交了?

假设创建订单需要连续执行两次数据库操作:

  1. 写入订单。
  2. 扣减库存。

第二步失败时,我们希望第一步也撤销。于是很自然地写下:

java 复制代码
@Transactional
public void createOrder() {
    orderMapper.insert(order);
    stockMapper.deduct(productId, quantity);
}

大多数时候,这样做没有问题。但当代码经过重构、增加异常处理、拆分异步任务,或者调整事务传播行为之后,有时就会出现令人困惑的现象:

方法上明明有 @Transactional,异常也出现了,数据库里的部分数据却还在。

此时如果只盯着注解看,很容易陷入反复试错。真正需要回答的是三个问题:

  • 这次方法调用有没有经过 Spring 事务代理?
  • 事务管理器有没有收到需要回滚的信号?
  • 当前数据库操作究竟属于哪个事务?

下面先用一个可以复现的例子说明最典型的陷阱,再逐项排查。本文讨论的是常见的基于代理的声明式事务,不是 AspectJ 编织模式,也不是 Reactor 响应式事务。

一、先理解一句话:@Transactional 不等于方法自带事务

在默认的代理模式中,Spring 不是因为方法上写了注解,就自动让 JVM 中任何一次调用都拥有事务。

简化后的调用链是:

text 复制代码
Controller / 其他 Service
          │
          ▼
Spring 管理的 Service 代理对象
          │
          ├── 事务拦截器:判断传播行为,开始或加入事务
          │
          ▼
      目标方法执行
          │
          ├── 正常返回:尝试提交
          └── 抛出异常:按照回滚规则处理

关键是调用要经过代理对象。注解描述事务规则,真正执行规则的是 Spring 的事务基础设施。

这也解释了为什么"同一个类内部互相调用"值得特别警惕。

二、用 H2 复现:同一个方法,外部调用回滚,this 调用却留下数据

为了减少 MyBatis、数据库容器和业务代码带来的干扰,这里选择 Spring JDBC + H2 内存数据库。

示例以 Spring Boot 3.x、Java 17 为基础,项目使用常规 Spring Boot Maven 父工程即可。

1. 添加依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-jdbc 提供 JDBC 及数据源相关能力。示例使用单数据源和 Spring Boot 的常规事务自动配置,不需要手工声明事务管理器。

2. 准备数据表

在 src/main/resources/schema.sql 中写入:

sql 复制代码
CREATE TABLE tx_demo (
    id BIGINT PRIMARY KEY,
    note VARCHAR(100) NOT NULL
);

src/main/resources/application.yml:

yaml 复制代码
spring:
  datasource:
    url: jdbc:h2:mem:txdemo;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    driver-class-name: org.h2.Driver
    username: sa
    password:
  sql:
    init:
      mode: always

这里不涉及真实业务数据。schema.sql 在应用启动时初始化表结构,测试之间只清理表中的记录。

3. 写一个会抛异常的 Service

java 复制代码
package com.example.tx;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 演示事务代理调用与同类内部调用的区别。
 */
@Service
public class TransactionDemoService {

    private final JdbcTemplate jdbcTemplate;

    public TransactionDemoService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void saveAndFail(long id) {
        jdbcTemplate.update(
                "INSERT INTO tx_demo (id, note) VALUES (?, ?)",
                id, "demo");
        throw new IllegalStateException("模拟业务异常");
    }

    public void saveViaThis(long id) {
        this.saveAndFail(id);
    }
}

注意这里有两个入口:

  • saveAndFail():外部直接调用,经过 Spring 代理,正常情况下会开启事务。
  • saveViaThis():外部进入后,通过 this 调用同类方法,不再经过该次内部调用所需要的事务代理拦截。

4. 写两个测试,不靠猜测判断回滚

java 复制代码
package com.example.tx;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
 * 验证外部代理调用与内部自调用的事务行为。
 */
@SpringBootTest
class TransactionDemoTests {

    @Autowired
    private TransactionDemoService service;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    void clean() {
        jdbcTemplate.update("DELETE FROM tx_demo");
    }

    @Test
    void externalCallShouldRollBack() {
        assertThrows(IllegalStateException.class,
                () -> service.saveAndFail(1001L));

        assertEquals(0L, countRows());
    }

    @Test
    void selfInvocationShouldLeaveInsertedRow() {
        assertThrows(IllegalStateException.class,
                () -> service.saveViaThis(1002L));

        assertEquals(1L, countRows());
    }

    private long countRows() {
        return jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM tx_demo",
                Long.class);
    }
}

测试类不要额外添加 @Transactional,否则测试本身可能建立外层事务,干扰我们对 Service 行为的观察。

在上述单数据源、正常自动配置的前提下,预期结果是:

调用方式 是否经过对应方法的事务拦截 抛异常后表中记录
service.saveAndFail(1001L) 是 0 条
service.saveViaThis(1002L) 内部 this.saveAndFail() 没有经过 1 条

这里展示的是基于代码和 Spring 代理语义推导的预期测试结果,不是声称已经在本文环境执行过测试。复制到工程后请实际运行验证;若已有外层事务、手动调整了事务配置,结果可能不同。

这就是我们排查的第一个重要结论:

自调用不会让被调用方法上的事务注解重新生效;但如果外部方法已经启动了事务,内部数据库操作仍可能参与那个现有事务。

换句话说,不能粗暴地把"this 调用"解释为"肯定没有任何事务"。

5. 推荐修复:拆成两个 Spring Bean

java 复制代码
@Service
public class OrderEntryService {

    private final OrderWriteService orderWriteService;

    public OrderEntryService(OrderWriteService orderWriteService) {
        this.orderWriteService = orderWriteService;
    }

    public void submit(long id) {
        orderWriteService.saveAndFail(id);
    }
}

@Service
public class OrderWriteService {

    private final JdbcTemplate jdbcTemplate;

    public OrderWriteService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void saveAndFail(long id) {
        jdbcTemplate.update(
                "INSERT INTO tx_demo (id, note) VALUES (?, ?)",
                id, "demo");
        throw new IllegalStateException("模拟业务异常");
    }
}

此时 OrderEntryService 调用的是容器提供的 OrderWriteService,事务边界清楚,也更容易写测试。相比为了绕过自调用而注入自身代理或使用 AopContext.currentProxy(),拆分职责通常更值得优先考虑。

三、第二个坑:抛的是受检异常,默认规则可能不回滚

看下面的代码:

java 复制代码
@Transactional
public void importOrders() throws IOException {
    jdbcTemplate.update(
            "INSERT INTO tx_demo (id, note) VALUES (?, ?)",
            2001L, "import");
    throw new IOException("导入文件失败");
}

我们直觉上可能认为"既然抛了异常,就应该回滚"。但 Spring 声明式事务的传统默认回滚规则是:

  • RuntimeException:回滚。
  • Error:回滚。
  • 受检异常,例如 IOException:默认不回滚。

因此,在没有其他回滚规则的情况下,IOException 从代理方法抛出去,事务仍可能提交。

更明确的写法是:

java 复制代码
@Transactional(rollbackFor = IOException.class)
public void importOrders() throws IOException {
    // 数据库写入与文件导入流程
}

如果项目业务异常较多,也可以考虑 rollbackFor = Exception.class,但不要不经评估就全局修改历史项目的回滚约定。

一个容易漏掉的版本变化: 从 Spring Framework 6.2 开始,可以通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 全局调整默认回滚规则。因此排查时还要检查项目是否已经设置了全局规则,而不是只看单个方法上的注解。

四、第三个坑:异常在方法内部被吞掉,事务拦截器看见的是"正常结束"

考虑这样的处理:

java 复制代码
@Transactional
public void saveWithCatch(long id) {
    try {
        jdbcTemplate.update(
                "INSERT INTO tx_demo (id, note) VALUES (?, ?)",
                id, "demo");

        throw new IllegalStateException("业务校验失败");
    } catch (IllegalStateException ex) {
        // 记录错误后直接返回
        System.err.println(ex.getMessage());
    }
}

IllegalStateException 在事务方法内部就被捕获了,没有传播到事务拦截器。

如果没有其他代码把事务标记为 rollback-only,Spring 看见的是方法正常返回,便会尝试提交。

比较稳妥的处理方式:

java 复制代码
@Transactional
public void saveWithRollback(long id) {
    jdbcTemplate.update(
            "INSERT INTO tx_demo (id, note) VALUES (?, ?)",
            id, "demo");

    if (id <= 0) {
        throw new IllegalArgumentException("ID 不合法");
    }
}

如果你必须捕获异常来补充上下文,可以记录后重新抛出,或转换成符合回滚规则的业务异常。确实无法抛出时,也可以在确认存在当前 Spring 事务的前提下,使用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();但它会让业务代码依赖 Spring 事务 API,不宜作为首选。

这里还要区分另一种情况:被调用的另一个代理方法已经把共享事务标记为回滚 。即使外层捕获了异常,最终提交仍可能失败,并出现 UnexpectedRollbackException。这不是事务注解突然失效,而是内部事务已经明确要求回滚,外层却还尝试提交。

五、第四个坑:private、final,以及"不全是 public"的方法可见性

网上经常能看到一句话:"@Transactional 必须加在 public 方法上,其他方法一定不生效。"

作为日常编码约定,优先使用 public 方法没有问题;但作为严格的框架结论,这句话已经不够准确。

在常见的 Spring AOP 代理模型中:

方法或调用方式 需要注意
private 方法 不能由普通 Spring AOP 代理直接拦截
类代理下的 final 方法 不能通过重写实现拦截
public 方法 推荐的事务入口,但仍需经过代理
protected / 包可见方法 Spring 6.0 起,在类代理的相应条件下也可能被事务拦截
JDK 接口代理 事务入口需要满足接口代理的方法约束
同类内部 this 调用 即使目标方法是 public,也绕过那次代理拦截

所以我更推荐一条工程约定,而不是死记某个版本的可见性细节:

把事务边界放在 Spring 管理的 Service 的清晰、可从外部调用的 public 方法上;不要依赖私有方法、构造或初始化阶段的方法来建立事务。

还有一点:new OrderService(...) 得到的是自己创建的对象,不是 Spring 容器注入的事务代理。即便类上有 @Service,自行 new 也不会自动取得事务增强。

六、第五个坑:开启了异步线程,以为事务会跟着过去

例如:

java 复制代码
@Transactional
public void createAndNotify() {
    orderMapper.insert(order);

    CompletableFuture.runAsync(() -> {
        auditMapper.insert(auditRecord);
    });

    throw new IllegalStateException("订单流程失败");
}

这里存在两条执行链:

text 复制代码
请求线程:开启事务 → 写订单 → 抛异常 → 订单事务回滚

异步线程:                → 写审计记录 → 使用异步线程自己的数据库访问上下文

对于通常由 PlatformTransactionManager 管理的命令式事务,事务上下文是线程绑定的,不会因为创建了新线程就自动传递过去。

所以异步操作如果独立提交,就可能出现"订单回滚了,审计记录还在"的结果;至于异步操作具体执行、报错或提交的时序,还取决于线程调度和数据访问配置。

更合适的做法取决于业务语义:

  • 必须与订单原子提交的数据,不要随便放进新线程。
  • 可以独立完成的通知、审计,要明确设计独立事务和失败补偿。
  • 使用 @Async 的方法可以在异步线程中通过自己的代理调用建立自己的事务,但那不是原线程事务的延续。
  • 如果需要"本地事务提交成功后再触发后续动作",可以考虑 @TransactionalEventListener(phase = AFTER_COMMIT);若涉及消息可靠投递,还应评估 Outbox 等机制,不能把监听器当成可靠消息队列。

七、第六个坑:REQUIRES_NEW 写了,但没有发生预期中的"新事务"

例如:

java 复制代码
@Service
public class OrderService {

    @Transactional
    public void create() {
        saveOrder();
        this.saveAudit();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAudit() {
        // 保存审计记录
    }
}

开发者可能期待 saveAudit() 使用一个独立事务:审计提交后,外层事务即使回滚也不影响审计记录。

但这里仍然是同类自调用 。REQUIRES_NEW 的拦截没有发生,审计操作通常仍处于外层 create() 的事务范围内。

要让传播设置生效,最直接的做法还是拆成另一个受 Spring 管理的 Service,通过代理调用。

同时记住两种传播行为的差别:

传播行为 事务语义
REQUIRED(默认) 有事务就加入,没有就新建
REQUIRES_NEW 建立独立物理事务,外层事务暂时挂起

REQUIRES_NEW 也不是万能的"防回滚注解"。外层事务可能仍占用连接,内层又需要获取新连接。如果并发量较高、连接池容量不足,可能造成连接等待甚至资源耗尽。

如果你的实际意图是"订单和库存要么一起成功,要么一起失败",绝大多数情况下,使用一个正常的 REQUIRED 事务更简单。

八、第七个坑:事务管理器管着 A 数据源,SQL 却操作了 B 数据源

到了多数据源系统,问题可能不在异常,也不在代理。

比如:

  • 订单数据写入 MySQL 的订单数据源。
  • 日志或统计数据写入另一个 PostgreSQL 数据源。
  • 方法只使用了某一个 DataSourceTransactionManager。

这时,即便方法里的 @Transactional 生效了,也不能据此认为两个数据库的写入会一起回滚。

本地 JDBC 事务管理器负责的是对应数据源上的事务,而不是自动帮你建立跨数据库的原子提交。

多事务管理器项目可以显式指定:

java 复制代码
@Transactional(transactionManager = "orderTransactionManager")
public void saveOrder() {
    // 使用订单数据源
}

但指定一个管理器仍不会自动包含其他数据源。真正需要跨系统一致性时,应根据业务选择消息最终一致性、Outbox、补偿流程或合适的分布式事务方案。

同样要警惕绕过 Spring 数据源管理的手工连接:

java 复制代码
Connection connection =
        DriverManager.getConnection(url, username, password);

这条连接并不会因为当前方法上存在 @Transactional,就自动加入由另一个 Spring 数据源管理的事务。使用 JdbcTemplate、正确配置的数据源和事务管理器,通常更不容易踩这个坑。

此外,数据库或存储引擎本身也必须支持相应事务。例如使用 MySQL 时,应确认相关表使用支持事务的引擎(通常是 InnoDB),而不是只根据 Java 代码判断结果。

九、遇到事务不生效,我会按这个顺序排查

我不建议第一步就修改 rollbackFor,也不建议一上来就把所有传播行为改成 REQUIRES_NEW。先确认最基本的链路。

第一步:确认事务是否真的激活

在需要观察的代码位置临时打印:

java 复制代码
boolean active =
        TransactionSynchronizationManager
                .isActualTransactionActive();

System.out.println("当前线程是否存在实际事务:" + active);

注意,这只能说明当前线程此刻 有没有 Spring 管理的实际事务,并不能单独证明某个特定注解已经按预期生效。若外层已有事务,自调用内部也可能打印 true。

第二步:打开事务日志

application.yml:

yaml 复制代码
logging:
  level:
    org.springframework.transaction.interceptor: TRACE
    org.springframework.jdbc.datasource: DEBUG

根据实际事务管理器的实现,日志类和输出内容可能不同。重点观察:

  • 有没有进入事务拦截器?
  • 创建了新事务,还是加入已有事务?
  • 是否设置了回滚标记?
  • 最终是提交还是回滚?

调试完成后关闭过细日志,避免生产环境长期输出大量事务细节。

第三步:逐项检查

检查项 关键问题
Bean 与代理 是容器注入的 Bean,还是自己 new 出来的?
调用路径 是否通过 this 调用另一个带事务注解的方法?
异常 异常类型是什么?是否被方法内部捕获?
回滚规则 有没有 rollbackFor、noRollbackFor 或全局覆盖?
传播行为 是 REQUIRED 还是 REQUIRES_NEW?是否真的经过代理?
线程 有无 @Async、线程池、CompletableFuture?
数据源 SQL 所在数据源是否归当前事务管理器负责?
数据库 表和驱动是否支持事务?是否混用了独立连接?

第四步:写一个真正检查数据库结果的测试

不要只断言抛出异常:

java 复制代码
assertThrows(IllegalStateException.class,
        () -> service.saveAndFail(1001L));

还应该检查最终数据:

java 复制代码
assertEquals(0L, jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM tx_demo", Long.class));

"异常抛出来了"和"数据库真的回滚了"是两回事。

对于事务边界测试,还要特别留意测试框架自身的事务设置。让测试方法自动回滚,有时反而会掩盖业务 Service 没有回滚的问题。

十、总结:别只看有没有注解,要看事务边界在哪里

@Transactional 最容易让人产生的误解,是以为它会给一个 Java 方法永久绑定"执行失败就全部撤销"的能力。

实际上,在常见的 Spring 代理事务模型里,它能否按预期工作,还取决于:

  1. 入口有没有经过代理。
  2. 异常或回滚标记有没有被事务管理器感知。
  3. 数据库操作是否处在同一线程、同一个受管理的事务资源中。
  4. 传播行为是否真的建立了你以为的事务边界。

如果只能记住一句话,我希望是:

排查事务问题时,先画出"谁调用谁、谁开启事务、谁提交回滚"的链路,再决定修改哪个注解。

这通常比反复加 rollbackFor = Exception.class 更有效,也更不容易把一个局部问题扩大成新的事务一致性问题。


参考资料

相关推荐
曼岛_1 小时前
[图解密码学]第5章 对称加密基础
java·数据库·密码学
哦虎!2 小时前
【JavaEE进阶】SpringBoot
spring
天河归来11 小时前
第三方接口不稳定,如何让核心任务继续完成
java·大数据
Knight_AL12 小时前
Java 文件下载时文件名中的 `#` 被转换成 `%23`?一次 URL 编码与解码问题排查
java·开发语言
砚底藏山河13 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
宇宙无敌暴龙战士66614 小时前
MySQL 8 库表操作学习笔记:SQL 基础、库表管理、约束与用户授权全流程
数据库·sql·mysql·学习笔记·后端开发
猿究院_夏燊14 小时前
MySQL 的四种隔离级别
java·数据库·mysql
码流子14 小时前
移动收费怎么做?手持终端+App+收费中心的架构实践
java·开发语言·架构
霸道流氓气质14 小时前
AI 流式对话与实时聊天平台:SSE 解码竞态、有状态会话隔离与背压取消的 Vue3 + Spring AI 生产级实战
java·人工智能·spring