本节目标:看清没有事务时数据会怎样不一致,掌握 @Transactional 的放置位置、rollbackFor 默认回滚规则的坑、readOnly 与 timeout 的用途,以及 TransactionTemplate 编程式事务的适用场景。
适用版本:Spring Boot 4.1.x(Java 21)
14.1 @Transactional 基本用法
第 12、13 章把数据存进了数据库,也把查询写顺了。但一个业务动作往往要改好几张表:借一本书,要扣图书库存、要写一条借阅流水、还要扣读者的可借额度。这三步只要中间一步失败,前两步若已经生效,数据库就处于一种「说不清」的状态。本节先把这个状态演示出来,再讲 Spring Boot 给的第一件工具——@Transactional。
14.1.1 一个没有事务的借阅流程
沿用第 12、13 章的图书领域模型,再加两个实体:借阅流水 BorrowRecord 与读者 Reader。读者身上有一个 quota 字段,表示还能借几本。借书的 Service 长这样:
package com.example.library.service;
import org.springframework.stereotype.Service;
import com.example.library.domain.Book;
import com.example.library.domain.BorrowRecord;
import com.example.library.domain.Reader;
import com.example.library.repository.BookRepository;
import com.example.library.repository.BorrowRecordRepository;
import com.example.library.repository.ReaderRepository;
@Service
public class BorrowService {
private final BookRepository bookRepository;
private final BorrowRecordRepository recordRepository;
private final ReaderRepository readerRepository;
public BorrowService(BookRepository bookRepository,
BorrowRecordRepository recordRepository,
ReaderRepository readerRepository) {
this.bookRepository = bookRepository;
this.recordRepository = recordRepository;
this.readerRepository = readerRepository;
}
public void borrow(Long readerId, Long bookId) {
// 步骤 1:扣库存
Book book = bookRepository.findById(bookId).orElseThrow();
if (book.getStock() <= 0) {
throw new IllegalStateException("库存不足: " + bookId);
}
book.setStock(book.getStock() - 1);
bookRepository.save(book);
// 步骤 2:写借阅流水
BorrowRecord record = new BorrowRecord(readerId, bookId);
recordRepository.save(record);
// 步骤 3:扣读者额度
Reader reader = readerRepository.findById(readerId).orElseThrow();
reader.setQuota(reader.getQuota() - 1);
readerRepository.save(reader);
}
}
这段代码没有任何事务注解。Spring Data JPA 的 save() 在方法返回时会各自把改动刷进数据库(简单保存通常在一次 save 内就提交了)。于是三个步骤是三次独立的提交。
14.1.2 中间失败会留下什么
假设 borrow_record 表上有一条唯一约束 (reader_id, book_id),同一个人不能重复借同一本书。某次调用中,步骤 1 成功,步骤 2 违反约束抛出异常:
2026-10-02T10:12:41.220+08:00 ERROR 51230 --- [nio-8080-exec-1] c.e.library.service.BorrowService : borrow failed
org.springframework.dao.DataIntegrityViolationException: could not execute statement
[Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_4 ON PUBLIC.BORROW_RECORD(READER_ID, BOOK_ID)"]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:322)
...
步骤 2 抛异常,方法直接中断,步骤 3 根本没执行。此刻数据库里的状态是:
| 表 | 期望 | 实际 |
|---|---|---|
book | 库存 -1 | 库存 -1(已提交) |
borrow_record | 新增一条 | 没有新增(插入失败) |
reader | 额度 -1 | 额度没变(步骤 3 未执行) |
书少了一本,却没有对应的借阅记录;读者额度也没扣。这就是数据不一致:一次借书动作被拆成了半成品。若这是库存扣减,长此以往会出现「库存对不上流水」的对账事故。
14.1.3 事务要保证的核心:原子性
事务(Transaction)把「多个数据库写操作」打包成一个不可分割的整体:要么全部成功提交,要么全部失败回滚,不允许停留在中间状态。这依赖数据库本身的能力,而不是 Java 代码的 try-catch——try-catch 只能捕获异常,无法把已经提交的步骤 1「撤销」。
关系型数据库事务有四个特性,合称 ACID:
| 特性 | 含义 | 本场景的体现 |
|---|---|---|
| 原子性(Atomicity) | 全做或全不做 | 三步要么都生效,要么都不生效 |
| 一致性(Consistency) | 事务前后数据满足约束 | 库存数始终等于「总数 - 在借数」 |
| 隔离性(Isolation) | 并发事务互不干扰 | 两个人同时借最后一本,只能一个成功 |
| 持久性(Durability) | 提交后永久保存 | 提交后断电也不丢 |
入门阶段最需要理解的是原子性:它正是「加一个注解就能解决问题」的原因。隔离性留到 14.2。
14.1.4 加上 @Transactional
在方法上加 @Transactional,Spring 就会为这次调用开启一个数据库事务:
package com.example.library.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BorrowService {
private final BookRepository bookRepository;
private final BorrowRecordRepository recordRepository;
private final ReaderRepository readerRepository;
public BorrowService(BookRepository bookRepository,
BorrowRecordRepository recordRepository,
ReaderRepository readerRepository) {
this.bookRepository = bookRepository;
this.recordRepository = recordRepository;
this.readerRepository = readerRepository;
}
@Transactional
public void borrow(Long readerId, Long bookId) {
Book book = bookRepository.findById(bookId).orElseThrow();
if (book.getStock() <= 0) {
throw new IllegalStateException("库存不足: " + bookId);
}
book.setStock(book.getStock() - 1);
bookRepository.save(book);
BorrowRecord record = new BorrowRecord(readerId, bookId);
recordRepository.save(record);
Reader reader = readerRepository.findById(readerId).orElseThrow();
reader.setQuota(reader.getQuota() - 1);
readerRepository.save(reader);
}
}
执行过程变成:
- 进入
borrow前,Spring 通过代理向PlatformTransactionManager申请一个事务,从连接池借一条连接,并设置autoCommit = false; - 三个步骤都在同一条连接、同一个事务里执行,任何一次
save都不会立即提交; - 方法正常返回时,代理提交事务;方法抛出符合回滚规则的异常时,代理回滚事务。
同样跑一次重复借阅,这次步骤 2 失败后,步骤 1 的库存扣减会被一起回滚,数据库回到调用前的状态。
14.1.5 加在类上还是方法上
@Transactional 既能标在方法上,也能标在类上:
@Transactional
public class BorrowService {
// 类内所有 public 方法都带上事务
}
| 放置位置 | 作用范围 | 适用场景 |
|---|---|---|
| 方法上 | 仅该方法 | 类里只有部分方法需要事务,最常用 |
| 类上 | 类内所有 public 方法 | 整个 Service 都是写操作,省去逐个标注 |
| 类上 + 方法上 | 方法上的配置覆盖类上的 | 类上给默认值,个别方法改 readOnly 或 rollbackFor |
方法级配置优先级高于类级。一个常见的用法是类上标 @Transactional(readOnly = true) 作为默认(大多数是查询),再给写方法单独标 @Transactional 覆盖。
14.1.6 rollbackFor:最容易踩的坑
这是入门阶段最常出事故的一条规则:@Transactional 默认只在抛出 RuntimeException(运行时异常)或 Error 时回滚;受检异常(Checked Exception)默认不回滚。
看这个例子。假设借书前要调用一个外部系统做资格校验,校验失败时抛出一个受检异常:
@Transactional
public void borrow(Long readerId, Long bookId) throws IOException {
Book book = bookRepository.findById(bookId).orElseThrow();
book.setStock(book.getStock() - 1);
bookRepository.save(book);
// 调用外部接口,失败时抛出受检异常
if (!remoteChecker.check(readerId)) {
throw new IOException("remote check failed");
}
BorrowRecord record = new BorrowRecord(readerId, bookId);
recordRepository.save(record);
}
IOException 是受检异常。按照默认规则,它不会触发回滚:方法抛出后,事务被提交,步骤 1 的库存扣减留在了库里——你以为失败会回滚,实际却提交了,问题比完全没有事务更隐蔽。
对照表如下:
| 抛出的异常 | 默认是否回滚 | 原因 |
|---|---|---|
RuntimeException 及其子类 | 是 | 默认回滚规则 |
Error 及其子类 | 是 | 默认回滚规则 |
受检异常(IOException、SQLException 等) | 否 | 不在默认规则内 |
未捕获的异常(被 try-catch 吞掉) | 否 | 根本没抛到代理层 |
修正方式是把回滚范围显式放大到 Exception:
@Transactional(rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) throws IOException {
// ...
}
rollbackFor = Exception.class 覆盖了所有异常类型(Exception 是所有受检异常与运行时异常的父类,Error 则始终回滚)。项目里养成统一写 rollbackFor = Exception.class 的习惯,可以规避绝大多数「异常了却没回滚」的问题。若反过来想排除某类异常,可以用 noRollbackFor。
14.1.7 readOnly = true
对纯查询方法,可以声明 readOnly = true:
@Transactional(readOnly = true)
public List<Book> search(String keyword) {
return bookRepository.findByTitleContaining(keyword);
}
它的含义是「本事务只读,不做写操作」。收益有两层:
- 语义表达:明确告诉读代码的人,这个方法不会改数据;
- 性能优化:Hibernate 会跳过脏检查(不再为每个受管实体在提交前做快照对比),JDBC 层也可能把连接设为只读、由数据库走只读路径。
需要提醒的是,readOnly = true 只是提示,不是强制约束。它不会阻止你误写,因此不能当作安全保障;真写错了,行为取决于驱动与数据库。它是「声明意图 + 顺带优化」,不是「只读锁」。
14.1.8 timeout
timeout 给事务设一个超时上限(单位秒):
@Transactional(timeout = 5, rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) {
// 超过 5 秒未完成,事务被回滚并抛 TransactionTimedOutException
}
当某一步卡住(比如慢 SQL、锁等待)超过 timeout 秒,Spring 会标记事务回滚,后续操作抛出 TransactionTimedOutException。它的作用是防止一次请求长时间占着连接与锁,在连接池资源紧张时尤其重要。
要注意 timeout 依赖底层 JDBC 驱动的支持(如 Statement.setQueryTimeout),并非所有数据库都能精确到毫秒;它是「兜底保护」,不能替代对慢 SQL 本身的优化。
14.1.9 TransactionTemplate:需要精细控制回滚点时
声明式事务适合「方法正常就提交、异常就回滚」这种粗粒度场景。但有些逻辑需要在方法中途主动决定回滚——比如校验失败但不想抛异常、只想把已做的改动撤销。这时可以用编程式事务 TransactionTemplate:
package com.example.library.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class BorrowService {
private final BookRepository bookRepository;
private final BorrowRecordRepository recordRepository;
private final TransactionTemplate txTemplate;
public BorrowService(BookRepository bookRepository,
BorrowRecordRepository recordRepository,
TransactionTemplate txTemplate) {
this.bookRepository = bookRepository;
this.recordRepository = recordRepository;
this.txTemplate = txTemplate;
}
public boolean borrow(Long readerId, Long bookId) {
return txTemplate.execute(status -> {
Book book = bookRepository.findById(bookId).orElseThrow();
if (book.getStock() <= 0) {
status.setRollbackOnly(); // 主动标记回滚,不抛异常
return false;
}
book.setStock(book.getStock() - 1);
bookRepository.save(book);
recordRepository.save(new BorrowRecord(readerId, bookId));
return true;
});
}
}
要点:
TransactionTemplate由 Spring Boot 在检测到PlatformTransactionManager时自动配置,直接注入即可,无需手动创建;execute里是一段TransactionCallback,返回true表示成功;status.setRollbackOnly()把事务标记为回滚,即使方法正常返回也会回滚——这是声明式事务做不到的精细控制。
选择建议:优先用声明式 @Transactional(代码干净、符合直觉);只有当回滚时机需要在方法内部按条件决定、或需要把一段逻辑包在事务里但不想拆成新方法时,才用 TransactionTemplate。
14.1.10 事务与数据库隔离的关系
上面所有讨论都建立在「数据库真的支持事务」之上。事务的隔离级别、并发读写行为,都是数据库层面的能力,Spring 只是把它包装成了注解。@Transactional(isolation = ...) 可以指定隔离级别,但它只是把参数透传给数据库连接,并不改变数据库本身的默认行为。
隔离级别的细节、七种传播行为的差异,放到下一节 14.2 展开。本节只需记住:事务的「原子性」由数据库保证,Spring 负责的是「什么时候开、什么时候提交或回滚」。
14.1.11 为什么 @Transactional 靠 AOP 代理生效
还有一个关键问题没解答:@Transactional 只是一个注解,Spring 凭什么知道要在方法前后加开启与提交?答案是 AOP 代理。
Spring 在容器启动时,会为带 @Transactional 的 Bean 生成一个代理对象。你注入的 BorrowService 实际上是代理,调用 borrow() 时先进入代理逻辑(开启事务),再调用真实对象的方法,最后按结果提交或回滚。这也是下一节 14.3「事务失效」的根源——只要调用绕过了代理,事务就不会生效。
至此,@Transactional 的基本用法已经完整:加在哪、回滚规则怎么配、readOnly 与 timeout 怎么用、需要精细控制时怎么退回编程式。接下来要解决的是「多个事务相遇时怎么办」——传播行为。
小结
- 没有事务时,多次
save各自提交,中途失败会留下「库存扣了、流水没记」这类不一致数据;事务的核心价值是原子性,全做或全不做。 @Transactional可加在方法或类上,方法级配置覆盖类级;被标注的public方法由 Spring 生成的 AOP 代理包裹。- 默认只回滚
RuntimeException与Error,受检异常默认不回滚——这是最容易踩的坑,统一写rollbackFor = Exception.class可规避。 readOnly = true表达只读意图并跳过脏检查,但只是提示、不是强制;timeout限制事务最长耗时,防止长时间占用连接与锁。- 需要在方法内部按条件主动回滚时,用
TransactionTemplate的status.setRollbackOnly(),它比声明式更精细。 - 事务的原子性由数据库保证,Spring 只是包装;隔离级别与并发行为是数据库层面的能力,下一节展开。
阅读导航:上一节:13.3 分页与排序 · 下一节:14.2 传播行为与隔离级别 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。