代码语言

知识点思维导图

25 个知识节点

Java(16) - Spring 事务与 @Transactional

读完后,你应能完成以下任务:

  • 绘制“Java(16) - Spring 事务与 @Transactional / 事务边界应该放在哪里”的关键对象与数据流,解释“事务通常放在 Service 的公开方法上,因为 Service 表达一个完整业务动作,能够覆盖多个 Mapper 调用。”,并用源码位置、日志或 Trace 标注证据。
  • 为“Java(16) - Spring 事务与 @Transactional / 为什么注解能控制提交和回滚”设计正常与异常输入,验证“方法正常返回就提交,抛出符合规则的异常就回滚。”,输出首个偏差位置与回归测试结果。
  • 实现“Java(16) - Spring 事务与 @Transactional / 回滚规则不要靠猜”的最小代码或配置,检验“如果业务必须返回错误对象,也要先抛到事务边界之外再转换,或明确调用”,输出命令、结果与 Diff,并说明不适用边界。

@Transactional 却没有回滚”的常见问题。

一次业务操作经常不止一条 SQL。例如创建订单时,需要同时写订单、扣库存、记录流水。三步只成功两步,数据库就进入了业务上不允许的中间状态。事务解决的不是“代码报错”,而是保证一组数据库操作要么全部提交,要么全部回滚。

一、事务边界应该放在哪里

事务通常放在 Service 的公开方法上,因为 Service 表达一个完整业务动作,能够覆盖多个 Mapper 调用。

@Service
public class TransferService {
    /** 账户数据访问对象,用于读取和更新余额。 */
    private final AccountMapper accountMapper;

    /**
     * 创建转账服务。
     * @param accountMapper 账户数据访问对象
     */
    public TransferService(AccountMapper accountMapper) {
        this.accountMapper = accountMapper;
    }

    /**
     * 在同一事务中完成扣款和入账。
     * @param fromAccountId 转出账户 ID
     * @param toAccountId 转入账户 ID
     * @param amount 转账金额,必须大于零
     */
    @Transactional(rollbackFor = Exception.class, timeout = 5)
    public void transfer(long fromAccountId, long toAccountId, BigDecimal amount) {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("转账金额必须大于零");
        }

        /** 成功扣减余额的记录数,用于识别余额不足或并发竞争。 */
        int deductedRows = accountMapper.deductIfEnough(fromAccountId, amount);
        if (deductedRows != 1) {
            throw new IllegalStateException("余额不足或账户不存在");
        }

        /** 成功增加余额的记录数,用于确保收款账户真实存在。 */
        int creditedRows = accountMapper.credit(toAccountId, amount);
        if (creditedRows != 1) {
            throw new IllegalStateException("收款账户不存在");
        }
    }
}

关键点不是注解本身,而是边界:校验可以在事务前完成;两条更新必须在同一个事务内;任何一步不满足预期都要抛异常,不能带着错误继续提交。

二、为什么注解能控制提交和回滚

Spring 会为 Bean 创建代理对象。外部调用 transfer() 时,代理先开启事务,再调用真实方法;方法正常返回就提交,抛出符合规则的异常就回滚。

flowchart LR
    A[Controller] --> B[Spring 事务代理]
    B --> C[开启事务]
    C --> D[Service 方法]
    D --> E[Mapper SQL 1]
    D --> F[Mapper SQL 2]
    D -->|正常返回| G[提交]
    D -->|异常抛出| H[回滚]

这也解释了一个高频坑:同一个类中用 this 调用另一个事务方法,没有经过代理,事务配置可能不会生效。

@Service
public class ImportService {
    /**
     * 错误示例:类内部调用绕过 Spring 代理。
     * @param rows 待导入的数据行
     */
    public void importAll(List<ImportRow> rows) {
        this.saveBatch(rows);
    }

    /**
     * 保存一批数据。
     * @param rows 待保存的数据行
     */
    @Transactional(rollbackFor = Exception.class)
    public void saveBatch(List<ImportRow> rows) {
        // 这里的事务可能不会生效,因为调用没有经过代理对象。
    }
}

可靠做法是把事务方法移动到另一个 Spring Bean,由调用方注入后调用;不要通过暴露代理或自注入来掩盖职责混乱。

三、回滚规则不要靠猜

Spring 默认只对 RuntimeExceptionError 回滚。企业项目经常使用受检异常或统一业务异常,因此通常显式声明:

@Transactional(rollbackFor = Exception.class)

下面这种写法会吞掉异常,代理看到方法“正常返回”,于是提交事务:

try {
    paymentMapper.createPayment(payment);
    ledgerMapper.createLedger(ledger);
} catch (Exception exception) {
    log.error("写入失败", exception);
    // 错误:异常被吞掉,事务代理无法感知失败。
}

应该让异常继续抛出;如果业务必须返回错误对象,也要先抛到事务边界之外再转换,或明确调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),但后者会让控制流更难理解,只适合确有必要的兼容场景。

四、传播行为怎么选

传播行为 含义 典型用途 风险
REQUIRED 有事务就加入,没有就新建 默认业务写操作 外层失败会整体回滚
REQUIRES_NEW 挂起外层事务,创建独立事务 必须独立落库的审计记录 连接占用增加,容易误解一致性
SUPPORTS 有事务就加入,没有也可运行 只读查询 调用者可能误以为它强制事务
MANDATORY 必须运行在已有事务中 强制由上层控制边界 单独调用会直接报错

