Redis 中 Lua 脚本的应用和实践

引言

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

redis lua 脚本相关命令

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

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

  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 脚本的原子性。调试尽量放在测试环境完成之后再发布到生产环境,在生产环境调试千万不要使用同步模式,原因下文会详细讨论。

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

  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 中 Lua 脚本的应用和实践》

建立一个 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 脚本已经有了很好的了解,可以实现一些自己想要的功能了,感谢大家的阅读。

    原文作者:后端开发
    原文地址: https://segmentfault.com/a/1190000018070172
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