CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/事务管理
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

事务管理

@Transactional、传播行为、rollback 规则。

30 分钟

事务管理

这一课你将能做到

  1. 用 @Transactional 实现「多步写操作同生共死」,并亲手验证回滚
  2. 正确使用 rollbackFor、传播行为(REQUIRED / REQUIRES_NEW)处理「部分要回滚、部分要保留」的场景
  3. 亲手复现三个经典的事务失效场景,并知道每种失效的修复方式

为什么要学

转账场景:A 扣 100 元、B 加 100 元,两步 UPDATE 之间如果程序崩了,钱凭空消失。事务保证一组操作要么全部成功、要么全部撤销——这是数据库系统的底线能力,也是后端面试的重灾区。

Spring 的 @Transactional 把事务控制从「手动开连接、commit、rollback」变成一个注解,但它建立在前几课的知识之上:事务底层是 AOP 代理(第 6 课),注解失效的根源全是「代理没接管调用」。学懂这课,你能解决 90% 的「数据不一致」线上问题。

先建立图像

@Transactional 方法被调用
        ↓
AOP 代理拦截(不是你的方法先执行!)
        ↓
① 从连接池借连接,关闭自动提交
        ↓
② 执行你的方法体(所有 SQL 共用这一个连接/事务)
        ↓
③ 分岔:
   正常返回 → commit(提交)
   抛出 RuntimeException/Error → rollback(回滚)
   抛出受检异常 → 默认不回滚(大坑,见步骤 2)
        ↓
④ 连接归还池子

事务的四个经典特性(ACID):原子性(全成或全不成)、一致性(转账前后总金额不变)、隔离性(并发事务互不干扰的程度)、持久性(提交后落盘)。@Transactional 主要操控「原子性」(回滚规则)和「隔离性」(隔离级别)。

跟着做

步骤 1:转账例子——最直观的原子性

先在 schema.sql 补一张账户表(沿用第 9 课的 H2 环境):

CREATE TABLE IF NOT EXISTS account (
    id      BIGINT AUTO_INCREMENT PRIMARY KEY,
    name    VARCHAR(20) NOT NULL,
    balance DECIMAL(12, 2) NOT NULL DEFAULT 0
);

INSERT INTO account(name, balance) VALUES ('alice', 1000), ('bob', 500);

转账 Service(JdbcTemplate 版,最贴近事务本质):

package com.example.taskflow.service;

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

import java.math.BigDecimal;

@Service
public class TransferService {

    private final JdbcTemplate jdbc;

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

    @Transactional                                  // 关键注解
    public void transfer(long fromId, long toId, BigDecimal amount) {
        int updated = jdbc.update(
                "UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?",
                amount, fromId, amount);
        if (updated == 0) {
            throw new BusinessException(ErrorCode.BALANCE_NOT_ENOUGH);   // 余额不足
        }
        jdbc.update("UPDATE account SET balance = balance + ? WHERE id = ?", amount, toId);

        // 故意制造异常验证回滚:取消注释后,alice 已扣款但 bob 不会加钱——重启后数据完全不变
        // if (true) throw new IllegalStateException("模拟转账后半程崩溃");
    }
}

验证回滚:放开那行异常,调用转账接口,接口报 500,然后查表——alice 的余额一分没少。因为整个方法在一个事务里,异常触发整体回滚。删掉那行,转账正常。

步骤 2:rollbackFor——受检异常默认不回滚

Spring 的默认回滚规则:只回滚 RuntimeException 和 Error。受检异常(如 IOException、自定义的 extends Exception)默认照样提交:

// 危险:抛受检异常时,已执行的扣款会被提交!
@Transactional
public void transferChecked(long fromId, long toId, BigDecimal amount) throws Exception {
    jdbc.update("UPDATE account SET balance = balance - ? WHERE id = ?", amount, fromId);
    if (amount.compareTo(new BigDecimal("10000")) > 0) {
        throw new Exception("金额超限");      // 受检异常 → 默认不回滚 → 钱已扣!
    }
    jdbc.update("UPDATE account SET balance = balance + ? WHERE id = ?", amount, toId);
}

企业规范写法:写操作的事务一律显式声明 rollbackFor:

@Transactional(rollbackFor = Exception.class)
public void transferSafe(long fromId, long toId, BigDecimal amount) throws Exception {
    // 现在任何异常都会回滚
}

记忆口诀:默认规则记「运行时才回滚」,代码就按 rollbackFor = Exception.class 写,一步到位消除歧义。

步骤 3:传播行为——事务怎么「嵌套」

场景:转账成功后要写审计日志;但转账失败回滚时,审计日志必须留下来(记录失败也是审计)。这需要两个操作在「不同的事务」里。

