本节目标:理解「事务套事务」时 Spring 如何处理,逐个掌握七种传播行为的语义与选择依据,看清 REQUIRED 与 REQUIRES_NEW 的实测差异,并建立对四种隔离级别与三类并发问题的正确直觉。
适用版本:Spring Boot 4.1.x(Java 21)
14.2 传播行为与隔离级别
14.1 里的借阅流程只有一层事务,问题简单。真实业务中,BorrowService.borrow() 往往还要调用别的 Service——记审计日志、发通知、写积分。当两个都带 @Transactional 的方法碰到一起,Spring 必须回答一个问题:被调用的方法,是加入调用方的事务,还是另起一个? 这就是传播行为(Propagation)。本节从这个问题出发,把七种传播行为讲透,再看隔离级别。
14.2.1 传播行为要解决什么问题
给借阅流程加一个审计需求:无论借书成功还是失败,都要写一条「借阅尝试」记录,方便事后追溯风控。审计服务长这样:
package com.example.library.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuditService {
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional
public void recordAttempt(Long readerId, Long bookId, String result) {
auditLogRepository.save(new AuditLog(readerId, bookId, result));
}
}
BorrowService.borrow() 在开头调用它:
@Transactional(rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) {
auditService.recordAttempt(readerId, bookId, "START");
Book book = bookRepository.findById(bookId).orElseThrow();
if (book.getStock() <= 0) {
throw new IllegalStateException("库存不足: " + bookId); // 触发外层回滚
}
book.setStock(book.getStock() - 1);
bookRepository.save(book);
recordRepository.save(new BorrowRecord(readerId, bookId));
}
问题来了:库存不足时外层回滚,那条「START」审计记录会怎样?如果 recordAttempt 加入外层事务,它会跟着一起回滚,审计记录消失——这与「无论成败都要留痕」的需求相反。要让它独立提交,就得改传播行为。这就是传播行为存在的意义。
14.2.2 七种传播行为总览
@Transactional(propagation = ...) 取值来自 Propagation 枚举,共七种:
| 传播行为 | 当前无事务时 | 当前有事务时 | 典型用途 |
|---|---|---|---|
REQUIRED(默认) | 新建事务 | 加入现有事务 | 绝大多数业务方法 |
REQUIRES_NEW | 新建事务 | 挂起现有事务,新建 | 日志、审计必须独立提交 |
SUPPORTS | 以非事务方式执行 | 加入现有事务 | 可选事务的查询方法 |
NOT_SUPPORTED | 以非事务方式执行 | 挂起现有事务,非事务执行 | 不需要事务的耗时操作 |
MANDATORY | 抛异常 | 加入现有事务 | 强制要求调用方已有事务 |
NEVER | 以非事务方式执行 | 抛异常 | 明确禁止在事务中执行 |
NESTED | 新建事务 | 在现有事务内建保存点 | 内层失败不影响外层 |
下面逐个说明,重点是「什么时候用」和「和默认行为的差别」。
14.2.3 REQUIRED:默认行为
REQUIRED 是默认值,@Transactional 不写 propagation 时就是它。规则只有一句:有事务就加入,没有就新建。
@Transactional(propagation = Propagation.REQUIRED)
public void recordAttempt(Long readerId, Long bookId, String result) {
auditLogRepository.save(new AuditLog(readerId, bookId, result));
}
在上面的场景里,recordAttempt 会加入 borrow 的事务,两者是同一个事务:外层回滚,审计记录一起消失;外层提交,审计记录一起落库。绝大多数业务方法都用它——你希望「一次业务动作的所有写操作同生共死」,这正是默认值合理的原因。
14.2.4 REQUIRES_NEW:挂起当前,另起一个
REQUIRES_NEW 总是新建一个事务,并把当前事务挂起(suspend)。两个事务各自独立提交或回滚:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAttempt(Long readerId, Long bookId, String result) {
auditLogRepository.save(new AuditLog(readerId, bookId, result));
}
改动只有这一处,但行为完全不同:recordAttempt 在自己的事务里执行并提交,不受外层 borrow 回滚影响。库存不足导致借阅失败时,那条「START」审计记录仍然留在库里。
代价也要清楚:REQUIRES_NEW 会占用两条数据库连接(外层一条被挂起、新事务再借一条),并发高时可能加剧连接池压力。它是「日志/审计/通知必须留痕」这类需求的专用手段,不要滥用。
14.2.5 SUPPORTS:有就加入,没有就算了
SUPPORTS 表示「我支持事务,但不强求」:调用方有事务就加入,没有就以非事务方式执行。
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
public Optional<Book> findById(Long id) {
return bookRepository.findById(id);
}
适用场景是那些「被事务方法调用时希望共享事务、被非事务方法调用时也能正常跑」的查询方法。它不新建事务,因此不会带来额外开销。日常业务里用得不多,但它是「可选事务」的准确表达。
14.2.6 NOT_SUPPORTED:挂起事务,非事务执行
NOT_SUPPORTED 与 SUPPORTS 相反:它挂起当前事务,以非事务方式执行方法体。
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendSlowNotification(Long readerId, String message) {
// 发短信/邮件,耗时且不希望占用事务连接
}
用途是「这段逻辑不该被包在事务里」——比如一个耗时的外部通知,如果加入事务会长时间占着连接和锁。注意它同样会挂起外层事务、额外占用连接,且方法内的数据库操作不会回滚,要谨慎。
14.2.7 MANDATORY:必须有事务,否则报错
MANDATORY 要求调用方已经处于事务中,否则直接抛 IllegalTransactionStateException:
@Transactional(propagation = Propagation.MANDATORY)
public void decreaseQuota(Long readerId) {
// 必须在事务里被调用,否则报错
}
它的价值是契约保护:用于「绝不允许脱离事务单独调用」的方法。如果被误用(比如被一个没有事务的 Controller 直接调用),会立刻在启动期或调用期暴露错误,而不是静默地产生不一致数据。
14.2.8 NEVER:禁止事务
NEVER 是 MANDATORY 的反面:如果调用方已有事务,则抛异常:
@Transactional(propagation = Propagation.NEVER)
public void refreshCache() {
// 明确不能在事务中执行
}
现实中很少用,但它的存在让「这个方法一定不能在事务里跑」成为可执行的约束,比写在注释里可靠。
14.2.9 NESTED:保存点机制
NESTED 在现有事务内部创建一个保存点(Savepoint)。它看起来像「嵌套事务」,但本质是同一个物理事务:
- 外层事务回滚,内层一定回滚;
- 内层回滚到保存点,不影响外层(外层可以继续执行并最终提交);
- 若当前没有事务,
NESTED等同于REQUIRED,新建一个事务。
@Transactional(rollbackFor = Exception.class)
public void borrowWithOptionalGift(Long readerId, Long bookId) {
// 主流程
recordRepository.save(new BorrowRecord(readerId, bookId));
try {
giftService.giveCoupon(readerId); // 内部用 NESTED
} catch (Exception ex) {
// 送券失败,但借阅主流程继续
}
}
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
public void giveCoupon(Long readerId) {
couponRepository.save(new Coupon(readerId));
}
送券失败时,只有保存点之后的操作回滚,借阅记录照常提交。这正是 NESTED 与 REQUIRES_NEW 的关键差别:REQUIRES_NEW 是两个物理事务(内层成功即提交,外层再回滚也追不回来),NESTED 是一个物理事务里的保存点(外层回滚则内层必被带走)。
NESTED 需要数据库支持保存点(主流数据库都支持),且它只对 DataSourceTransactionManager(JDBC)生效,JPA 的 JpaTransactionManager 对 NESTED 的支持有限——这一点在实际使用时要留意。
14.2.10 REQUIRED vs REQUIRES_NEW 实测对比
回到 14.2.1 的场景,用同一段「库存不足」的失败流程,把 recordAttempt 的传播行为分别设为 REQUIRED 与 REQUIRES_NEW,观察 audit_log 表:
| 传播行为 | 借阅结果 | book.stock | borrow_record | audit_log |
|---|---|---|---|---|
REQUIRED | 失败(库存不足) | 不变 | 无 | 无(随外层回滚) |
REQUIRES_NEW | 失败(库存不足) | 不变 | 无 | 有(独立提交) |
两次调用唯一的差别就是那一行 propagation = Propagation.REQUIRES_NEW。这正是「主流程回滚、日志必须留存」的标准解法。
反过来的场景也要知道:如果审计失败必须导致借阅失败(比如合规要求「没记上日志就不能借」),那就该用 REQUIRED,让两者绑定,审计失败时借阅一起回滚。
14.2.11 四种隔离级别与三类并发问题
传播行为管的是「事务之间怎么组合」,隔离级别管的是「并发事务之间能互相看到多少」。先看三类经典并发问题:
| 问题 | 现象 | 例子 |
|---|---|---|
| 脏读(Dirty Read) | 读到了别的事务尚未提交的数据 | 读到另一事务中途修改、随后回滚的库存 |
| 不可重复读(Non-Repeatable Read) | 同一事务内两次读同一行,结果不同 | 第一次读到库存 5,第二次读到 4(别人改了并提交) |
| 幻读(Phantom Read) | 同一事务内两次范围查询,行数不同 | 第一次查出 3 本可借,第二次查出 4 本(别人插入了) |
四种隔离级别对它们的容忍度如下(可能 = 该级别允许此问题发生):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
READ_UNCOMMITTED | 可能 | 可能 | 可能 |
READ_COMMITTED | 不可能 | 可能 | 可能 |
REPEATABLE_READ | 不可能 | 不可能 | 可能 |
SERIALIZABLE | 不可能 | 不可能 | 不可能 |
级别越高,一致性越强、并发性能越差。SERIALIZABLE 让事务串行执行,最安全也最慢,生产环境几乎不用。
14.2.12 实际工作中怎么选
给初学者的结论很直接:绝大多数情况不要动隔离级别,用数据库默认值。
| 数据库 | 默认隔离级别 |
|---|---|
| MySQL(InnoDB) | REPEATABLE_READ |
| PostgreSQL | READ_COMMITTED |
| Oracle | READ_COMMITTED |
| SQL Server | READ_COMMITTED |
理由:
- 默认级别是数据库厂商基于自身实现权衡后的结果,通常够用;
- 隔离级别由数据库实现,改它可能引入锁等待、死锁甚至性能骤降,需要实测而非凭直觉;
- 真正需要「防并发写坏」的地方,往往用乐观锁(
@Version)或唯一约束解决,比调高隔离级别更精准、代价更小。
如果确实要指定,写在方法上即可:
@Transactional(isolation = Isolation.REPEATABLE_READ, rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) {
// ...
}
记住 isolation 只是把参数透传给数据库连接,不改变数据库本身的行为。
14.2.13 事务与连接池:同一事务同一连接
一个容易被忽略但很关键的机制:同一个事务内的所有数据库操作,用的是同一条 JDBC 连接。
事务开始时,Spring 从连接池(第 12 章的 HikariCP)借一条连接,把它绑定到当前线程;事务提交或回滚后,连接才归还。因此:
- 事务方法内多次
repository.save(),走的是同一条连接、同一个物理事务; - 这也解释了为什么
REQUIRES_NEW或NOT_SUPPORTED会多占一条连接——它们挂起外层事务,另起一个,于是同时持有两条连接; - 连接池大小(
spring.datasource.hikari.maximum-pool-size)因此与并发事务数直接相关。若一个请求里嵌套了多层REQUIRES_NEW,连接消耗会成倍增长,极端情况下把连接池占满、请求全部阻塞。
入门阶段的实践建议:能用一个事务解决就不要拆成多个;确实需要 REQUIRES_NEW 时,控制它的粒度,避免在循环里调用。
小结
- 传播行为解决「事务套事务」时的归属问题,默认
REQUIRED:有则加入、无则新建,绝大多数业务方法用它。 REQUIRES_NEW挂起当前事务、另起一个并独立提交,用于「日志/审计必须留存」;代价是额外占用一条连接。SUPPORTS/NOT_SUPPORTED分别表示「可选事务」与「挂起事务、非事务执行」;MANDATORY/NEVER是两种强制约束,靠抛异常保护契约。NESTED用的是保存点,属于同一个物理事务:内层回滚不影响外层,外层回滚会带走内层;与REQUIRES_NEW的差别在于物理事务的数量。- 四种隔离级别对应三类并发问题(脏读、不可重复读、幻读);实际工作中不要随意改隔离级别,需要防并发写坏时优先用乐观锁或唯一约束。
- 同一事务使用同一条连接;
REQUIRES_NEW等多事务会同时占用多条连接,与连接池大小直接相关。
阅读导航:上一节:14.1 @Transactional 基本用法 · 下一节:14.3 事务失效的常见场景 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。