• 第四节:抢单流程优化3(lua整合限流、购买限制、方法幂等、扣减库存)


    一. Lua简介

    1. 介绍

     Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

     该章节主要是Redis调用Lua脚本。

    2. 好处

     (1). 减少网络开销:本来多次网络请求的操作,可以用一个请求完成,原先多次次请求的逻辑都放在redis服务器上完成,使用脚本,减少了网络往返时延。

     (2). 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。

     (3). 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。

    注:lua整合一系列redis操作, 是为了保证原子性, 即redis在处理这个lua脚本期间不能执行其它操作, 但是lua脚本自身假设中间某条指令出错,并不会回滚的,会继续往下执行或者报错了。

    3. 基本语法

    (1). 基本结构,类似于js,前面声明方法,后面调用方法。

    (2). 获取传过来的参数:ARGV[1]、ARGV[2] 依次类推,获取传过来的Key,用KEYS[1]来获取。

    (3). 调用redis的api,用redis.call( )方法调用。

    (4). int类型转换 tonumber

    参考代码:

    local function seckillLimit()
    --(1).获取相关参数
    -- 限制请求数量
    local tLimits=tonumber(ARGV[1]);
    -- 限制秒数
    local tSeconds =tonumber(ARGV[2]);
    -- 受限商品key
    local limitKey = ARGV[3];
    --(2).执行判断业务
    local myLimitCount = redis.call('INCR',limitKey);
    
    -- 仅当第一个请求进来设置过期时间
    if (myLimitCount ==1) 
    then
    redis.call('expire',limitKey,tSeconds) --设置缓存过期
    end;   --对应的是if的结束
    
    -- 超过限制数量,返回失败
    if (myLimitCount > tLimits) 
    then
    return 0;  --失败
    end;   --对应的是if的结束
    
    end;   --对应的是整个代码块的结束
    
    
    --1. 单品限流调用
    local status1 = seckillLimit();
    if status1 == 0 then
    return 2;   --失败
    end

    参考菜鸟教程:https://www.runoob.com/lua/lua-tutorial.html 

    详细语法分析参照的Redis章节的文章。 

    二. CSRedisCore使用

    1. 简介

      CSRedisCore 比 StackExchange.Redis 性能更高,提供的Api更加丰富,支持主从、哨兵、Cluster等模式,提供一个简易RedisHelper帮助类,方便快速调用API。

     GitHub地址:https://github.com/2881099/csredis

    2. 调用Lua脚本

    (1). 初始CSRedisCore ,这里可以直接使用csredis,也可以使用RedisHelper帮助类

      var csredis = new CSRedisClient("localhost:6379,defaultDatabase=0");
      services.AddSingleton(csredis);  //官方建议单例
      //初始化RedisHelper帮助类,官方建议静态
      RedisHelper.Initialization(csredis);

    (2). 读取Lua脚本,可以暂时放到缓存中,方便全局调用

      FileStream fileStream1 = new FileStream(@"Luas/SeckillLua1.lua", FileMode.Open);
      using (StreamReader reader=new StreamReader(fileStream1))
      {
            string line = reader.ReadToEnd();
            string luaSha = RedisHelper.ScriptLoad(line);
    
                    //保存到缓存中
             _cache.Set<string>("SeckillLua1", luaSha);
       }

    (3). 调用lua脚本 

    RedisHelper.EvalSHA(_cache.Get<string>("SeckillLua1"), "ypf12345", tLimits, tSeconds, limitKey, goodNum, tGoodBuyLimits, userBuyGoodLimitKey, userRequestId, arcKey);

     PS:ypf12345只是一个key,在脚本中可以通过 KEYS[1] 来获取。

     详细语法分析参照的Redis章节的文章。 

    三. Lua整合四项操作

    1.设计思路

     A.编写Lua脚本,将单品限流、购买商品限制、方法幂等、扩建库存整合在一个lua脚本中,程序通过相关的Api调用即可。

     B.启动项目的是加载读取Lua脚本并转换→转换后的结果存到服务器缓存中→业务中调用的时候直接从缓存中读取传给Redis的Api。

    2.分析

     A. 整合在一个脚本中,程序相当于只链接了一次Redis,提高了性能,解决以上四个业务相互之间可能存在的并发问题

     B. 在集群环境中,能替代分布式锁吗?

    3.代码分享

     lua整合脚本

    --[[本脚本主要整合:单品限流、购买的商品数量限制、方法幂等、扣减库存的业务]]
    
    --[[
        一. 方法声明
    ]]--
    
    --1. 单品限流--解决缓存覆盖问题
    local function seckillLimit()
    --(1).获取相关参数
    -- 限制请求数量
    local tLimits=tonumber(ARGV[1]);
    -- 限制秒数
    local tSeconds =tonumber(ARGV[2]);
    -- 受限商品key
    local limitKey = ARGV[3];
    --(2).执行判断业务
    local myLimitCount = redis.call('INCR',limitKey);
    
    -- 仅当第一个请求进来设置过期时间
    if (myLimitCount ==1) 
    then
    redis.call('expire',limitKey,tSeconds) --设置缓存过期
    end;   --对应的是if的结束
    
    -- 超过限制数量,返回失败
    if (myLimitCount > tLimits) 
    then
    return 0;  --失败
    end;   --对应的是if的结束
    
    end;   --对应的是整个代码块的结束
    
    
    --2. 限制一个用户商品购买数量(这里假设一次购买一件,后续改造)
    local function userBuyLimit()
    --(1).获取相关参数
    local tGoodBuyLimits = tonumber(ARGV[5]); 
    local userBuyGoodLimitKey = ARGV[6]; 
    
    --(2).执行判断业务
    local myLimitCount = redis.call('INCR',userBuyGoodLimitKey);
    if (myLimitCount > tGoodBuyLimits)
    then
    return 0;  --失败
    else
    redis.call('expire',userBuyGoodLimitKey,600)  --10min过期
    return 1;  --成功
    end;
    end;    --对应的是整个代码块的结束
    
    --3. 方法幂等(防止网络延迟多次下单)
    local function recordOrderSn()
    --(1).获取相关参数
    local requestId = ARGV[7];    --请求ID
    --(2).执行判断业务
    local requestIdNum = redis.call('INCR',requestId);
    --表示第一次请求
    if (requestIdNum==1)                            
    then
    redis.call('expire',requestId,600)  --10min过期
    return 1; --成功
    end;
    --第二次及第二次以后的请求
    if (requestIdNum>1)
    then
    return 0;  --失败
    end;
    end;  --对应的是整个代码块的结束
    
    --4、扣减库存
    local function subtractSeckillStock()
    --(1) 获取相关参数
    --local key =KEYS[1];   --传过来的是ypf12345没有什么用处
    --local arg1 = tonumber(ARGV[1]);--购买的商品数量
    -- (2).扣减库存
    -- local lastNum = redis.call('DECR',"sCount");
    local lastNum = redis.call('DECRBY',ARGV[8],tonumber(ARGV[4]));  --string类型的自减
    -- (3).判断库存是否完成
    if lastNum < 0 
    then
    return 0; --失败
    else
    return 1; --成功
    end
    end
    
    
    
    --[[
        二. 方法调用   返回值1代表成功,返回:0,2,3,4 代表不同类型的失败
    ]]--
    
    --1. 单品限流调用
    local status1 = seckillLimit();
    if status1 == 0 then
    return 2;   --失败
    end
    
    --2. 限制购买数量
    local status2 = userBuyLimit();
    if status2 == 0 then
    return 3;   --失败
    end
    
    
    --3.  方法幂等
    local status3 = recordOrderSn();
    if status3 == 0 then
    return 4;   --失败
    end
    
    
    --4.扣减秒杀库存
    local status4 = subtractSeckillStock();
    if status4 == 0 then
    return 0;   --失败
    end
    return 1;    --成功
    View Code

    lua回滚脚本

    --[[本脚本主要整合:单品限流、购买的商品数量限制、方法幂等、扣减库存的业务的回滚操作]]
    
    --[[
        一. 方法声明
    ]]--
    
    --1.单品限流恢复
    local function RecoverSeckillLimit()
    local limitKey = ARGV[1];-- 受限商品key
    redis.call('INCR',limitKey);
    end;
    
    --2.恢复用户购买数量
    local function RecoverUserBuyNum()
    local userBuyGoodLimitKey =  ARGV[2]; 
    local goodNum = tonumber(ARGV[5]); --商品数量
    redis.call("DECRBY",userBuyGoodLimitKey,goodNum);
    end
    
    --3.删除方法幂等存储的记录
    local function DelRequestId()
    local userRequestId = ARGV[3];  --请求ID
    redis.call('DEL',userRequestId);
    end;
    
    --4. 恢复订单原库存
    local function RecoverOrderStock()
    local stockKey = ARGV[4];  --库存中的key
    local goodNum = tonumber(ARGV[5]); --商品数量
    redis.call("INCRBY",stockKey,goodNum);
    end;
    
    --[[
        二. 方法调用
    ]]--
    RecoverSeckillLimit();
    RecoverUserBuyNum();
    DelRequestId();
    RecoverOrderStock();
    View Code

    加载lua脚本到缓存

        /// <summary>
        /// 后台任务,初始化lua文件到服务器缓存中
        /// </summary>
        public class LuasLoadService : BackgroundService
        {
    
            private IMemoryCache _cache;
            public LuasLoadService(IMemoryCache cache)
            {
                _cache = cache;
            }
    
            protected override Task ExecuteAsync(CancellationToken stoppingToken)
            {
                FileStream fileStream1 = new FileStream(@"Luas/SeckillLua1.lua", FileMode.Open);
                using (StreamReader reader=new StreamReader(fileStream1))
                {
                    string line = reader.ReadToEnd();
                    string luaSha = RedisHelper.ScriptLoad(line);
    
                    //保存到缓存中
                    _cache.Set<string>("SeckillLua1", luaSha);
                }
                FileStream fileStream2 = new FileStream(@"Luas/SeckillLuaCallback1.lua", FileMode.Open);
                using (StreamReader reader = new StreamReader(fileStream2))
                {
                    string line = reader.ReadToEnd();
                    string luaSha = RedisHelper.ScriptLoad(line);
    
                    //保存到缓存中
                    _cache.Set<string>("SeckillLuaCallback1", luaSha);
                }
                return Task.CompletedTask;
            }
        }
    View Code

    下单接口

    /// <summary>
            ///08-Lua整合
            /// </summary>
            /// <param name="userId">用户编号</param>
            /// <param name="arcId">商品编号</param>
            /// <param name="totalPrice">订单总额</param>
            /// <param name="requestId">请求ID</param>
            /// <param name="goodNum">用户购买的商品数量</param>
            /// <returns></returns>
            public string POrder8(string userId, string arcId, string totalPrice, string requestId = "125643", int goodNum = 1)
            {
                int tLimits = 100;    //限制请求数量
                int tSeconds = 1;     //限制秒数
                string limitKey = $"LimitRequest{arcId}";//受限商品ID
                int tGoodBuyLimits = 3;  //用户单个商品可以购买的数量
                string userBuyGoodLimitKey = $"userBuyGoodLimitKey-{userId}-{arcId}";  //用户单个商品的限制key
                string userRequestId = requestId;    //用户下单页面的请求ID
                string arcKey = $"{arcId}-sCount";  //该商品库存key
                try
                {
                    //调用lua脚本
                    //参数说明:ypf12345没有什么用处,当做一个参数传入进去即可
                    var result = RedisHelper.EvalSHA(_cache.Get<string>("SeckillLua1"), "ypf12345", tLimits, tSeconds, limitKey, goodNum, tGoodBuyLimits, userBuyGoodLimitKey, userRequestId, arcKey);
    
                    //int.Parse("3242fgdfg");    //模拟报错
    
                    if (result.ToString() == "1")
                    {
                        //2. 将下单信息存到消息队列中
                        var orderNum = Guid.NewGuid().ToString("N");
                        _redisDb.ListLeftPush(arcId, $"{userId}-{arcId}-{totalPrice}-{orderNum}");
                        //3. 把部分订单信息返回给前端
                        return $"下单成功,订单信息为:userId={userId},arcId={arcId},orderNum={orderNum}";
                    }
                    else
                    {
                        //请求被禁止,或者是商品卖完了
                        throw new Exception($"没抢到");
                    }
                }
                catch (Exception ex)
                {
                    //lua回滚
                    RedisHelper.EvalSHA(_cache.Get<string>("SeckillLuaCallback1"), "ypf12345", limitKey, userBuyGoodLimitKey, userRequestId, arcKey, goodNum);
                    throw new Exception(ex.Message);
                }
            }
    View Code

    !

    • 作       者 : Yaopengfei(姚鹏飞)
    • 博客地址 : http://www.cnblogs.com/yaopengfei/
    • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
    • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
     
  • 相关阅读:
    实习的一些感想,感触,心得体会
    一张优惠券引发的血案(redis并发安全问题)
    Java各种对象(PO,BO,VO,DTO,POJO,DAO,Entity,JavaBean,JavaBeans)的区分
    Redis 集群
    Maven Pom文件标签详解
    Google Guava 基本工具
    context:component-scan的使用说明
    logback的简单分析
    轮询和长轮询
    StringUtils中 isNotEmpty 和isNotBlank的区别?
  • 原文地址:https://www.cnblogs.com/yaopengfei/p/13826478.html
Copyright © 2020-2023  润新知