bigywater.tech
Spring · 93

@Version 乐观锁与重试

乐观锁到底是怎么发现冲突的?冲突之后该怎么办?为什么加了 @Version 还是丢更新?批量 update 会不会绕过它?这篇在 Spring Boot 4.0.8、Hibernate 7、MySQL 上用 8 个线程撞同一行实测。

169 → 0
8 个线程各加 25 次:没有 @Version 少了 169;有 @Version 加重试少了 0
110 vs 150
批量更新不带 versioned 时最终余额 110(丢了 50);带 versioned 时 A 失败、余额 150
72 次冲突 / 272 次尝试
重试方案里乐观锁冲突的次数,与总尝试次数

机制

实测 · 本机执行输出
初始 version = 0
  SQL:update accv set balance=?,version=? where id=? and version=?
  SQL:update accv set balance=?,version=? where id=? and version=?
  SQL:update accv set balance=?,version=? where id=? and version=?
3 次修改之后 version = 3,balance = 130
  -- 两个事务同时改同一行(A 先读,B 完整地改完提交,A 再改并提交)
  A 读到的 version = 3,B 提交后 A 准备提交
  A 提交失败:ObjectOptimisticLockingFailureException:Unexpected row count (expected row count 1 but was 0) [update accv set balance=?,version=? where id=? and version=?]
  最终 balance = 1130(B 的 +1000 保住了,A 的 +1 没有写进去),version = 4

update … set version=新 … where version=旧;命中 0 行就抛 ObjectOptimisticLockingFailureException。

重试

实测 · 本机执行输出
  没有 @Version:最终 balance = 131(应为 300),比应得的少 169;乐观锁冲突 0 次;放弃 0 次;总尝试 200 次;用时 73 ms
  @Version,失败就放弃:最终 balance = 131(应为 300),比应得的少 169;乐观锁冲突 169 次;放弃 169 次;总尝试 200 次;用时 53 ms
  @Version,失败重试(最多 20 次,随机退避):最终 balance = 300(应为 300),比应得的少 0;乐观锁冲突 72 次;放弃 0 次;总尝试 272 次;用时 174 ms

乐观锁只发现冲突,要成功得靠重试;不重试就是显式失败而不是丢更新。

绕过

实测 · 本机执行输出
  B 用 update(不带 versioned) 加 50,A(已读到旧值 100)加 10 并提交:成功;最终 balance = 110(两笔都应该保住,应为 160),version = 1
  B 用 update versioned 加 50,A 提交:失败 ObjectOptimisticLockingFailureException:Unexpected row count (expected row count 1 but was 0) [update accv set balance=?,version=? where id=? and version=?];最终 balance = 150,version = 1

批量 JPQL 不改 version,要写 update versioned。

表单

实测 · 本机执行输出
  save(带旧 version=0 的游离对象,而库里已经是 version=1):失败 ObjectOptimisticLockingFailureException:Row was already updated or deleted by another transaction for entity [demo.AccV with id '1']
  save(没带 version 的游离对象):失败 DataIntegrityViolationException:Duplicate entry '1' for key 'accv.PRIMARY'
  重新 find 一个最新的实体、再把表单里的字段拷进去(丢掉了页面上带回来的旧 version):提交成功,最终 balance = 200,version = 2——别人改的 500 被静默覆盖了

旧 version 要带回到更新语句;丢了它就会静默覆盖。

分步演示

乐观锁
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK 21.0.12(Homebrew) / Spring Boot 4.0.8(Spring Framework 7.0.9、Tomcat 11.0.24) / Spring Cloud 2025.1.3;用 javac 编译、java 直接运行,依赖由 Maven 从 Maven Central 解析;数字每次运行会有小幅抖动(Hibernate 7、Spring Data JPA 4 随 spring-boot-starter-data-jpa;MySQL 是本机自己起的 26.7.0 实例,默认隔离级别 REPEATABLE-READ)。一行账户余额;8 个线程各 25 次「读、加 1、提交」,线程池 16 个连接;重试是手写的循环(最多 20 次,随机退避,没有用 Spring 7 新增的重试注解);每个数字是单次运行的结果,冲突次数会随运行抖动
    • 没有验证:Spring Framework 7 / Spring Retry 的 @Retryable;悲观锁(@Lock(PESSIMISTIC_WRITE))与乐观锁的对比;时间戳型 @Version;version 字段溢出;乐观锁在大批量更新里的表现;SERIALIZABLE 隔离级别;分布式场景下的乐观锁(数据库 CAS 与应用层版本号)
    京ICP备2026067502号-1京公网安备11010502063487号