☰
JPA乐观锁实战:@Version原理、失效场景与库存扣减落地
2026/10/15 21:53:13 网站建设 项目流程

JPA乐观锁,我用过很多年,也看团队里不少人用错过。它表面上非常简单——实体类里加一个@Version字段,并发更新时数据就“不会丢了”。但等你真正把接口压到几千并发,或者遇到批量更新、DTO直传、分布式事务这些场景时,各种版本冲突、静默覆盖、事务回滚问题会一起涌出来。

这篇文章我不打算把@Version注解再抄一遍文档,而是围绕“为什么需要乐观锁、JPA底层到底做了什么、哪些场景会悄悄失效、以及一个完整的库存扣减例子怎么落地”来讲。适合用过Spring Data JPA、但对并发控制还停留在“加个注解就行”阶段的同学;如果你已经在生产环境被OptimisticLockException折腾过,那后半部分的排查经验和踩坑实录应该能直接帮上忙。

1. 乐观锁和悲观锁,先搞清楚为什么选它

1.1 悲观锁的思路与局限性

先说一个最常见的并发问题:两个请求同时读到同一条订单记录,都做了修改,后提交的那个把先提交的覆盖了。这就是经典的“丢失更新”。

解决这个问题,最直觉的做法是“先锁住再说”——查询时就把这一行锁住,别人不能改,等自己事务提交后再释放。关系型数据库里对应的就是SELECT ... FOR UPDATE,在JPA里可以用LockModeType.PESSIMISTIC_WRITE来声明。我早年做传统的进销存系统时,整个团队都是这么干的,遇到扣库存、写余额就加锁,逻辑确实简单。

但悲观锁有两个让人头疼的地方。第一是并发性能差,锁住的期间其他事务只能等着,如果事务里再夹杂外部接口调用、文件处理这些耗时操作,锁的持有时间会变得很长,数据库的连接池很快就会被占满。第二是死锁风险,两个事务各锁一行,再互相申请对方持有的行锁,如果数据库的锁顺序处理不得当,死锁检测一触发,就有一方要滚回去重来。说白了,悲观锁是“用数据库的锁换数据安全”,在低频、强一致的内网系统里挺好用,一旦放到高并发、长事务的环境里,往往就成了瓶颈本身。

1.2 乐观锁的思路与实现前提

乐观锁的思路完全反过来:我不锁,默认大家不会冲突,只有在最后提交更新的时候,检查一下“这行数据还是我当初读到的那一版吗?”如果是,就更新;如果不是,说明在我处理期间别人动过了,这次更新拒绝执行。这个“版本”在JPA里通常就是一个整数版本号字段,也就是@Version。

这个思路听起来简单,但它依赖一个前提:更新语句必须带上版本条件。比如我读到的记录版本是3,那么更新语句就是UPDATE ... SET version = 4 ... WHERE id = ? AND version = 3。如果影响行数是0,说明版本对不上,更新失败。数据库在这里帮我们做了原子性保证——检查版本和更新版本是同一个语句完成的,不存在“先查后改”的中间窗口。

从实现层面讲,乐观锁特别适合那种“读多写少、冲突概率低”的业务。比如文章编辑、用户资料修改、配置中心条目更新,大多数时候并发写同一行的概率很小,偶尔撞上,让其中一个请求重试一下或者直接提示稍后再试,代价完全可接受。它的并发能力比悲观锁高得多,因为读操作完全不阻塞,只有在提交那一瞬间才可能失败。

1.3 读多写少用乐观锁,简单场景怎么判断

判断到底该用哪种锁,我个人的经验是看两个维度:冲突概率和冲突代价。

  • 冲突概率低,比如用户修改自己的昵称、编辑一篇草稿,选乐观锁,省去锁的开销。
  • 冲突概率高,比如热点库存的扣减、秒杀场景的限量商品,单纯的乐观锁会导致大量请求在最后一步失败,这时候通常要配合前置校验、限流甚至队列削峰来用,单纯指望乐观锁不够。
  • 冲突代价高,比如账务系统中的余额变更,一旦失败不能简单重试(可能产生重复扣款),这种场景宁可牺牲一点性能也要用悲观锁或者引入分布式锁。

