关于订单退库存时的并发处理(超卖)
关于订单退库存时的并发处理(超卖)
1、需求背景
现有商品表(goods),其数据如下:
| goods_id | goods_name | stock |
|---|---|---|
| 1001 | 老干妈炒饭 | 9 |
商品库存订单表(goods_stock),用来存储订单扣减的记录,其数据如下:
| id | goods_id | order_id | deduct_stock | back_stock | create_time | back_time |
|---|---|---|---|---|---|---|
| 1 | 1001 | 123456 | 1 | NULL | 2025-03-26 17:38:01 | NULL |
商品库存流水表(goods_stock_record),用来存储库存的流水记录,其数据如下:
| id | goods_id | order_id | stock | status | create_time |
|---|---|---|---|---|---|
| 1 | 1001 | 123456 | -1 | 成功 | 2025-03-26 17:38:01 |
现在要实现某些场景(如退款)后退回库存的功能,实现的 Service 方法伪代码如下:
/**
* 根据订单ID回退库存
*
* @param orderId 非空,订单ID
* @param stock 可空,本次退回数量,若不指定则全部退回
* @return 处理结果
*/
@Transactional(rollbackFor = Exception.class)
public ApiResponse<?> backStock(Long orderId, Integer stock) {
GoodsStockRecordEntity record = new GoodsStockRecordEntity();
record.setOrderId(orderId);
record.setStatus(StatusEnum.STATUS_FAIL.getValue());
// 1先查一下有没有此订单的库存
GoodsStockEntity tempGoodsStock = getByOrderId(orderId);
if (tempGoodsStock == null || tempGoodsStock.getDeductStock() == null) {
record.setCreateTime(LocalDateTime.now());
goodsStockRecordMapper.insert(record);
return ApiResponse.badRequest("此订单无库存");
}
// 2如果没有指定库存则取原库存
stock = stock == null ? tempGoodsStock.getDeductStock() : stock;
record.setGoodsId(tempGoodsStock.getGoodsId()).setStock(stock);
Integer finalStock = stock;
// 3加上分布式锁
String lockGoods = LOCK_GOODS_KEY_ + tempGoodsStock.getGoodsId();
ApiResponse<?> response = redisLockService.lock(lockGoods, LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, () -> {
// 4再次查询此订单的库存
GoodsStockEntity goodsStock = getByOrderId(orderId);
// 5如果之前如果没有退过,则初始化为0
int backedStock = goodsStock.getBackStock() == null ? 0 : goodsStock.getBackStock();
int deductStock = Math.abs(goodsStock.getDeductStock());
// 6如果之前已退过的大于原有订单库存,则不让退
if (backedStock >= deductStock) {
return ApiResponse.badRequest("订单库存已全部退还,不可再次操作");
}
// 7如果之前已退过的加上本次要退的大于原有订单库存,则不让退
if ((backedStock + finalStock) > deductStock) {
return ApiResponse.badRequest("订单可退库存不足");
}
// 8更新商品总库存
// update goods set stock = stock + #{finalStock} where goods_id = #{goodsId} and stock + #{finalStock} >= 0
int i = goodsMapper.updateStock(goodsStock.getGoodsId(), finalStock);
if (i <= 0) {
return ApiResponse.badRequest("更新库存失败");
}
goodsStock.setBackStock(backedStock + finalStock);
goodsStock.setBackTime(LocalDateTime.now());
// 9更新订单库存已退总数量和时间
i = goodsStockMapper.updateById(goodsStock);
if (i <= 0) {
throw new SystemException("退回库存失败");
}
record.setStatus(StatusEnum.STATUS_OK.getValue());
return ApiResponse.success();
}, () -> ApiResponse.badRequest("资源繁忙,请稍后再试"));
record.setCreateTime(LocalDateTime.now());
goodsStockRecordMapper.insert(record);
return response;
}
private GoodsStockEntity getByOrderId(Long orderId) {
// select * from goods_stock where order_id = xxx
return goodsStockMapper.selectByOrderId(orderId);
}2、问题现象
我们使用10个线程来测试,结果:
goods 表的 stock字段比10大,期望 10;
goods_stock 表的 back_stock 字段 变为1,期望1;
goods_stock_record 表新增了5条成功的和5条失败的,期望1条成功,9条失败;
3、问题分析
回过头来看看上面的代码,看似天衣无缝,实则在并发环境下会出现超退(超卖)的问题。根据现象进行分析,既然 goods 表的库存发生了多次变更,那么证明操作(8)的前置判断条件并没有生效,仔细观察发现,前面的条件数据来源于 getByOrderId() 方法,而这个方法只是一个简单的查询,这证明了同时有多个线程查询到了相同的数据!惊!不对啊,我已经加了分布式锁,为什么还能查询到同样的数据呢?再往上一看,方法有个注解可不简单——@Transactional,厚礼蟹!在事务中的查询是可重复读(快照读),虽然有分布式锁,但修改完数据后事务还没有提交,分布式锁便已经释放了。
4、解决方案
4.1、方案一:去掉事务
把方法上的事务注解去掉后,再次测试结果正常,因为分布式锁中的修改操作已经实时提交了,其他线程再查询已是最新数据。但这个方案失去了事务的保护,不推荐。
4.2、方案二:在查询方法中开启新事务
改造 getByOrderId() 方法,最终目的是使这个方法开启一个新事务,这样每次查询就是最新的数据了。
4.3、方案三:在查询方法中使用当前读
即对于 getByOrderId() 方法使用 select ... for update 的方式查询,再次测试结果正常。因为使用当前读会加锁,其他线程再查询只能等待更新完。
💡然鹅,需要注意的是,使用此方式一定要给对应的字段加索引(比如这里应该给 order_id 加索引),有了索引MySQL会加间隙锁(有死锁的可能),否则会锁表,这就得不偿失了。
4.4、方案四:考虑使用乐观锁
因为乐观锁的写效率很低(多次重试),因此乐观锁适用于读多写少的场景,量力而行。

