Redis 中 Lua 脚本的应用和实践

引言

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

redis lua 脚本相关命令

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

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

  1. EVAL执行一段lua脚本,每次都须要将完整的lua脚本传递给redis服务器。
  2. SCRIPT LOAD将一段lua脚本缓存到redis中并返回一个tag串,并不会执行。
  3. EVALSHA执行一个脚本,不过传入参数是「2」中返回的tag,节省网络带宽。
  4. SCRIPT EXISTS判断「2」返回的tag串是否存在服务器中。
  5. SCRIPT FLUSH清除服务器上的全部缓存的脚本。
  6. SCRIPT KILL杀死正在运行的脚本。
  7. SCRIPT DEBUG设置调试模式,可设置同步、异步、关闭,同步会阻塞全部请求。

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

Redis 中 lua 脚本的书写和调试

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

一个秒杀抢购示例

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

  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

另外 redis lua 脚本中用到 lua table 的地方还比较多,这里要注意,lua 脚本中的 table 下标是从 1 开始的,好比KEYSARGV,这里跟其余语言不同,须要注意。sql

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

在调试以前呢,咱们先看看效果,将上面的代码保存到 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
  1. 在调试其中执行 redis 命令
  2. 设置展现内容的最大长度,0表示不限制
  3. 退出调试模式,同步模式下(设置了参数--ldb-sync-mode)修改会保留。
  4. 执行一行 lua 代码。
  5. 展现执行栈。

详细说下[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 脚本有了基本的认识,基本语法、调试也作了了解,接下来就实现一个请求限流器。流程和代码以下:
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 脚本已经有了很好的了解,能够实现一些本身想要的功能了,感谢你们的阅读。

相关文章
相关标签/搜索