前言

Redis 当中提供了许多重要的高级特性,比如发布与订阅,Lua 脚本等。Redis 当中也提供了自增的原子命令,但是假如我们需要同时执行好几个命令的同时又想让这些命令保持原子性,该怎么办呢?这时候就可以使用本文介绍的 Lua 脚本来实现。

发布与订阅

发布与订阅功能理论上来说可以直接通过一个双端链表就可以实现了,然而这种通过普通的双端链表来实现的发布与订阅功能有两个局限性:

因为普通双端链表来实现发布与订阅功能有这两个局限性,故而 Redis 当中并没有直接通过双端列表来实现。在 Redis 中的发布与订阅可以分为两种类型:基于频道基于模式

基于频道的实现

基于频道的实现方式主要通过以下三个命令:

打开一个客户端 一,输入订阅命令 subscribe music movie,表示当前客户端订阅了 musicmovie 两个频道的消息:

然后再打开一个客户端二,执行以下发布消息命令:

publish movie myCountry //发布消息 myCountry 到 movie 频道
publish music love  //发布消息 love 到 music 频道
publish tv myHome  //发布消息 myHome 到 tv 频道

前面两个频道发布之后返回 1 就表示当前有 1 个客户端订阅了该频道,消息已经发送到这个客户端。

这时候我们再回到之前的客户端一,就会发现客户端一收到了消息 myCountrylove 两条消息,而 myHome 这条消息是属于频道 tv,客户端一并没有订阅,故而不会收到:

同时,还有以下 2 个命令可以查看当前客户端订阅的频道信息:

打开一个客户端 一,输入订阅命令 psubscribe m*,表示当前客户端订阅了所有以 m 开头的频道:

然后再打开一个客户端二,执行一下发布消息命令:

publish movie myCountry //发布消息 myCountry 到 movie 频道
publish music love  //发布消息 love 到 music 频道
publish tv myHome  //发布消息 myHome 到 tv 频道

前面两个频道发布之后返回 1 就表示当前有 1 个客户端订阅了该频道(上面基于频道订阅的客户端关闭之后会自动取消订阅),消息已经发送到这个客户端。

这时候我们再回到之前的客户端一,就会发现客户端一收到了 myCountrylove 两条消息,因为这两个频道都是以 m 开头的,而 myHome 这条消息是属于频道 tv,并不是以 m 开头,客户端一并没有订阅,故而不会收到:

同样的,基于模式的订阅也提供了一个查询命令:

typedef struct pubsubPattern {
    client *client;//订阅模式的客户端
    robj *pattern;//被订阅的模式
} pubsubPattern;

PS:当基于频道和基于模式两种订阅同时都存在时,Redis 会先去寻找频道字典,再去遍历模式链表进行消息发送。

Lua 脚本

Redis2.6 版本开始支持 Lua 脚本,为了支持 Lua 脚本,Redis 在服务器中嵌入了 Lua 环境。

使用 Lua 脚本最大的好处是 Redis 会将整个脚本作为一个整体执行,不会被其他请求打断,可以保持原子性且减少了网络开销。

Lua 脚本的调用

Lua 脚本的执行语法如下:

eval lua-script numkeys key [key ...] arg [arg ...]

接下来我们执行一个不带任何参数的简单 Lua 脚本命令:

eval "return 'Hello Redis'" 0

Lua-hello

Lua 脚本中执行 Redis 命令

Lua 脚本中执行 Redis 命令时需要使用以下语法:

redis.call(command, key [key ...] argv [argv])

假如我们想执行一个命令 set name lonely_wolf,那么利用 Lua 脚本则应该这么执行:

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name lonely_wolf

需要注意的是:KEYSARGV 必须要大写,参数的下标从 1 开始。上面命令中 1 表示当前需要传递 1key

Lua 脚本摘要

有时候如果我们执行的一个 Lua 脚本很长的话,那么直接这么调用 Lua 脚本的话非常不方便,所以 Redis 当中提供了一个命令 script load 来手动给每一个 Lua 脚本生成摘要,这里之所以要说手动的原因是即使我们不使用这个命令,每次调用完 Lua 脚本的时候,Redis 也会为每个 Lua 脚本生成一个摘要

其他相关命令:

