从乐观锁到悲观锁:一次库存并发问题的排查与重构

从乐观锁到悲观锁:一次库存并发问题的排查与重构

背景

在开发毕业设计项目 SICS(物资借用管理系统)时,系统的核心业务是物品借用与归还。每次借用操作涉及两个层面的库存一致性:

  • 品类层(Item):扣减可用库存、增加借出库存,是一行记录的数值更新
  • 单品层(ItemIndividual) :选择指定数量的"在库"单品,将其状态从 IN_STOCK 改为 PENDING_BORROW,是跨多行的状态变更

两层操作必须在同一事务内完成,否则会出现库存数与单品状态不一致的情况。这就形成了一个大事务套小事务的结构------外层事务管理借用流程,内层事务负责库存扣减。

问题浮现:乐观锁的重试为何无效?

最初,品类层库存扣减采用 乐观锁 机制:实体类通过 @Version 注解标记版本号字段,配合自定义的 @OptimisticLockRetry 注解实现冲突重试。思路很清晰------并发扣减时版本号冲突,重试即可。

但实际测试中发现:重试永远读到旧数据,版本号永远冲突,最终抛出异常。

根因在于数据库的隔离级别。本项目使用 PostgreSQL,默认隔离级别为 READ COMMITTED,但在大事务中如果内层方法以默认的 REQUIRED 传播方式加入外层事务,整个事务共享同一个快照。更关键的是,当外层事务已经对目标行做过读取后,内层重试读取到的仍然是事务开启时的快照数据,而非其他事务已提交的最新值。这意味着:

乐观锁检测到冲突后重试,但重试读到的还是同一份旧快照,版本号自然还是冲突的。重试变成了死循环。

方案抉择:三条路,哪条适合?

方案一:改事务传播方式为 REQUIRES_NEW

让库存扣减以独立事务运行,每次重试都能读到最新提交的数据。

问题:外层事务回滚时,内层事务已经提交,库存扣减不会回滚。需要引入补偿机制(如 TCC),复杂度骤增,对于一个毕设项目来说过于笨重。

方案二:事件驱动架构

将库存扣减改为异步事件通知,解耦借用流程与库存操作。

问题:需要引入消息队列、死信队列、消费幂等处理,架构复杂度大幅提升,属于杀鸡用牛刀。

方案三:悲观锁

在读取 Item 行时直接加 SELECT ... FOR UPDATE,其他事务等待行锁释放后再操作。

优点

  • 简单直接,不需要额外的补偿机制或中间件
  • 加锁读取天然读到最新数据,不存在快照问题
  • 对于本项目规模,悲观锁的性能损耗可以忽略不计

最终选择:方案三。对于一个并发量有限的物资管理系统,悲观锁是最务实的选择。

重构实施

1. 品类层:从 @VersionFOR UPDATE

ItemMapper 中新增悲观锁查询方法:

java 复制代码
@Select("SELECT * FROM item WHERE id = #{id} FOR UPDATE")
Item selectByIdForUpdate(@Param("id") Long id);

ItemServiceImpl 中所有库存写操作的 selectById() 替换为 selectByIdForUpdate(),同时移除所有 @OptimisticLockRetry 注解。

涉及五个核心方法:

方法 作用
restoreStock 归还库存
addStock 入库加库存
handleItemReturn 处理单品归还
updateQuantities 批量更新库存数
allocateForBorrow 借用分配(扣减库存+锁单品)

updateBatchByIds 中的 version = version + 1 也顺带保留了行级变更审计能力。

2. 单品层:FOR UPDATE SKIP LOCKED 保持不变

单品层的锁定策略本身没有问题------FOR UPDATE SKIP LOCKED 是处理此类场景的最佳实践:

java 复制代码
wrapper.eq(ItemIndividual::getItemId, itemId);
wrapper.eq(ItemIndividual::getStatus, IndividualStatus.IN_STOCK);
wrapper.orderByAsc(ItemIndividual::getBatchNo);
wrapper.orderByAsc(ItemIndividual::getCreatedAt);
wrapper.last("FOR UPDATE SKIP LOCKED LIMIT " + quantity);

它的语义是:找到指定品类下状态为"在库"的单品,按批次和创建时间排序,加行锁,跳过已被其他事务锁定的行。这天然避免了并发借用同一批单品时的等待和死锁。

3. 索引优化:防止 SKIP LOCKED 变成表锁

FOR UPDATE SKIP LOCKED 虽然只锁匹配的行,但如果 WHERE item_id = ? AND status = 'IN_STOCK' 没有合适的索引,PostgreSQL 不得不扫描全表来定位目标行,途中遇到的每一行都会被加上行锁------即使最终不需要这些行。

为避免锁升级,需要创建一个覆盖查询条件的复合索引:

sql 复制代码
CREATE INDEX idx_item_individual_borrowable
ON item_individual(item_id, status, batch_no, created_at);

COMMENT ON INDEX idx_item_individual_borrowable IS
'借用分配查询索引:按品类+在库状态定位可借单品,按批次和创建时间排序,覆盖 FOR UPDATE SKIP LOCKED 查询避免锁升级';

索引列的设计遵循以下原则:

  • item_id:等值过滤,放最左
  • status:等值过滤,放第二位
  • batch_no, created_at:排序字段,放后两位

这样查询可以直接走索引定位 + 索引有序返回,不需要回表,也不会在扫描过程中锁定无关行。

反思

这次排查给我最大的感触是:并发问题的解决方案必须与事务模型匹配。乐观锁的适用前提是"冲突少、重试成本低",但它有一个隐含假设------重试时能读到最新数据。在大事务的快照隔离下,这个假设不成立。

而悲观锁虽然看起来"不够优雅",但它与事务模型的契合是天然的:加锁读取本身就是事务的一部分,不存在快照失效的问题。技术选型不应该追求"高级",而应该追求"合适"。

对于一个小型管理系统而言,悲观锁 + FOR UPDATE SKIP LOCKED + 合适的索引,已经足够应对并发场景,且代码清晰、易于维护。这不是妥协,而是取舍。

相关推荐
㳺三才人子5 小时前
初探 Flask
后端·python·flask·html
星栈独行5 小时前
我在 Rust 全栈项目里用 JWT 做无状态认证
开发语言·后端·rust·前端框架·开源·github·web
Java爱好狂.6 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
陈随易6 小时前
Redis 8.8发布,一定要更新
前端·后端·程序员
装不满的克莱因瓶7 小时前
SpringBoot 如何将 lib 目录中jar包打包进最终的jar包里面
spring boot·后端·maven·jar·mvn
ltl7 小时前
Transformer 原论文实验结果:为什么 28.4 BLEU 足以改写路线图
后端
excel8 小时前
为什么我推荐使用 Termius:现代 SSH 工具的完整体验
前端·后端
卷毛的技术笔记8 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
java·人工智能·redis·后端·spring·ai·系统架构
IT_陈寒9 小时前
Java的Optional差点让我掉坑里,这几个坑你别踩
前端·人工智能·后端
子兮曰10 小时前
Harness 驾驭工程深度教程:从 AGENTS.md 到全链路 AI 编码基础设施
前端·后端·ai编程