第八节:基于Lua+Redis实现 库存增加/扣减 和 流水表对账 方案实操

一. 前言

1. 什么时候扣减库存?有何弊端?

方案1:下单时扣减库存

 优势:

   (1) 能防止超卖 (有效避免多人同时下单导致超卖问题)

   (2) 实时性强:下单就扣库存,确保库存及时更新

 弊端:

   (1).取消订单问题:如果用户取消订单或超时未支付,需要额外处理库存回滚的逻辑。

   (2).恶意占用库存:如果用户不支付或支付失败,需要额外处理库存的恢复,会导致少卖。

   (3).并发量高:下单并发量高,这个环节处理库存扣减会存在显著的热点问题。

方案2:支付成功在回调接口扣减库存

  优势:

    (1) 避免少卖和订单浪费:确保了库存的准确性,因为只有支付成功才会扣减库存。

    (2) 避免取消订单问题:用户取消订单或超时未支付,无需额外处理库存的回滚。

    (3) 并发量没那么高:支付成功回调的并发没有那么高,而且可以异步重试。

  弊端:

    (1) 超卖风险:比如用户支付成功了,但是没库存了,给用户的体验不好。

     【解决方案:退款 或者 沟通延迟发货】

 

2. 本节目标

   本节实操高并发下,如何基于redis扣减库存、增加库存,引入对账表的作用

   至于redis扣减完库存的后续操作,使用MQ也好,或者其他方案?不在本节探讨,后续的秒杀方案章节,会有详细介绍。

 

 

二. 方案实操

1. 初始化库存

   模拟100个库存即可

    /// <summary>
    /// 01-初始化库存
    /// </summary>
    /// <returns></returns>
    [HttpPost]
    public IActionResult InitStock()
    {
        RedisHelper.Set("stock_001", 100);
        return Json(new { status = "ok", msg = "初始化成功" });
    }

2. 扣减库存

   先判断流水表中是否有记录→对库存进行相关校验→扣减库存→插入流水 

 /// <summary>
 /// 03-扣减库存
 /// </summary>
 /// <param name="stockKey">库存的key</param>
 /// <param name="streamHashId">流水表对应的HashId </param>
 /// <param name="needStock">本次要扣减的库存数</param>
 /// <param name="operationKey">本次要增加流水中的唯一编号,前端传递,可以用来做幂等</param>
 /// <returns></returns>
 [HttpPost]
 public IActionResult DecreaseStock(string stockKey = "stock_001", string streamHashId = "stock:stream_001",
     int needStock = 1, string operationKey = "id_123456")
 {

     //KEYS[1]=库存的key               【stock_001】
     //ARGV[1]=本次要扣减的库存数      【1】
     //ARGV[2]=流水对应hash的hashId    【stock:stream_001】
     //ARGV[3]=本次要扣减的唯一编号    【id_123456 前端传递】

     //string operationKey = $"id_{Guid.NewGuid():N}";   //改为前端传递
     try
     {
         var luaScript = """
             -- 检查流水表中是否已经执行过
             if redis.call('hexists',ARGV[2], ARGV[3]) == 1 then
                 return redis.error_reply('The operation has already existed')
             end
             -- 判断库存的key是否存在
             local current = redis.call('get', KEYS[1])
             if current == false then
                 return redis.error_reply('key not found')
             end
             -- 判断库存是否为数字
             if tonumber(current) == nil then
                 return redis.error_reply('current value is not a number')
             end
             -- 判断库存是否为0
             if tonumber(current) == 0 then
                 return redis.error_reply('inventory is empty')
             end
             -- 判断库存数量是否小于扣减数量
             if tonumber(current) < tonumber(ARGV[1]) then
                 return redis.error_reply('inventory is not enough')
             end

             -- 计算库存
             local new_stock = tonumber(current) - tonumber(ARGV[1])
             redis.call('set', KEYS[1], tostring(new_stock))

             -- 获取Redis服务器的当前时间(秒和微秒)
             local time = redis.call("time")
             -- 转换为毫秒级时间戳
             local currentTimeMillis = (time[1] * 1000) + math.floor(time[2] / 1000)

             -- 使用哈希结构存储日志
             redis.call('hset',ARGV[2], ARGV[3], cjson.encode({
                 action = "decrease",
                 from = current,
                 to = new_stock,
                 change = ARGV[1],
                 by = ARGV[3],
                 timestamp = currentTimeMillis
             }))

             return new_stock

             """;
         var result = RedisHelper.Eval(luaScript, stockKey, [needStock, streamHashId, operationKey]);
         return Json(new { status = "ok", msg = "扣减成功", data = result });
     }
     catch (Exception ex)
     {

         return Json(new { status = "error", msg = "扣减失败", data = ex.InnerException.Message });
     }
 }

