☰
Hibernate乐观锁@Version实战:用版本号机制解决并发丢失更新
2026/10/14 17:21:33 网站建设 项目流程

先说我见过不止一次的线上事故:秒杀场景里,库存就剩最后一件,两个请求同时发起扣减,前后脚读到的都是 1,然后各自把库存改成了 0。结果一查流水,两个订单都扣款成功,货却只有一件。这不是数据库事务没开,也不是网络抖动,而是典型的并发控制缺位——最后提交的事务覆盖了先提交的结果。

本系列前面聊过 Hibernate 的映射、缓存、批量操作,这次专门把乐观锁讲透。乐观锁不是数据库锁,而是一种在应用层通过版本号(version)实现“冲突检测”的机制,Hibernate 对它的支持非常成熟,配置成本低、侵入性小,能防住大部分“读改写”场景下的数据错乱。这篇文章适合正在用 Hibernate/JPA 做业务系统、想把并发正确性搞明白的开发者,也适合被线上“丢失更新”坑过、想系统复盘的人。

1. 不加锁的更新为什么防不住“最后写入覆盖”

1.1 一个典型的丢失更新现场

先不急着写 @Version,把病根看清楚。

假设有一张商品库存表,核心字段是id和qty,在没有任何锁的情况下,两个事务对同一行数据做“扣减库存”操作:

时间事务 A事务 B数据库实际库存
T1读到 qty=1010
T2读到 qty=1010
T3扣减后写成 qty=99
T4扣减后写成 qty=99

如果两个事务代表两个真实用户,最后的正确结果应当是 8(10 减两次),但数据库里只剩 9。第二次更新把第一次的成果覆盖掉了,这就是典型的“丢失更新”(lost update)。数据库默认的提交读(Read Committed)隔离级别下,这种问题非常普遍,因为两个事务互相看不到对方未提交的修改,等到各自按着自己读到的旧值覆盖时,另一个人的结果就悄悄消失了。

1.2 问题出在“以覆盖为前提的写操作”

有人会问:如果让事务 A 先提交,事务 B 提交时数据库不就知道数据变了吗?答案是:MySQL 默认的 InnoDB 行锁只在更新时生效,B 去 UPDATE 的时候不会主动检查自己读过的旧值是否已经失效,它只会把整行锁定、覆盖。数据库只保证“你写入的最终是对的”,但不保证“你的写入基于的是最新数据”。

这句话很关键:丢失更新的本质不是数据库写坏了,而是业务逻辑读旧了。

想要解决,大致有两条路线:

  • 悲观锁:先SELECT ... FOR UPDATE把行锁住,其他人必须排队,谁先拿到锁谁改,后面的人等锁释放后再读取新值。优点是逻辑简单,缺点是并发性能会明显下降,长时间持锁还会引发锁等待和死锁。
  • 乐观锁:不锁读,只在最后写入时检验“我读到的值还是不是当时的那个值”,如果变了就拒绝提交。实现方式通常是加一个版本号字段,每次更新version = version + 1,同时条件里带上version = 旧版本号。更新影响行数为 0 就说明有别人抢先改了。

Hibernate 的乐观锁核心就是这个思路。它不会在 SQL 层面凭空出现,而是在你有版本号字段的前提下,自动把带版本条件的 UPDATE 给生成出来。下面具体拆解它到底改了什么。

2. 版本号到底改变了哪条 SQL

2.1 建表时多一列,更新多一个条件

在没有版本号的时候,Hibernate 做一次实体更新,生成的 SQL 类似:

update t_stock set qty = ? where id = ?

加了版本号字段后,SQL 会变成:

update t_stock set qty = ?, version = ? where id = ? and version = ?

最后的where条件里多了一个version = ?。前一个?是新的版本号,后一个?是 Hibernate 从实体对象上读到的旧版本号。执行完这条 SQL 后,Hibernate 会检查数据库返回的影响行数:

  • 影响行数为 1,说明更新成功,版本号也顺利加一了对吧?实际是 Hibernate 会把实体内的版本号同步成新值,下次再更新时拿到的就是最新版本。
  • 影响行数为 0,说明where id = ? and version = ?没有匹配到任何行,最常见的原因就是版本号变了——有其他人在这期间提交过。此时 Hibernate 会抛出异常,事务回滚。

