分类:架构与高并发 · 作者:管理员 · 发布于 2026-09-22 · 阅读 0 · 标签:库存扣减 / 防超卖 / Redis Lua / 分布式锁 / 高并发

1. 场景:为什么库存会超卖?

电商秒杀/抢购场景下,多个用户同时下单扣减同一商品库存,如果扣减操作不是原子的,就会出现「超卖」——卖出去的数量 > 实际库存。

典型错误代码:

// 问题代码:先查后扣,非原子操作
public boolean deductStock(Long skuId, Integer quantity) {
    // 步骤1:查询当前库存
    Integer stock = stockMapper.selectStock(skuId);
    // 步骤2:判断是否足够
    if (stock == null || stock < quantity) {
        return false;
    }
    // 步骤3:扣减(UPDATE)
    int affected = stockMapper.deductStock(skuId, quantity);
    return affected > 0;
}

并发下线程 A 查到 stock=1,线程 B 也查到 stock=1,两线程都进入扣减,导致库存变成 -1。这就是经典的「读-改-写」并发冲突。

2. 方案一:数据库乐观锁(低并发兜底)

利用 MySQL 行锁+版本号或条件 UPDATE 保证扣减原子性:

// 乐观锁:扣减的同时校验库存 >= 扣减量
@Update("UPDATE sku_stock SET stock = stock - #{quantity}, version = version + 1 " +
        "WHERE sku_id = #{skuId} AND stock >= #{quantity} AND version = #{version}")
int deductByOptimisticLock(@Param("skuId") Long skuId,
                           @Param("quantity") Integer quantity,
                           @Param("version") Integer version);

优点:实现简单,依赖数据库本身的行锁机制。

缺点:

  • 每次扣减都要重试(affected=0 时用户重试),并发越高重试次数越多
  • MySQL 行锁在热点 SKU 上会形成锁竞争,TPS 天花板低(单机 MySQL 约 500~2000 TPS)
  • 库存频繁更新导致 InnoDB 二级索引页分裂和 Binlog 量暴增

适用场景:低并发(<100 TPS)、非热点商品、作为兜底校验。

3. 方案二:Redis SETNX 分布式锁(防重复扣减)

对同一个 SKU 加锁,使扣减操作串行化:

public boolean deductWithLock(Long skuId, Integer quantity) {
    String lockKey = "lock:stock:" + skuId;
    String lockValue = UUID.randomUUID().toString();
    // 加锁:SET NX EX 3(3秒自动过期,防死锁)
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(3));
    if (Boolean.FALSE.equals(locked)) {
        return false; // 获取锁失败,提示稍后重试
    }
    try {
        // 查库存 → 扣库存(此时已串行化)
        Integer stock = (Integer) redisTemplate.opsForValue().get("stock:" + skuId);
        if (stock == null || stock < quantity) return false;
        redisTemplate.opsForValue().decrement("stock:" + skuId, quantity);
        return true;
    } finally {
        // Lua 脚本保证原子释放锁(只释放自己的锁)
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                        "then return redis.call('del', KEYS[1]) " +
                        "else return 0 end";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue);
    }
}

踩坑记录:

  • 锁续命(Watchdog):3 秒锁过期时间在 GC STW 或网络慢时不够,要用 Redisson 的 Watchdog 机制(默认每 10 秒续一次,续到业务完成)
  • 锁粒度:锁 SKU 粒度比锁整个方法细很多,但仍属于「串行化」,高并发下大量线程阻塞等待
  • Redis 主从延迟:主写锁 key 未同步到从时主宕机,新主无锁→多线程同时进入,库存仍然超卖

4. 方案三:Redis Lua 原子扣减(生产首选)

Redis 单线程执行 Lua 脚本,整个脚本原子完成「查库存 → 判足 → 扣减」三步,不需要外部锁:

-- redis_stock_deduct.lua
-- KEYS[1] = stock:sku:{skuId}  库存 key
-- KEYS[2] = sold:sku:{skuId}   已售 key(可选,用于记录销量)
-- ARGV[1] = 扣减数量
-- ARGV[2] = 当前时间戳(用于记录)

local stock = redis.call('GET', KEYS[1])
if not stock then
    return {-1, '库存不存在'}
end
local stockNum = tonumber(stock)
local deductNum = tonumber(ARGV[1])

if stockNum < deductNum then
    return {0, '库存不足'}
end

redis.call('DECRBY', KEYS[1], deductNum)
-- 可选:记录已售数量
redis.call('INCRBY', KEYS[2], deductNum)
return {1, '扣减成功'}
// Java 调用 Lua 脚本
private static final String DEDUCT_SCRIPT =
    "local stock = redis.call('GET', KEYS[1])\n" +
    "if not stock then return {-1, '库存不存在'} end\n" +
    "local stockNum = tonumber(stock)\n" +
    "local deductNum = tonumber(ARGV[1])\n" +
    "if stockNum < deductNum then return {0, '库存不足'} end\n" +
    "redis.call('DECRBY', KEYS[1], deductNum)\n" +
    "redis.call('INCRBY', KEYS[2], deductNum)\n" +
    "return {1, '扣减成功'}";