3. 新增库存

   先判断流水表中是否有记录→对库存进行相关校验→扣减库存→插入流水

    /// <summary>
    /// 02-增加库存
    /// </summary>
    /// <param name="stockKey">库存的key</param>
    /// <param name="streamHashId">流水表对应的HashId </param>
    /// <param name="needStock">本次要增加的库存数</param>
    /// <param name="operationKey">本次要增加流水中的唯一编号,前端传递,可以用来做幂等</param>
    /// <returns></returns>
    [HttpPost]
    public IActionResult AddStock(string stockKey = "stock_001", string streamHashId = "stock:stream_001",
        int needStock = 1,string operationKey= "id_123456")
    {

        //KEYS[1]=库存的key               【stock_001】
        //ARGV[1]=本次要增加的库存数      【1】
        //ARGV[2]=流水对应hash的hashId    【stock:stream_001】
        //ARGV[3]=本次要增加流水中的唯一编号    【eg: id_123456,前端传递】

        //string operationKey = $"id_{Guid.NewGuid():N}";  //改为前端传递
        try
        {
            var luaScript = """
                -- 检查流水表中是否已经执行过
                if redis.call('hexists', ARGV[2], ARGV[3]) == 1 then
                    return redis.error_reply('The operation has already existed')
                end
                -- 判断库存的key是否存在             
                local current = redis.call('get', KEYS[1])
                if current == false then
                    return redis.error_reply('key not found')
                end
                -- 判断库存是否为数字
                if tonumber(current) == nil then
                    return redis.error_reply('current value is not a number')
                end
                -- 计算库存                
                local new_stock = (current == nil and 0 or tonumber(current)) + tonumber(ARGV[1])
                redis.call('set', KEYS[1], tostring(new_stock))
                                
                -- 获取Redis服务器的当前时间(秒和微秒)
                local time = redis.call("time")
                -- 转换为毫秒级时间戳
                local currentTimeMillis = (time[1] * 1000) + math.floor(time[2] / 1000)
                                
                -- 使用哈希结构存储日志
                redis.call('hset', ARGV[2], ARGV[3], cjson.encode({
                    action = "increase",
                    from = current,
                    to = new_stock,
                    change = ARGV[1],
                    by = ARGV[3],
                    timestamp = currentTimeMillis
                }))
                                
                return new_stock

                """;

            var result = RedisHelper.Eval(luaScript, stockKey, [needStock, streamHashId, operationKey]);
            return Json(new { status = "ok", msg = "增加成功", data = result });
        }
        catch (Exception ex)
        {

            return Json(new { status = "error", msg = "增加失败", data = ex.InnerException.Message });
        }
    }

 

 

 

三.  流水表原理

1 原理

   采用hash结构,hashId-key-value,其中

   hashId可以基于商品Id进行生成,

   key可以用买家 id,下单的 token 以及扣减数量拼接到一起的。

   value 中明确的记录了 本次扣减的变化的库存数变化前的库存数 以及 变化后的库存数,还有 变化的操作ID 以及 变化的时间戳

 

2 流水表作用

 (1) 幂等:当我们在lua脚本执行时,会先去查询下是否存在对应的流水,如果查询到了,则说明本次是一个重复请求,直接幂等掉

 (2) 对账:Redis的库存扣减之后,数据库还是要扣减的,那么如何保证双方都一定成功呢,如何发现不一致的情况呢,那就需要这个流水了。

           每一次扣减都有一条流水记录,这样就可以用Redis中的流水和数据库中的流水做核对,如果一致的话则没问题,但是不一致的话,

           可能是丢消息了,或者系统执行异常了。这时候就需要人工介入来解决这个问题了

 

3 流水记录何时删除?

 (1) 主动删除:流水对完帐后,主动删除

 (2) 被动删除:商品下架后,对流水设置24小时到期,到期后基于redis的过期策略进行删除

 

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鹏飞)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
  • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2025-08-07 09:25  Yaopengfei  阅读(88)  评论(2)    收藏  举报