Java 并发踩坑:高并发库存扣减丢失更新,从悲观锁到分布式锁的终极方案
引子
上一篇文章是昨天写的:
实战派 JMeter 指南:核心功能、并发压测实操与常见问题解决方案
测试的时候发现问题——库存变化不对:10并发时正常,但若并发高(比如100、1000),数据库只减去了4个库存,别的订单等都没有问题(之前为了防止并发提前给订单加了redis+RocketMQ )SaaS 订单系统技术架构解析与并发优化方案
核心问题:并发丢失更新(Lost Update)
问题分析
当1000个并发请求同时处理同一个商品的库存减少时,会触发经典数据库并发问题。
业务流程:库存扣减需先查询原始库存,再基于查询结果更新数据库。
源查询代码如下:
private InventorySum getInventorySum(InventorySumDTO inventorySumDTO) {LambdaQueryWrapper<InventorySum> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(InventorySum::getStore_id, inventorySumDTO.getStore_id());queryWrapper.eq(InventorySum::getProduct_id, inventorySumDTO.getProduct_id());return inventorySumMapper.selectOne(queryWrapper); // 未加锁!
}
核心原因
多个事务并发执行时,对同一批数据进行“读取-修改-写入”(Read-Modify-Write)操作。
因缺乏同步机制(锁、隔离级别控制等),操作顺序的随机性会引发逻辑错误。
典型例子
以库存扣减为例:
假设商品初始库存为10,两个并发订单(事务A、事务B)各需扣减5个库存,预期最终库存为0。
- 事务A读取库存:10;
- 事务B同时读取库存:10(此时A未完成修改);
- 事务A计算后写入:10 - 5 = 5;
- 事务B计算后写入:10 - 5 = 5(覆盖A的结果);
最终库存为5,出现“超卖”隐患。
本质问题
多个事务基于相同的初始数据修改,后执行的事务会覆盖先执行事务的结果。
导致数据状态与业务逻辑预期脱节。
解决方案
需通过同步机制保证操作的“原子性”或“顺序性”,常见方案如下:
1. 悲观锁(行锁)
使用 SELECT ... FOR UPDATE 锁定目标记录,阻止其他事务并发修改。
- 优点:数据正确性有保障,实现简单;
- 缺点:高并发时会串行化执行,降低系统吞吐量;
- 适用场景:并发度中等(<1000 QPS)、数据准确性要求高(库存、金额);
- 注意:超高并发(>10000 QPS)需替换为分布式锁或MQ异步处理。
2. 乐观锁
通过版本号(如 version 字段)控制更新权限,仅当版本号匹配时允许更新,避免覆盖已有修改。
3. 提高事务隔离级别
使用 REPEATABLE READ 或 SERIALIZABLE 隔离级别,减少并发冲突。
注意:可能导致性能下降,需权衡使用。
4. 原子操作
用单条SQL完成“读取+修改”,避免分步骤操作。
示例:UPDATE inventory SET num = num - 5 WHERE id = 1。
5. 分布式锁(超高并发场景解决方案)
- 核心思想:在分布式系统中(多服务/多进程),主动锁定共享资源,确保同一时间只有一个进程能操作资源,直接避免冲突。
- 特点:通过“加锁-操作-释放锁”的流程实现互斥,会阻塞其他竞争进程;适用于写冲突频繁、数据一致性要求高的分布式场景(如分布式库存扣减、分布式任务调度)。
- 实现示例:基于Redis的
SET NX命令、ZooKeeper的临时节点、MySQL的行锁(跨库时无效,需分布式锁)。
