19. Lua脚本

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

 

1. 创建并修改Lua环境

为了在Redis服务器中执行Lua脚本,Redis在服务器内嵌了一个Lua环境,并对这个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脚本

 

1.1 创建Lua环境

服务器首先调用Lua的C API函数lua_open,创建一个新的Lua环境。

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

 

1.2 载入函数库

Redis修改Lua环境的第一步,就是将一下函数库载入到Lua环境里:

1)基础库

2)表格库

3)字符串库

4)数学库

5)调试库

6)Lua CJSON库

7)Struct库

8)Lua cmsgpack库

通过这些函数库,Lua脚本可以直接对函数Redis命令获得的数据进行复杂操作

 

1.3 创建redis全局表格

在这一步,服务器将在Lua环境中创建一个redis表格,并将它设为全局变量。这个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函数

 

1.4 使用Redis自制的随机函数来替换Lua原有的随机函数

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

但是,在之前载入Lua环境的math函数库中,用于生成随机数的math.random函数和math.randomseed函数都是带有副作用的,不适合。

所以,要使用Redis自制的函数替换math库中原有的math.random函数和math.randomseed函数,替换之后的两个函数有以下特征:

1)对于相同的seed来说,math.random总产生相同的随机数序列,这个函数是一个纯函数

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

 

1.5 创建排序辅助函数

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

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

SINTER、SUNION、SDIFF、SMEMBERS、HKEYS、HVALS、KEYS

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

 

1.6 创建redis.pcall函数的错误报告辅助函数

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

 

1.7 保护Lua的全局环境

服务器将对Lua环境中的全局环境进行保护,确保传入服务器的脚本不会因为忘记使用local关键字而将额外的全局变量添加到Lua环境里面。

因为全局变量保护的原因,当一个脚本试图创建一个全局变量时,服务器将报错。除此之外,试图获取一个不存在的全局变量也会引发一个错误。

不过Redis并未禁止用户修改已存在的全局变量,所以在执行Lua脚本的时候,必须非常小心,以免错误的修改已存在的全局变量。

 

1.8 将Lua环境保存到服务器状态的lua属性里面

经过一系列的修改,Redis服务器对Lua环境的修改工作到此结束,最后,服务器会将Lua环境和服务器状态的lua属性关联起来。

因为Redis使用串行化的方式来执行Redis命令,所以在任何特定时间里,最多只会有一个脚本能够被放进Lua环境里面运行,因此,整个Redis服务器只需要创建一个Lua环境即可。

 

2.Lua环境协作组件

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

 

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函数会将命令结果作为函数返回值返回给脚本中的调用者

 

2.2 lua_scripts字典

除了伪客户端之外,Redis服务器为Lua环境创建的另一个协作组件是lua_scripts字典,这个字典的键为某个Lua脚本的SHA1校验和,而字典的值是SHA1校验和相对应的Lua脚本

struct redisServer{
    //...

    dict *lua_scripts;

    //...
};

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

lua_scripts字典有两个作用:一个是实现SCRIPT EXISTS命令,另一个是实现脚本复制功能

 

3.EVAL命令的实现

EVAL命令的执行可以分为三个步骤:

1)根据客户端给定的Lua脚本,在Lua环境下定义一个Lua函数

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

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

 

3.1 定义脚本函数

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

使用函数来保存客户端传入的脚本有以下好处:

1)执行脚本的步骤非常简单,只需要调用与脚本相对应的函数即可

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

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

 

3.2 将脚本保存到lua_scripts字典

EVAL命令要做的第二件事就是将客户端传入的脚本保存到服务器的lua_scripts字典里面

 

3.3 执行脚本函数

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

1)将EVAL命令中传入的键名参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里面

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

3)执行脚本函数

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

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

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

 

4.EVALSHA命令的实现

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

 

5.脚本管理命令的实现

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

 

5.1 SCRIPT FLUSH

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

 

5.2 SCRIPT EXISTS

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

SCRIPT EXISTS命令时通过检查给定的校验和是否存在于lua_scripts字典来实现的

 

5.3 SCRIPT LOAD

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

 

5.4 SCRIPT KILL

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

超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长,钩子将定期在脚本运行的间隙中,查看是否有SCRIPT KILL或者SHUTDOWN命令。

如果超市运行的脚本未执行过任何写入操作,那么客户端可以通过SCRIPT KILL来指示服务器停止执行这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL之后,服务器继续运行。

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

 

6.脚本复制

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

 

6.1 复制EVAL、SCRIPT FLUSH、SCRIPT LOAD

Redis复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制其他普通Redis命令的方法一样。当主服务器执行完以上三个命令之一时,主服务器就会直接将被执行的命令传播给所有从服务器,从服务器均执行这些命令

 

6.2 复制EVALSHA命令

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

更为复杂的是,因为多个从服务器之间载入Lua脚本的情况也不相同,所以即使一个EVALSHA命令可以在某个从服务器成功执行,也不代表可以在另一个从服务器成功执行。

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

 

6.2.1 判断传播EVALSHA命令是否安全的方法

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

struct redisServer{
    //...

    dict *repl_scriptcache_dict;

    //...
};

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

另一方面,如果一个脚本的SHA1校验和存在于lua_scripts字典,但是却不存在于repl_scriptcache_dict字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但是还没有传播给所有从服务器。

 

6.2.2 清空repl_scriptcache_dict字典

每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为随着新从服务器的出现,repl_scriptcache_dict字典字典里面记录的脚本已经不再被所有从服务器载入过,所以会清空repl_scriptcache_dict字典。

 

6.2.3 EVALSHA命令转换成EVAL命令的方法

通过使用EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器可以讲一个EVALSHA命令转换成一个等价的EVAL命令:

EVALSHA <sha1> <numkeys> <key ...> <arg ...>


EVAL <script> <numkeys> <key ...> <arg ...>

1)根据SHA1校验和sha1,在lua_scripts字典中查找sha1对应的Lua脚本script

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

如果一个SHA1值所对应的Lua脚本没有被所有从服务器载入过,那么主服务器可以将EVALSHA命令转换成等价的EVAL命令,然后通过传播等价的EVAL命令代替EVALSHA产生相同的效果。

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

 

6.2.4 传播EVALSHA命令的方法

当主服务器成功在本机执行完一个EVALSHA命令之后,它将根据EVALSHA命令指定的SHA1校验和是否存在于repl_scriptcache_dict字典来决定事项从服务器传播EVALSHA命令还是EVAL命令:

1)存在,传播EVALSHA命令

2)不存在,将EVALSHA转化成EVAL,然后传播EVAL,并将EVALSHA指定的SHA1校验和添加到repl_scriptcache_dict字典

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

鹏哥哥啊Aaaa

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值