不要为了“保证写成功”随意使用 REQUIRES_NEW。例如订单事务失败,但独立事务中的流水已经提交,反而会制造不一致。只有当数据在业务上允许独立提交时才使用它。

五、隔离级别与并发更新

事务保证原子性,不自动解决所有并发问题。两个请求同时读取余额再各自扣减,仍可能发生丢失更新。金额、库存等场景应优先使用带条件的原子更新:

UPDATE account
SET balance = balance - #{amount}
WHERE id = #{accountId}
  AND balance >= #{amount};

随后检查影响行数是否为 1。复杂计算必须“先查再改”时,可在事务内使用 SELECT ... FOR UPDATE 加行锁,但必须保持固定加锁顺序并缩短事务时间,避免死锁和连接池耗尽。

六、六类不生效问题

  1. 类内部调用this.transactionalMethod() 绕过代理。
  2. 异常被捕获:方法正常返回,代理执行提交。
  3. 回滚类型不匹配:抛出受检异常,但没有配置 rollbackFor
  4. 对象不是 Spring Bean:手动 new 出来的对象没有事务代理。
  5. 事务管理器选错:多数据源项目使用了错误的 transactionManager
  6. 跨线程执行@Async、线程池任务不继承调用线程的事务上下文。

排查时不要只看有没有注解。应打开事务日志,确认调用对象是否为代理、事务是否创建、SQL 使用的连接是否相同,以及最终执行了 commit 还是 rollback。

logging:
  level:
    org.springframework.transaction: DEBUG # 输出事务创建、提交和回滚过程。
    org.springframework.jdbc.datasource: DEBUG # 输出连接获取与释放过程。

七、最小集成测试

事务测试必须验证数据库最终状态,不能只断言“抛出了异常”。

@SpringBootTest
class TransferServiceTest {
    /** 转账业务服务。 */
    @Autowired
    private TransferService transferService;

    /** 账户数据访问对象。 */
    @Autowired
    private AccountMapper accountMapper;

    /** 验证第二步失败时第一步扣款也被回滚。 */
    @Test
    void shouldRollbackDeductionWhenTargetAccountDoesNotExist() {
        /** 转账前的原始余额。 */
        BigDecimal originalBalance = accountMapper.findBalance(1001L);

        assertThrows(
            IllegalStateException.class,
            () -> transferService.transfer(1001L, -1L, new BigDecimal("10.00"))
        );

        /** 失败后的实际余额。 */
        BigDecimal actualBalance = accountMapper.findBalance(1001L);
        assertEquals(0, originalBalance.compareTo(actualBalance));
    }
}

八、上线前检查

  • 事务是否包住一个完整业务动作,而不是包住 Controller 或单条 Mapper。
  • 外部网络调用、文件上传和大模型调用是否移出数据库事务,避免长时间占用连接。
  • 所有失败分支是否抛出可触发回滚的异常。
  • 并发更新是否使用条件更新、行锁或版本号,而不是“先查再裸改”。
  • 多数据源是否明确事务管理器,跨库一致性是否采用消息、Outbox 或补偿机制。
  • 集成测试是否验证了提交和回滚后的真实数据库状态。

掌握这篇后,再进入企业项目代码导读时,就能准确判断 Service 为什么是业务边界,也能看懂 @Transactional、Mapper 调用和数据库一致性之间的关系。

九、总结

  • 事务边界应该放在哪里:事务通常放在 Service 的公开方法上,因为 Service 表达一个完整业务动作,能够覆盖多个 Mapper 调用。
  • 为什么注解能控制提交和回滚:方法正常返回就提交,抛出符合规则的异常就回滚。
  • 回滚规则不要靠猜:如果业务必须返回错误对象,也要先抛到事务边界之外再转换,或明确调用
  • 传播行为怎么选:| 传播行为 | 含义 | 典型用途 | 风险 |
  • 隔离级别与并发更新:随后检查影响行数是否为 1。
  • 六类不生效问题:类内部调用:this.transactionalMethod() 绕过代理。 -> 异常被捕获:方法正常返回,代理执行提交。 -> 回滚类型不匹配:抛出受检异常,但没有配置 rollbackFor。 -> 对象不是 Spring Bean:手动 new 出来的对象没有事务代理。

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“Spring 事务与 @Transactional”中,需要同时满足“事务边界应该放在哪里”与“为什么注解能控制提交和回滚”。给定正文约束“事务通常放在 Service 的公开方法上,因为 Service 表达一个完整业务动作,能够覆盖多个 Mapper 调用。”,哪些判断保持了原有处理机制?多选
2“Spring 事务与 @Transactional”出现偏差:“在“Spring 事务与 @Transactional / 回滚规则不要靠猜”中,即使不满足“企业项目经常使用受检异常或统一业务异常,因此通常显式声明”,结果与副作用仍会保持不变。”已成为实际行为。围绕“回滚规则不要靠猜”与“传播行为怎么选”,哪些判断能定位被改变的职责或边界?多选
3评审“Spring 事务与 @Transactional”方案时,验收条件包含“加行锁,但必须保持固定加锁顺序并缩短事务时间,避免死锁和连接池耗尽。”。关于“隔离级别与并发更新”与“六类不生效问题”的哪些决策符合正文机制?多选