从乐观锁到悲观锁:一次库存并发问题的排查与重构
背景
在开发毕业设计项目 SICS(物资借用管理系统)时,系统的核心业务是物品借用与归还。每次借用操作涉及两个层面的库存一致性:
- 品类层(Item):扣减可用库存、增加借出库存,是一行记录的数值更新
- 单品层(ItemIndividual) :选择指定数量的"在库"单品,将其状态从
IN_STOCK改为PENDING_BORROW,是跨多行的状态变更
两层操作必须在同一事务内完成,否则会出现库存数与单品状态不一致的情况。这就形成了一个大事务套小事务的结构------外层事务管理借用流程,内层事务负责库存扣减。
问题浮现:乐观锁的重试为何无效?
最初,品类层库存扣减采用 乐观锁 机制:实体类通过 @Version 注解标记版本号字段,配合自定义的 @OptimisticLockRetry 注解实现冲突重试。思路很清晰------并发扣减时版本号冲突,重试即可。
但实际测试中发现:重试永远读到旧数据,版本号永远冲突,最终抛出异常。
根因在于数据库的隔离级别。本项目使用 PostgreSQL,默认隔离级别为 READ COMMITTED,但在大事务中如果内层方法以默认的 REQUIRED 传播方式加入外层事务,整个事务共享同一个快照。更关键的是,当外层事务已经对目标行做过读取后,内层重试读取到的仍然是事务开启时的快照数据,而非其他事务已提交的最新值。这意味着:
乐观锁检测到冲突后重试,但重试读到的还是同一份旧快照,版本号自然还是冲突的。重试变成了死循环。
方案抉择:三条路,哪条适合?
方案一:改事务传播方式为 REQUIRES_NEW
让库存扣减以独立事务运行,每次重试都能读到最新提交的数据。
问题:外层事务回滚时,内层事务已经提交,库存扣减不会回滚。需要引入补偿机制(如 TCC),复杂度骤增,对于一个毕设项目来说过于笨重。
方案二:事件驱动架构
将库存扣减改为异步事件通知,解耦借用流程与库存操作。
问题:需要引入消息队列、死信队列、消费幂等处理,架构复杂度大幅提升,属于杀鸡用牛刀。
方案三:悲观锁
在读取 Item 行时直接加 SELECT ... FOR UPDATE,其他事务等待行锁释放后再操作。
优点:
- 简单直接,不需要额外的补偿机制或中间件
- 加锁读取天然读到最新数据,不存在快照问题
- 对于本项目规模,悲观锁的性能损耗可以忽略不计
最终选择:方案三。对于一个并发量有限的物资管理系统,悲观锁是最务实的选择。
重构实施
1. 品类层:从 @Version 到 FOR 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 + 合适的索引,已经足够应对并发场景,且代码清晰、易于维护。这不是妥协,而是取舍。