redis与Lua整合以及使用lua实现秒杀功能

一、安装lua

  centos使用以下命令安装

curl -R -O http://www.lua.org/ftp/lua-5.3.0.tar.gz
tar zxf lua-5.3.0.tar.gz
cd lua-5.3.0
make linux test
make install

  安装过程中可能出现的异常及解决办法如下:

  问题:

[root@liconglong-aliyun lua-5.3.0]# make linux test

......
make[2]: *** [lua.o] Error 1
make[2]: Leaving directory `/root/rj/lua/lua-5.3.0/src'
make[1]: *** [linux] Error 2
make[1]: Leaving directory `/root/rj/lua/lua-5.3.0/src'
make: *** [linux] Error 2

  解决方案:

yum install libtermcap-devel ncurses-devel libevent-devel readline-devel -y

二、Redis整合lua

  从redis2.6.0版本开始,通过内置的lua编译器和解析器,可以使用eval命令对lua脚本进行求值

  eval命令:eval script numkeys key [key ...] arg[arg ...]

    其中script是一段lua脚本程序,numbers参数是指key参数个数,key参数和arg参数,分别可以使用KEYS[index]和ARGV[index]在script脚本中获取,其中index从1开始

  示例如下:

127.0.0.1:6380> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 lcl qmm 20 18
1) "lcl"
2) "qmm"
3) "20"
4) "18"

三、lua脚本调用redis命令

1、redis.call()

  返回值就是redis命令执行的返回值,如果出错,返回错误信息,不继续执行

2、redis.pcall()

  返回值就是redis命令执行的返回值,如果出错了,记录错误信息,继续执行

  在脚本中,使用return语句将返回值返回给客户端,如果没有return,则返回nil

127.0.0.1:6380> eval "return redis.call('set',KEYS[1],'testvalue')" 1 lclkey
OK
127.0.0.1:6380> get lclkey
"testvalue"

3、redis-cli --eval

   可以使用redis-cli --eval 命令指定一个lua脚本文件去执行。

--获取指定值
local num = redis.call('get',KEYS[1]);
if not num then
 return num;
else
 local res = num * KEYS[2] * ARGV[1];
 redis.call('set',KEYS[1],res);
 return res;
end;

  设置key为luat的值为5(set luat 5)

  然后在linux中执行

[root@liconglong-aliyun sentinel]# ./redis02/redis-5.0.4/bin/redis-cli -p 6380 --eval test.lua luat 20 , 30
(integer) 3000

  说明,这里要说明一下linux中执行lua脚本参数传值和redis命令执行lua脚本传值的差异问题,如果传入多个参数,那么在redis命令中,需要指定key的个数,所有的key和argv参数之间都使用空格分隔即可,lua脚本执行时,会根据传入的key个数自动区分开key参数和argv参数;但是在linux命令中,key参数和argv参数要用逗号分隔,key和key之间、argv与argv之间用空格分隔,如果key和argv之间不使用逗号,则会抛出异常,并且逗号前后需有空格,否则会被认为是传的一个参数,同样会抛出异常

  linux命令中参数传值异常及正常传值:

[root@liconglong-aliyun sentinel]# ./redis02/redis-5.0.4/bin/redis-cli -p 6380 --eval test.lua luat 20 30
(error) ERR Error running script (call to f_8444ebd7385d71e3ee4daa6dc99acca626c75f4c): @user_script:6: user_script:6: attempt to perform arithmetic on field '?' (a nil value)
[root@liconglong-aliyun sentinel]# ./redis02/redis-5.0.4/bin/redis-cli -p 6380 --eval test.lua luat 20, 30
(error) ERR Error running script (call to f_8444ebd7385d71e3ee4daa6dc99acca626c75f4c): @user_script:6: user_script:6: attempt to perform arithmetic on field '?' (a string value)
[root@liconglong-aliyun sentinel]# ./redis02/redis-5.0.4/bin/redis-cli -p 6380 --eval test.lua luat 20 , 30
(integer) 1800000

  redis中传值可以参考第二点(Redis整合lua)