还有个容易被忽略的判断点:你的更新是“单行简单更新”还是“跨多表联动更新”。乐观锁只能保证单行数据基于版本的更新安全,因为它约束的是这一行的version字段。如果你的业务里更新A表的同时必须一致性地更新B表,那乐观锁管不了这种跨表一致性,需要靠事务本身来兜底。搞清楚这一点,后面很多迷惑行为就都能解释了。

2. JPA乐观锁的基础实现,@Version的正确玩法

2.1 实体里加version字段,配置分三步

用Spring Data JPA实现乐观锁,第一步自然是给实体类加版本字段。大多数人就照着文档写:

@Entity @Table(name = "inventory") public class Inventory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String skuCode; private Integer stock; @Version private Integer version; // 省略 getter/setter }

就这么简单。@Version加在Integer、Long、Short或者java.sql.Timestamp类型上都可以。我个人强烈推荐用Integer或Long,不要用Timestamp。原因是时间戳类型的版本粒度是毫秒,高并发下两个事务可能在同一毫秒内更新,虽然理论上数据库的datetime精度能保证有所不同,但实际测试中依然有极小概率出现版本无法变化的情况。用整数版本号,每次更新严格加1,语义最清晰,排查问题也容易。

第二步是数据库建表时给version列一个默认值,比如0或者1,千万不能默认NULL。第三步就是确保所有更新操作都通过JPA的实体管理机制来进行,后面会展开讲哪些操作会绕过版本控制。

2.2 版本号的更新机制,数据库里发生了什么

这可能是整个乐观锁理解中最关键的一环:你并没有在代码里写UPDATE ... SET version = version + 1,这些是JPA在flush时自动拼好的。

假设我们执行下面的逻辑:

Inventory inventory = inventoryRepository.findById(1L).orElseThrow(); inventory.setStock(inventory.getStock() - 10); inventoryRepository.save(inventory);

在save调用后,事务提交前,Hibernate会在flush阶段生成类似这样的一条SQL:

UPDATE inventory SET stock = 90, version = 3 WHERE id = 1 AND version = 2;

其中version = 3是Hibernate根据当前实体的版本号加1算出来的,WHERE version = 2是当前实体持有的版本号。如果执行后影响行数为1,说明更新成功;如果影响行数为0,Hibernate会抛出OptimisticLockException。

理解这个机制对后面很多坑的排查都非常重要。当你看到日志里某条UPDATE影响了0行,不用懵,那不是SQL写错了,而是乐观锁帮你拦截了一次过期更新。另外,不要手动给version赋值,JPA管理这个字段,任何手工setVersion的操作都可能干扰它自动加1的逻辑,导致版本号不按预期增长。

2.3 version字段的操作禁忌:别手欠

@Version字段虽然看起来就是个普通数字,但在实践中有几条铁律,每一条都是我见过线上事故后总结出来的。

第一,永远不要在业务代码里手动修改version字段,比如inventory.setVersion(10),这会导致Hibernate在flush时认为版本号已经被更新,生成SET version = 11这样的SQL,绕过了基于当前版本的判断。第二,查询出来展示给前端的DTO可以带version,但更新接口接收的参数一定要校验非空。因为如果前端没有传version这个字段,反序列化到实体时它就是null,保存时数据库字段不能为null,相关操作会直接报错;如果前端传了旧版本号,那么更新时会带着一个过期的WHERE version = ?条件,更新被拒绝,表现就是“偶发性更新无效”。第三,批量导出的Excel里如果包含版本号,导入时千万别直接把它当成当前版本用,因为你导出那一刻的版本可能几分钟后就失效了,导入模块一般都需要重新校验。

我自己写更新接口时的习惯是,把id和version都作为必传参数,在服务端先做一次幂等校验,再进入实体更新逻辑。这样虽然多一次判断,但能很大程度上避免“前端缓存了旧数据导致静默覆盖”的问题。

3. 乐观锁失效的典型场景,这些坑我都踩过

3.1 部分更新和DTO直传,版本号丢了

生产环境里最常见的乐观锁失效,不是配置错了,而是更新根本没有带着版本条件走。典型场景就是DTO直接转实体,然后调用save更新。

很多团队习惯写一个通用更新方法,把前端传的字段直接映射到实体上:

