Spring 事务详解:@Transactional 如何生效、何时回滚、传播行为怎么选

写订单服务时,我们常把"扣库存"和"创建订单"放进同一个方法,再加上 @Transactional。平时它像一个开关:方法正常结束,两次写入一起成功;中途抛异常,两次写入一起撤销。但只记住"加注解就能回滚",遇到类内调用、异常被捕获或内外层事务嵌套时,很容易得出错误结论。

这篇文章从一笔具体的订单写入讲起,拆解 Spring 事务代理的调用链,再用可以运行的 JDBC + H2 示例观察提交和回滚。最后比较 REQUIRED、REQUIRES_NEW、NESTED,以及事务离开数据库后的边界。示例使用 Spring Boot 3.5.7、Java 17 目标版本;文中的五个测试在示例工程中执行通过。不同数据库和事务管理器的行为仍应在自己的环境里验证。

先看问题:两条 SQL 为什么要一起成功?

假设商品 book 还有 10 件,用户购买 2 件。服务先执行 UPDATE inventory SET available = available - 2,再执行 INSERT INTO orders ...。如果第一条成功、第二条失败,库存变成 8,订单却不存在。下一次请求读到的状态就不完整。

数据库事务把这两次写入放进同一个提交单元。两条都成功才提交;失败时回滚到开始前。这里说的"同一个事务"必须有相同的资源边界:在本文示例里,两条 SQL 使用同一个数据源,并由 Spring 的 JDBC 事务管理器管理。@Transactional 本身不替数据库执行回滚,它负责安排事务边界;真正提交或回滚的是数据库连接所承载的事务。

再把失败时间往后挪一步:库存与订单都已写入,但方法返回前的参数校验又发现错误。只要这两次写入尚未提交、抛出的异常满足回滚规则,数据库仍可以撤销它们。若把事务边界只包在"插订单"这一个方法上,扣库存已经在外部独立提交,后面无论怎么回滚订单都救不了库存。因此划定事务范围时,要先找出必须共同成功的那组写入,再看它们是否确实处在同一个事务管理器能够覆盖的资源里。

不要把事务简单理解成"给方法加锁"。它还涉及并发隔离和持久性;隔离级别决定不同事务彼此可以看到什么,行锁或其他并发控制由数据库实现。本文聚焦 Spring 如何定义事务范围,暂不把 MySQL 的 MVCC、间隙锁等数据库内部细节混进来。

一次调用究竟经过了谁?

Spring 的声明式事务通常通过代理工作。容器交给调用方的是代理对象;调用事务方法时,代理先读取注解元数据,再由事务拦截器与合适的事务管理器协作:需要时开启事务,调用目标方法,最后依据结果提交或回滚。Spring 官方把这条链描述为 AOP 代理、TransactionInterceptor 和 TransactionManager 的组合。Spring 声明式事务实现

看图时注意两个入口。控制器通过 Spring 注入的 OrderService 调用 place(),请求经过代理,注解才有机会被处理;而 this.inner() 是对象内部调用,不经过外部代理入口。代理拦截的是调用路径,不是把某段源码永久改造成"总在事务里执行"。这也解释了为什么"方法上明明写了注解"不足以证明事务一定生效。

以本文的普通 JDBC 方法为例,可以把执行过程理解为六步:

  1. 调用方拿到 Spring Bean 的代理,调用 place()。
  2. 事务拦截器读取 @Transactional,根据传播行为判断创建事务还是加入已有事务。
  3. 事务管理器取得连接、设置事务状态,并把参与同一事务的数据访问绑定到当前执行上下文。
  4. 目标方法执行扣库存、插订单等 JDBC 操作。
  5. 正常返回时尝试提交;有未处理异常时依据回滚规则决定回滚还是提交。
  6. 清理当前线程上的事务上下文和连接资源。

这里的"当前线程"很关键:普通命令式事务依赖与线程关联的上下文。方法里新建线程或把工作交给 @Async 后,不能假定原事务自动跟着走。响应式事务使用另一套基于 Reactor 上下文的传播方式,不应把这两种模型混为一谈。Spring 官方实现说明