四、redis+lua秒杀

  先说一下秒杀需求,首先,现在有一个活动,活动ID为123,在这个活动中,可以秒杀或者抢购某件商品(假设商品的SKUID为369),一个用户只允许抢购在该活动中抢购一件商品,同时,该用户名下也最多只能有一件该商品(就是如果通过其他途径买入,也不能参加该抢购活动)。

  了解了需求,那么首先就是编写lua脚本,在项目的resource目录下创建一个lua文件夹,专门放置lua脚本。

  根据需求,捋一下处理逻辑

    1、redis中要存sku的库存总量,以防抢购超库存量;要存用户在该活动中已抢购数量,用来限制用户在同一活动中抢购数量;要存用户已购sku数量,用户限制用户购入sku总量;记录一次活动中秒杀成功的次数,用以记录

    2、校验逻辑:库存是否大于0;用户抢购同一sku数量是否超过限制;用户购买同一sku数量是否超过限制;本次抢购sku数量是否超过了库存

    3、如果校验通过,则抢购成功,sku库存减一;用户购买该sku的数量加一;用户在该活动中抢购sku数量加一;用户秒杀成功数加一

  脚本如下所示:里面已经加了注释,不再一一说明代码逻辑

--用户Id
local userId = KEYS[1]
--用户购买数量
local buynum = tonumber(KEYS[2])
--用户购买的SKU
local skuid = KEYS[3]
--每人购买此SKU的数量限制
local perSkuLimit = tonumber(KEYS[4])
--活动Id
local actId = KEYS[5]
--此活动中商品每人购买限制
local perActLimit = tonumber(KEYS[6])
--订单下单时间
local ordertime = KEYS[7]
--每个用户购买的某一sku数量
local user_sku_hash = 'sec_'..actId..'_u_sku_hash'
--每个用户购买的某一活动中商品的数量(已购买)
local user_act_hash = 'sec_'..actId..'_u_act_hash'
--sku的库存数
local sku_amount_hash = 'sec_'..actId..'_sku_amount_hash'
--秒杀成功的记录数
local second_log_hash = 'sec_'..actId..'_u_sku_hash'

--判断的流程:

--判断商品库存数(当前sku是否还有库存)
local skuAmountStr = redis.call('hget',sku_amount_hash,skuid) --获取目前sku的库存量
if skuAmountStr == false then  --如果没有获取到,则说明商品设置有误,直接返回异常
    redis.log(redis.LOG_NOTICE,'skuAmountStr is nil ')
    return '-3'
end

local skuAmount = tonumber(skuAmountStr)
--如果库存不大于0,则说明无库存,不能再抢购
if skuAmount <= 0 then
    return '0'
end

local userActKey = userId..'_'..actId
--判断用户已购买的同一sku数量,

if perActLimit > 0 then  --如果每个人可以抢购的数量大于0,才能进行抢购,否则逻辑错误
    local userActNumInt = 0
    --获取该活动中该用户已经抢购到的数量
    local userActNum = redis.call('hget',user_act_hash,userActKey)
    --如果没有获取到,则说明用户还未抢购到,直接抢购用户下单的数量
    if userActNum == false then
        userActNumInt = buynum
    else   --如果获取到了用户在该活动中已经抢购到的数量,则用户抢购成功后的sku总量=原有数量 + 本次下单数量
        local curUserActNumInt = tonumber(userActNum)
        userActNumInt = curUserActNumInt + buynum
    end

    --如果抢购成功后用户在活动中抢购sku的数量大于每个用户限制的数量,则返回异常
    if userActNumInt > perActLimit then
        return '-2'
    end
end

--判断用户已购买的同一秒杀活动中的商品数量
local goodsUserKey = userId..'_'..skuid
if perSkuLimit > 0 then --判断每个用户允许下单该sku的最大数量
    --获取用户已购买的sku数量
    local goodsUserNum = redis.call('hget',user_sku_hash,goodsUserKey)
    local goodsUserNumInt = 0
    --逻辑同上,如果获取异常,说明用户目前没有购买过该sku,那么秒杀成功后购买sku的数量就是本次购买数量,否则就是本次购买数量 + 原有已购sku数量
    if goodsUserNum == false then
        goodsUserNumInt = buynum
    else
        local curSkuUserNumInt = tonumber(goodsUserNum)
        goodsUserNumInt = curSkuUserNumInt + buynum
    end
    --逻辑同上,如果本次购买成功后已购sku数量大于限制值,则返回异常
    if goodsUserNumInt > perSkuLimit then
        return '-1'
    end
end