public DeductResult deductByLua(Long skuId, int quantity) {
    DefaultRedisScript<List> script = new DefaultRedisScript<>(DEDUCT_SCRIPT, List.class);
    List<Object> result = redisTemplate.execute(script,
            Arrays.asList("stock:sku:" + skuId, "sold:sku:" + skuId),
            String.valueOf(quantity));
    int code = ((Long) result.get(0)).intValue();
    String msg = (String) result.get(1);
    return new DeductResult(code == 1, msg);
}

性能对比(实测):

  • MySQL 乐观锁:~800 TPS(单机)
  • Redis 分布式锁(串行等待):~3,000 TPS
  • Redis Lua 原子扣减:~15,000 TPS(单 Redis 节点)

关键注意点:

  • Lua 脚本中 禁止使用随机值/非确定性函数(如 TIME、SRANDMEMBER),否则 Redis Cluster 模式下无法保证所有 key 在同一 slot,导致 CROSSSLOT 错误
  • 所有操作涉及的 key 必须传 KEYS 数组,不能在脚本内拼接,否则 Cluster 下无法路由
  • 脚本内容建议注册(SCRIPT LOAD + EVALSHA),减少带宽

5. 方案四:分段库存(热点 SKU 终极解法)

单 key 在极端热点(如 iPhone 首发 100 万人抢 1000 台)下,即使 Lua 原子扣减,Redis 单线程执行也会成为瓶颈。此时将库存拆成多个段(segment):

// 初始化:将库存 1000 拆成 10 段,每段 100
public void initSegmentStock(Long skuId, int totalStock, int segmentCount) {
    int base = totalStock / segmentCount;
    int remainder = totalStock % segmentCount;
    for (int i = 0; i < segmentCount; i++) {
        int segStock = base + (i < remainder ? 1 : 0);
        redisTemplate.opsForValue().set("stock:seg:" + skuId + ":" + i, segStock);
    }
}

// 扣减:随机选段,Lua 原子扣其中一段
public boolean deductSegment(Long skuId, int quantity, int segmentCount) {
    int startIdx = ThreadLocalRandom.current().nextInt(segmentCount);
    for (int i = 0; i < segmentCount; i++) {
        int idx = (startIdx + i) % segmentCount;
        String segKey = "stock:seg:" + skuId + ":" + idx;
        // 用 Lua 脚本尝试扣该段
        Long result = redisTemplate.execute(SEGMENT_DEDUCT_SCRIPT,
                Collections.singletonList(segKey),
                String.valueOf(quantity));
        if (result != null && result >= 0) {
            return true;
        }
    }
    return false; // 所有段都不足
}

分段后,压力从 1 个 key 分散到 N 个 key,Redis 单线程可交错处理不同段的请求,整体吞吐提升 3~5 倍。

坑:需要处理「段碎片」——最后一段可能有余量但所有段分布不均,扣减时需遍历所有段才返回失败。

6. 完整方案设计:三层防线

生产环境不要只依赖一层,建议三层防线组合:

层级技术作用
第一层(Redis Lua)Redis 原子扣减库存承载 99% 流量,高吞吐、低延迟
第二层(数据库乐观锁)UPDATE ... WHERE stock >= quantity最终一致性保障,防 Redis 宕机或主从切换丢数据
第三层(异步对账)定时任务 + Binlog 消费,核对订单数与库存扣减数发现不一致自动补偿(退款或补货)

7. 最佳实践总结

  • 原子性是第一原则:无论用 MySQL 还是 Redis,扣减操作必须在一个原子步骤内完成,绝不能「先查后扣」
  • Redis Lua 是性价比最高的方案:性能足够、实现简单、无锁竞争,适合 95% 的库存场景
  • 分布式锁要配合 Lua 释放:非必要不引入外部锁,如果没有分段需求,Lua 加 Redis 单 key 就够
  • 库存预热:活动开始前将库存从 MySQL 加载到 Redis,用 EXEC 或 Lua 保证批量写入原子性
  • 库存归还:下单后 30 分钟未支付自动取消,通过延时消息(RabbitMQ 死信 / Redis Keyspace Notification)归还库存
  • 只减不增陷阱:严禁直接从数据库 UPDATE stock = stock + 1 恢复库存——必须经过过期时间校验,防止刷单恶意占库存后退款套利

8. 踩坑实录

  • Redis Cluster 模式下 Lua 玩脱了:脚本内用了 KEYS 之外的动态 key,报 CROSSSLOT——修复方式是将所有 key 通过 KEYS 数组传入
  • 锁的过期时间太短:一次 Full GC 耗时 2.8 秒,锁过期,另一个线程进入扣减——后面用 Redisson Watchdog 或手动延长超时
  • 库存初始化为 0 导致全部失败:活动开始前没有预热,Redis 中 key 不存在,Lua 脚本返回「库存不存在」——加一层兜底从 MySQL 同步加载
  • 分段库存碎片导致用户看到「有货但买不到」:每段 100,用户买 5 件,但每段只剩 3 件——解决方案是扣减时遍历所有段聚合判断