• 企业400电话
  • 微网小程序
  • AI电话机器人
  • 电商代运营
  • 全 部 栏 目

    企业400电话 网络优化推广 AI电话机器人 呼叫中心 网站建设 商标✡知产 微网小程序 电商运营 彩铃•短信 增值拓展业务
    详解Redis中Lua脚本的应用和实践

    引言

    前段时间组内有个投票的产品,上线前考虑欠缺,导致被刷票严重。后来,通过研究,发现可以通过 redis lua 脚本实现限流,这里将 redis lua 脚本相关的知识分享出来,讲的不到位的地方还望斧正。

    redis lua 脚本相关命令

    这一小节的内容是基本命令,可粗略阅读后跳过,等使用的时候再回来查询

    redis 自 2.6.0 加入了 lua 脚本相关的命令,EVALEVALSHASCRIPT EXISTSSCRIPT FLUSHSCRIPT KILLSCRIPT LOAD,自 3.2.0 加入了 lua 脚本的调试功能和命令SCRIPT DEBUG。这里对命令做下简单的介绍。

    生产环境中,推荐使用EVALSHA,相较于EVAL的每次发送脚本主体、浪费带宽,会更高效。这里要注意SCRIPT KILL,杀死正在运行脚本的时候,如果脚本执行过写操作了,这里会杀死失败,因为这违反了 redis lua 脚本的原子性。调试尽量放在测试环境完成之后再发布到生产环境,在生产环境调试千万不要使用同步模式,原因下文会详细讨论。

    Redis 中 lua 脚本的书写和调试

    redis lua 脚本是对其现有命令的扩充,单个命令不能完成、需要多个命令,但又要保证原子性的动作可以用脚本来实现。脚本中的逻辑一般比较简单,不要加入太复杂的东西,因为 redis 是单线程的,当脚本执行的时候,其他命令、脚本需要等待直到当前脚本执行完成。因此,对 lua 的语法也不需完全了解,了解基本的使用就足够了,这里对 lua 语法不做过多介绍,会穿插到脚本示例里面。

    一个秒杀抢购示例

    假设有一个秒杀活动,商品库存 100,每个用户 uid 只能抢购一次。设计抢购流程如下:

    1. 先通过 uid 判断是否已经抢过,已经抢过返回0结束。
    2. 判断商品剩余库存是否大于0,是的话进入「3」,否的话返回0结束。
    3. 将用户 uid 加入已购用户set中。
    4. 物品数量减一,返回成功1结束。
    local goodsSurplus
    local flag
    -- 判断用户是否已抢过
    local buyMembersKey  = tostring(KEYS[1])
    local memberUid    = tonumber(ARGV[1])
    local goodsSurplusKey = tostring(KEYS[2])
    local hasBuy = redis.call("sIsMember", buyMembersKey, memberUid)
    
    -- 已经抢购过,返回0
    if hasBuy ~= 0 then
     return 0
    end
    
    -- 准备抢购
    goodsSurplus = redis.call("GET", goodsSurplusKey)
    if goodsSurplus == false then
     return 0
    end
    
    -- 没有剩余可抢购物品
    goodsSurplus = tonumber(goodsSurplus)
    if goodsSurplus = 0 then
     return 0
    end
    
    flag = redis.call("SADD", buyMembersKey, memberUid)
    flag = redis.call("DECR", goodsSurplusKey)
    
    return 1

    即使不了解 lua,相信你也可以将上面的脚本看个一二,其中--开始的是单行注释。local用来声明局部变量,redis lua 脚本中的所有变量都应该声明为local xxx,避免在持久化、复制的时候产生各种问题。KEYSARGV是两个全局变量,就像 PHP 中的$argc$argv一样,脚本执行时传入的参数会写入这两个变量,供我们在脚本中使用。redis.call用来执行 redis 现有命令,传参跟 redis 命令行执行时传入参数顺序一致。

    另外 redis lua 脚本中用到 lua table 的地方还比较多,这里要注意,lua 脚本中的 table 下标是从 1 开始的,比如KEYSARGV,这里跟其他语言不一样,需要注意。

    对于主要使用 PHP 这种弱类型语言开发同学来说,一定要注意变量的类型,不同类型比较的时候可能会出现类似attempt to compare string with number的提示,这个时候使用 lua 的tonumber将字符串转换为数字在进行比较即可。比如我们使用GET去获取一个值,然后跟 0 比较大小,就需要将获取出来的字符串转换为数字。

    在调试之前呢,我们先看看效果,将上面的代码保存到 lua 文件中/path/to/buy.lua,然后运行redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984即可执行脚本,执行之后返回-1,因为我们未设置商品数量,set goodsSurplus 5之后再次执行,效果如下:

    ➜ ~ redis-cli set goodsSurplus 5
    OK
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984
    (integer) 1
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984
    (integer) 0
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742983
    (integer) 1
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742982
    (integer) 1
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742981
    (integer) 1
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742980
    (integer) -1
    ➜ ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 58247
    (integer) -1

    在命令行运行脚本的时候,脚本后面传入的是参数,通过 , 分隔为两组,前面是键,后面是值,这两组分别写入KEYSARGV。分隔符一定要看清楚了,逗号前后都有空格,漏掉空格会让脚本解析传入参数异常。

    debug 调试

    上一小节,我们写了很长一段 redis lua 脚本,怎么调试呢,有没有像 GDB 那样的调试工具呢,答案是肯定的。redis 从 v3.2.0 开始支持 lua debugger,可以加断点、print 变量信息、展示正在执行的代码......我们结合上一小节的脚本,来详细说说 redis 中 lua 脚本的调试。

    如何进入调试模式

    执行redis-cli --ldb --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984,进入调试模式,比之前执行的时候多了参数--ldb,这个参数是开启 lua dubegger 的意思,这个模式下 redis 会 fork 一个进程进入隔离环境,不会影响 redis 正常提供服务,但调试期间,原始 redis 执行命令、脚本的结果也不会体现到 fork 之后的隔离环境之中。因此呢,还有另外一种调试模式--ldb-sync-mode,也就是前面提到的同步模式,这个模式下,会阻塞 redis 上所有的命令、脚本,直到脚本退出,完全模拟了正式环境使用时候的情况,使用的时候务必注意这点。

    调试命令详解

    这一小节的内容是调试时候的详细命令,可以粗略阅读后跳过,等使用的时候再回来查询

    帮助信息

    [h]elp

    调试模式下,输入h或者help展示调试模式下的全部可用指令。

    流程相关

    [s]tep 、 [n]ext 、 [c]continue

    执行当前行代码,并停留在下一行,如下所示

    * Stopped at 4, stop reason = step over
    -> 4  local buyMembersKey  = tostring(KEYS[1])
    lua debugger> n
    * Stopped at 5, stop reason = step over
    -> 5  local memberUid    = tonumber(ARGV[1])
    lua debugger> n
    * Stopped at 6, stop reason = step over
    -> 6  local goodsSurplusKey = tostring(KEYS[2])
    lua debugger> s
    * Stopped at 7, stop reason = step over
    -> 7  local hasBuy = redis.call("sIsMember", buyMembersKey, memberUid)

    continue从当前行开始执行代码直到结束或者碰到断点。

    展示相关

    [l]list 、 [l]list [line] 、 [l]list [line] [ctx] 、 [w]hole

    展示当前行附近的代码,[line]是重新指定中心行,[ctx]是指定展示中心行周围几行代码。[w]hole是展示所有行代码

    打印相关

    [p]rint 、 [p]rint var>

    打印当前所有局部变量,var>是打印指定变量,如下所示:

    lua debugger> print
    value> goodsSurplus = nil
    value> flag = nil
    value> buyMembersKey = "hadBuyUids"
    value> memberUid = 58247
    lua debugger> print buyMembersKey
    value> "hadBuyUids"

    断点相关

    [b]reak 、 [b]reak line> 、 [b]reak -line> 、 [b]reak 0

    展示断点、像指定行添加断点、删除指定行的断点、删除所有断点

    其他命令

    [r]edis cmd> 、 [m]axlen [len] 、 [a]bort 、 [e]eval code> 、 [t]race

    详细说下[m]axlen [len]命令,如下代码:

    local myTable = {}
    local count = 0
    while count  1000 do
      myTable[count] = count
      count = count + 1
    end
    
    return 1

    在最后一行打印断点,执行print可以看到,输出了一长串内容,我们执行maxlen 10之后,再次执行print可以看到打印的内容变少了,设置为maxlen 0之后,再次执行可以看到所有的内容全部展示了。

    详细说下[t]race命令,代码如下:

    local function func1(num)
     num = num + 1
     return num
    end
    
    local function func2(num)
     num = func1(num)
     num = num + 1
     return num
    end
    
    func2(123)

    执行b 2在 func1 中打断点,然后执行c,断点地方停顿,再次执行t,可以到如下信息:

    lua debugger> t
    In func1:
    ->#3   return num
    From func2:
      7   num = func1(num)
    From top level:
      12 func2(123)

    请求限流

    至此,算是对 redis lua 脚本有了基本的认识,基本语法、调试也做了了解,接下来就实现一个请求限流器。流程和代码如下:

    --[[
     传入参数:
     业务标识
     ip
     限制时间
     限制时间内的访问次数
    ]]--
    local busIdentify  = tostring(KEYS[1])
    local ip      = tostring(KEYS[2])
    local expireSeconds = tonumber(ARGV[1])
    local limitTimes  = tonumber(ARGV[2])
    
    local identify = busIdentify .. "_" .. ip
    
    local times   = redis.call("GET", identify)
    
    --[[
     获取已经记录的时间
     获取到继续判断是否超过限制
     超过限制返回0
     否则加1,返回1
    ]]--
    if times ~= false then
     times = tonumber(times)
     if times >= limitTimes then
      return 0
     else
      redis.call("INCR", identify)
      return 1
     end
    end
    
    -- 不存在的话,设置为1并设置过期时间
    local flag = redis.call("SETEX", identify, expireSeconds, 1)
    
    return 1

    将上面的 lua 脚本保存到/path/to/limit.lua,执行redis-cli --eval /path/to/limit.lua limit_vgroup 192.168.1.19 , 10 3,表示 limit_vgroup 这个业务,192.168.1.1 这个 ip 每 10 秒钟限制访问三次。

    好了,至此,一个请求限流功能就完成了,连续执行三次之后上面的程序会返回 0,过 10 秒钟在执行,又可以返回 1,这样便达到了限流的目的。

    有同学可能会说了,这个请求限流功能还有值得优化的地方,如果连续的两个计数周期,第一个周期的最后请求 3 次,接着马上到第二个周期了,又可以请求了,这个地方如何优化呢,我们接着往下看。

    请求限流优化

    上面的计数器法简单粗暴,但是存在临界点的问题。为了解决这个问题,引入类似滑动窗口的概念,让统计次数的周期是连续的,可以很好的解决临界点的问题,滑动窗口原理如下图所示:

    建立一个 redis list 结构,其长度等价于访问次数,每次请求时,判断 list 结构长度是否超过限制次数,未超过的话,直接加到队首返回成功,否则,判断队尾一条数据是否已经超过限制时间,未超过直接返回失败,超过删除队尾元素,将此次请求时间插入队首,返回成功。

    local busIdentify  = tostring(KEYS[1])
    local ip      = tostring(KEYS[2])
    local expireSeconds = tonumber(ARGV[1])
    local limitTimes  = tonumber(ARGV[2])
    -- 传入额外参数,请求时间戳
    local timestamp   = tonumber(ARGV[3])
    local lastTimestamp
    
    local identify = busIdentify .. "_" .. ip
    local times   = redis.call("LLEN", identify)
    if times  limitTimes then
     redis.call("RPUSH", identify, timestamp)
     return 1
    end
    
    lastTimestamp = redis.call("LRANGE", identify, 0, 0)
    lastTimestamp = tonumber(lastTimestamp[1])
    
    if lastTimestamp + expireSeconds >= timestamp then
     return 0
    end
    
    redis.call("LPOP", identify)
    redis.call("RPUSH", identify, timestamp)
    
    return 1

    上面的 lua 脚本保存到/path/to/limit_fun.lua,执行redis-cli --eval /path/to/limit_fun.lua limit_vgroup 192.168.1.19 , 10 3 1548660999即可。

    最开始,我想着把时间戳计算redis.call("TIME")也放入 redis lua 脚本中,后来发现使用的时候 redis 会报错,这是因为 redis 默认情况复制 lua 脚本到备机和持久化中,如果脚本是一个非纯函数(pure function),备库中执行的时候或者宕机恢复的时候可能产生不一致的情况,这里可以类比 mysql 中基于 SQL 语句的复制模式。redis 在 3.2 版本中加入了redis.replicate_commands函数来解决这个问题,在脚本第一行执行这个函数,redis 会将修改数据的命令收集起来,然后用MULTI/EXEC包裹起来,这种方式称为script effects replication,这个类似于 mysql 中的基于行的复制模式,将非纯函数的值计算出来,用来持久化和主从复制。我们这里将变动参数提到调用方这里,调用者传入时间戳来解决这个问题。

    另外,redis 从版本 5 开始,默认支持script effects replication,不需要在第一行调用开启函数了。如果是耗时计算,这样当然很好,同步、恢复的时候只需要计算一次后边就不用计算了,但是如果是一个循环生成的数据,可能在同步的时候会浪费更多的带宽,没有脚本来的更直接,但这种情况应该比较少。

    至此,脚本优化完成了,但我又想到一个问题,我们的环境是单机环境,如果是分布式环境的话,脚本怎么执行、何处理呢,接下来一节,我们来讨论下这个问题。

    集群环境中 lua 处理

    redis 集群中,会将键分配的不同的槽位上,然后分配到对应的机器上,当操作的键为一个的时候,自然没问题,但如果操作的键为多个的时候,集群如何知道这个操作落到那个机器呢?比如简单的mget命令,mget test1 test2 test3,还有我们上面执行脚本时候传入多个参数,带着这个问题我们继续。

    首先用 docker 启动一个 redis 集群,docker pull grokzen/redis-cluster,拉取这个镜像,然后执行docker run -p 7000:7000 -p 7001:7001 -p 7002:7002 -p 7003:7003 -p 7004:7004 -p 7005:7005 --name redis-cluster-script -e "IP=0.0.0.0" grokzen/redis-cluster启动这个容器,这个容器启动了一个 redis 集群,3 主 3 从。

    我们从任意一个节点进入集群,比如redis-cli -c -p 7003,进入后执行cluster nodes可以看到集群的信息,我们链接的是从库,执行set lua fun,有同学可能会问了,从库也可以执行写吗,没问题的,集群会计算出 lua 这个键属于哪个槽位,然后定向到对应的主库。

    执行mset lua fascinating redis powerful,可以看到集群反回了错误信息,告诉我们本次请求的键没有落到同一个槽位上

    (error) CROSSSLOT Keys in request don't hash to the same slot

    同样,还是上面的 lua 脚本,我们加上集群端口号,执行redis-cli -p 7000 --eval /tmp/limit_fun.lua limit_vgroup 192.168.1.19 , 10 3 1548660999,一样返回上面的错误。

    针对这个问题,redis官方为我们提供了hash tag这个方法来解决,什么意思呢,我们取键中的一段来计算 hash,计算落入那个槽中,这样同一个功能不同的 key 就可以落入同一个槽位了,hash tag 是通过{}这对括号括起来的字符串,比如上面的,我们改为mset lua{yes} fascinating redis{yes} powerful,就可以执行成功了,我这里 mset 这个操作落到了 7002 端口的机器。

    同理,我们对传入脚本的键名做 hash tag 处理就可以了,这里要注意不仅传入键名要有相同的 hash tag,里面实际操作的 key 也要有相同的 hash tag,不然会报错Lua script attempted to access a non local key in a cluster node,什么意思呢,就拿我们上面的例子来说,执行的时候如下所示,可以看到 , 前面的两个键都加了 hash tag —— yes,这样没问题,因为脚本里面只是用了一个拼接的 key —— limit_vgroup{yes}_192.168.1.19{yes}

    redis-cli -c -p 7000 --eval /tmp/limit_fun.lua limit_vgroup{yes} 192.168.1.19{yes} , 10 3 1548660999

    如果我们在脚本里面加上redis.call("GET", "yesyes")(别让这个键跟我们拼接的键落在一个solt),可以看到就报了上面的错误,所以在执行脚本的时候,只要传入参数键、脚本里面执行 redis 命令时候的键有相同的 hash tag 即可。

    另外,这里有个 hash tag 规则:

    键中包含{字符;建中包含{字符,并在{字符右边;并且{,}之间有至少一个字符,之间的字符就用来做键的 hash tag。

    所以,键limit_vgroup{yes}_192.168.1.19{yes}的 hash tag 是 yesfoo{}{bar}键的 hash tag就是它本身。foo{{bar}}键的 hash tag 是 {bar

    使用 golang 连接使用 redis

    这里我们使用 golang 实例展示下,通过ForEachMaster将 lua 脚本缓存到集群中的每个 node,并保存返回的 sha 值,以后通过 evalsha 去执行代码。

    package main
    
    import (
      "github.com/go-redis/redis"
      "fmt"
    )
    
    func createScript() *redis.Script {
      script := redis.NewScript(`
        local busIdentify  = tostring(KEYS[1])
        local ip      = tostring(KEYS[2])
        local expireSeconds = tonumber(ARGV[1])
        local limitTimes  = tonumber(ARGV[2])
        -- 传入额外参数,请求时间戳
        local timestamp   = tonumber(ARGV[3])
        local lastTimestamp
    
        local identify = busIdentify .. "_" .. ip
        local times   = redis.call("LLEN", identify)
        if times  limitTimes then
         redis.call("RPUSH", identify, timestamp)
         return 1
        end
    
        lastTimestamp = redis.call("LRANGE", identify, 0, 0)
        lastTimestamp = tonumber(lastTimestamp[1])
    
        if lastTimestamp + expireSeconds >= timestamp then
         return 0
        end
    
        redis.call("LPOP", identify)
        redis.call("RPUSH", identify, timestamp)
    
        return 1    
      `)
    
      return script
    }
    
    func scriptCacheToCluster(c *redis.ClusterClient) string {
      script := createScript()
      var ret string
    
      c.ForEachMaster(func(m *redis.Client) error {
        if result, err := script.Load(m).Result(); err != nil {
          panic("缓存脚本到主节点失败")
        } else {
          ret = result
        }
        return nil
      })
    
      return ret
    
    }
    
    func main() {
      redisdb := redis.NewClusterClient(redis.ClusterOptions{
        Addrs: []string{
          ":7000",
          ":7001",
          ":7002",
          ":7003",
          ":7004",
          ":7005",
        },
      })
      // 将脚本缓存到所有节点,执行一次拿到结果即可
      sha := scriptCacheToCluster(redisdb)
    
      // 执行缓存脚本
      ret := redisdb.EvalSha(sha, []string{
        "limit_vgroup{yes}",
        "192.168.1.19{yes}",
      }, 10, 3,1548660999)
    
      if result, err := ret.Result(); err != nil {
        fmt.Println("发生异常,返回值:", err.Error())
      } else {
        fmt.Println("返回值:", result)
      }
    
     // 示例错误情况,sha 值不存在
      ret1 := redisdb.EvalSha(sha + "error", []string{
        "limit_vgroup{yes}",
        "192.168.1.19{yes}",
      }, 10, 3,1548660999)
    
      if result, err := ret1.Result(); err != nil {
        fmt.Println("发生异常,返回值:", err.Error())
      } else {
        fmt.Println("返回值:", result)
      }
    }

    执行上面的代码,返回值如下:

    返回值: 0
    发生异常,返回值: NOSCRIPT No matching script. Please use EVAL.

    好了,目前为止,相信你对 redis lua 脚本已经有了很好的了解,可以实现一些自己想要的功能了,感谢大家的阅读。希望对大家的学习有所帮助,也希望大家多多支持脚本之家。

    您可能感兴趣的文章:
    • 详解利用redis + lua解决抢红包高并发的问题
    • 简介Lua脚本与Redis数据库的结合使用
    • Redis执行Lua脚本的好处与示例代码
    • redis中如何使用lua脚本让你的灵活性提高5个逼格详解
    • 利用Lua定制Redis命令的方法详解
    • Redis如何使用lua脚本实例教程
    • Nginx利用Lua+Redis实现动态封禁IP的方法
    • Redis和Lua使用过程中遇到的小问题
    • 通过redis的脚本lua如何实现抢红包功能
    上一篇:如何使用Redis保存用户会话Session详解
    下一篇:Redis连接错误的情况总结分析
  • 相关文章
  • 

    © 2016-2020 巨人网络通讯 版权所有

    《增值电信业务经营许可证》 苏ICP备15040257号-8

    详解Redis中Lua脚本的应用和实践 详解,Redis,中,Lua,脚本,的,