--如果库存数量大于秒杀数量,则将sku库存减一;将用户购买该sku的数量加一;将用户在该活动中抢购sku数量加一;将用户秒杀成功数加一;最终返回订单号
if skuAmount >= buynum then
    local decrNum = 0-buynum
    -- sku库存减一
    redis.call('hincrby',sku_amount_hash,skuid,decrNum)
    -- 用户购买该sku的数量加一
    if perSkuLimit > 0 then
        redis.call('hincrby',user_sku_hash,goodsUserKey,buynum)
    end
    -- 用户在该活动中抢购sku数量加一
    if perActLimit > 0 then
        redis.call('hincrby',user_act_hash,userActKey,buynum)
    end
    local orderKey = userId..'_'..'_'..buynum..'_'..ordertime
    local orderStr = '1'
    -- 用户秒杀成功数加一
    redis.call('hset',second_log_hash,orderKey,orderStr)
    return orderKey
else
    return '0'
end

  然后就是对lua脚本的调用工具类

@Service
@Slf4j
public class RedisUtils {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    
    public String runLuaScript(String luaFileName, List<String> keyList) {
        DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/" + luaFileName)));
        redisScript.setResultType(String.class);
        String result = "";
        String argsone = "none";
        //logger.error("开始执行lua");
        try {
            result = stringRedisTemplate.execute(redisScript, keyList, argsone);
        } catch (Exception e) {
            log.error("秒杀失败", e);
        }

        return result;
    }
}

  然后就是service层调用

@Service
public class LuaService {
    @Autowired
    private RedisUtils redisUtils;

    /**
     * 秒杀功能,调用lua脚本
     *
     * @param actId     活动id
     * @param userId    用户id
     * @param buyNum    购买数量
     * @param skuId     skuid
     * @param perSkuLim 每个用户购买当前sku的数量限制
     * @param perActLim 每个用户购买当前活动内所有sku的数量限制
     * @return
     */
    public String skuSecond(String actId, String userId, int buyNum, String skuId, int perSkuLim, int perActLim) {

        //时间字串,用来区分秒杀成功的订单
        int START = 100000;
        int END = 900000;
        int rand_num = ThreadLocalRandom.current().nextInt(END - START + 1) + START;
        String order_time = getTime(rand_num);

        List<String> keyList = new ArrayList<>();
        keyList.add(userId);
        keyList.add(String.valueOf(buyNum));
        keyList.add(skuId);
        keyList.add(String.valueOf(perSkuLim));
        keyList.add(actId);
        keyList.add(String.valueOf(perActLim));
        keyList.add(order_time);

        String result = redisUtils.runLuaScript("order.lua", keyList);
        System.out.println("------------------lua result:" + result);
        return result;
    }

    private String getTime(int rand_num) {
        Date d = new Date();
        System.out.println(d);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String dateNowStr = sdf.format(d);
        return dateNowStr + "-" + rand_num;
    }
}

  Controller

@RestController
@RequestMapping("/redis")
public class RedisApi {

    @Autowired
    private LuaService luaService;

    @GetMapping("/lua")
    public String luaTest(String actId,String userId,int buyNum,String skuId,int perSkuLim,int perActLim){
        return luaService.skuSecond(actId,userId,buyNum,skuId,perSkuLim,perActLim);
    }
}

  在调用前,先在redis中设置skuid(369)的库存,活动123中用户147258369已抢购sku数量及用户已购买数量

127.0.0.1:6388> hset sec_123_sku_amount_hash 369 10000
(integer) 0
127.0.0.1:6388> hset sec_123_u_act_hash 147258369_123 0
(integer) 0
127.0.0.1:6388> hset sec_123_u_sku_hash 147258369_369 0
(integer) 0

  然后调用:http://localhost:8080/redis/lua?actId=123&userId=147258369&buyNum=1&skuId=369&perSkuLim=1&perActLim=1

  就是在活动123中用户147258369准备抢购1件skuid为369的商品,一个人最多在该活动中抢购一件369商品,一个人最多只能购买一件369商品

  

 

   调用成功,最后再查看响应的库存等信息

127.0.0.1:6388> hget sec_123_sku_amount_hash 369
"9999"127.0.0.1:6388> hget sec_123_u_act_hash 147258369_123
"1"
127.0.0.1:6388> hget sec_123_u_sku_hash 147258369_369
"1"

  其实这里还可以模拟一下在高并发下进行调用,可以使用一些压测工具进行测试,这里就不再说明。

posted @ 2021-02-02 21:47  李聪龙  阅读(2664)  评论(0编辑  收藏  举报