《Spring Boot 实战》12.2 乐观锁与悲观锁

用「同一本书被多人同时借」讲透并发写冲突:乐观锁 @Version 的失败原理与重试封装、悲观锁 SELECT ... FOR UPDATE 的写法与代价、两者的取舍表、加锁顺序与死锁避免,为什么不推荐在长事务里做乐观锁重试,以及库存扣减的原子更新方案。

本节目标:为「并发写同一行数据」选出正确的锁策略——掌握乐观锁的失败与重试、悲观锁的写法与代价、加锁顺序对死锁的影响,以及为什么长事务里不能做乐观锁重试。
适用版本: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 UPDATEsuccess = 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 分布式事务的取舍 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计