Bootstrap

Redis设计与实现 学习笔记 第二十章 Lua脚本

Redis从2.6版本引入对Lua脚本的支持,通过在服务器中嵌入Lua环境,Redis客户端可以使用Lua脚本,直接在服务器端原子地执行多个Redis命令。

其中EVAL命令可以直接对输入的脚本进行求值:
在这里插入图片描述
而使用EVALSHA命令则可以根据脚本的SHA1校验和来对脚本进行求值,但这个命令要求校验和对应的脚本必须被EVAL命令执行过一次:
在这里插入图片描述
或者这个校验和对应的脚本曾被SCRIPTLOAD命令载入过:
在这里插入图片描述
20.1 创建并修改Lua环境

为了在Redis服务器中执行Lua脚本,Redis在服务器内嵌了一个Lua环境(environment),并对这个Lua环境进行了一系列修改,从而确保这个Lua环境可以满足Redis服务器的需要。

Redis服务器创建并修改Lua环境的过程如下:
1.创建一个基础的Lua环境,之后的所有修改都是针对这个环境进行的。

2.载入多个函数库到Lua环境里,让Lua脚本可以使用这些函数库。

3.创建全局表格redis,这个表格包含了对Redis进行操作的函数,如用于在Lua脚本中执行Redis命令的redis.call函数。

4.使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,从而避免在脚本中引入副作用。

5.创建排序辅助函数,Lua环境使用这个辅助函数来对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性。

6.创建redis.pcall函数的错误报告辅助函数,让这个函数可以提供更详细的出错信息。

7.对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全局变量添加到Lua环境中。

8.将完成修改的Lua环境保存到服务器状态的lua属性中,等待执行服务器传来的Lua脚本。

20.1.1 创建Lua环境

在最开始的这一步,服务器首先调用Lua的C API函数lua_open,创建一个新的Lua环境。

因为lua_open函数创建的只是一个基本的Lua环境,为了让这个Lua环境可以满足Redis的操作要求,接下来服务器将对这个Lua环境进行一系列修改。

20.1.2 载入函数库

Redis修改Lua环境的第一步,就是将以下函数库载入Lua环境里:
1.基础库(base library):这个库包含Lua的核心(core)函数,比如assert、error、pairs、tostring、pcall等。另外,为了防止用户从外部文件中引入不安全的代码,库中的loadfile函数会被删除。

2.表格库(table library):这个库包含用于处理表格的通用函数,如table.concat、table.insert、table.remove、table.sort等。

3.字符串库(string library):这个库包含用于处理字符串的通用函数,如用于对字符串进行查找的string.find函数,对字符串进行格式化的string.format函数,查看字符串长度的string.len函数,对字符串进行翻转的string.reverse函数等。

4.数学库(math library):这个库是标准C语言数学库的接口,它包括计算绝对值的math.abs函数,返回多个数中的最大值和最小值的math.max和math.min函数,计算二次方根的math.sqrt函数,计算对数的math.log函数等。

5.调试库(debug library):这个库提供了对程序进行调试所需的函数,比如对程序设置钩子和取得钩子的debug.sethook函数和debug.gethook函数,返回给定函数相关信息的debug.getinfo函数,为对象设置元数据的debug.setmetatable函数,获取对象元数据的debug.getmetatable函数等。

6.Lua CJSON库:这个库用于处理UTF-8编码的JSON格式,其中cjson.decode函数将一个JSON格式的字符串转换为一个Lua值,而cjson.encode函数将一个Lua值序列化为JSON格式的字符串。