注意看这条 SQL 的精妙之处:它并没有锁住读取动作,也没有阻止别人同时读,只是在写入的那一刻检查“版本号还是不是我当初读到的那个”。如果版本号变了,说明数据被别人改过,你的写入条件不成立了,数据库层面就直接拒绝,连“覆盖”的机会都没有。

2.2 版本号加一与条件失败的连锁反应

把上一章 T1 到 T4 的丢失更新场景用版本号重演一遍:

时间事务 A事务 B数据库中的 version
T1读到 qty=10, version=00
T2读到 qty=10, version=00
T3update ... set qty=9, version=1 where id=? and version=0,影响行数 11
T4update ... set qty=9, version=1 where id=? and version=0,影响行数 01

T3 成功后,version 从 0 变成了 1。T4 还想用旧版本 0 去更新,where条件对不上,影响行数为 0,Hibernate 直接让后者报错,而不是静默覆盖。这就是乐观锁的全部秘密:把“读到的旧状态”变成更新条件,让数据库帮助应用做版本校验。

2.3 Hibernate 在哪个时间点做版本检查

有的人会疑惑:版本检查到底是发生在session.save(item)、session.update(item)还是事务提交的时候?答案是刷新(flush)的时候。

Session 是“脏检查 + 延迟写”的模型。你修改实体字段后,Hibernate 并不会马上执行 UPDATE,而是等到 flush 时,比较当前实体属性和数据库快照,如果有变化就生成对应 SQL 执行。flush 的时机有三个主要触发点:

  • 事务提交前;
  • 执行查询前,如果 Session 的刷新模式是FlushMode.AUTO,会先 flush 未提交的修改;
  • 显式调用session.flush()。

所以你在 Service 方法里先stock.setQty(...),然后在方法结束返回后由事务提交触发 flush,版本检查这时才真正发生。如果这个方法外面还包着一层事务,那么异常会在提交时抛出来,看起来就像“代码都执行完了,最后突然报错”。

这也提醒我们:如果代码里有 try-catch 包住了修改操作,但事务还没提交,异常可能不会立刻出现在 try 块里。一定要把事务边界和异常捕获边界理清楚,否则你以为捕获了乐观锁异常,实际上它是在事务提交阶段才冒出来的,原有的 catch 很可能接不住。框架层的事务代理通常会在提交时才抛出ObjectOptimisticLockingFailureException。

3. 从实体配置到并发实测:乐观锁完整落地过程

3.1 创建带版本字段的实体

下面是一个标准做法,用 JPA 的@Version注解,Hibernate 完全兼容:

@Entity @Table(name = "t_stock") public class Stock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "qty", nullable = false) private Integer qty; @Version @Column(name = "version", nullable = false) private Integer version; public Stock() { } public Stock(Integer qty) { this.qty = qty; } public Long getId() { return id; } public Integer getQty() { return qty; } public void setQty(Integer qty) { this.qty = qty; } public Integer getVersion() { return version; } public void setVersion(Integer version) { this.version = version; } }

这里有几个细节值得刻意关注:

  • @Version可以放在int、long、short、Timestamp、Instant等类型上,最推荐的是Long或Integer。整数版本在并发下非常直观,既不依赖系统时间,也不会因为时间精度丢失而失效。
  • 版本号字段在数据库里要设计成非空,默认值为 0。比如建表语句里的version int not null default 0。
  • @Version字段不需要手动赋值。Hibernate 在新增实体时会自动把版本号初始化为 0(实际是 JVM 默认值或数据库默认值),之后每次更新自动递增。
  • 这个字段对业务代码基本透明,不该被业务层随意修改。你要做的只是把它作为查询结果的一部分传入 Service,最终参与 WHERE 条件。

3.2 写一个带库存扣减的业务方法

库存扣减逻辑大概是这样的:先找到商品,检查库存充足,再扣减数量保存。用 Spring 事务管理的话看起来很简单:

@Service public class StockService { private final StockRepository stockRepository; public StockService(StockRepository stockRepository) { this.stockRepository = stockRepository; } @Transactional public boolean deduct(Long id, int delta) { Stock stock = stockRepository.findById(id).orElseThrow(); if (stock.getQty() < delta) { return false; } stock.setQty(stock.getQty() - delta); stockRepository.save(stock); return true; } }

看起来就是普通的“查出来改回去”,但加了@Version的实体,save(stock)不再是原先的insert或update,而是在 UPDATE 时自动携带版本条件。整个方法在一个事务里,提交时 Hibernate 执行带版本号的 SQL,如果被别人抢先更新,就会丢出一个乐观锁异常。

