本节目标:把
@Transactional的边界画对——知道它该落在哪一层、为什么自调用会失效、只读事务和超时到底管什么,以及事务里哪些事绝对不能做。
适用版本:Spring Boot 4.1.x(Java 21)
12.1 事务边界设计
入门卷讲过 @Transactional 的基本用法:加在方法上,抛异常回滚。但生产上的事故几乎都不是「没加注解」,而是边界画错了——注解加在了不生效的位置,或者边界太宽,把远程调用、消息发送、文件读写都圈进了同一个事务。本节用 book-loan 的「借书」操作,把边界这件事讲透。
先对齐一个事实:@Transactional 的实现机制是代理。Spring 在启动时为带事务注解的 bean 生成一个代理对象,调用方拿到的是代理;代理在方法进入前开启事务、退出时提交或回滚。理解这一点,后面所有「失效」现象都能自己推导出来。
12.1.1 边界该画在哪一层
三层里只有一层适合放事务:
| 层 | 放不放事务 | 原因 |
|---|---|---|
| Controller | 不放 | 事务应只包住一次业务操作;Controller 还要做参数校验、序列化、拼响应,把它们圈进事务会拉长持锁时间 |
| Repository | 不放 | 一个业务操作往往要跨多个 Repository 调用,单个 Repository 方法各自一个事务等于没有原子性 |
| Service | 放 | 一次业务操作的完整原子边界,通常是「读实体 → 改状态 → 写记录」这一串 |
借书的正确边界就落在 Service 的一个方法上:
@Service
class LoanService {
private final BookRepository bookRepository;
private final LoanRepository loanRepository;
LoanService(BookRepository bookRepository, LoanRepository loanRepository) {
this.bookRepository = bookRepository;
this.loanRepository = loanRepository;
}
@Transactional
public LoanResponse borrow(Long bookId, Long memberId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new BookNotFoundException(bookId));
if (book.getAvailableCopies() <= 0) {
throw new BookNotAvailableException(bookId);
}
book.decrementCopies();
Loan loan = Loan.create(book, memberId, Instant.now());
loanRepository.save(loan);
return LoanResponse.from(loan);
}
}
这里「扣库存」和「插借阅记录」必须在同一个事务里:扣了库存却没插记录,书就凭空少了一本;插了记录却没扣库存,同一本书能被无限借。
把事务注解加到 Controller 上,会把 borrow 调用、响应序列化、甚至异常映射全部包进事务。序列化通常很快,但只要 Controller 里多做了任何一次 I/O(写审计日志、调用户中心),事务的持锁时间就被这些无关操作拉长,数据库连接也占得更久。连接池只有几十个连接,这类「事务边界过宽」会直接表现为连接池打满。
加到 Repository 上则相反——边界太碎。bookRepository.save(book) 自带一个事务,loanRepository.save(loan) 自带另一个,两者之间崩溃就产生不一致。Spring Data 的 Repository 方法默认已经是 @Transactional(写操作)或只读事务,不需要再手动加。
12.1.2 自调用与 private 方法为什么会失效
这是面试和排障的高频点,根源就是「代理」。看一段错误代码:
@Service
class LoanService {
public LoanResponse borrowAndNotify(Long bookId, Long memberId) {
LoanResponse loan = this.borrow(bookId, memberId); // 自调用
notificationService.send(loan);
return loan;
}
@Transactional
public LoanResponse borrow(Long bookId, Long memberId) {
// ...
}
}
borrowAndNotify 里用 this.borrow(...) 调用。此时 this 是原始对象,不是 Spring 注入到别处的代理对象,所以 @Transactional 的拦截逻辑根本没机会执行——borrow 在一个「没有事务」的上下文里运行。这类问题在代码里完全看不出来,只有并发写脏数据时才暴露。
同理,private 方法和 final 方法上的 @Transactional 也不会生效:CGLIB 代理通过子类覆盖来拦截,private 无法被子类覆盖,final 不允许覆盖。static 方法同理。
修法有三条,按推荐顺序:
@Service
class LoanService {
private final LoanService self; // 注入自己(拿到的仍是代理)
private final NotificationService notificationService;
LoanService(LoanService self, NotificationService notificationService) {
this.self = self;
this.notificationService = notificationService;
}
public LoanResponse borrowAndNotify(Long bookId, Long memberId) {
LoanResponse loan = self.borrow(bookId, memberId); // 走代理
notificationService.send(loan);
return loan;
}
@Transactional
public LoanResponse borrow(Long bookId, Long memberId) {
// ...
}
}
- 注入自身:
self是代理,self.borrow会经过拦截。缺点是多了一层间接调用。 - 拆到另一个 bean:把
borrow放到独立的LoanWriteService,由调用方注入——最干净,也是大项目里的默认做法。 TransactionTemplate编程式:见 12.1.5,不依赖代理,边界显式可见。
注意 @Transactional 生效的前提是「调用来自外部 bean」。只要记住「注解靠代理拦截,this 调用绕过代理」,这类问题就能一眼识别。
12.1.3 只读事务:不是为了「只读校验」,是为了省掉脏检查
查询方法加 @Transactional(readOnly = true) 常被当成可有可无的礼节,其实它有两个实际收益:
- Hibernate 侧:
readOnly = true时,Spring 的 JPA 方言会把 Hibernate 会话的 flush 模式设为MANUAL(HibernateJpaDialect的prepareFlushMode行为,已在本机spring-orm-7.0.9.jar核实)。这意味着查询事务结束时不会触发脏检查与自动 flush。对一次加载大量实体的查询,这能省掉一轮全量快照对比。 - 连接侧:事务管理器会把只读标志传给底层连接(
Connection.setReadOnly),为数据库路由(如读写分离的从库)留出信号。
@Service
class BookQueryService {
@Transactional(readOnly = true)
public Page<BookResponse> search(String keyword, Pageable pageable) {
return bookRepository.findByTitleContaining(keyword, pageable)
.map(BookResponse::from);
}
}
要诚实说明它的边界:readOnly = true 不会阻止你写。它只是一个提示,Hibernate 不会因此抛异常;如果你在只读事务里改了实体,改动可能因为 flush 模式是 MANUAL 而静默丢失,或者等到事务外某次 flush 才落库,行为很反直觉。所以只读事务是给「确定只读」的查询用的,不要拿它当安全网。
12.1.4 事务里绝对不能做的事
事务一旦开启,就占着数据库连接,并且可能持有行锁。下面三类操作放进事务,是生产事故的常见来源:
| 操作 | 危害 | 正确做法 |
|---|---|---|
| 远程调用(HTTP / RPC) | 网络慢则事务长时间不提交,连接与锁被占死;被调方若也写同一批数据,形成跨系统死锁 | 移出事务,在事务提交之后调用 |
| 发消息(MQ) | 消息可能先于事务提交被消费,消费者读不到未提交的数据 | 用本地消息表 + 补偿,或事务提交后发送(见 12.3) |
| 读写大文件 / 大对象 | 事务内做耗时 I/O,持锁时间不可控 | 先落临时存储,事务里只写指针 |
一个具体对照。错误写法把通知发在事务里:
@Transactional
public LoanResponse borrow(Long bookId, Long memberId) {
// ... 扣库存、插记录 ...
notificationClient.send(loan); // 远程调用在事务内
return LoanResponse.from(loan);
}
notificationClient.send 若耗时 800 ms,这 800 ms 里数据库连接不释放、book 行的锁不释放。并发一上来,连接池先被打满。更糟的是:如果消息已经发出,随后事务回滚,用户收到了「借书成功」但数据库里根本没这条记录。
正确写法是把通知移到事务提交之后。最省事的做法是用 TransactionSynchronizationManager 注册一个提交后回调:
@Transactional
public LoanResponse borrow(Long bookId, Long memberId) {
// ... 扣库存、插记录 ...
LoanResponse response = LoanResponse.from(loan);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
notificationService.send(response);
}
});
return response;
}
afterCommit 只在事务真正提交后触发;回滚时不会调用。这样「事务成功」与「发通知」的顺序就正确了。但要注意:afterCommit 里抛异常不会回滚已提交的事务,所以通知失败要另行处理(重试或落补偿表),这部分留给 12.3。
12.1.5 TransactionTemplate:边界显式,绕开代理
当一段逻辑需要在事务里,但你又不想让整个方法被事务包住(比如只包住写操作、前面还要做只读校验),编程式的 TransactionTemplate 更合适。它是 TransactionOperations 的实现,构造后调用 execute 或 executeWithoutResult(已在 spring-tx-7.0.9.jar 核实签名)。
@Service
class LoanService {
private final TransactionTemplate txTemplate;
private final BookRepository bookRepository;
private final LoanRepository loanRepository;
LoanService(PlatformTransactionManager txManager,
BookRepository bookRepository,
LoanRepository loanRepository) {
this.txTemplate = new TransactionTemplate(txManager);
this.bookRepository = bookRepository;
this.loanRepository = loanRepository;
}
public LoanResponse borrow(Long bookId, Long memberId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new BookNotFoundException(bookId)); // 事务外只读校验
return txTemplate.execute(status -> {
book.decrementCopies();
Loan loan = Loan.create(book, memberId, Instant.now());
loanRepository.save(loan);
return LoanResponse.from(loan);
});
}
}
TransactionTemplate 继承 DefaultTransactionDefinition,所以可以直接设置传播行为、隔离级别、超时、只读:
TransactionTemplate tx = new TransactionTemplate(txManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
tx.setTimeout(5);
tx.setReadOnly(false);
什么时候用编程式事务:
- 事务只包住方法里的一小段,而不是整个方法。
- 需要动态决定是否开事务、用什么传播行为。
- 调用发生在同一个类内部(自调用),又不想拆类或注入自身。
代价是事务边界散落在代码里,不如注解直观。默认还是用声明式 @Transactional,只有上面几种情况才换编程式。
12.1.6 REQUIRES_NEW 与 NESTED:语义差在哪
两者都用来「在已有事务中再开一个事务」,但机制完全不同,混用会踩坑。
| 传播行为 | 机制 | 内层回滚的影响 | 外层回滚的影响 |
|---|---|---|---|
REQUIRED(默认) | 加入当前事务 | 整个事务回滚 | 回滚 |
REQUIRES_NEW | 挂起外层事务,新开一个独立事务(独立连接) | 只回滚内层,外层可继续 | 内层已提交,不受影响 |
NESTED | 在当前事务内建保存点(savepoint),仍是同一个事务 | 回滚到保存点,外层可继续 | 外层回滚会一起回滚 |
关键差异用一句话概括:REQUIRES_NEW 是两个事务,NESTED 是一个事务里的一个保存点。
REQUIRES_NEW 的典型用途是「审计/日志不能因为主业务失败而丢失」:
@Service
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action, Long memberId) {
auditRepository.save(new AuditLog(action, memberId, Instant.now()));
}
}
因为它是独立事务,即使主业务随后回滚,审计记录也已经提交。代价是它占两条数据库连接(外层被挂起但连接不释放),高频调用会翻倍消耗连接池。
NESTED 有一个必须知道的前提:它要求事务管理器支持保存点。 Spring 的 DataSourceTransactionManager 通过 JDBC 保存点支持 NESTED;而 JPA 事务(JpaTransactionManager)不支持——JPA 的 EntityManager 没有暴露保存点,Spring 会直接抛 NestedTransactionNotSupportedException。这一点已在本机 spring-tx-7.0.9.jar 与 spring-orm-7.0.9.jar 核实:DefaultTransactionStatus 在事务对象不是 SavepointManager 时抛该异常,而 JDBC 的 JdbcTransactionObjectSupport 实现了 SavepointManager,JPA 的事务对象没有。
@Transactional(propagation = Propagation.NESTED) // 在 JPA 下会失败
public void nestedStep() {
// ...
}
本机没有可用的数据库实例,下面这段是示例输出,用于说明异常形态,不是实测结果:
org.springframework.transaction.NestedTransactionNotSupportedException:
JpaTransactionManager does not support savepoints
结论:用 JPA 时不要指望 NESTED。需要「内层独立、外层可继续」的语义,就用 REQUIRES_NEW;需要「内层失败但外层保留」,就用业务状态机自己控制,而不是依赖保存点。
12.1.7 超时:软边界,不是「到点就杀」
@Transactional(timeout = 5) 的单位是秒,-1(TransactionDefinition.TIMEOUT_DEFAULT)表示用默认值。注意两点:
- 超时由事务管理器处理,能把它落到资源的会尽量落(JDBC 事务管理器会设置连接/语句超时),但它不会强行中断正在执行的线程。一段耗时的远程调用在事务里跑,超时到了也不会被「掐断」,只会让后续提交失败。
- 因此超时不能替代「把慢操作移出事务」。它的作用是在数据库层面兜底,防止个别语句永久挂住。
一个务实的组合:给写事务设一个略大于正常处理时间的超时(比如 3~5 秒),并在业务上把慢操作全部移出事务。这样超时真正触发时,说明确实出了异常,而不是被无关操作拖累。
12.1.8 常见坑
坑一:@Transactional 加在接口或 private 方法上。 接口注解在 CGLIB 代理下可能不生效;private 方法一定不生效。统一加在实现类的 public 方法上。
坑二:try/catch 吞掉异常导致不回滚。 默认只对 RuntimeException 和 Error 回滚。下面这段会「捕获了异常但事务照常提交」:
@Transactional
public void borrow(Long bookId, Long memberId) {
try {
// ... 写操作 ...
} catch (Exception e) {
log.warn("borrow failed", e); // 吞掉,事务不回滚
}
}
要么别吞,要么在 catch 里 throw,要么显式 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
坑三:受检异常不回滚。 @Transactional 默认不回滚受检异常。要让受检异常也回滚,显式写 @Transactional(rollbackFor = Exception.class)。
坑四:同一个类里自调用。 见 12.1.2,注解被绕过。拆类或注入自身。
坑五:事务里做远程调用/发消息。 见 12.1.4,移出事务,用 afterCommit 或补偿表。
坑六:以为只读事务能防写。 它不会拦你,只是让 Hibernate 不自动 flush,写了可能静默丢。见 12.1.3。
坑七:在 JPA 下用 NESTED。 会抛 NestedTransactionNotSupportedException。改用 REQUIRES_NEW。
小结
- 事务边界画在 Service 的
public方法上:Controller 太宽、Repository 太碎。 @Transactional靠代理生效,自调用、private、final、static都会失效;修法是注入自身、拆 bean 或用TransactionTemplate。- 只读事务让 Hibernate 会话进入
MANUALflush 模式、并把只读标志传给连接,但它不阻止写。 - 事务里禁止做远程调用、发消息、读写大文件;通知类操作移到
afterCommit或补偿表。 TransactionTemplate适合「只包住一小段」或需要动态边界的场景,绕开代理。REQUIRES_NEW是两个独立事务(占用两条连接),NESTED是同一事务里的保存点——JPA 不支持保存点,NESTED会失败。- 超时是软边界,不能替代「把慢操作移出事务」;受检异常默认不回滚,要显式
rollbackFor。
边界画对了,下一个问题就是「同一行数据被两个事务同时改」。12.2 会讲乐观锁与悲观锁的取舍,以及为什么长事务里不该做乐观锁重试。
阅读导航:上一节:11.3 分库分表的接入边界 · 下一节:12.2 乐观锁与悲观锁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。