// 外层:REQUIRED(默认)。已有事务就加入,没有就新建
@Transactional(rollbackFor = Exception.class)
public void transferWithAudit(long fromId, long toId, BigDecimal amount) {
    try {
        transferCore(fromId, toId, amount);      // 核心转账
        auditService.record("转账成功", fromId);
    } catch (Exception e) {
        auditService.record("转账失败: " + e.getMessage(), fromId);   // 失败也要留痕
        throw e;                                  // 继续抛出让外层感知失败
    }
}

// 内层:REQUIRES_NEW。挂起当前事务,开一个全新事务执行,互不影响
@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void record(String content, long accountId) {
        jdbc.update("INSERT INTO audit_log(content, account_id, created_at) VALUES (?, ?, NOW())",
                content, accountId);
        // 这个事务在 record 返回时就已提交,外层之后怎么回滚都动不了它
    }
}

常用传播行为速查(面试至少掌握前三个):

传播行为人话场景
REQUIRED(默认)有事务就加入,没有就新建绝大多数业务方法
REQUIRES_NEW挂起现有事务,自己开新的审计日志、操作流水(必须独立提交)
NESTED在当前事务里开保存点,可单独回滚部分失败可容忍的批量操作
SUPPORTS有事务就加入,没有就非事务跑只读查询
NOT_SUPPORTED挂起事务,非事务执行大批量读取,不想占事务连接

注意:内层用 REQUIRES_NEW 时,外层的操作对外不可见(隔离),内层查不到外层未提交的数据,设计时别让两边互相依赖。

步骤 4:失效场景一——自调用(self-invocation)

@Service
public class OrderService {

    @Transactional(rollbackFor = Exception.class)
    public void batchCreate(List<String> titles) {
        for (String t : titles) {
            this.createOne(t);          // 事务失效!
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public void createOne(String title) {
        jdbc.update("INSERT INTO task(title, priority, status) VALUES (?, 3, 'TODO')", title);
        // 中间某次抛异常时,batchCreate 没有事务保护,已插入的不会回滚
    }
}

原因与第 6 课 AOP 完全同源:this.createOne(...) 走的是原始对象,代理的「开事务/提交/回滚」逻辑根本没机会执行。修复:把内层逻辑挪到另一个 Service 注入调用,或通过自身代理调用(self.createOne(...))。判断口诀:@Transactional 必须由「外部调用」触发。

步骤 5:失效场景二与三——吞异常与非 public

// 场景二:catch 吞掉异常,Spring 根本不知道出错
@Transactional(rollbackFor = Exception.class)
public void transferSwallow(long fromId, long toId, BigDecimal amount) {
    try {
        jdbc.update("UPDATE account SET balance = balance - ? WHERE id = ?", amount, fromId);
        throw new RuntimeException("中途出错");
    } catch (Exception e) {
        log.error("出错了但我假装没事", e);      // 异常没抛出去 → 正常提交 → 脏数据!
    }
}

修复:要么不 catch(交给全局异常处理器),要么 catch 后手动标记回滚:

} catch (Exception e) {
    log.error("业务失败", e);
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();  // 手动回滚
}
// 场景三:注解标在非 public 方法上,静默失效(无任何报错!)
@Transactional
protected void doTransfer(...) { }      // protected/private 方法:代理无法增强,事务不生效

修复:事务方法一律 public(Spring 6 的 @Transactional 显式声明了这个约束)。三个场景共同点:注解加了、报错没有、事务悄悄失效——这是它比空指针更危险的地方。

步骤 6:readOnly、timeout 与加层纪律

@Transactional(readOnly = true, timeout = 5, rollbackFor = Exception.class)
public List<Task> complexReport() {
    // readOnly:Hibernate 关脏检查 + 驱动只读优化;timeout:5 秒未完成强制回滚,防慢 SQL 拖垮连接池
}

加层纪律(项目规范级):事务注解只加在 Service 层;Controller 不加(它不该知道事务边界);DAO 层不加(粒度太碎);事务方法保持短小,别在事务里调 HTTP 接口、发消息队列——远程调用会把数据库连接占住几秒,连接池瞬间耗尽。

讲清楚要点

  1. @Transactional = AOP 代理 + ThreadLocal 绑定连接:代理在方法前从池子借连接存进 ThreadLocal,之后同线程的 MyBatis/JPA 都取这个连接,这就是「一个方法一个事务」的实现机制。理解了它,所有失效场景都能自己推导出来。
  2. 默认回滚规则是半个坑:RuntimeException/Error 回滚、受检异常不回滚。统一写 rollbackFor = Exception.class 消除歧义,团队规范应强制。
  3. 传播行为回答「多个事务方法相遇怎么办」:默认 REQUIRED 让调用链共享一个事务(全成全败);REQUIRES_NEW 开独立事务(日志类旁路)。选错的代价:该留的日志没了,或该回滚的数据没回滚。
  4. 失效三连自查表:自调用(this 调用)、吞异常(catch 不抛)、非 public 方法。线上「数据不一致但没报错」,先按这张表排查。
  5. 事务边界 = 业务语义边界:「完成一次下单」是一个事务,不是「一次 SQL」是一个事务。事务内只放数据库操作,远程调用、耗时计算移出去。

你可能会懵的地方