public void updateInventory(InventoryDTO dto) { Inventory inventory = new Inventory(); BeanUtils.copyProperties(dto, inventory); inventoryRepository.save(inventory); }

如果DTO里没有定义version字段,或者定义了对不上号的旧版本,那么save时实体里的version要么是null,要么是过期值。null会导致插入语义(实体没有主键时会走persist),过期值会让更新永远匹配不上最新版本。更隐蔽的是,当DTO只传了部分字段时,BeanUtils把非空字段复制过去,那些没传的字段在实体里保持默认值,一保存就把数据库里的其他字段覆盖成默认值了。

所以我的建议是:更新操作不要用“整个实体覆盖”的思路,而是先findById查出数据库中的完整实体,再把允许更新的字段一个个赋值过去,save时JPA只会更新那些发生变化的字段。同时确保version是从前端传回来的、并由后端二次校验,这样做既能防止丢失更新,也能避免无意的字段覆盖。

3.2 批量更新和原生SQL不会自动带version

@Version的保护边界是“通过JPA实体生命周期管理的更新”。也就是说,只要你不小心用了@Modifying自定义查询、原生SQL或者批量更新,版本检查就完全没有用了。

举个例子,你可能为了性能写了这样的批量扣减:

@Modifying @Query("UPDATE Inventory i SET i.stock = i.stock - :count WHERE i.id = :id") int deductStock(@Param("id") Long id, @Param("count") int count);

这条JPQL根本没有生成SET version = version + 1 WHERE version = ?,即使你在实体上加了@Version,它也不关心版本号。多个事务同时执行这条语句,最后的效果实际是同时基于同一旧值计算扣减,损失更新的问题依然存在。

正确的做法是手动把版本条件拼进去。两种方案:第一种是保持JPA风格,在@Query里带上版本号作为条件:

@Modifying @Query("UPDATE Inventory i SET i.stock = i.stock - :count, i.version = i.version + 1 " + "WHERE i.id = :id AND i.version = :version") int deductStockWithVersion(@Param("id") Long id, @Param("count") int count, @Param("version") int version);

执行后检查返回值,如果方法是int且返回值等于0,就说明版本冲突。第二种是用原生SQL按version条件更新,效果一样。总之记住一点:凡是绕过实体管理的更新,都必须自己实现版本号检查和递增逻辑。

3.3 事务太长,冲突率成倍上涨

还有一个隐性失效场景,不是版本控制的逻辑有问题,而是事务边界设计不当导致冲突率暴涨。

假设你的服务里有这么一段逻辑:

@Transactional public void processOrder(Long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); // 这里调用外部服务,可能耗时几百毫秒 boolean paid = externalPaymentService.checkPayment(order); if (paid) { order.setStatus(OrderStatus.PAID); orderRepository.save(order); } }

问题在于:事务从查询订单那一刻开始,直到状态更新并提交,中间的长耗时操作让“读版本号”和“更新版本号”之间拉出一条很长的时间窗口。在这段时间里,任何其他事务只要更新了同一条订单,当前事务的WHERE version = ?就会失效。而高并发场景下,更新请求越密集,这个窗口里的冲突概率越高。

解决思路很简单:尽量缩短事务的时间窗口。把外部服务调用移到事务之外,先把必要的数据查出来,调完外部服务再开一个新事务去更新状态。如果确实无法避免长事务,那要考虑调整业务设计,比如状态改为异步校验后单独更新。我用过一个比较实用的模式:查询订单时不开启事务,只允许只读连接;确认支付成功后再调用一个独立事务去更新状态。这个调整让订单状态的冲突率从原先的30%降到了接近1%。

4. 实战案例:库存扣减从零跑通

4.1 表结构和实体设计

理论讲再多,不如跑一个具体例子。我们用一个最经典的库存扣减场景来串一遍完整的乐观锁落地方案。

表结构很简单:

CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_code VARCHAR(32) NOT NULL, stock INT NOT NULL, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_sku (sku_code) );