再区分两个经常混用的词。逻辑事务范围 是某个被注解方法声明的规则,例如"加入现有事务"或"失败时标记回滚";物理事务 是数据库连接上实际发生的一次事务。外层方法和内层方法各有逻辑范围,也可能共用一个物理事务。一个方法调用链有三个 @Transactional,不意味着数据库执行了三次 BEGIN。要判断结果,必须同时看代理是否拦截了调用、传播行为如何选择,以及物理事务最终由谁提交。

一个能验证结果的最小工程

为避免 ORM 的延迟写入干扰观察,示例直接用 JdbcTemplate 和内存 H2。项目使用 Spring Boot 3.5.7、JDK 17 及以上和 Maven;H2 仅用于演示,应用退出后数据不保留。Spring Boot 的 JDBC starter 与嵌入式数据库配置见官方 SQL 文档。

pom.xml 的主要配置如下,spring-boot-starter-test 用于验证数据库中的实际结果:

xml 复制代码
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.7</version>
  <relativePath/>
</parent>
<properties><java.version>17</java.version></properties>
<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>

在 src/main/resources/schema.sql 建三张表。inventory 保存库存,orders 保存订单,audit_log 专门观察内层事务的结果:

sql 复制代码
CREATE TABLE inventory (
  sku VARCHAR(40) PRIMARY KEY,
  available INT NOT NULL
);
CREATE TABLE orders (
  id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  sku VARCHAR(40) NOT NULL,
  quantity INT NOT NULL
);
CREATE TABLE audit_log (
  id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  message VARCHAR(100) NOT NULL
);
INSERT INTO inventory (sku, available) VALUES ('book', 10);

启动类使用普通的 @SpringBootApplication。在这个 JDBC 示例里,Boot 根据依赖配置数据源和事务管理器。下面是核心服务方法,字段 jdbc 通过构造器注入 JdbcTemplate:

java 复制代码
@Service
public class OrderService {
    private final JdbcTemplate jdbc;

    public OrderService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public void place(String sku, int quantity, boolean fail) {
        int updated = jdbc.update(
            "UPDATE inventory SET available = available - ? " +
            "WHERE sku = ? AND available >= ?",
            quantity, sku, quantity);
        if (updated != 1) {
            throw new IllegalArgumentException("insufficient stock");
        }

        jdbc.update("INSERT INTO orders(sku, quantity) VALUES (?, ?)",
                    sku, quantity);
        if (fail) {
            throw new IllegalStateException("payment preparation failed");
        }
    }
}

生产接口还应检查 quantity > 0、SKU 是否存在、请求幂等键等;这些业务校验不属于事务注解自动提供的能力。UPDATE ... WHERE available >= ? 把库存检查和扣减放在同一条 SQL 中,并通过受影响行数判断是否成功,比"先查库存,再无条件更新"更适合并发请求。

测试不要只看方法是否抛异常,要读取数据库状态。测试方法本身不加 @Transactional,否则测试框架可能在测试结束时回滚数据,掩盖业务方法的真实提交结果:

java 复制代码
@SpringBootTest
class OrderServiceTest {
    @Autowired OrderService service;
    @Autowired JdbcTemplate jdbc;

    @Test
    void runtimeFailureRollsBackBothWrites() {
        assertThatThrownBy(() -> service.place("book", 2, true))
            .isInstanceOf(IllegalStateException.class);

        Integer stock = jdbc.queryForObject(
            "SELECT available FROM inventory WHERE sku = 'book'",
            Integer.class);
        Integer orders = jdbc.queryForObject(
            "SELECT COUNT(*) FROM orders", Integer.class);
        assertThat(stock).isEqualTo(10);
        assertThat(orders).isZero();
    }
}

这个测试依赖 JUnit 5、AssertJ,以及从 Spring Test 引入的 @SpringBootTest。把代码放在 src/test/java,为测试类补齐对应的 import,运行 mvn test。完整示例还分别断言:成功路径库存为 8 且有一条订单;失败路径库存仍为 10 且没有订单。两种结果能区分"代码执行了"与"事务真正提交了"。

