库存问题到底难在哪 库存链路最容易被误解成一句话:扣减时判断一下库存大于 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、超卖兜底至少要有入口控制加数据库底线两层保护