Skip to content

库存扣减如何避免超卖和少卖?

1. 问题根源分析

  • 超卖原因
    • 并发竞争:多个用户同时请求同一商品库存,未加锁或原子性操作导致库存扣减覆盖(如库存100,两个请求同时读100,各自扣减后写回99,实际应扣减到98)。
    • 缓存与数据库不一致:缓存(如Redis)中库存未及时同步到数据库,或数据库事务未提交时已更新缓存。
  • 少卖原因
    • 事务回滚未恢复库存:扣减库存后因订单创建失败、支付超时等场景回滚事务,但未恢复库存。
    • 异步处理丢失:消息队列消费失败未重试,导致已扣减的库存未真正占用。

2. 核心解决方案

2.1 数据库层保障
  • 悲观锁(行锁)
    • 在事务中通过SELECT FOR UPDATE锁定库存行,确保串行操作。
    sql
    BEGIN;
    SELECT stock FROM item WHERE id = 123 FOR UPDATE;  -- 锁定行
    UPDATE item SET stock = stock - 1 WHERE id = 123 AND stock >= 1;
    COMMIT;
  • 乐观锁(CAS机制)
    • 基于版本号或库存值进行条件更新,避免直接覆盖。
    sql
    UPDATE item 
    SET stock = stock - 1, version = version + 1 
    WHERE id = 123 AND stock >= 1 AND version = {current_version};
    -- 返回影响行数,若为0则说明库存不足或版本冲突,需重试或拒绝
2.2 缓存层优化
  • 预扣库存(Redis原子操作)
    • 使用Redis的DECRINCRBY原子指令扣减库存,避免并发覆盖。
    redis
    SET item:123:stock 100  -- 初始化库存
    DECR item:123:stock     -- 原子扣减,返回当前值
    • 防超卖逻辑:扣减后检查结果值是否≥0,若为负数则回滚(INCR恢复)。
  • 缓存与数据库一致性
    • 采用双写策略(先更新数据库再删缓存)或订阅数据库Binlog同步缓存。
2.3 异步队列削峰
  • 请求串行化
    • 将库存扣减请求发送到消息队列(如Kafka),由消费者单线程处理,确保顺序性。
    java
    // 生产者发送消息
    kafkaTemplate.send("stock-topic", itemId, userId);
    
    // 消费者单线程消费
    @KafkaListener(topics = "stock-topic")
    public void consume(String itemId, String userId) {
        deductStock(itemId);  // 串行扣减
    }
  • 预占库存与超时释放
    • 用户下单时预占库存(状态为“预占”),支付成功后转为“已扣减”,超时未支付则释放。
    sql
    UPDATE item 
    SET prehold_stock = prehold_stock + 1, 
        available_stock = available_stock - 1 
    WHERE id = 123 AND available_stock >= 1;
2.4 分布式锁控制
  • Redis分布式锁
    • 使用Redisson或Lua脚本实现锁机制,确保同一商品库存扣减的原子性。
    lua
    -- Lua脚本(原子执行)
    if redis.call('GET', KEYS[1]) >= ARGV[1] then
        return redis.call('DECRBY', KEYS[1], ARGV[1])
    else
        return -1
    end

3. 异常场景兜底

  • 少卖补偿
    • 定时对账任务:每小时扫描“预占库存但未支付”的订单,自动释放库存。
    • 事务回滚监听:通过数据库事务钩子(如Spring @TransactionalEventListener),在回滚时恢复库存。
  • 超卖熔断
    • 实时监控库存阈值,当剩余库存≤5%时触发限流(如Sentinel),防止雪崩。

4. 业务规则增强

  • 分层库存设计
    • 区分可用库存(前端展示)、预占库存(下单未支付)、实际库存(仓库物理库存)。
    sql
    CREATE TABLE item (
      id INT PRIMARY KEY,
      total_stock INT,          -- 总库存
      available_stock INT,      -- 可售库存
      prehold_stock INT         -- 预占库存
    );
  • 动态库存调整
    • 根据销售速率动态调整前端显示的库存量(如每秒减少N个),避免瞬时超卖。

5. 面试回答示例

问题:“如何避免库存扣减时的超卖和少卖?”
回答结构

  1. 问题分析:说明并发竞争、数据不一致等核心原因。
  2. 分层解决方案
  • 数据库层:悲观锁(行锁)与乐观锁(CAS)的实现;
  • 缓存层:Redis原子操作预扣库存;
  • 异步队列:请求串行化与预占超时释放;
  • 分布式锁:Redis Lua脚本保证原子性。
  1. 兜底机制:定时对账、事务回滚监听、熔断限流。
  2. 案例补充:举例说明某秒杀系统通过Redis预扣库存+数据库最终一致,支撑10万QPS且零超卖。
  3. 扩展思考:提及分库分表策略(库存按商品ID散列)以应对高并发场景。

6. 总结

通过 数据库锁机制缓存原子操作异步削峰分布式锁 四层防护,结合 兜底对账动态规则,可系统性解决超卖与少卖问题。需根据业务场景权衡方案(如秒杀场景优先性能,普通订单优先强一致性)。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我