示例中的 fail 参数只用来稳定制造"第二条 SQL 已执行、方法即将结束时出现异常"这个测试位置,实际业务不需要这个参数。把故障放在不同位置会得到不同问题:扣库存前失败时什么都没写;扣库存后、插订单前失败时需要撤销库存;插订单后失败时两条都要撤销。可逐个位置做测试,而不是只写一个"抛异常就通过"的断言。若改用 JPA,还应考虑 SQL 可能在 flush 或提交时才真正发往数据库,测试观察点要随之调整。

回滚规则:抛出异常还不够

上面的 IllegalStateException 是运行时异常。Spring 默认会对未处理的 RuntimeException 和 Error 回滚;受检异常通常不会触发默认回滚。默认传播行为是 REQUIRED,默认隔离级别交给底层事务系统,不能把"发生了任何异常都会回滚"当成通则。Spring 注解设置、回滚规则

例如,方法执行了插入后抛出 IOException。如果没有配置相应规则,方法虽然以异常结束,事务仍可能提交。需要该异常触发回滚时,明确写出类型:

java 复制代码
@Transactional(rollbackFor = IOException.class)
public void importOrders() throws IOException {
    jdbc.update("INSERT INTO orders(sku, quantity) VALUES ('book', 1)");
    throw new IOException("import failed");
}

另外一种常见情况是异常在事务方法内部被 catch 后只记录日志,方法最终正常返回。拦截器看到的是正常返回,就会尝试提交。业务允许吞掉异常时,要明确决定是否仍需回滚;通常更清晰的做法是把异常继续抛出,或者设计明确的业务结果与回滚规则。setRollbackOnly() 也能手工标记回滚,但会让业务代码直接依赖事务基础设施,不应当成随手补丁。

还要分清"在哪里捕获"。如果异常从内层事务方法抛出,已经经过内层代理,内层可能先把共享事务标为 rollback-only;这时外层再捕获异常,标记也不会自动消失。相反,如果异常始终在同一个目标方法内部被捕获,从未到达代理边界,默认回滚判断就没有机会根据它做决定。两段代码都写了 catch,最终结果可能完全不同。调试时画出异常经过哪些代理,比只盯着异常类型更有效。

注意版本差异:较新的 Spring Framework 提供了调整全局默认回滚行为的选项,但本文示例采用 Spring Boot 3.5.7 的默认规则。复制其他版本的配置前,先确认当前框架版本与实际事务管理器。

传播行为:内层方法和外层方法到底共用什么?

假设 place() 里还要写一条审计记录。外层创建订单,内层 AuditService 记录审计;如果外层随后失败,审计记录该留下还是一起撤销?不同答案对应不同事务传播行为。Spring 文档特意区分逻辑事务范围 与物理事务 :两个方法都标了 @Transactional,并不代表一定有两个独立数据库事务。Spring 传播行为文档

图里把同一笔订单的外层写入和审计写入画在三条时间线上。要观察的不是"方法调用了几次",而是最终有几个物理事务、谁决定提交、失败时哪一段写入留下。示例把 AuditService 放在独立的 Spring Bean 中,确保外层调用会经过它的事务代理。

REQUIRED:加入外层事务

默认的 REQUIRED 在没有事务时新建事务,已有事务时参与其中。在订单示例里,审计方法加入 place() 的外层物理事务。外层最后抛异常,订单和审计都会回滚。它适用于"这些写入必须同生共死"的普通业务。

java 复制代码
@Transactional
public void required(String message) {
    jdbc.update("INSERT INTO audit_log(message) VALUES (?)", message);
}

一个更隐蔽的情形:内层 REQUIRED 已经把共享事务标记为 rollback-only,外层捕获内层异常后继续正常返回。外层到提交时仍不能真正提交,Spring 会抛 UnexpectedRollbackException,提醒调用方"你以为成功了,其实已经回滚"。只在外层 catch 并不等于清除了回滚标记。Spring 对 REQUIRED 的说明

可以用两个 Bean 做一个小实验:AuditService.requiredThenFail() 先插审计,再抛 IllegalStateException;OrderService.outer() 先插订单,再调用它,并在外层捕获异常后正常返回。因为内层 REQUIRED 与外层共享物理事务,内层失败会把这笔事务标成不可提交;outer() 走到方法末尾时才收到 UnexpectedRollbackException。测试要断言订单和审计都不存在。这个实验说明"捕获内层异常让外层继续"不等于"外层数据可以照常提交"。