接下来我们来验证一下,依次执行以下命令:

script load "return redis.call('set',KEYS[1],ARGV[1])"  //给当前 Lua脚本生成摘要,这时候会返回一个摘要
evalsha "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" 1 address china  //相当于执行命令 set address china
get address //获取 adress,确认上面的脚本是否执行成功
script exists "c686f316aaf1eb01d5a4de1b0b63cd233010e63d"  //判断当前摘要的 Lua脚本是否存在
script flush //清除所有 Lua脚本缓存
script exists "c686f316aaf1eb01d5a4de1b0b63cd233010e63d"  //清除之后这里就不存在了

执行之后得到如下效果:

Lua 脚本文件

当我们的 Lua 脚本很长时,直接在命令窗口中写脚本是不直观的,也很难发现语法问题,所以 Redis 当中也支持我们直接把先把脚本写入文件中,然后直接调用文件。 比如我们新建一个 test.lua 脚本:

redis.call('set',KEYS[1],ARGV[1])
return redis.call('get',KEYS[1])

将文件上传到指定目录之后,执行如下命令:

redis-cli --eval test.lua 1 age , 18 //注意 key 和 arg 参数之间要以逗号隔开,且逗号两边的空格不能省略

这时候就可以正常返回 18

脚本异常

我们知道,Redis 的指令是单线程执行的,而现在使用了 Lua 脚本,我们就可以通过 Lua 脚本来实现一些业务逻辑,那么如果 Lua 脚本执行超时或者陷入了死循环,这个时候其他的指令就会被阻塞,导致 Redis 无法正常使用。这个时候应该如何处理呢?

脚本超时

为了解决 Lua 脚本超时的问题,Redis 提供了一个超时时间的参数 lua-time-limit 来控制 Lua 脚本执行的超时时间,单位是毫秒,默认是 5000 (即 5 秒),到达超时时间之后 Lua 会自动中断脚本。

脚本陷入死循环

假如脚本陷入了死循环,这时候超时时间就不起作用了,我们来模拟一下: 首先打开客户端一,执行一个死循环的 lua 脚本:

eval 'while(true) do end' 0

lua-dead-loop

然后打开另一个客户端二,任意执行一个命令:

get name

这时候会返回 busy,表示当前无法执行这个命令:

Lua-blocking

提示 busy 之后,同时 Redis 也给出了解决方案,我们只能只用 script kill 或者 shutdown nosave 命令,这两个命令又是做什么用的呢?

接下来让我们在客户端二执行命令 script kill,然后再去看看陷入死循环的客户端一的效果:

可以看到,客户端一的 Lua 脚本已经退出了,根据后面的提示可以知道就是因为执行了 script kill 命令而导致了 Lua 脚本的中断。

现在我们重新用客户端一执行下面这个 Lua 脚本,这个脚本和上面的脚本区别就是这里执行成功了一个 Redis 命令之后才开始死循环:

eval "redis.call('set','age','28') while true do end" 0

这时候再去客户端二执行 script kill 命令,发现无法中止 Lua 脚本了:

script-kill2

这里不允许直接中断 Lua 脚本是因为在死循环前已经有 Redis 命令被成功执行了,如果直接中断,那么就会造成数据不一致问题。

在这种场景下,只能通过执行 shutdown nosave 命令来强行中断 Lua 脚本,这里因为加了 nosave 之后不会触发 Redis 的持久化,所以当重启 Redis 服务之后,可以保证数据的一致性,下图就是执行 shutdown nosave 命令之后客户端一的效果图:

shutdown-nosave

为什么可以执行 script kill 命令

Redis 当中执行命令是单线程的,那么为什么 Lua 脚本陷入死循环之后其他客户端还可以执行 script kill 命令呢?

这是因为 Lua 脚本引擎提供了钩子(hook)函数,它允许在内部虚拟机执行指令时运行钩子代码,所以 Redis 正是利用了这一原理,在执行 Lua 脚本之前设置了一个钩子,也就是说 script kill 命令是通过钩子(hook)函数来执行的。

总结

本文主要介绍了 Redis 中的发布订阅功能和 Lua 脚本的使用,使用 Lua 脚本可以让多个命令原子执行,减少网络开销,但是同时也要注意 Lua 脚本引发的死循环问题。