  • @Transactional 加在类上和方法上有什么区别? 方法上的优先;类上标则类内所有 public 方法生效。混用没问题,但建议只标方法——读代码的人一眼看到事务边界。

  • private 方法为什么不能加 @Transactional? CGLIB 代理通过子类覆写增强方法,private/protected/final 方法无法被覆写,代理只能原样执行——注解等于没写,而且没有任何警告。

  • 事务里发 HTTP 请求/发 MQ 有什么问题? 数据库连接被占满远程调用的时长(可能几秒),并发一高连接池耗尽全站卡死;且远程成功、本地回滚会造成「消息已发、数据没落」的最终不一致。做法:事务内只写一张「待发送表」,事务提交后再发(本地消息表模式)。

  • 隔离级别什么时候需要改? 默认数据库给的级别(MySQL 是 REPEATABLE READ)已满足绝大多数场景。只有出现「读到不该读的数据」(脏读/不可重复读/幻读)的具体故障时,才针对性调高;调高的代价是并发性能下降,@Transactional(isolation = Isolation.READ_COMMITTED) 按方法覆盖。

  • 同一个类里方法 A(无事务)调用方法 B(有事务)为什么失效,注入自身代理就行? 自注入拿到的也是代理对象,self.B() 经过代理 → 事务开启。这就是第 4 课循环依赖里 @Lazy 自注入的合法用例之一,但优先考虑拆类。

错法对照

错法问题正解
事务方法内 try-catch 吞异常事务照常提交,脏数据入库重抛或 setRollbackOnly
@Transactional 标在 private 方法静默失效,最阴险的 bug事务方法一律 public
同类 this 调用带事务的方法事务不生效拆类或经代理调用
默认注解不写 rollbackFor受检异常不回滚统一 rollbackFor = Exception.class
事务里调第三方接口连接被占,池耗尽事务外调用,或本地消息表异步化

卡点急救

  1. 数据不一致但日志无报错:按失效三连排查——自调用?吞异常?非 public?再确认异常类型在回滚规则内(受检异常默认不回滚)。
  2. Transaction silently rolled back because it has been marked as rollback-only:内层 REQUIRED 方法抛了异常又被外层 catch,内层已把共享事务标记为只回滚,外层再提交就报这个错。修法:内层异常要么别 catch,要么内层改 REQUIRES_NEW。
  3. 高并发下 Connection is not available, request timed out:连接池耗尽。查两样——事务方法里有没有远程调用/慢 SQL;hikari.maximum-pool-size 是否过小;用 actuator 的 metrics 观察活跃连接数(第 15 课)。
  4. 多数据源事务错乱:@Transactional 默认绑主数据源,第二个库的操作不在事务里。多数据源场景需要指定事务管理器(@Transactional("bizTxManager")),初学阶段先保证单库。

面试常问

  • Spring 事务的实现原理? AOP 动态代理:代理对象拦截 public 方法 → 开启事务并把连接绑定到 ThreadLocal → 执行目标方法 → 按回滚规则决定 commit/rollback。因此「不经过代理的调用」一律失效。

  • @Transactional 事务失效的场景列举? 自调用(this)、非 public 方法、catch 吞异常、rollbackFor 未覆盖的受检异常、类没被 Spring 管理(new 出来的)、多线程(事务绑定 ThreadLocal,子线程不在事务内)、传播行为配错(NOT_SUPPORTED 等)。能按「代理机制」逐一推导是最强答法。

  • REQUIRED 和 REQUIRES_NEW 的区别与使用场景? REQUIRED 加入当前事务,一起提交回滚,用于主业务链;REQUIRES_NEW 挂起当前事务独立执行,用于审计日志、流水记录等「无论成败都要落库」的旁路操作。代价是连接占用翻倍,注意嵌套深度。

  • Spring 事务和数据库事务的关系? Spring 事务是抽象层,底层委托给具体平台(DataSourceTransactionManager 对 JDBC/MyBatis,JpaTransactionManager 对 JPA);隔离级别、锁行为最终由数据库实现。Spring 管「边界与回滚规则」,数据库管「并发正确性」。

自测三问

  1. Spring 默认回滚哪些异常?为什么团队规范要求统一写 rollbackFor = Exception.class?
  2. 「转账失败但审计日志必须保留」用哪个传播行为?为什么 REQUIRED 做不到?
  3. 事务失效三连是哪三个场景?共同根源是什么?

小结一句话

@Transactional 用代理在方法边界织入「开事务—提交/回滚」,而代理机制决定了它的铁律:外部调用、public 方法、异常必须抛出——三条全守住,事务才真实存在。

延伸

  • Spring 官方文档:Transaction Management
  • MyBatis 与 Spring 事务集成说明
  • 下一课:Spring Security 基础——接口的第一道门卫:认证与授权。
加载本节练习…
← 上一课Spring Data JPA 入门下一课 →Spring Security 基础