《Redis设计与实现》读书笔记(三十四) ——Redis Lua脚本环境设计与实现
(原创内容,转载请注明来源,谢谢)
一、创建lua环境
为了在redis服务器执行lua脚本,redis服务器内嵌了一个lua环境,redis服务器启动的时候,会自动创建lua环境,步骤如下:
1)创建一个基础lua环境。
调用lua的C API函数lua_open,创建新的lua环境。但是这个是原生的环境,redis会对其进行定制。
2)载入多个lua函数库,以便lua脚本的执行。
包括基础库、表格库、字符串库、数学库、调试库、CJSON库、Struct库、cmsgpack库等。
3)创建全局表格redis,包含lua脚本可以多redis进行的操作。
包括redis.call、redis.pcall、redis.log函数等,以便在lua脚本中执行redis命令。
4)使用redis自制的随机函数替换lua脚本原生的随机函数,避免随机机制不统一导致的错误。
lua的随机函数具有副作用,不符合redis的要求。因此,redis用自制的函数,替换了lua脚本的math库中,所有math.random、math.randomseed函数。相同的seed总是会保证生成相同的随机序列。
5)创建排序辅助函数,供lua调用,避免排序结果的不一致。
除了随机函数,另一个不确定的是排序辅助函数。对于集合、hash等操作,输出的结果可能是无序的,同样的内容输出的有可能会不同,为了消除这种不确定性,lua执行一次不确定性的redis命令后,redis会自动调用redis.sort函数进行一次排序,保证相同数据集有相同的输出。
6)创建错误报告函数redis.pcall,包含更详细的报错信息。
该函数输出更详细的错误信息,以便于开发者进行调试。
7)对lua环境的全局环境进行保护,防止全局变量被修改。
主要是保证避免忘了添加local关键字,导致额外的全局变量在脚本中被增加到lua中。但是redis没有保护已经存在的全局变量,即可用修改现有全局变量,这个要注意。
8)将上述操作后的lua环境,保存到服务器的lua属性中。
redis将lua环境保存在redisServer结构体的lua属性中。
二、lua环境协作组件
除了创建lua环境,redis还创建了两个环境协作组件,分别是负责执行lua脚本中的redis命令的伪客户端、负责保存lua脚本的lua_scripts字典。
1、伪客户端
执行redis命令都必须要有redis的客户端状态,因此redis服务器为执行lua,设定了一个伪客户端,专门用于执行lua命令。
lua用redis.call或redis.pcall执行redis命令,步骤如下:
1)lua环境将想要执行的命令传给伪客户端,伪客户端将命令传给命令执行器。
2)命令执行器执行命令,并将结果返回给伪客户端。
3)伪客户端返回给lua脚本,lua脚本再返回给脚本调用者。
2、lua_scripts字典
这个字典的键是某个lua脚本sha1校验和,值是该校验和对应的脚本。该字段也保存在redisServer结构体中。
redis服务器会保存所有eval命令执行过的脚本,以及所有script load命令加载过的lua脚本。
该字典有两个作用,一个是实现scripts exists命令,一个是实现脚本复制功能。
三、eval命令的实现
eval执行过程分为3个步骤:
1)根据客户端给定的lua脚本,在lua环境中定义一个lua函数。
2)将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用。
3)执行lua环境中给定的函数,来执行lua脚本。
1、定义脚本函数
服务器会为传入的脚本,定义一个函数,函数的名字以f_开头,后面是脚本的sha1校验和(40个字符长度),整个函数名长度共42个字符,函数体是脚本本身。
这样做的好处在于,执行脚本步骤非常简单,只要调用与脚本相对应的函数,每个脚本有一个唯一的函数;另外,函数的局部性让环境保持清洁,避免全局变量;还有,脚本本定义过一次后,服务器后续再调用脚本,不需要知道脚本本身,只要知道脚本sha1校验和即可。
2、执行lua脚本函数
执行步骤如下:
1)将eval传入的键名参数和脚本参数分别保存到keys和argv数组,将这两个数组作为全局变量传入到lua环境。
2)为lua环境装载超时处理钩子,这个钩子可以在脚本出现超时运行时,让客户端执行script kill命令,停止脚本,或者通过shutdown命令直接关闭服务器。
3)执行脚本函数。
4)移除之前装载的超时处理钩子。
5)将指向脚本函数得到的结果保存到客户端状态的输出缓冲区中,等待服务器将结果返回给客户端。
6)对lua环境进行垃圾回收。
四、evalsha命令
evalsha命令,是直接执行脚本的sha1函数。
这个函数必须之前已经成功执行过,则此次只需要直接传入sha1的结果,服务器会从lua_scripts字典中,查找是否存在该sha1结果的键,如果存在,则会自动拼接出函数的名字,并且去执行。
具体执行过程同eval命令的第三步。
五、脚本管理命令的实现
redis关于lua脚本管理的命令有四个:script flush、script exists、script load、script kill。
1、script flush
该命令会清除服务器所有和lua有关的信息,会清空lua_scripts字典,并且关闭现有lua环境,重新初始化一个lua环境。
2、scriptexists
该命令传入sha1校验和,判断在lua_scripts字典中,是否存在该校验和。该命令允许一次传入多个校验和。存在返回1,不存在返回2。
3、script load
该命令等同于eval命令的前两步,即没有执行脚本,但是创建了脚本的函数,并且将校验和存入到lua_scripts字典。因此,eval命令,等同于script load命令加上evalsha命令。
4、script kill
脚本运行前,会创建钩子,防止执行时间超过redis设置选项中的lua-time-limit。在执行期间,会定期检查脚本运行时间,如果超时,则会停止脚本。
停止脚本有两种方式,redis服务器会区分脚本是否执行过写命令:
如果已经执行过写命令,并且lua脚本超时,redis会执行shutdownnosave命令,停止服务器,防止脏数据写入;如果没执行过写命令,则redis服务器会执行script kill。
六、脚本复制
当服务器运行在复制模式,具有写性质的脚本命令,会被复制到从服务器。包括eval、evalsha、script flush、script load。
1、eval、script flush、script load命令复制
eval、script flush、script load三个命令的复制方式和普通的redis写命令一样,主服务器会直接将命令传播给从服务器。
2、evalsha命令复制
evalsha命令复制较为复制,由于这个命令是直接执行sha1编码的函数名,因此要首先保证每个从服务器都存在该名字,并且从服务器要能找到该名字对应的函数。
为了防止这个情况,redis会要求主服务器传播evalsha的时候,要保证从服务器已经存在该函数,否则主服务器会将evalsha命令转换成等价的eval命令发送给从服务器。
1)repl_scriptcache_dict
主服务器在redisServer结构体中,另外保存了一个字典,repl_scriptcache_dict,用于记录哪些脚本已经传播给从服务器。该字典键是lua脚本sha1校验和,值是null。记录在这个字典中的键,都是已经传播给从服务器的。
2)清空repl_scriptcache_dict
当添加一个从服务器的时候,redis主服务器会清空repl_scriptcache_dict字典,确保新服务器不会发生错误。
3)evalsha转换成eval
从lua_scripts字典中,找到对应的lua脚本函数的内容。这样传播后,由于从服务器可以记录其校验和并存在自身的lua_scripts字典,因此每次这样传播后,redis服务器都会将脚本的校验和存入repl_scriptcache_dict字典,下次就可以直接发生evalsha。
因此,evalsha传播的过程,是先判断主服务器自身的repl_scriptcache_dict字典是否存在该校验和,如果有则直接传播;如果没有,则从lua_scripts字典找到对应的lua脚本,传给从服务器,并且将其校验和存入repl_scriptcache_dict字典。
七、总结
1、redis服务器启动的时候,会执行一系列的lua环境初始化操作,保证lua脚本正常进行。其专门创建一个伪客户端,并且为lua脚本定制随机函数、排序函数等,保证脚本的执行结果在redis服务器可预测的范围内。
2、redis所有用eval执行过的lua脚本,或被script load载入过的脚本,都会通过校验和—脚本函数的键值对的方式,保存到服务器中,用于后续evalsha、script exists、脚本复制等功能。
3、redis为每个lua脚本定义一个函数,函数的名称是f_开头,以脚本sha1的40位字符串连接到其后。函数的内容是脚本本身。
4、eval直接执行脚本(前置是先有一系列保存操作),evalsha通过执行脚本函数名称来执行脚本,script load载入脚本的校验和但不执行脚本,script flush清空所有保存的脚本字典并且重置lua环境,script exists接收一个或多个sha1校验和以判断脚本是否已经存在,script kill是在lua脚本超时的情况下未执行过写命令情况下强制停止脚本,shutdown nosave是lua脚本超时并且执行过写命令的情况下关闭服务器防止脏数据写入。
5、主服务器在复制eval、script flush、script load命令同其他redis写命令。
6、主服务器在复制evalsha命令时,会先判断主服务器自身的repl_scriptcache_dict字典是否存在该校验和,如果有则直接传播;如果没有,则从lua_scripts字典找到对应的lua脚本,传给从服务器,并且将其校验和存入repl_scriptcache_dict字典。
——written by linhxx 2017.09.30