告别超卖!Redis Lua 原子扣库存实战——秒杀双库存模型解析

久滴直播电商平台 · 技术博客系列 第 4 篇

🔖 标签:Redis Lua 秒杀 库存一致性

前言

秒杀是电商系统中最考验技术功底的场景之一。瞬间涌入的大量请求,如果直接打到数据库上,轻则响应变慢,重则数据库宕机。更严重的是库存超卖——100 件商品卖出了 120 单,用户体验直接崩盘。

久滴直播电商平台在秒杀场景中设计了一套 Redis Lua 原子扣库存 + 双库存模型的方案,在保证高性能的同时,从根本上杜绝了超卖问题。本文将从数据模型到 Lua 脚本逐行解析,带你完整理解这套方案。

目录

  1. 秒杀场景下的库存超卖问题分析
  2. 双库存模型设计
  3. Lua 脚本逐行解析
  4. SeckillStockRedisService 三层能力
  5. 异常情况与降级策略
  6. DB 层的最终一致性保障

1. 秒杀场景下的库存超卖问题分析

先来看一个经典的超卖时序:

线程A: 读取库存 = 1  →  判断 > 0  →  扣减库存 (1→0)  →  创建订单
线程B: 读取库存 = 1  →  判断 > 0  →  扣减库存 (1→0)  →  创建订单

两个线程同时读到库存为 1,都判断"有库存",然后都执行了扣减——结果就是超卖了。

传统的解决方案是数据库加锁(SELECT ... FOR UPDATE)或者用乐观锁(WHERE stock >= count),但在高并发场景下,这两种方案要么性能太差,要么有大量请求失败。

最佳实践是把库存判断和扣减操作前置到 Redis 中,利用 Redis 的单线程执行模型来保证原子性。

2. 双库存模型设计

久滴平台的秒杀场景引入了双库存模型

  • SKU 维度库存seckill:sku_stock:{activityId}:{skuId}):每个 SKU 的独立库存
  • 活动维度库存seckill:activity_stock:{activityId}):整个秒杀活动的总库存

为什么要双库存?考虑一个秒杀活动下有多个 SKU(如不同颜色/尺码)的场景:

  • SKU 库存控制单个商品的售罄
  • 活动库存控制整场秒杀的总量(防止多个 SKU 累计超卖)

两者必须同时扣减成功才算扣库存成功,这就是"双扣模型"的由来。

3. Lua 脚本逐行解析

Redis 的 Lua 脚本在 Redis 中是原子执行的——脚本执行期间不会有其他命令被插入。久滴平台用两个 Lua 脚本分别实现库存预扣和回补。

预扣脚本 seckill-stock-decr.lua

-- 秒杀库存预扣 Lua 脚本
-- KEYS[1] = seckill:sku_stock:{activityId}:{skuId}
-- KEYS[2] = seckill:activity_stock:{activityId}
-- ARGV[1] = count(扣减数量)
-- 返回: 1=成功, 0=库存不足, -1=key不存在

local skuStock = redis.call('GET', KEYS[1])
if not skuStock then return -1 end

local actStock = redis.call('GET', KEYS[2])
if not actStock then return -1 end

local count = tonumber(ARGV[1])
if tonumber(skuStock) < count or tonumber(actStock) < count then
    return 0
end

redis.call('DECRBY', KEYS[1], count)
redis.call('DECRBY', KEYS[2], count)
return 1

逻辑非常清晰,分四步:

  1. GET SKU 库存:如果 key 不存在返回 -1(说明没有预热,需要降级到 DB)
  2. GET 活动库存:同理,key 不存在返回 -1
  3. 比较判断:两个库存都必须 >= 扣减数量,否则返回 0(库存不足)
  4. 双扣 DECRBY:同时扣减两个 key,由于是 Lua 脚本内执行,保证原子性

回补脚本 seckill-stock-incr.lua

-- 秒杀库存回补 Lua 脚本
-- KEYS[1] = seckill:sku_stock:{activityId}:{skuId}
-- KEYS[2] = seckill:activity_stock:{activityId}
-- ARGV[1] = count(回补数量)
-- 返回: 1=成功

redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
redis.call('INCRBY', KEYS[2], tonumber(ARGV[1]))
return 1

回补脚本更简单——直接对两个 key 做 INCRBY。在用户取消订单或支付超时时调用,把库存"还回去"。

4. SeckillStockRedisService 三层能力

Java 层的 SeckillStockRedisService 封装了三个核心方法,分别对应库存生命周期的三个阶段:

预热(warmUp)

活动开始前,把库存数据从数据库加载到 Redis:

