本节目标:为「并发写同一行数据」选出正确的锁策略——掌握乐观锁的失败与重试、悲观锁的写法与代价、加锁顺序对死锁的影响,以及为什么长事务里不能做乐观锁重试。
适用版本:Spring Boot 4.1.x(Java 21)
12.2 乐观锁与悲观锁
12.1 把事务边界画对了,但边界对了不代表并发就安全。book-loan 里最典型的冲突场景是:同一本书只剩最后 1 本,两个会员几乎同时点「借书」。两个事务都读到 availableCopies = 1,都判断「可以借」,都执行 -1,结果库存变成 -1,一本书被借出去两次。这就是并发写冲突。
本节先分清两类冲突,再讲乐观锁和悲观锁各自怎么解决、代价是什么,最后给出选择表。所有演示都围绕 book-loan 的 Book / Loan。
12.2.1 先分清两类冲突
并发写冲突有两种,处理方式不同:
- 丢失更新(lost update):两个事务读同一行、各自改、后提交的覆盖先提交的。比如会员在两个设备上同时改联系方式。
- 超卖(over-sell):库存类数据被并发扣减到不该出现的值。比如只剩 1 本,被借走 2 次。
两者都能用锁解决,但乐观锁更擅长前者,悲观锁或原子更新更擅长后者。原因在下一节的取舍表里展开。先记住一句话:乐观锁假设冲突少,冲突了就让一个失败重来;悲观锁假设冲突多,干脆先把行锁住,让后来者排队。
12.2.2 乐观锁:@Version 的原理
乐观锁不加数据库锁,而是在更新时检查「版本有没有被别人改过」。JPA 用 jakarta.persistence.Version(已在本机 jakarta.persistence-api-3.2.0.jar 核实该注解存在)标记版本字段:
@Entity
class Book {
@Id
private Long id;
@Version
private Long version; // 乐观锁版本号
private String title;
private int availableCopies;
void decrementCopies() {
if (availableCopies <= 0) {
throw new BookNotAvailableException(id);
}
this.availableCopies--;
}
}
有了 @Version,Hibernate 在更新时会带上版本条件,并在提交时把版本加一。等效 SQL 大致是:
update book
set available_copies = ?, version = version + 1
where id = ?
and version = ?;
如果两个事务基于同一个 version 更新,第二个事务的 where 匹配不到任何行(影响行数为 0),Hibernate 就判定为「乐观锁冲突」,抛出 ObjectOptimisticLockingFailureException(继承自 org.springframework.dao.OptimisticLockingFailureException,已在本机 spring-orm-7.0.9.jar 核实其存在与继承关系)。Hibernate 内部抛出的原始异常是 org.hibernate.StaleObjectStateException(继承 StaleStateException,同样已核实),Spring 的异常转换把它包装成上面的 Spring 异常。
关键点:乐观锁不提前加锁,冲突只在提交那一刻才暴露。 所以它的读路径完全没有锁开销——这是它读多写少场景下的最大优势。
12.2.3 失败之后:重试封装
乐观锁失败不是「错误」,是「冲突了,重来」。但重试必须满足两个条件:重试要在新事务里做(旧事务已经因异常回滚),重试要有次数上限。
Spring Boot 4.x 的一个变化要记住:Spring Retry 的依赖管理已被移除(见 4.0 迁移口径),也就是说 spring-retry 不再由 Boot 管理版本,想用它得自己指定版本。对「乐观锁重试」这种简单需求,手写一个重试循环更透明:
@Service
class LoanService {
private static final int MAX_RETRIES = 3;
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 borrowWithRetry(Long bookId, Long memberId) {
ObjectOptimisticLockingFailureException last = null;
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return txTemplate.execute(status -> doBorrow(bookId, memberId));
} catch (ObjectOptimisticLockingFailureException ex) {
last = ex; // 每次重试都在新事务里重新读、重新写
}
}
throw new LoanConflictException(bookId, last);
}
private LoanResponse doBorrow(Long bookId, Long memberId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new BookNotFoundException(bookId));
book.decrementCopies();
Loan loan = Loan.create(book, memberId, Instant.now());
loanRepository.save(loan);
return LoanResponse.from(loan);
}
}
三个细节:
- 用
TransactionTemplate而不是@Transactional:因为重试要「每次一个新事务」。如果整个重试循环在一个@Transactional方法里,第一次异常就把外层事务标记回滚了,后续重试全在已回滚的事务里跑,必然失败。 - 重试时要重新
findById:拿到新版本号再改。如果用同一个内存实体重试,版本号还是旧的,照样失败。 - 重试次数要有上限,并在耗尽后转成业务异常(比如返回 409/412,或提示用户重试)。无限重试在冲突密集时会拖垮系统。
注意:重试次数不是越多越好。3 次是常见起点;如果冲突率高到需要重试很多次,说明这个场景根本不该用乐观锁,应该换悲观锁(见 12.2.5)。
12.2.4 悲观锁:先把行锁住
悲观锁的思路是「假设一定会冲突,先锁住再说」。在 SQL 层面就是 SELECT ... FOR UPDATE,在 Spring Data JPA 里用 @Lock 配合查询方法。
这里有一个容易记错的点:@Lock 不是 JPA 注解,而是 Spring Data JPA 的 org.springframework.data.jpa.repository.Lock,它的 value() 类型是 jakarta.persistence.LockModeType(已在本机 spring-data-jpa-4.1.1.jar 与 jakarta.persistence-api-3.2.0.jar 核实)。JPA 本身只有 LockModeType 枚举,没有 @Lock 注解。
public interface BookRepository extends JpaRepository<Book, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from Book b where b.id = :id")
Optional<Book> findByIdForUpdate(@Param("id") Long id);
}
LockModeType.PESSIMISTIC_WRITE 对应数据库的排他锁(SELECT ... FOR UPDATE)。事务拿到锁后,其他事务对同一行的 FOR UPDATE 会阻塞等待,直到锁释放(事务提交或回滚)。LockModeType 的完整取值(已核实):READ、WRITE、OPTIMISTIC、OPTIMISTIC_FORCE_INCREMENT、PESSIMISTIC_READ、PESSIMISTIC_WRITE、PESSIMISTIC_FORCE_INCREMENT、NONE。
借书用悲观锁的写法:
@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 borrowWithLock(Long bookId, Long memberId) {
Book book = bookRepository.findByIdForUpdate(bookId) // 先锁住这一行
.orElseThrow(() -> new BookNotFoundException(bookId));
book.decrementCopies();
Loan loan = Loan.create(book, memberId, Instant.now());
loanRepository.save(loan);
return LoanResponse.from(loan);
}
}
悲观锁的代价很直接:持锁期间,其他想借这本书的请求全部排队。如果事务里还夹着慢操作,排队时间会被无限放大——这正是 12.1 强调「事务里别做远程调用」的另一个原因。
如果拿不到锁(比如数据库锁等待超时),Spring 会抛出 PessimisticLockingFailureException(继承自 ConcurrencyFailureException,已核实)。它同样应该被转成业务可理解的冲突响应,而不是把堆栈抛给用户。
12.2.5 取舍表
把两种锁放在一起对比,选型就有依据了:
| 维度 | 乐观锁(@Version) | 悲观锁(FOR UPDATE) |
|---|---|---|
| 冲突概率 | 低(读多写少) | 高(写密集、热点行) |
| 锁开销 | 无锁,仅更新时校验版本 | 每次读都加行锁 |
| 持有时间 | 不提前持有,提交时才校验 | 从 FOR UPDATE 到事务结束 |
| 吞吐 | 冲突少时高 | 冲突多时靠排队保证正确,吞吐受限 |
| 失败表现 | ObjectOptimisticLockingFailureException,需重试 | 阻塞等待,超时抛 PessimisticLockingFailureException |
| 死锁风险 | 低(不加锁) | 有(多行加锁顺序不当会死锁) |
| 适合 | 用户编辑、低频状态更新 | 库存扣减、秒杀、热点账户 |
选择规则:
- 读多写少、冲突低 → 乐观锁。典型是「会员改资料」。
- 写密集、热点行 → 悲观锁或原子更新。典型是「抢最后几本库存」。
- 库存扣减还有一种更轻的选择:原子更新,用一条带条件的
update直接完成,不读回实体。
public interface BookRepository extends JpaRepository<Book, Long> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Book b
set b.availableCopies = b.availableCopies - 1
where b.id = :id
and b.availableCopies > 0
""")
int decrementIfAvailable(@Param("id") Long id);
}
decrementIfAvailable 返回受影响行数:返回 1 表示扣减成功,返回 0 表示库存已空。整个判断与扣减在一条 SQL 里完成,既不需要乐观锁重试,也不需要显式加锁。@Modifying 的 clearAutomatically / flushAutomatically 属性已在 spring-data-jpa-4.1.1.jar 核实。
12.2.6 加锁顺序与死锁避免
悲观锁最大的坑是死锁。经典的场景是「转账」:事务 A 锁了账户 1 再锁账户 2,事务 B 锁了账户 2 再锁账户 1,两边互相等待,数据库检测到死锁后杀掉其中一个。book-loan 里如果一次借多本书(批量借阅),就会遇到同样的问题。
避免死锁只有一条铁律:所有事务按同一个全局顺序获取锁。 具体做法是把要锁的 id 排序后再逐个加锁:
@Transactional
public List<LoanResponse> borrowBatch(List<Long> bookIds, Long memberId) {
List<Long> ordered = bookIds.stream().sorted().toList(); // 统一升序,消除循环等待
List<LoanResponse> results = new ArrayList<>();
for (Long bookId : ordered) {
Book book = bookRepository.findByIdForUpdate(bookId)
.orElseThrow(() -> new BookNotFoundException(bookId));
book.decrementCopies();
results.add(LoanResponse.from(loanRepository.save(Loan.create(book, memberId, Instant.now()))));
}
return results;
}
只要所有代码路径都按 id 升序加锁,「A 等 B、B 等 A」的环形等待就不可能形成。除了排序,还要注意:
- 一次只锁必要的行,锁的粒度越小,竞争越少。
- 锁要尽快释放,事务越短越好;不要在持锁期间做远程调用。
- 数据库侧设一个合理的 锁等待超时(PostgreSQL 的
lock_timeout、MySQL 的innodb_lock_wait_timeout),避免一个卡住的事务拖垮所有等待者。
12.2.7 为什么不推荐在长事务里做乐观锁重试
这是一个容易被忽略的组合陷阱。乐观锁重试要求每次重试都在新事务里(见 12.2.3)。如果把它放进一个长事务——比如整个请求方法是一个 @Transactional,重试循环写在里面——就会出问题:
- 第一次冲突抛出
ObjectOptimisticLockingFailureException后,当前事务已被标记为回滚。后续重试无论怎么改,都在一个「注定回滚」的事务里,最终全部失败。 - 更隐蔽的是:某些数据库里,乐观锁冲突会伴随行锁或间隙锁的残留,长事务里反复重试会把这些锁越积越多,反而加剧冲突。
- 长事务本身还占着连接,重试期间连接不释放,高并发下连接池先被拖垮。
正确姿势:重试循环放在事务外面,每一轮用 TransactionTemplate.execute 开一个全新的短事务(就像 12.2.3 那样)。这样「读 → 改 → 冲突 → 回滚 → 重新读 → 重新改」每一步都是干净的短事务。
反过来说,如果某个操作必须在一个长事务里完成、又频繁冲突,那它就不该用乐观锁——应该改用悲观锁,或者用 12.2.5 的原子更新把冲突消解在一条 SQL 里。
12.2.8 完整演示:同一本书被多人同时借
把上面的片段拼成一个可运行的并发场景:库存只剩 1 本,两个线程同时借。
@Test
void onlyOneBorrowSucceeds() throws Exception {
Book book = bookRepository.save(new Book("Effective Java", 1)); // 库存 1
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
AtomicInteger success = new AtomicInteger();
AtomicInteger conflict = new AtomicInteger();
for (long memberId : List.of(7L, 8L)) {
pool.submit(() -> {
try {
start.await();
loanService.borrowWithRetry(book.getId(), memberId);
success.incrementAndGet();
} catch (LoanConflictException ex) {
conflict.incrementAndGet();
} catch (Exception ignored) {
} finally {
done.countDown();
}
});
}
start.countDown();
done.await(5, TimeUnit.SECONDS);
pool.shutdown();
assertThat(success.get()).isEqualTo(1); // 只有一个借成功
assertThat(conflict.get()).isEqualTo(1); // 另一个拿到冲突
assertThat(bookRepository.findById(book.getId()).orElseThrow().getAvailableCopies()).isZero();
}
三种实现下这个测试的表现:
| 实现 | 结果 | 说明 |
|---|---|---|
| 无任何并发控制 | success = 2,库存 -1 | 超卖,测试失败 |
@Version 乐观锁 + 重试 | success = 1,另一个抛冲突或重试后失败 | 库存正确,但失败方要处理冲突 |
悲观锁 FOR UPDATE | success = 1,另一个排队后看到库存 0 | 无需重试,但并发吞吐低 |
本机没有可用的数据库实例,上面的断言是示例输出的预期形态,不是实测结果。要真正验证,需要接一个 PostgreSQL 或 MySQL 实例并让两个线程真的并发——单线程跑不出冲突。
一个容易犯的错:把 @Version 加上了,却没有在更新时读回实体(比如用一条原生 update 绕过 Hibernate 的版本检查),版本号就形同虚设。乐观锁只在「Hibernate 感知到的实体更新」上生效。
12.2.9 常见坑
坑一:@Version 字段用了可变类型或未初始化。 用 Long 并让 Hibernate 管理;新实体的 version 应为 null(由 Hibernate 在首次插入时置 0),手动赋初值可能干扰乐观锁语义。
坑二:@Lock 写成了 JPA 注解。 不存在 jakarta.persistence.Lock;正确导入是 org.springframework.data.jpa.repository.Lock。
坑三:在 @Transactional 方法内部写重试循环。 第一次异常后事务已标记回滚,后续重试必然失败。重试要在事务外、每轮开新事务。
坑四:批量加锁不排序。 多行加锁顺序不一致会死锁。统一按 id 升序(或任何全局一致的顺序)加锁。
坑五:把乐观锁失败直接抛给用户。 ObjectOptimisticLockingFailureException 是内部异常,应转成 409/412 并附上 retryable,而不是暴露堆栈。
坑六:以为乐观锁能防超卖。 乐观锁能保证「不覆盖别人的更新」,但「读-判断-写」这个窗口如果没有版本校验保护(比如判断用了过期数据),仍可能超卖。库存扣减优先用原子更新或悲观锁。
小结
- 并发写冲突分两类:丢失更新(乐观锁擅长)和超卖(悲观锁/原子更新擅长)。
- 乐观锁用
@Version,冲突在提交时以ObjectOptimisticLockingFailureException暴露,不提前加锁,适合读多写少。 - 重试必须在新事务里做、重新读实体、有次数上限;Boot 4.x 已移除 Spring Retry 的依赖管理,简单场景手写循环即可。
- 悲观锁用 Spring Data 的
@Lock(LockModeType.PESSIMISTIC_WRITE)(不是 JPA 注解),持锁期间其他请求排队,拿不到锁抛PessimisticLockingFailureException。 - 库存扣减的首选是原子更新:一条带
availableCopies > 0条件的update,用受影响行数判断成败,无需重试也无需显式锁。 - 避免死锁的铁律是统一加锁顺序(如按 id 升序);不要把乐观锁重试塞进长事务,否则事务已标记回滚,重试全废。
单库单表的并发靠锁解决,一旦操作跨了多个服务(借书要同时扣库存、发通知),锁就不够用了——这正是下一节要处理的分布式事务问题。
阅读导航:上一节:12.1 事务边界设计 · 下一节:12.3 分布式事务的取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。