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

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

背景

在开发毕业设计项目 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 + 合适的索引,已经足够应对并发场景,且代码清晰、易于维护。这不是妥协,而是取舍。

相关推荐
程序员包打听1 小时前
MoonBit 是什么?给第一次听说这门语言的你
前端·后端
RuoyiOffice2 小时前
2026 年开源 BPM/工作流引擎大盘点:Flowable vs Camunda vs Activiti vs Turbo——谁才是企业级首选?
java·spring boot·后端·开源·流程图·ruoyi·anti-design-vue
SamDeepThinking2 小时前
别把业务逻辑塞进存储过程,适当用表驱动法
java·后端·架构
只做人间不老仙2 小时前
C++ grpc 截止时间示例学习
后端·grpc
Rust研习社2 小时前
Weak 弱引用:如何用 Weak 打破 Rc 与 Arc 的循环引用
开发语言·后端·rust
贫民窟的勇敢爷们2 小时前
Spring Boot+Vue电商系统开发实战:架构设计与核心实现
vue.js·spring boot·后端
小码哥_常13 小时前
Spring Boot:别再重复造轮子,这些内置功能香麻了
后端
皮皮林55113 小时前
OpenFeign 首次调用卡 3 秒?八年老开发扒透 5 个坑,实战优化到 100ms!
后端