7.Struct库(http://www.inf.puc-rio.br/~roberto/struct/):这个库用于在Lua值和C结构(struct)之间进行转换,函数struct.pack将多个Lua值打包成一个类结构(struct-like)字符串,而函数struct.unpack则从一个类结构字符串中解包出多个Lua值。

8.Lua cmsgpack库(https://github.com/antirez/lua-cmsgpack):这个库用于处理MessagePack格式的数据,其中cmsgpack.pack函数将Lua值转换为MessagePack数据,而cmsgpack.unpack函数将MessagePack数据转换为Lua值。

通过使用这些功能强大的库,Lua脚本可以直接对执行Redis命令获得的数据进行复杂的操作。

20.1.3 创建redis全局表格

在这一步,服务器将在Lua环境中创建一个redis表格(table),并将它设为全局变量。这个redis表格包含以下函数:
1.用于执行Redis命令的redis.call和redis.pcall函数。

2.用于记录Redis日志的redis.log函数,以及相应的日志级别常量:redis.LOG_DEBUG,redis.LOG_VERBOSE,redis.LOG_NOTICE,redis.LOG_WARNING。

3.用于计算SHA1校验和的redis.sha1hex函数。

4.用于返回错误信息的redis.error_reply和redis.status_reply函数。

通过redis.call和redis.pcall函数,用户可以直接在Lua脚本中执行Redis命令:
在这里插入图片描述
20.1.4 使用Redis自制的随机函数来替换Lua原有的随机函数

为了保证相同的脚本可以在不同机器上产生相同的效果,Redis要求所有传入服务器的Lua脚本,以及Lua环境中的所有函数,都必须是无副作用(side effect)的纯函数(pure function)。

但之前载入Lua环境的math函数库中,用于生成随机数的math.random函数和math.randomseed函数都是带有副作用的,它们不符合Redis对Lua环境的无副作用要求。

因此,Redis使用自制的函数替换了math库中原有的math.random和math.randomseed函数,替换后的两个函数有以下特征:
1.对于相同的seed来说,math.random总产生相同的随机数序列,这个函数是一个纯函数。

2.除非在脚本中使用math.randomseed显式地修改seed,否则每次运行脚本时,Lua环境都使用固定的math.randomseed(0)语句来初始化seed。

例如,使用以下脚本,我们可以打印seed值为0时,math.random对于输入10至1所产生的随机序列:
在这里插入图片描述
无论执行这个脚本多少次,产生的值都是相同的:
在这里插入图片描述
但如果我们在另一个脚本里,调用math.randomseed将seed修改为10086:
在这里插入图片描述
那么这个脚本生成的随机数序列和使用默认seed值0时生成的随机序列不同:
在这里插入图片描述
20.1.5 创建排序辅助函数

上一小节说到,为了防止带有副作用的函数令脚本产生不一致的数据,Redis对math库的math.random和math.randomseed函数进行了替换。

对于Lua脚本来说,另一个可能产生不一致数据的地方是那些带有不确定性质的命令。比如对于一个集合键来说,因为集合元素的排列是无序的,所以即使两个集合的元素完全相同,它们的输出结果也可能不同。

考虑以下集合例子:
在这里插入图片描述
上例中的fruit和another-fruit集合包含的元素是完全相同的,只是因为集合添加元素的顺序不同,SMEMBERS命令的输出就产生了不同的结果。

Redis将SMEMBERS这种在相同数据集上可能会产生不同输出的命令称为“带有不确定性的命令”,这些命令包括:
1.SINTER

2.SUNION

3.SDIFF

4.SMEMBERS

5.HKEYS

6.HVALS

7.KEYS

为了消除这些命令带来的不确定性,服务器会为Lua环境创建一个排序辅助函数__redis__compare_helper,当Lua脚本执行完一个带有不确定性的命令后,程序会使用__redis__compare_helper作为对比函数,自动调用table.sort函数对命令返回值做一次排序,以此来保证Lua脚本中,相同的数据集总是产生相同的输出。

例如,我们在Lua脚本中对fruit和another-fruit集合执行SMEMBERS命令,那么两个脚本将得到相同的结果,因为脚本已经对SMEMBERS命令的输出排过序了:
在这里插入图片描述
20.1.6 创建redis.pcall函数的错误报告辅助函数

在这一步,服务器将为Lua环境创建一个名为__redis_err__handler的错误处理函数,当脚本调用redis.pcall函数执行Redis命令,且被执行的命令出现错误时,__redis_err__handler就会打印出错代码的来源和发生错误的行数,为程序的调试提供方便。

例如,客户端要求服务器执行以下Lua脚本:
在这里插入图片描述
那么服务器将向客户端返回一个错误:
在这里插入图片描述
其中@user_script说明这是一个用户自定义的函数,而之后的4则说明出错的代码位于Lua脚本的第四行。

20.1.7 保护Lua的全局环境

在这一步,服务器将对Lua环境中的全局变量进行保护,确保传入服务器的脚本不会因为忘记使用local关键字而将额外额全局变量添加到Lua环境里面(Lua里,变量声明时如果不加local,就会被当做全局变量)。

因为全局变量保护的原因,当一个脚本试图创建一个全局变量时,服务器将报告一个错误:
在这里插入图片描述
此外,试图获取一个不存在的全局变量也会引发一个错误:
在这里插入图片描述
但Redis并未禁止用户修改已存在的全局变量,所以在执行Lua脚本时,必须非常小心,以免错误地修改了已存在的全局变量:
在这里插入图片描述
20.1.8 将Lua环境保存到服务器状态的lua属性里面

经过以上一系列的修改,Redis服务器对Lua环境的修改工作到此就结束了,最后这一步中,服务器会将Lua环境和服务器状态的lua属性关联起来,如图20-1所示:
在这里插入图片描述
因为Redis使用串行化的方式来执行Redis命令,所以在任何特定时间里,最多都只会有一个脚本能够被放进Lua环境里运行,因此,整个Redis服务器只需创建一个Lua环境即可。

20.2 Lua环境协作组件

Redis服务器创建了两个用于与Lua环境进行协作的组件,它们分别是负责执行Lua脚本中的Redis命令的伪客户端,以及用于保存Lua脚本的lua_scripts字典。

20.2.1 伪客户端

因为Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中包含的Redis命令,Redis服务器专门为Lua环境创建了一个伪客户端,并由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。

Lua脚本使用redis.call函数或redis.pcall函数执行一个Redis命令,步骤如下:
1.Lua环境将redis.call或redis.pcall函数想要执行的命令传给伪客户端。

2.伪客户端将脚本想要执行的命令传给命令执行器。

3.命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端。

4.伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境。

5.Lua环境在接收到命令结果后,将该结果返回给redis.call或redis.pcall函数。

6.接收到结果的redis.call或redis.pcall函数会将命令结果作为函数返回值返回给脚本中的调用者。

图20-2展示了Lua脚本在调用redis.call函数时,Lua环境、伪客户端、命令执行器三者之间的通信过程(调用redis.pcall函数时产生的通信过程也是一样的):
在这里插入图片描述
例如,图20-3展示了Lua脚本在执行以下命令时:
在这里插入图片描述
Lua环境、伪客户端、命令执行器三者之间的通信过程:
在这里插入图片描述
20.2.2 lua_scripts字典

lua_scripts字典的键为某个Lua脚本的SHA1校验和(checksum),而字典的值则是SHA1校验和对应的Lua脚本:

struct redisServer {
    // ...
    dict *lua_script;
    // ...
};

Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里。

例如,客户端向服务器发送以下命令:
在这里插入图片描述
那么服务器的lua_scripts字典将包含被SCRIPT LOAD命令载入的三个Lua脚本,如图20-4所示:
在这里插入图片描述
lua_scripts字典有两个作用,一是实现SCRIPT EXISTS命令,另一个是实现脚本复制功能,稍后会介绍这两个作用。

20.3 EVAL命令的实现

EVAL命令的执行过程可分为以下三个步骤:
1.根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数。

2.将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用。

3.执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本。

20.3.1 定义脚本函数

当客户端向服务器发送EVAL命令,要求执行某个Lua脚本时,服务器首先要做的就是在Lua环境中,为传入的脚本定义一个与这个脚本相对应的Lua函数,其中,Lua函数的名字由f_前缀加上脚本的SHA1校验和(四十个字符长)组成,而函数的体(body)则是脚本本身。

例如,对于命令:
在这里插入图片描述
来说,服务器将在Lua环境中定义以下函数:
在这里插入图片描述
使用函数来保存客户端传入的脚本有以下好处:
1.执行脚本的步骤非常简单,只要调用与脚本相对应的函数即可。

2.通过函数的局部性来让Lua环境保持清洁,减少了垃圾回收的工作量,并且避免了使用全局变量。

3.如果某个脚本所对应的函数在Lua环境中被定义过至少一次,那么只要记得这个脚本的SHA1校验和,服务器就可以在不知道脚本本身的情况下,直接通过调用Lua函数来执行脚本,这是EVALSHA命令的实现原理。

20.3.2 将脚本保存到lua_scripts字典

EVAL命令要做的第二件事是将客户端传入的脚本保存到服务器的lua_scripts字典里。例如,对于命令:
在这里插入图片描述
来说,服务器将在lua_scripts字典中新添加一个键值对,其中键为Lua脚本的SHA1校验和:
在这里插入图片描述
而值则为Lua脚本本身:
在这里插入图片描述
添加新键值对后的lua_scripts字典如图20-5所示:
在这里插入图片描述
20.3.3 执行脚本函数

在为脚本定义函数,并将脚本保存到lua_scripts字典后,服务器还需进行一些设置钩子、传入参数之类的准备动作,才能正式开始执行脚本。

整个准备和执行脚本的过程如下:
1.将EVAL命令中传入的键名参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里。

2.为Lua环境装载超时处理钩子(hook),这个钩子可以在脚本运行超时时,让客户端通过SCRIPT KILL命令停止脚本,或通过SHUTDOWN命令直接关闭服务器。

3.执行脚本函数。

4.移除之前装载的超时钩子。

5.将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里,等待服务器将结果返回给客户端。

6.对Lua环境执行垃圾回收操作。

例如,对于命令:
在这里插入图片描述
服务器将执行以下动作:
1.因为这个脚本没有任何键名参数或脚本参数,所以服务器会跳过传值到KEYS和ARGV数组这一步。

2.为Lua环境装载超时处理钩子。

3.在Lua环境中执行根据脚本定义的函数(f_开头的那个)。

4.移除超时钩子。

5.将执行函数所得的结果"hello world"保存到客户端状态的输出缓冲区里。

6.对Lua环境执行垃圾回收操作。

至此,命令的执行就告一段落了,之后服务器只要将保存在输出缓冲区里的执行结果返回给执行EVAL命令的客户端就可以了。

20.4 EVALSHA命令的实现

每个被EVAL命令成功执行过的Lua脚本,在Lua环境里都有一个与这个脚本相对应的Lua函数。

只要脚本对应的函数曾经在Lua环境里定义过,那么即使不知道脚本本身,客户端也可根据脚本的SHA1校验和来调用脚本对应的函数,从而达到执行脚本的目的,这就是EVALSHA命令的实现原理。

可用伪代码来描述这一原理:

def EVALSHA(sha1):
    # 拼接出函数名
    func_name = "f_" + sha1
    
    # 查看这个函数在Lua环境中是否存在
    if function_exists_in_lua_env(func_name):
        # 如果函数存在,那么执行它
        execute_lua_function(func_name)
    else:
        # 如果函数不存在,那么返回一个错误
        send_script_error("SCRIPT NOT FOUND")

例如,当服务器执行完以下EVAL命令后:
在这里插入图片描述
Lua环境里就定义了函数:
在这里插入图片描述
当客户端执行以下EVALSHA命令时:
在这里插入图片描述
服务器首先根据客户端输入的SHA1校验和,检查对应函数是否存在于Lua环境中,得到的回应是函数确实存在,于是服务器执行Lua环境中的对应函数,并将结果返回给客户端。

20.5 脚本管理命令的实现

除了EVAL和EVALSHA命令外,Redis中与Lua脚本有关的命令还有四个,它们分别是SCRIPT FLUSH、SCRIPT EXISTS、SCRIPT LOAD、SCRIPT KILL命令。

20.5.1 SCRIPT FLUSH

SCRIPT FLUSH命令用于清除服务器中所有和Lua脚本有关的信息,这个命令会释放并重建lua_scripts字典,关闭现有的Lua环境并重新创建一个新的Lua环境。

以下是SCRIPT FLUSH命令实现的伪代码:

def SCRIPT_FLUSH():
    # 释放脚本字典
    dictRelease(server.lua_scripts)
    # 重建脚本字典
    server.lua_scripts = dictCreate(...)
    # 关闭Lua环境
    lua_close(server.lua)
    # 初始化一个新的Lua环境
    server.lua = init_lua_env()

20.5.2 SCRIPT EXISTS

SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中。

SCRIPT EXISTS命令是通过检查给定的校验和是否存在于lua_scripts字典来实现的,以下是该命令实现的伪代码:

def SCRIPT_EXISTS(*sha1_list):
    # 结果列表
    result_list = []
    # 遍历输入的所有SHA1校验和
    for sha1 in sha1_list:
        # 检查校验和是否为lua_scripts字典的键
        # 如果是,表示校验和对应的脚本存在
        # 否则,脚本就不存在
        if sha1 in server.lua_scripts:
            # 存在用1表示
            result_list.append(1)
        else:
            # 不存在用0表示
            result_list.append(0)
    # 向客户端返回结果列表
    send_list_reply(result_list)

例如,对于图20-6所示的lua_scripts字典来说:
在这里插入图片描述
我们可以进行以下测试:
在这里插入图片描述
从测试结果可知,除了最后一个校验和外,其他校验和对应的脚本都存在于服务器中。

SCRIPT EXISTS命令允许一次传入多个SHA1校验和,不过因为SHA1校验和太长,所以上图中分开多次来进行测试。

实现SCRIPT EXISTS实际上不需要lua_scripts字典的值。如果lua_scripts字典只用于实现SCRIPT EXISTS命令的话,那么字典只需保存Lua脚本的SHA1校验和就可以了,并不需要保存Lua脚本本身。lua_scripts字典既保存脚本的SHA1校验和,又保存脚本本身的原因是为了实现脚本复制功能,该功能稍后会介绍。

20.5.3 SCRIPT LOAD

SCRIPT LOAD命令所做的事情和EVAL命令执行脚本时所做的前两步完全一样:命令首先在Lua环境中为脚本创建相应的函数,然后再将脚本保存到lua_scripts字典里。

例如,我们执行以下命令:
在这里插入图片描述
那么服务器将在Lua环境中创建以下函数:
在这里插入图片描述
并将该脚本的SHA1校验和和脚本内容的键值对添加到服务器的lua_scripts字典里,如图20-7所示:
在这里插入图片描述
完成这些步骤后,客户端就可以使用EVALSHA命令来执行前面被SCRIPT LOAD命令载入的脚本了:
在这里插入图片描述
20.5.4 SCRIPT KILL

如果服务器设置了lua_time-limit配置选项,那么每次执行Lua脚本前,服务器都会在Lua环境里设置一个超时处理钩子(hook)。

超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长,钩子将定期在脚本运行的间隙中,查看是否有SCRIPT KILL或SHUTDOWN命令到达服务器。
在这里插入图片描述
如果超时运行的脚本未执行过任何数据库写入操作,那么客户端可以通过SCRIPT KILL命令来指示服务器停止执行这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令后,服务器可以继续运行。

如果脚本已经执行过数据库写入操作,那么客户端只能用SHUTDOWN nosave命令来停止服务器,从而防止不合法的数据被写入数据库中。

20.6 脚本复制

与其他普通Redis命令一样,当服务器运行在复制模式下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括EVAL、EVALSHA、SCRIPT FLUSH、SCRIPT LOAD命令。

20.6.1 复制EVAL、SCRIPT FLUSH、SCRIPT LOAD命令

当主服务器执行完三个命令其中之一时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器,像复制普通Redis命令的方法一样,如图20-9所示:
在这里插入图片描述
1.EVAL

对于EVAL命令来说,在主服务器执行的Lua脚本同样会在所有从服务器中执行。

例如,客户端向主服务器执行以下命令:
在这里插入图片描述
那么主服务器在执行这个EVAL命令后,将向所有从服务器传播这条EVAL命令,从服务器会接收并执行这条EVAL命令,最终结果是,主从服务器双方都会将数据库"msg"键的值设为"hello world",并且将脚本存到脚本字典里。

2.SCRIPT FLUSH

如果客户端向主服务器发送SCRIPT FLUSH命令,那么主服务器也会向所有从服务器传播SCRIPT FLUSH命令。

最终的结果是,主从服务器双方都会重置自己的Lua环境,并清空自己的脚本字典。

3.SCRIPT LOAD

如果客户端使用SCRIPT LOAD命令向主服务器载入一个Lua脚本,那么主服务器将向所有从服务器传播相同的SCRIPT LOAD命令,使得所有从服务器也载入相同的Lua脚本。

例如,客户端向主服务器发送命令:
在这里插入图片描述
那么主服务器也会向从服务器传播同样的命令:
在这里插入图片描述
最终的结果是,主从服务器双方都会载入脚本:
在这里插入图片描述
20.6.2 复制EVALSHA命令

EVALSHA命令是所有与Lua脚本有关的命令中,复制操作最复杂的一个,因为主从服务器载入Lua脚本的情况可能有所不同,所以主服务器不能直接将EVALSHA命令传播给从服务器。对于一个在主服务器被成功执行的EVALSHA命令来说,相同的EVALSHA命令在从服务器执行时可能会出现脚本未找到(not found)错误。

例如,现在有一个主服务器master,如果客户端向主服务器发送命令:
在这里插入图片描述
那么在执行这个SCRIPT LOAD命令后,该脚本及其SHA1值就存在于主服务器中了。

现在,假设一个从服务器slave1开始复制主服务器master,如果master没有将脚本传送给slave1的话,那么当客户端向主服务器发送命令:
在这里插入图片描述
的时候,master将成功执行这个EVALSHA命令,而当master将这个命令传播给slave1执行时,slave1会出错:
在这里插入图片描述
更为复杂的是,因为多个从服务器之间载入Lua脚本的情况也可能各有不同,所以即使一个EVALSHA命令可以在某个从服务器成功执行,也不代表这个EVALSHA命令就一定可以在另一个从服务器成功执行。

例如,有主服务器master和从服务器slave1,并且slave1一直复制着master,所以master载入的所有Lua脚本,slave1也有载入(通过传播EVAL或SCRIPT LOAD命令来实现)。

如果客户端向master发送命令:
在这里插入图片描述
那么这个命令也会被传播到slave1上面,所以master和slave1都会成功载入上图所示的Lua脚本。

如果这时,一个新的从服务器slave2开始复制主服务器master,如果master没有把上图脚本传送给slave2的话,那么当客户端向主服务器发送命令:
在这里插入图片描述
的时候,master和slave1都将成功执行这个EVALSHA命令,而slave2却会发生脚本未找到错误。

为了防止以上情况出现,Redis要求主服务器在传播EVALSHA命令时,必须确保EVALSHA命令要执行的脚本已经被所有从服务器载入过,如果不能确保这一点,主服务器会将EVALSHA命令转换成一个等价的EVAL命令,然后通过传播EVAL命令来代替EVALSHA命令。

1.判断EVALSHA命令是否安全的方法

主服务器使用服务器状态的repl_scriptcache_dict字典记录自己已经将哪些脚本传播给了所有从服务器:

struct redisServer {
    // ...
    dict *repl_scriptcache_dict;
    // ...
};

repl_scriptcache_dict字典的键是一个个Lua脚本的SHA1校验和,而字典的值则全部都是NULL,当一个校验和出现在repl_scriptcache_dict字典时,说明这个校验和对应的Lua脚本已经传播给了所有从服务器,主服务器可以直接向从服务器传播包含这个SHA1校验和的EVALSHA命令,而不必担心从服务器会出现脚本未找到错误。

例如,主服务器repl_scriptcache_dict字典的当前状态如图20-10所示:
在这里插入图片描述
那么主服务器可以向从服务器传播以下三个EVALSHA命令,并且从服务器在执行这些EVALSHA命令时不会出现脚本未找到错误:
在这里插入图片描述
另一方面,如果一个脚本的SHA1校验和存在于lua_scripts字典,但却不存在于repl_scriptcache_dict字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但并没有传播给所有从服务器,如果我们尝试向从服务器传播包含这个SHA1校验和的EVALSHA命令,那么至少有一个从服务器会出现脚本未找到错误。

例如,对于图20-11所示的lua_scripts字典,对于图20-10所示的repl_scriptcache_dict字典来说,SHA1校验和为:
在这里插入图片描述
的脚本:
在这里插入图片描述
虽然存在于lua_scripts字典,但其校验和不存在于repl_scriptcache_dict字典,这说明该脚本虽然已经载入到主服务器里,但并未传播给所有从服务器,如果主服务器尝试向从服务器发送命令:
在这里插入图片描述
那么至少会有一个从服务器遇上脚本未找到错误。

2.清空repl_scriptcache_dict字典

每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为随着新从服务器的出现,repl_scriptcache_dict字典里记录的脚本已经不再被所有从服务器载入过。主服务器清空repl_scriptcache_dict字典,可以强制自己向所有从服务器传播脚本,从而确保新的从服务器不会出现脚本未找到错误。

3.EVAL命令转换成EVAL命令的方法

通过使用EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器总可以将一个EVALSHA命令:
在这里插入图片描述
转换成一个等价的EVAL命令:
在这里插入图片描述
具体的转换方法如下:
1.根据SHA1校验和sha1,在lua_scripts字典中查找sha1对应的Lua脚本script。

2.将原来的EVALSHA命令请求改写成EVAL命令请求,并且将校验和sha1改成脚本script,至于numkeys、key、arg参数则保持不变。

例如,对于图20-11所示的lua_scripts字典,以及图20-10所示的repl_scriptcache_dict字典来说,我们总可以将命令:
在这里插入图片描述
改写成命令:
在这里插入图片描述
其中脚本中的内容:
在这里插入图片描述
来源于lua_scripts字典中SHA1键对应的值。

如果一个SHA1值所对应的Lua脚本没有被所有从服务器载入过,那么主服务器可以将EVALSHA命令转换成等价的EVAL命令,然后通过传播等价的EVAL命令来代替原本想要传播的EVALSHA命令,以此来产生相同的脚本执行效果,并确保所有从服务器都不会出现脚本未找到错误。

另外,因为主服务器在传播完EVAL后,会将被传播脚本的SHA1校验和添加到repl_scriptcache_dict字典里,如果之后EVALSHA命令再次指定这个SHA1校验和,主服务器就可以直接传播EVALSHA命令,而不必再次对EVALSHA命令进行转换。

4.传播EVALSHA命令的方法

当主服务器成功在本机执行完一个EVALSHA命令后,它将根据EVALSHA命令指定的SHA1校验和是否存在于repl_scriptcache_dict字典来决定是向从服务器传播EVALSHA还是EVAL命令:
(1)如果EVALSHA命令指定的SHA1校验和存在于repl_scriptcache_dict字典,那么主服务器直接向从服务器传播EVALSHA命令。

(2)如果EVALSHA命令指定的SHA1校验和不存在于repl_scriptcache_dict字典,那么主服务器会将EVALSHA命令替换成等价的EVAL命令,然后传播这个等价的EVAL命令,并将EVALSHA命令指定的SHA1校验和添加到repl_scriptcache_dict字典里。
在这里插入图片描述
20.7 重点回顾

1.Redis服务器在启动时,会对内嵌的Lua环境执行一系列修改操作,从而确保内嵌的Lua环境可以满足Redis在功能性、安全性等方面的需要。

2.Redis服务器专门使用一个伪客户端来执行Lua脚本中包含的Redis命令。

3.Redis使用脚本字典来保存所有被EVAL命令执行过,或被SCRIPT LOAD命令载入过的Lua脚本,这些脚本可用于实现SCRIPT EXISTS命令,以及实现脚本复制功能。

4.EVAL命令为客户端输入的脚本在Lua环境中定义一个函数,并通过调用这个函数来执行脚本。

5.EVALSHA命令通过直接调用Lua环境中已定义的函数来执行脚本。

6.SCRIPT FLUSH命令会清空服务器lua_scripts字典中保存的脚本,并重置Lua环境。

7.SCRIPT EXISTS命令接受一个或多个SHA1校验和为参数,并通过检查lua_scripts字典来确认校验和对应的脚本是否存在。

8.SCRIPT LOAD命令接受一个Lua脚本为参数,为该脚本在Lua环境中创建函数,并将脚本保存到lua_scripts字典中。

9.服务器在执行脚本前,会为Lua环境设置一个超时处理钩子,当脚本出现超时运行情况时,客户端可通过向服务器发送SCRIPT KILL命令来让钩子停止正在执行的脚本,或发送SHUTDOWN nosave命令来让钩子关闭整个服务器。

10.主服务器复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制普通Redis命令一样,只要将相同的命令传播给从服务器就可以了。

11.主服务器在复制EVALSHA命令时,必须确保所有从服务器都已经载入了EVALSHA命令指定的SHA1校验和所对应的脚本,如果不能确保这一点,主服务器会将EVALSHA命令替换成等效的EVAL命令,并通过传播EVAL命令来获得相同的脚本执行效果。

;