public void warmUpStock(Long activityId, Long skuId, int stock) {
    String skuKey = buildSkuStockKey(activityId, skuId);
    String actKey = buildActivityStockKey(activityId);
    stringRedisTemplate.opsForValue()
        .set(skuKey, String.valueOf(stock), STOCK_KEY_TTL_DAYS, TimeUnit.DAYS);
    stringRedisTemplate.opsForValue()
        .set(actKey, String.valueOf(stock), STOCK_KEY_TTL_DAYS, TimeUnit.DAYS);
    log.info("[warmUpStock] 预热库存成功,activityId={}, skuId={}, stock={}",
        activityId, skuId, stock);
}

Key 设置了 7 天的 TTL,防止僵尸 key 占用内存。

预扣(tryDecr)

用户下单时调用,返回三种结果:

public Boolean tryDecrStock(Long activityId, Long skuId, int count) {
    String skuKey = buildSkuStockKey(activityId, skuId);
    String actKey = buildActivityStockKey(activityId);
    List<String> keys = Arrays.asList(skuKey, actKey);

    try {
        Long result = stringRedisTemplate.execute(decrScript, keys, String.valueOf(count));
        if (result == null) return null;
        if (result == 1L) return true;      // 扣减成功
        else if (result == 0L) return false; // 库存不足
        else return null;                     // key 不存在,需降级
    } catch (Exception e) {
        log.error("[tryDecrStock] Redis 预扣异常,降级到 DB", e);
        return null;
    }
}

返回值设计很有讲究:

  • true → 扣减成功,可以创建订单
  • false → 库存不足,直接拒绝
  • null → 异常情况(key 不存在或 Redis 不可用),降级到数据库走常规扣减逻辑

回补(incr)

订单取消时调用,把库存加回去:

public void incrStock(Long activityId, Long skuId, int count) {
    String skuKey = buildSkuStockKey(activityId, skuId);
    String actKey = buildActivityStockKey(activityId);
    List<String> keys = Arrays.asList(skuKey, actKey);
    try {
        stringRedisTemplate.execute(incrScript, keys, String.valueOf(count));
        log.info("[incrStock] 库存回补成功,activityId={}, skuId={}, count={}",
            activityId, skuId, count);
    } catch (Exception e) {
        log.error("[incrStock] Redis 库存回补异常", e);
    }
}

5. 异常情况与降级策略

生产环境中,Redis 不是万能的。久滴平台设计了完整的降级链路:

用户下单
  ↓
Redis Lua 预扣
  ├── 成功 → 创建订单 → DB 扣减(异步)
  ├── 库存不足 → 直接返回"已售罄"
  ├── key 不存在 → 降级到 DB 乐观锁扣减
  └── Redis 异常 → 降级到 DB 乐观锁扣减

tryDecrStock 返回 null 时,上层业务会 fallback 到数据库层面的库存扣减——使用 CAS 乐观锁保证一致性:

UPDATE seckill_activity_sku
SET stock = stock - #{count}, sales = sales + #{count}
WHERE activity_id = #{activityId}
  AND sku_id = #{skuId}
  AND stock >= #{count}

6. DB 层的最终一致性保障

Redis 预扣只是"快速通道",数据库才是最终的"账本"。在 Redis 预扣成功后,系统会异步执行 DB 扣减:

  • CAS 乐观锁WHERE stock >= count 确保不超卖
  • 唯一索引UNIQUE(activity_id, sku_id, order_id) 防止重复扣减
  • 定时对账:后台定时任务比对 Redis 和 DB 的库存数据,发现不一致时以 DB 为准修正

这套"Redis 预扣 + DB 兜底"的架构,既保证了秒杀的高性能(绝大部分请求在 Redis 层就被拦截),又保证了数据一致性(DB 是最终真相源)。

总结

本文拆解了久滴直播电商平台秒杀库存的核心方案:

  • 双库存模型:SKU 库存 + 活动库存双维度控制,防止单品和总量两个层面的超卖
  • Lua 脚本原子操作:GET → 判空 → 比较 → DECRBY 四步在一个 Redis 命令中原子完成
  • 三层返回值:成功/不足/异常,异常时优雅降级到 DB
  • DB 最终一致性:CAS 乐观锁 + 唯一索引 + 定时对账

下一篇我们将走进直播间弹幕系统,看看腾讯云 IM 在直播互动场景下的工程化落地。


📦 久滴直播电商平台 是一个基于 Spring Boot 3 + Vue3 + UniApp 的全栈开源直播电商解决方案。

🔗 GitHub:https://github.com/jiudi-dev/live-mall-community(Community 版)

如果觉得本文对你有帮助,欢迎 Star ⭐ 支持!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值