REQUIRES_NEW:挂起外层,开启独立事务

REQUIRES_NEW 会为内层方法建立独立的物理事务。审计记录先提交,随后外层订单失败并回滚时,审计可以保留。这适合确实希望"主业务失败也留下失败记录"的场景,但要先决定审计和主业务状态不一致是否符合需求。

java 复制代码
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void requiresNew(String message) {
    jdbc.update("INSERT INTO audit_log(message) VALUES (?)", message);
}

独立事务需要自己的数据库资源。外层连接仍被占用时,内层可能再申请一个连接;并发线程都这么做而连接池过小时,会出现等待甚至耗尽。不要为了"保证日志写入"给所有内部方法一律加 REQUIRES_NEW。Spring 官方也特别提醒了连接池容量问题。Spring 传播行为文档

还要看记录的含义。如果审计行写的是"订单已经成功",却在订单提交前通过 REQUIRES_NEW 独立落库,外层稍后失败时这条审计会留下一个错误结论。若记录的是"处理尝试及失败原因",独立保留才可能合理。传播行为只规定提交关系,不替你定义这条数据代表什么。先把业务语义写清,再选传播行为。

NESTED:同一物理事务里的保存点

NESTED 常通过 JDBC savepoint 工作。内层开始前记录一个保存点;内层失败时可回到保存点,外层在处理异常后继续工作并最终提交。它没有给内层单独创建一个已独立提交的事务:如果外层最后也回滚,整笔事务仍一起撤销。

java 复制代码
@Transactional(propagation = Propagation.NESTED)
public void nestedThenFail(String message) {
    jdbc.update("INSERT INTO audit_log(message) VALUES (?)", message);
    throw new IllegalStateException("nested failure");
}

在本文 JDBC + H2 示例中,外层插入一条订单、调用这个内层方法并捕获异常后,订单保留,审计记录没有留下;对应测试已通过。能否使用 NESTED 取决于具体事务管理器和底层资源是否支持保存点。不要把这段 JDBC 结果直接推断成任何 JPA、远程调用或多数据库组合下都一样。Spring 对 NESTED 的说明

行为 物理事务关系 本文"外层最终失败"时的审计结果 选择依据
REQUIRED 内外层共享同一物理事务 回滚,不留记录 审计与订单必须一致
REQUIRES_NEW 内层独立事务 已提交,可留记录 失败仍要留审计,且能接受资源成本
NESTED 同一物理事务,内层使用保存点 外层若失败仍整体回滚 希望局部失败可恢复,且资源支持保存点

表中最后一行容易误读:如果外层捕获内层异常并正常提交,NESTED 的内层写入可回退、外层写入可保留;如果外层本身最后失败,则整笔物理事务回滚。本文示例与测试分别覆盖了这些具体条件,不把三种传播行为概括成"谁更安全"。

注解写了却没生效,按调用路径排查

最典型的错误是同类自调用:

java 复制代码
@Service
public class ReportService {
    public void run() {
        this.saveOne(); // 同一对象内部调用,没有再次经过事务代理
    }

    @Transactional
    public void saveOne() {
        // 数据库写入
    }
}

如果 run() 自身没有事务,这样调用 saveOne() 不会因为它的注解自动开启新事务;若 run() 已经在事务里,saveOne() 的数据库操作仍可能参加外层事务,但它自己的传播、超时和回滚设置没有经代理单独应用 。修复时通常把 saveOne() 移到另一个 Spring Bean,让调用经过容器注入的代理,或者把事务边界上移到合适的公开业务方法。Spring 官方明确指出默认代理模式下的自调用限制。Spring @Transactional 文档

排查时可以沿着四个问题走:

  1. 拿到的是 Spring Bean 吗? 自己 new 的对象不会因为类上有注解就自动得到 Spring 代理。
  2. 调用经过代理吗? this.method()、初始化阶段调用与外部 Bean 调用的路径不同。
  3. 异常到达事务边界了吗? 在内部被吞掉、转换成普通返回值,就不符合通常的异常回滚路径;受检异常还要看规则。
  4. 数据库访问确实参加这个事务吗? 检查所用数据源、事务管理器和线程上下文。多个数据源时,必须弄清每个方法由哪个管理器负责。