3.3 手动模拟并发冲突

为了直观感受乐观锁发威,可以写一个简单的并发测试。下面用两个线程模拟两个用户同时扣减同一个商品库存:

public class OptimisticLockDemo { public static void main(String[] args) throws InterruptedException { StockService stockService = getStockService(); Long stockId = 1L; CountDownLatch startLatch = new CountDownLatch(1); Runnable userTask = () -> { try { startLatch.await(); stockService.deduct(stockId, 1); System.out.println(Thread.currentThread().getName() + " 扣减成功"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (ObjectOptimisticLockingFailureException e) { System.out.println(Thread.currentThread().getName() + " 发现版本冲突,扣减失败"); } }; Thread t1 = new Thread(userTask, "用户A"); Thread t2 = new Thread(userTask, "用户B"); t1.start(); t2.start(); startLatch.countDown(); t1.join(); t2.join(); } }

不指定悲观锁、不额外加同步块,两个线程各自开着事务去读取同一个库存实体,然后尝试扣减。运行后你会看到其中一个线程扣减成功,另一个线程大概率会在事务提交阶段抛出ObjectOptimisticLockingFailureException,被自己的 catch 捕获并打印“发现版本冲突”。

这里有个容易误导人的地方:如果你把异常打印在save()的调用点,可能根本 catch 不到,原因前面说过——时机问题。事务提交发生在方法返回之后,由 Spring 的代理对象去 commit。所以测试代码要在最外层调用点捕获RuntimeException,或者让事务边界尽量靠外,才能真正兜住乐观锁异常。

3.4 如何确认版本号真的递增了

跑完测试后,查一下表数据:

select id, qty, version from t_stock;

正常的结果应该是:库存只减了一次,version 从 0 变成了 1,或者从初始值变成了初始值 +1。并发冲突时没有产生脏库存扣减,也没有出现两次覆盖的情况。

如果你用的是 ORM 的日志输出,还能看到类似这样的 SQL:

update t_stock set qty=?, version=? where id=? and version=?

这是最直观的证据:Hibernate 确实把版本条件拼进更新语句了。

4. 看到异常之后的冲突处理与重试

4.1 异常谱系与根因追溯

Hibernate 在版本条件不满足时,会抛出一系列异常,最底层的是org.hibernate.StaleObjectStateException。它经过 JPA 规范转换后,会变成jakarta.persistence.OptimisticLockException,Spring 再包装后对上层暴露的通常是org.springframework.orm.ObjectOptimisticLockingFailureException。

也就是说,你看到的异常名可能不是同一个,但根因都是一样的:你的实体所携带的版本号已经过时,数据库拒绝这次更新。

常见的处理方式不是去修改这个异常,而是根据业务场景决定重试、提示还是拒绝。

4.2 一个可落地的重试循环

很多“读改写”类业务,比如点赞量统计、库存扣减、余额扣款,其实可以通过重试来降低冲突影响。关键点是重试之前必须重新读取最新的实体数据,不能用旧对象继续操作,否则你从数据库里拿到的还是一个带着旧版本号的旧快照,重试多少次都会失败。

下面是一个带重试次数的降级实现:

public boolean deductWithRetry(Long id, int delta, int maxRetries) { for (int attempt = 0; attempt < maxRetries; attempt++) { try { return deduct(id, delta); } catch (ObjectOptimisticLockingFailureException ex) { if (attempt == maxRetries - 1) { throw ex; } // 短暂避让,然后重新读取最新数据并重试 Thread.sleep(50L * (attempt + 1)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } } return false; }

注意这里deduct方法内是从findById开始重新读取的,也就是说,每次重试都是全新的实体获取,版本号是最新的。这点很重要,我在代码评审里见过不止一个同事把实体对象存在方法外面反复传,最后每次都是同一个旧 version,重试形同虚设。

4.3 重试也不一定成功,要准备“主动放弃”

重试只是缓解冲突概率的手段,不是银弹。如果冲突频率很高,重试反而会放大数据库压力。而且在部分业务里,重试会造成重复扣减的假象——用户点击下单,第一次其实已经扣失败了,但前端不知道,用户再点一次,如果重试逻辑不小心处理成“每次都重新走一遍完整业务”,还可能产生重复请求。

更稳妥的做法是:

  • 冲突发生后,把错误信息返回给前端,让用户手动点击“重试”或“重新提交”;
  • 前端层面做请求幂等,同一个订单、同一个用户只允许一次有效扩展;
  • 如果在异步任务中,可以记录失败消息并投递到死信队列,人工或定时任务补偿。

不推荐在方法内部使用无限制的 while 循环原地重试,看起来很省事,实际上在冲突率高的时候会拖垮线程。

5. 乐观锁的坑往往不在乐观锁本身

5.1 坑一:merge 方法的返回值被直接忽略

这是我最常看到的问题,没有之一。

@Transactional public void updateStock(Long id, Integer qty) { Stock stock = new Stock(); stock.setId(id); stock.setQty(qty); stockRepository.save(stock); }

这样写会有一个严重问题:stock是一个全新的脱管对象,它没有携带之前的版本号。Hibernate 的save()对脱管对象会走 merge 逻辑,但它拿到的脱管副本里版本号可能是 null 或旧值,如果依赖数据库默认值或者忽略返回值,版本检查就会失守。

正确做法是捕获 merge 返回的托管实例:

@Transactional public void updateStock(Long id, Integer qty) { Stock stock = stockRepository.findById(id).orElseThrow(); stock.setQty(qty); stockRepository.save(stock); }

从数据库重新加载一次,拿到的就是包含正确版本号的托管实体,修改后交给 Hibernate 管理,版本检查正常生效。

还有一种写法是手动 new 一个对象再merge:

Stock detached = new Stock(); detached.setId(id); detached.setQty(qty); Stock merged = stockRepository.save(detached); // 后续要用 merged,不要用 detached

把merged的返回值丢掉,你在方法后续代码里继续操作detached,又一次把旧版本状态带回持久化上下文,结果就是冲突检测失效或者下一次更新永远基于错误版本。merge 的返回值永远不要丢。

5.2 坑二:时间戳类型的版本字段会丢精度

有人图省事,把版本字段设计成Timestamp:

@Version @Temporal(TemporalType.TIMESTAMP) private Date updateTime;

这在数据库里对应的是timestamp或datetime。只要数据库的精度不够,或者并发发生在同一秒内,两个事务都可能读到完全相同的updateTime,更新条件也会完全相同。Hibernate 会认为没有冲突,实际上两次提交发生在一秒内,最后更新覆盖了前一次。

持有整数类型版本号的优势不仅是精度,还在于它的递增非常简单可靠。如果你真的在意“最后修改时间”这个业务字段,可以单独建一个last_update_time列,跟version分开。版本号管并发,业务时间管展示,不要混用。

5.3 坑三:版本号字段允许 null

如果实体的@Version字段是包装类型(比如Integer),而数据库中该列允许 null,或者建表时没有给默认值,Hibernate 在新增记录时可能读到一个 null 版本号。null 版本号会导致 Hibernate 在更新时无法构造有效的版本条件,行为会变得不可预测,甚至直接抛出异常。

建表时务必这样写:

version int not null default 0

实体上@Version字段也不要赋 null。如果你是新增实体,这个字段通常不需要手动赋值;但从数据库读出来后,如果发现 version 是 null,要优先怀疑表结构和映射配置是不是出了问题。

5.4 坑四:埋在缓存里的共享实体

Hibernate 一级缓存(Session 缓存)只在同一个 Session 内有效。但我们经常在 Service 层把从数据库读到的实体对象当作参数传来传去,或者在方法里把它保存到静态 Map 里做所谓的“业务缓存”。

这是个巨大的隐患:实体一旦被做成了共享对象,两个线程同时调用它的 setter,再让 Hibernate 去更新,彼此的修改会直接覆盖对方在内存里的值,甚至不问数据库。乐观锁管的是“数据库更新条件”,管不了“同一个 Java 对象被多线程乱改”。遇到这种设计,通常要改成:

  • 每个方法内部都重新findById获取最新实体;
  • 或者使用 DTO 携带必要字段(id 和 version),不让业务逻辑共享整个实体;
  • 涉及高频访问的数据,用 Redis 之类外部缓存保存原始值,数据库只负责持久化和最终校验。

5.5 坑五:长事务里读、改之间的时间被拉得很长

乐观锁特别忌讳“先查询,然后长时间思考,最后再更新”的流程。比如用户打开一个编辑页面,系统把商品信息返回,用户在页面上填写了 10 分钟,提交时后端再拿这 10 分钟前的实体去更新。

这 10 分钟里如果有其他人改了这个商品,版本号早就变了,等用户提交时,乐观锁异常会把这次修改直接拒绝。从产品角度看,用户会觉得很莫名:“我填了半天,告诉我保存失败?”但这恰恰是乐观锁在保护数据,而不是故意为难用户。

应对办法通常是在前端拿到数据时记录版本号,提交时回传,后端把版本号作为更新条件。如果发生冲突,返回友好提示“数据已发生变化,请刷新后再试”,这比覆盖别人的数据要高明得多。

6. 扩展开关:除了 @Version,Hibernate 还有哪些乐观锁姿势

6.1 不带版本号字段也能做乐观锁校验

有一种相对少见但有时候很有用的玩法:@OptimisticLocking。它可以在实体上指定乐观锁策略,即使你没有@Version字段,Hibernate 也能帮你做冲突检测。

常见的策略有三类:

策略检查范围适用场景
OptimisticLockType.VERSION只检查版本列默认,必须有@Version字段
OptimisticLockType.ALL检查所有持久化字段没有版本列,所有字段都参与比较,禁止任何字段被偷偷改变
OptimisticLockType.DIRTY只检查本次修改过的字段只关心脏字段是否被他人变更,适合宽表优化场景

使用示例:

@Entity @OptimisticLocking(type = OptimisticLockType.ALL) @Table(name = "t_stock") public class Stock { // 没有 version 字段,所有列共同参与版本比较 }

实际生效时,Hibernate 会在 UPDATE 语句的 WHERE 里把全部列拼上去:

update t_stock set qty = ? where id = ? and qty = ? and name = ?

这种方式在“表结构简单、行宽度小”的场景里够用,但如果表字段非常多,WHERE 条件会变得很长,索引利用也不理想。而且如果表里存在大数据字段,比较成本会上升。大多数场景我还是建议老老实实加一个 version 列,清晰、高效、容易排查。

6.2 自己手动写 version 条件,到底行不行

有开发者会动脑筋:不依赖框架的 @Version,自己在 SQL 里加and version = ?,然后手动判断影响行数。这确实能做到,很多早期的持久层代码就是这么写的:

int affected = jdbcTemplate.update( "update t_stock set qty = ?, version = version + 1 where id = ? and version = ?", newQty, id, oldVersion ); if (affected == 0) { throw new OptimisticLockingException("并发冲突"); }

这不算错,但问题是你会失去 Hibernate 的一系列附带能力:脏检查自动判断是否需要更新、实体状态同步、二级缓存失效、事件监听等。如果项目已经用了 Hibernate,把并发控制交给框架是更划算的选择。真到了需要手写 SQL 的地步,往往意味着你的表结构已经不适合用 ORM 去做高频更新,那时候要考虑的是架构问题,而不是在一个查询上较劲。

6.3 选型:乐观锁、悲观锁、数据库锁怎么选

很多项目把乐观锁当成万能药,其实它只适合“冲突少、重试代价低、写入竞争不激烈”的场景。反过来,如果两个操作几乎同时修改同一行,概率极高,乐观锁的重试就可能变成无限抢锁的循环,还不如直接用悲观锁排队。

对比维度乐观锁悲观锁
原理更新时校验版本号读取时锁定行
并发度高,不阻塞读较低,写操作排队
实现成本增加 version 列,实体加注解SQL 加FOR UPDATE或框架 API
冲突处理抛异常,业务侧重试或提示阻塞等待,可能死锁
适用场景读多写少,冲突频率低冲突率高,必须串行处理

Hibernate 里悲观锁也很简单,通过 LockMode 加锁查询:

Stock stock = session.get(Stock.class, id, LockMode.PESSIMISTIC_WRITE);

或者用 JPA 的LockModeType.PESSIMISTIC_WRITE。使用时需要注意锁的持有时间和事务边界,尽量缩短事务体,避免持锁时间过长拖垮其他请求。

除了这两类,还有数据库原生的幂等方案可以做兜底,比如利用唯一索引约束重复提交、对关键操作使用条件更新字段,这些是另一个层面的话题。

最后再分享一个我自己调试乐观锁时的习惯:每次并发测试都不只在应用日志里看异常,而是直接去数据库确认 version 的最终值。如果 version 连续跳了几次,而业务实际只提交了一次,那就说明有异常路径没有正确回滚,或者有地方丢失了版本递增,这种问题光看上层日志很难发现。顺手把版的 SQL 打到日志里,排查时会省下不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询