库存问题到底难在哪

库存链路最容易被误解成一句话:扣减时判断一下库存大于 0 就行。

真正到了高并发场景,库存问题通常不是“会不会减”,而是下面这些:

1、并发请求下会不会超卖

2、扣减成功后下游失败,库存怎么回补

3、Redis 预扣和数据库实扣怎么对齐

4、活动峰值时是宁可少卖,还是绝不能超卖

这一篇只讲业务里常用的库存扣减方式和兜底思路,不展开讲分布式事务理论。

扣减别先查后改

库存最经典的错误写法就是先查库存,再 update。

1
2
3
4
5
6
7
public void deduct(Long skuId, int count) {
Stock stock = stockMapper.selectBySkuId(skuId);
if (stock.getAvailable() < count) {
throw new BizException("库存不足");
}
stockMapper.updateAvailable(skuId, stock.getAvailable() - count);
}

这种写法在并发下非常容易超卖,因为查询和更新之间有窗口。

更稳的方式是单 SQL 带条件扣减。

1
2
3
@Update("update stock set available = available - #{count}, locked = locked + #{count} "
+ "where sku_id = #{skuId} and available >= #{count}")
int lockStock(@Param("skuId") Long skuId, @Param("count") int count);

业务侧收口:

1
2
3
4
5
6
7
8
9
10
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderRequest request) {
int affected = stockMapper.lockStock(request.getSkuId(), request.getCount());
if (affected == 0) {
throw new BizException("库存不足");
}

orderMapper.insert(buildOrder(request));
stockLogMapper.insert(buildLockLog(request));
}

关键不是 SQL 多高级,而是扣减动作必须原子化。

库存状态最好拆开

很多系统库存表只有一个 available 字段,结果后面支付超时、下单失败、补偿回滚都不好收。

更接近业务实际的库存模型通常是:

1、available:可用库存

2、locked:已锁定待确认

3、deducted:已真正扣减

1
2
3
4
5
6
7
public class Stock {

private Long skuId;
private Integer available;
private Integer locked;
private Integer deducted;
}

这样做的价值是:

1、下单时先锁库存,不等于已经最终消费

2、支付成功再把 locked 转成 deducted

3、支付失败或订单关闭时可以精确回补

锁库存、扣库存、回补要分开

库存链路最怕一把梭:下单就直接减最终库存。

更稳的做法通常是三段式:

1、下单时锁库存

2、支付成功后确认扣减

3、订单关闭或超时取消后回补库存

1
2
3
4
5
6
7
8
@Transactional(rollbackFor = Exception.class)
public void confirmDeduct(Long orderId, Long skuId, int count) {
int affected = stockMapper.confirmDeduct(skuId, count);
if (affected == 0) {
throw new BizException("确认扣减失败");
}
stockLogMapper.insertConfirmLog(orderId, skuId, count);
}
1
2
3
4
5
6
7
8
@Transactional(rollbackFor = Exception.class)
public void releaseStock(Long orderId, Long skuId, int count) {
int affected = stockMapper.releaseLockedStock(skuId, count);
if (affected == 0) {
throw new BizException("回补库存失败");
}
stockLogMapper.insertReleaseLog(orderId, skuId, count);
}

这样拆开以后,超时关单、支付回调、补偿任务都更容易接。

Redis 预扣只负责扛峰值

大促或秒杀场景里,只靠数据库实扣,入口压力通常扛不住。

这时常见的做法是 Redis 先做一层预扣。

1
2
3
4
public boolean preDeduct(Long skuId, int count) {
Long remain = redisTemplate.opsForValue().decrement("stock:available:" + skuId, count);
return remain != null && remain >= 0;
}

这种方式的价值很直接:

1、入口响应更快

2、数据库写压力可以异步化

3、适合活动瞬时洪峰削峰

但它也有明确前提:Redis 预扣不能代替数据库最终对账。

常见落法一般是:

1、入口 Redis 预扣成功后写下单消息

2、消费侧数据库正式锁库或扣减

3、失败时回补 Redis 并记录补偿日志

1
2
3
4
5
6
7
8
9
public void handleCreateOrder(CreateOrderMessage message) {
try {
createOrderInDb(message);
} catch (Exception e) {
redisTemplate.opsForValue().increment("stock:available:" + message.getSkuId(), message.getCount());
compensationLogService.record(message, e.getMessage());
throw e;
}
}

回补一定要幂等

库存补偿最容易出现的不是没补,而是重复补。

比如:

1、关单任务跑了一次

2、支付失败回调又跑一次

3、人工重试再跑一次

如果回补没有幂等控制,库存会越补越多。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional(rollbackFor = Exception.class)
public void releaseStockIfNecessary(Long orderId, Long skuId, int count) {
StockReleaseLog log = stockReleaseLogMapper.selectByOrderId(orderId);
if (log != null) {
return;
}

int affected = stockMapper.releaseLockedStock(skuId, count);
if (affected == 0) {
throw new BizException("库存回补失败");
}
stockReleaseLogMapper.insert(orderId, skuId, count);
}

库存链路里,扣减要防重,回补一样要防重。

超卖兜底至少两层

真正线上稳一点的库存链路,一般不会只依赖一层保护。

比较常见的双保险是:

1、入口层 Redis 预扣或限流

2、数据库条件 update 作为最终底线

再往上一层,还会加:

1、下单总量监控

2、库存负数告警

3、订单与库存对账任务

4、活动结束后的补偿扫描

1
2
3
4
5
6
7
8
9
10
public void auditStock(Long skuId) {
int dbAvailable = stockMapper.queryAvailable(skuId);
int soldCount = orderMapper.queryPaidCount(skuId);
if (dbAvailable < 0) {
alarmService.send("库存出现负数, skuId=" + skuId);
}
if (soldCount > stockMapper.queryTotal(skuId)) {
alarmService.send("疑似超卖, skuId=" + skuId);
}
}

兜底的核心不是永远不出错,而是出错时能及时发现、能回收、能止损。

常见坑

1、库存扣减先查后改

这是最经典的超卖来源,并发一上来就暴露。

2、下单直接减最终库存

一旦支付失败或订单取消,后面回补和对账都会很难看。

3、Redis 预扣成功就当真实成功

Redis 只能做前置削峰,最终账还是要以数据库为准。

4、库存回补没有幂等

补偿任务、回调、人工重试叠在一起,库存很容易被补多。

5、库存异常没有监控

如果库存负数、预扣回补失败、对账不平都没有报警,问题往往只能靠用户投诉发现。

收一下

库存扣减真正难的地方,不是把数量减掉,而是高并发下还能把账做平。

真正值得保留的常用实践,核心就这几类:

1、数据库扣减必须条件更新,不能先查后改

2、库存状态最好拆成可用、锁定、已扣减

3、锁库存、确认扣减、回补库存要拆开设计

4、Redis 预扣可以抗峰值,但不能代替数据库底线

5、库存回补必须幂等

6、超卖兜底至少要有入口控制加数据库底线两层保护