实体类就是前面贴过的Inventory,加@Version字段。这里有个细节:库存扣减这类高频更新场景,如果使用悲观锁,并发性能会很差;如果完全不用锁,又会出现超卖。乐观锁配上重试机制,是这个场景下比较均衡的方案。注意,stock字段是单纯的数量,底层的version负责并发控制,两者职责完全分离,不要试图用stock数量的变化去代替版本号,那样会牺牲对“整体数据版本”的判断能力。

4.2 Service层实现与重试机制

先写一个最基本的扣减方法:

@Service public class InventoryService { @Autowired private InventoryRepository inventoryRepository; @Transactional public DeductResult deduct(Long skuId, int count) { Inventory inventory = inventoryRepository.findById(skuId).orElseThrow(); if (inventory.getStock() < count) { return DeductResult.fail("库存不足"); } inventory.setStock(inventory.getStock() - count); // 注意:这里不用手动 setVersion,JPA 会在 flush 时自动 +1 inventoryRepository.save(inventory); return DeductResult.success(); } }

这个方法在高并发下,多个线程同时读到stock = 100,同时扣减到90,只有一个能成功,其他都会抛OptimisticLockException。为了让这些冲突不会直接变成用户看到的报错,加一层重试:

public DeductResult deductWithRetry(Long skuId, int count, int maxRetries) { for (int attempt = 0; attempt < maxRetries; attempt++) { try { // 每次重试都重新开一个事务,重新读取最新版本 return deduct(skuId, count); } catch (OptimisticLockException e) { // 简单退避:等待后重试 try { Thread.sleep(50L * (attempt + 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } return DeductResult.fail("系统繁忙,请稍后重试"); }

这里有几个细节值得展开。第一,deduct必须是独立事务,重试循环不要放在被@Transactional包裹的大方法里,因为一旦OptimisticLockException抛出,当前事务已经被标记为rollback-only,再继续执行任何操作都没有意义。第二,重试次数不要太多,3到5次足够,超过上限直接返回失败,让用户稍后重试,否则在高并发下重试风暴会放大数据库压力。第三,退避策略用固定间隔就好,线性的50毫秒增长在小规模场景下足够,真到了秒杀级别,单纯靠数据库乐观锁加重试扛不住,需要前面的限流和队列来兜底。

4.3 并发模拟验证一下效果

写完代码以后,我建议你写一个并发测试来验证乐观锁真的起作用。用Java自带的并发工具就能做:

ExecutorService executor = Executors.newFixedThreadPool(20); CountDownLatch ready = new CountDownLatch(20); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(20); for (int i = 0; i < 20; i++) { executor.submit(() -> { ready.countDown(); start.await(); try { deductWithRetry(1L, 10, 3); } finally { done.countDown(); } }); } ready.await(); start.countDown(); done.await();

测试前库存100,20个线程各扣10。如果没有乐观锁,最终库存可能变成负数或者出现覆盖;加上乐观锁后,理想的结果是成功10次、失败或者重试后成功10次,最终库存为0。实际跑的时候,由于重试机制,可能不止10次成功,但最终库存不会低于0,这就是乐观锁的核心价值——它不是限制并发数量,而是保证数据不会被并发写坏。

我在测试中曾经加过一行日志,用来观察每个线程第一次尝试时的版本号。发现20个线程几乎都是基于version=0读的,只有一个能提交成功,剩下的19个全部冲突。重试后它们再读取时版本已经是1或2了。看到这个日志,你就真正理解了“乐观锁靠的是更新时的版本比对”这句话的含义。

5. 异常处理与排查技巧,OptimisticLockException不完全指南

5.1 捕获异常后,重试还是报警

OptimisticLockException抛出来的时机通常是事务flush或提交阶段,这意味着不能把重试逻辑放在同一个事务方法内部。前面案例里,重试循环是在无事务的deductWithRetry里调用独立事务的deduct方法,这样才能保证每次尝试都有全新的数据库快照。

但还有另一种情况,重试也不能解决问题:业务上的并发冲突不是偶发,而是持续存在。比如运营人员反复编辑同一个配置,两个编辑页面同时打开,每个都改了很久,谁都提交不上来。这时候与其无限重试,不如提示用户“数据已被其他人更新,请刷新后重试”。所以异常处理策略要区分业务类型来定:

  • 库存、余额等系统自动重试无害,可以重试几次。
  • 用户手工编辑的内容,重试基本无效,直接返回冲突提示更合理。
  • 后台批处理任务,建议把冲突记录落库,事后人工或任务重新执行。

还有个容易被忽略的点,OptimisticLockException发生时,事务已经回滚。如果上层代码用try-catch把异常吞掉,没有向外抛出,那么当前线程持有的实体对象继续操作也只是在已经标记为rollback-only的事务里做无用功,提交时会变成UnexpectedRollbackException,错误信息比原本的乐观锁异常难懂得多。所以我在团队里的规范是:OptimisticLockException默认不吞,要么抛给上层重试逻辑处理,要么在边界统一捕获转成友好提示。

5.2 日志排查:一眼定位版本冲突

线上排查乐观锁问题,如果日志打印得清晰,定位起来其实很快。我常用的办法是三个地方打出日志:

第一,查询时打印出实体的ID和版本号,确认读到的数据状态。第二,更新完成后利用返回的int行数判断是否命中,没有命中就打警告日志,带上ID、期望版本、实际版本。第三,在异常处理处打印完整堆栈,同时把事务状态打出来。

一条典型的报警日志长这样:

warn: [inventory] optimistic lock conflict, id=1, expectedVersion=2, actualVersion=3, retry=1

看到这个日志就能判断:当前线程在读到版本2之后,有其他事务已经把它更新到了3,于是当前线程的更新被拒绝。如果这种日志在高频出现,说明事务太长或者冲突概率远超预期,要回到第3节说的场景里去排查。

关于数据库层面的排查,我还有一个习惯:在测试环境开启SQL日志,观察Hibernate生成的UPDATE语句。重点检查WHERE条件里是否带了version,以及SET部分是否包含version = version + 1。如果更新语句里完全没有version的影子,说明这条路根本没有走实体管理的流程,是批量更新或原生查询导致的版本失效。这个检查在代码审查阶段就能发现问题。

5.3 和其他框架配合的细节

最后聊几个乐观锁与Spring、数据库配合时的常见细节,都是我在实际项目里遇到过、并且查了很久才搞明白的。

第一,@Version字段的类型在不同数据库方言下有差异。整数类型基本通用,如果某个团队用的是时间戳类型,迁移数据库时要特别小心,不同数据库对时间精度的处理不一致,很可能在迁移后出现更新报错或者版本不递增的情况。第二,配合@Modifying(clearAutomatically = true)更新时,一级缓存会被清空,紧接着如果代码里再用之前查出来的实体,可能拿到的是过期数据。解决方法是清空之后不需要立刻使用旧实体,或者用flushAutomatically来控制刷新顺序。第三,Spring Data JPA的save方法对detached实体会走merge,merge时Hibernate会先发送一条select再执行update,如果版本对不上,在merge的select阶段并不会报错,直到flush时的update才报错,所以很多同事刚开始排查时很困惑,为什么前面都好好的,提交那一瞬间突然异常。

还有一个跨服务场景值得提一句:如果订单、库存这些核心数据分散在多个服务,每个服务都有自己的数据库,乐观锁只能保护单库单表的一致性。跨服务的数据一致性得靠分布式事务、事件驱动或者最终一致性方案来解决,不要指望A服务的@Version能让B服务的数据也跟着安全。我见过团队花了很大力气在一个微服务里加乐观锁,结果另一个服务直接改库,加了等于没加,最后还是要靠统一的接口约束加数据库层兜底。

最后分享一点实际操作中的体会

从我自己的经验来看,乐观锁不是万能的,但它是并发控制里性价比很高的一个工具。它的最大价值,不是让你在高并发下还能顺畅地把所有请求都处理完,而是保证那些同时发生的请求不会把数据写坏。换句话说,乐观锁是一个“安全底线”,真正的容量和性能问题,还得靠缓存、限流、队列这些手段去解决。

如果你打算在下一个项目里用乐观锁,我给三个实操建议:第一,实体类里的version字段就用Integer,所有更新走实体管理流程;第二,所有自定义更新SQL都必须手动带上版本条件;第三,设计好重试和报错机制,别让OptimisticLockException一路冒到用户屏幕上。这些看起来是细节,但每一次并发事故的背后,几乎都能在这些细节里找到原因。

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

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

立即咨询