CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
事务管理
这一课你将能做到
用 @Transactional 实现「多步写操作同生共死」,并亲手验证回滚
正确使用 rollbackFor、传播行为(REQUIRED / REQUIRES_NEW)处理「部分要回滚、部分要保留」的场景
亲手复现三个经典的事务失效场景,并知道每种失效的修复方式
为什么要学
转账场景: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 接口、发消息队列 ——远程调用会把数据库连接占住几秒,连接池瞬间耗尽。
讲清楚要点
@Transactional = AOP 代理 + ThreadLocal 绑定连接 :代理在方法前从池子借连接存进 ThreadLocal,之后同线程的 MyBatis/JPA 都取这个连接,这就是「一个方法一个事务」的实现机制。理解了它,所有失效场景都能自己推导出来。
默认回滚规则是半个坑 :RuntimeException/Error 回滚、受检异常不回滚。统一写 rollbackFor = Exception.class 消除歧义,团队规范应强制。
传播行为回答「多个事务方法相遇怎么办」 :默认 REQUIRED 让调用链共享一个事务(全成全败);REQUIRES_NEW 开独立事务(日志类旁路)。选错的代价:该留的日志没了,或该回滚的数据没回滚。
失效三连自查表 :自调用(this 调用)、吞异常(catch 不抛)、非 public 方法。线上「数据不一致但没报错」,先按这张表排查。
事务边界 = 业务语义边界 :「完成一次下单」是一个事务,不是「一次 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 事务里调第三方接口 连接被占,池耗尽 事务外调用,或本地消息表异步化
卡点急救
数据不一致但日志无报错 :按失效三连排查——自调用?吞异常?非 public?再确认异常类型在回滚规则内(受检异常默认不回滚)。
Transaction silently rolled back because it has been marked as rollback-only :内层 REQUIRED 方法抛了异常又被外层 catch,内层已把共享事务标记为只回滚,外层再提交就报这个错。修法:内层异常要么别 catch,要么内层改 REQUIRES_NEW。
高并发下 Connection is not available, request timed out :连接池耗尽。查两样——事务方法里有没有远程调用/慢 SQL;hikari.maximum-pool-size 是否过小;用 actuator 的 metrics 观察活跃连接数(第 15 课)。
多数据源事务错乱 :@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 管「边界与回滚规则」,数据库管「并发正确性」。
自测三问
Spring 默认回滚哪些异常?为什么团队规范要求统一写 rollbackFor = Exception.class?
「转账失败但审计日志必须保留」用哪个传播行为?为什么 REQUIRED 做不到?
事务失效三连是哪三个场景?共同根源是什么?
小结一句话 @Transactional 用代理在方法边界织入「开事务—提交/回滚」,而代理机制决定了它的铁律:外部调用、public 方法、异常必须抛出——三条全守住,事务才真实存在。
延伸