readOnly = true、isolation、timeout 也需要按边界理解。只读是给事务管理器及底层资源的提示或优化条件,不是用来替代数据库权限和写入校验的安全开关;隔离级别取决于数据库支持和当前事务是否新建;超时声明不能保证已经发出的远程请求立即停止。内层 REQUIRED 加入现有事务时,默认参与外层的物理事务,不能以为内层随意改一个 isolation 或 timeout 就能覆盖外层设定。设计时先决定谁创建物理事务,再决定这些属性写在哪一层。Spring 注解设置、传播行为

不要只靠打印"进入了某个方法"判断事务生效。最有说服力的验证是在可控测试库中执行写入、制造失败、再从事务外读取最终状态;同时观察事务日志,核对连接和提交/回滚过程。

事务边界之外:Redis 锁、消息和远程接口

把创建订单写进数据库事务,并不会自动把 Redis 锁、HTTP 请求或消息队列发送纳入同一个本地数据库提交。比如数据库操作尚未提交时,服务已经调用外部支付接口;随后本地事务回滚,外部调用不可能由这个 JDBC 连接替你撤销。反过来,数据库已提交而消息发送失败,也需要单独设计恢复路径。

因此要先明确业务的不变量:订单和库存必须一致,可以由同一个数据库事务保护;同一请求不能重复下单,还需要唯一约束或幂等键;外部动作与本地提交要协调,可能需要 outbox、可重试消息和补偿流程。上一节的 REQUIRES_NEW 也只解决当前进程所管理的另一笔数据库事务,并不是跨系统的原子提交方案。

这里可以把"锁"和"事务"各自的职责分开看:Redis 分布式锁用于降低多个实例同时进入临界区的竞争;数据库事务用于约束同一数据库资源内的一组写入;数据库唯一约束与幂等逻辑负责结果层面的重复防护。三者组合是否足够,要看你的业务是否还涉及外部系统和过期后的旧持有者。

最后用结果反推设计

写事务代码前,不妨先填一张很小的结果表:方法正常返回时哪些记录应存在?异常发生在第几步时哪些应消失?内层失败而外层继续时留下什么?有了预期结果,再选择事务范围、传播行为和回滚规则,比先堆注解再猜运行结果可靠。

本文的订单示例给出三个可核对的结论:扣库存与插订单放在同一事务,运行时异常可以一起回滚;默认 REQUIRED 让内层审计加入外层,REQUIRES_NEW 允许审计独立提交;JDBC 保存点支持下的 NESTED 可以局部回退,但不能独立于外层最终提交。理解代理入口、物理事务和最终数据库状态,才算真正理解了 @Transactional。

参考资料

相关推荐
学长毕设1 小时前
基于SpringBoot的游乐园管理系统-附源码
java·spring boot·后端
SimonKing1 小时前
震惊:怎么回事,opencode一轮对话耗尽免费额度
java·后端·程序员
装不满的克莱因瓶1 小时前
【SpringAI Alibaba】ReactAgent响应SSE实时流式传输案例实战
java·人工智能·ai·sse·springai
杨凯凡1 小时前
Spring Boot 文件上传怎么做才安全?流式落盘、路径校验与数据库一致性实战
java·spring boot·文件上传·后端安全·数据库一致性
这个昵称不容易2 小时前
旅游管理系统的设计与实现(SpringBoot + MySQL + Vue 前后端分离)
java·springboot·毕设
Rain的Java大神之路2 小时前
LeetCode 37 解数独面试攻略:回溯 + 位掩码 + MRV,从暴力到毫秒级求解
java·后端·leetcode
howard20053 小时前
Spring Boot 版成绩统计分析
spring boot·成绩统计分析
天天被压力3 小时前
【A股分钟级高频数据完全手册 #11】券商实时行情数据接口:实时交易(券商源)接入与字段
java·人工智能·python
天天被压力3 小时前
【A股分钟级高频数据完全手册 #12】股票五档盘口数据接口:买卖五档实时盘口解析
java·人工智能·python