redis lua 脚本相关命令

redis lua 脚本相关命令

这一小节的内容是基本命令,可粗略阅读后跳过,等使用的时候再回来查询。
redis 自 2.6.0 加入了 lua 脚本相关的命令, EVAL、 EVALSHA、 SCRIPT EXISTS、 SCRIPT FLUSH、 SCRIPT KILL、 SCRIPT LOAD,自 3.2.0 加入了 lua 脚本的调试功能和命令 SCRIPT DEBUG。这里对命令做下简单的介绍。

EVAL执行一段lua脚本,每次都需要将完整的lua脚本传递给redis服务器。
SCRIPT LOAD将一段lua脚本缓存到redis中并返回一个tag串,并不会执行。
EVALSHA执行一个脚本,不过传入参数是「2」中返回的tag,节省网络带宽。
SCRIPT EXISTS判断「2」返回的tag串是否存在服务器中。
SCRIPT FLUSH清除服务器上的所有缓存的脚本。
SCRIPT KILL杀死正在运行的脚本。
SCRIPT DEBUG设置调试模式,可设置同步、异步、关闭,同步会阻塞所有请求。
生产环境中,推荐使用 EVALSHA,相较于 EVAL的每次发送脚本主体、浪费带宽,会更高效。这里要注意 SCRIPT KILL,杀死正在运行脚本的时候,如果脚本执行过写操作了,这里会杀死失败,因为这违反了 redis lua 脚本的原子性。调试尽量放在测试环境完成之后再发布到生产环境,在生产环境调试千万不要使用同步模式,原因下文会详细讨论。

Redis 中 lua 脚本的书写和调试

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

一个秒杀抢购示例

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

先通过 uid 判断是否已经抢过,已经抢过返回 0结束。
判断商品剩余库存是否大于0,是的话进入「3」,否的话返回 0结束。
将用户 uid 加入已购用户set中。
物品数量减一,返回成功 1结束。
代码:

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

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

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

在调试之前呢,我们先看看效果,将上面的代码保存到 lua 文件中 /path/to/buy.lua,然后运行 redis-cli–eval/path/to/buy.lua hadBuyUids goodsSurplus,5824742984即可执行脚本,执行之后返回 -1,因为我们未设置商品数量, setgoodsSurplus5之后再次执行,效果如下:
redis lua 脚本相关命令_第2张图片
在命令行运行脚本的时候,脚本后面传入的是参数,通过 ,分隔为两组,前面是键,后面是值,这两组分别写入 KEYS和 ARGV。分隔符一定要看清楚了,逗号前后都有空格,漏掉空格会让脚本解析传入参数异常。

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
执行当前行代码,并停留在下一行,如下所示:
redis lua 脚本相关命令_第3张图片
continue从当前行开始执行代码直到结束或者碰到断点。

展示相关

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

打印相关

[p]rint 、 [p]rint
打印当前所有局部变量, 是打印指定变量,如下所示:
redis lua 脚本相关命令_第4张图片
断点相关

[b]reak 、 [b]reak、 [b]reak -、 [b]reak 0
展示断点、像指定行添加断点、删除指定行的断点、删除所有断点。

其他命令

[r]edis、 [m]axlen [len] 、 [a]bort 、 [e]eval 、 [t]race
说明:

在调试其中执行 redis 命令
设置展示内容的最大长度,0表示不限制
退出调试模式,同步模式下(设置了参数 --ldb-sync-mode)修改会保留。
执行一行 lua 代码。
展示执行栈。
详细说下 [m]axlen[len]命令,如下代码:
redis lua 脚本相关命令_第5张图片
在最后一行打印断点,执行 print可以看到,输出了一长串内容,我们执行 maxlen10之后,再次执行 print可以看到打印的内容变少了,设置为 maxlen0之后,再次执行可以看到所有的内容全部展示了。

详细说下 [t]race命令,代码如下:
redis lua 脚本相关命令_第6张图片
执行 b2在 func1 中打断点,然后执行 c,断点地方停顿,再次执行 t,可以到如下信息:
redis lua 脚本相关命令_第7张图片
请求限流

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

redis lua 脚本相关命令_第8张图片

代码:
redis lua 脚本相关命令_第9张图片
将上面的 lua 脚本保存到 /path/to/limit.lua,执行 redis-cli–eval/path/to/limit.lua limit_vgroup192.168.1.19,103,表示 limit_vgroup 这个业务,192.168.1.1 这个 ip 每 10 秒钟限制访问三次。

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

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

请求限流优化

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

redis lua 脚本相关命令_第10张图片

建立一个 redis list 结构,其长度等价于访问次数,每次请求时,判断 list 结构长度是否超过限制次数,未超过的话,直接加到队首返回成功,否则,判断队尾一条数据是否已经超过限制时间,未超过直接返回失败,超过删除队尾元素,将此次请求时间插入队首,返回成功。
redis lua 脚本相关命令_第11张图片
上面的 lua 脚本保存到 /path/to/limit_fun.lua,执行 redis-cli–eval/path/to/limit_fun.lua limit_vgroup192.168.1.19,1031548660999即可。

最开始,我想着把时间戳计算 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-p7000:7000-p7001:7001-p7002:7002-p7003:7003-p7004:7004-p7005:7005–name redis-cluster-script-e"IP=0.0.0.0"grokzen/redis-cluster启动这个容器,这个容器启动了一个 redis 集群,3 主 3 从。

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

执行 mset lua fascinating redis powerful,可以看到集群反回了错误信息,告诉我们本次请求的键没有落到同一个槽位上:
在这里插入图片描述
同样,还是上面的 lua 脚本,我们加上集群端口号,执行 redis-cli-p7000–eval/tmp/limit_fun.lua limit_vgroup192.168.1.19,1031548660999,一样返回上面的错误。

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

同理,我们对传入脚本的键名做 hash tag 处理就可以了,这里要注意不仅传入键名要有相同的 hash tag,里面实际操作的 key 也要有相同的 hash tag,不然会报错 Luascript attempted to access a nonlocalkeyina cluster node,什么意思呢,就拿我们上面的例子来说,执行的时候如下所示,可以看到 ,前面的两个键都加了 hash tag —— yes,这样没问题,因为脚本里面只是用了一个拼接的 key —— limit_vgroup{yes}_192.168.1.19{yes}。
在这里插入图片描述
如果我们在脚本里面加上 redis.call(“GET”,“yesyes”)(别让这个键跟我们拼接的键落在一个solt),可以看到就报了上面的错误,所以在执行脚本的时候,只要传入参数键、脚本里面执行 redis 命令时候的键有相同的 hash tag 即可。

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

键中包含 {字符;建中包含 {字符,并在 {字符右边;并且 {, }之间有至少一个字符,之间的字符就用来做键的 hash tag。
所以,键 limit_vgroup{yes}_192.168.1.19{yes}的 hash tag 是 yes。 foo{}{bar}键的 hash tag就是它本身。 foo{{bar}}键的 hash tag 是 {bar。

使用 golang 连接使用 redis

这里我们使用 golang 实例展示下,通过 ForEachMaster将 lua 脚本缓存到集群中的每个 node,并保存返回的 sha 值,以后通过 evalsha 去执行代码。
redis lua 脚本相关命令_第12张图片
redis lua 脚本相关命令_第13张图片
执行上面的代码,返回值如下:
在这里插入图片描述

你可能感兴趣的:(redis)