Lua 脚本
程序员文章站
2022-05-28 12:09:59
...
Redis 2.6 版本开始引入对 Lua 脚本的支持,通过在服务器中嵌入 Lua 环境,Redis 客户端可以使用 Lua 脚本,直接在服务端原子地执行多个 Redis 命令。如下所示:
本文将对这整个过程进行介绍。
创建并修改 Lua 环境
为了在 Redis 服务器中执行 Lua 脚本,Redis 服务器内嵌了一个 Lua 环境,并对其进行了一系列修改,以确保其可以满足 Redis 服务器的需要。Redis 服务器按照以下步骤创建并修改 Lua 环境:
1、创建一个基础的 Lua 环境,之后的所有修改都是针对这个环境进行。
2、载入多个 Lua 函数库(如基础库、字符串库、数学库等)到 Lua 环境中,让 Lua 脚本可以用于数据操作。
3、创建全局变量 redis 表格,其中包含了以下函数:
1)用于在 Lua 脚本中执行 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 函数。
4、使用 Redis 自制的随机函数替换 Lua 原有的带有副作用的随机函数。这是为了保证相同的脚本可以在不同的机器上产生相同的结果,因为之前载入的 Lua 的数学库 math 中,用于生成随机数的 math.random 和 math.randomseed 函数都是带有副作用的,替换后,除非在脚本中使用 math.randomseed 显示地修改了 seed,否则每次 Lua 环境都使用固定的 math.randomseed(0) 语句来初始化 seed。如下面的代码片段所示:
执行结果多次都会得到相同的结果:
5、创建排序辅助函数,以便 Lua 环境用来对一部分 Redis 命令的结果进行排序,从而消除这些命令结果的不确定性。这些“带有不确定性的命令”包括:SINTER、SUNION、SDIFF、SMEMBERS、HKEYS、HVALS 和 KEYS,因为这些命令即使包含完全相同的元素,但只是因为元素添加顺序不同,就可能输出顺序不一致的结果。
6、创建 redis.pcall 函数的错误报告辅助函数,以提供更详细的出错信息。
7、对 Lua 环境中的全局环境进行保护,防止用户在执行 Lua 脚本的过程中,不会因为忘记使用 local 关键字而将额外的全局变量添加到 Lua 环境中(不过 Redis 并未禁止用户修改已存在的全局变量,如 redis 表格,所以在执行 Lua 脚本时务必小心,以免错误地修改了已存在的全局变量)。
8、将完成修改的 Lua 环境保存到服务器状态结构 redisServer 的 lua 属性中,等待执行服务器传来的 Lua 脚本。因为 Redis 使用串行化的方式来执行 Redis 命令,所以在任何特定时间里,最多只会有一个脚本能够被放进 Lua 环境里执行,因此,整个 Redis 服务器只需要创建一个 Lua 环境即可。
Lua 环境协作组件
除了创建并修改 Lua 环境,Redis 服务器还创建了以下两个用于与 Lua 环境进行协作的组件:
1、伪客户端。因为执行 Redis 命令必须要有相应的客户端状态,所以为了执行 Lua 脚本中 redis.call 或者 redis.pcall 函数里面包含的 Redis 命令,Redis 服务器专门为 Lua 环境创建了一个伪客户端。
2、lua_scripts 字典。RedisServer 结构的 lua_scripts 指针属性就指向这个字典,它的键为某个 Lua 脚本的 SHA1 校验和,字典的值则是对应的 Lua 脚本。Redis 服务器会将所有被 EVAL 命令执行过或者被 SCRIPT LOAD 命令载入过的 Lua 脚本都保存到这个字典里面。这个字典有两个作用,一个是实现 SCRIPT EXISTS 命令,另一个是实现 Lua 脚本复制功能,详情见下文内容。
EVAL 命令的实现
Redis 命令 EVAL 的执行过程可分为以下三个步骤:
1、根据客户端给定的 Lua 脚本,在 Lua 环境中定义一个 Lua 函数,其中,Lua 函数的名字由“f_”前缀加上该脚本的 SHA1 校验和(四十个字符长度)组成,而函数体则是脚本本身。使用函数来保存客户端传入的脚本主要有以下好处:
1)通过函数的局部性来让 Lua 环境保持清洁,减少了垃圾回收的工作量,并且避免使用了全局变量。
2)如果某个脚本所对应的函数在 Lua 环境中已定义过一次,则只要记得其所对应的 SHA1 校验和,服务器就可以在不知道脚本本身的情况下,直接调用 Lua 函数来执行脚本,这正是 EVALSHA 命令的实现原理。
2、将客户端给定的脚本保存到 lua_scripts 字典,以便将来进一步使用。
3、执行刚刚在 Lua 环境中定义的函数,以此来执行客户端给定的 Lua 脚本。不过在正式开始执行之前,服务器还需要进行一些设置钩子、传入参数之类的准备工作,整个准备和执行脚本的过程如下:
1)将 EVAL 命令中传入的键名参数和脚本参数(如果有的话)分别保存到 KEYS 数组和 ARGS 数组,然后将这两个数组作为全局变量传入到 Lua 环境里面。
2)为 Lua 环境装载超时处理钩子,以便脚本在出现超时运行的情况时,客户端可以通过 SCRIPT KILL 命令停止脚本,或者通过 SHUTDOWN 命令直接关闭服务器。
3)执行脚本函数。
4)移除之前装载的超时钩子。
5)将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端。
6)对 Lua 环境执行垃圾回收操作。
脚本管理命令的实现
除了 EVAL 和 EVALSHA 命令外,Redis 中与 Lua 脚本有关的命令还有以下四个:
1、SCRIPT FLUSH:这个命令用于清除服务器中所有和 Lua 脚本有关的信息,它会释放并重建 lua_scripts 字典,关闭现有的 Lua 环境并重新创建一个新的 Lua 环境。
2、SCRIPT EXISTS:这个命令可以根据输入的多个 SHA1 校验和来检查对应的 Lua 脚本是否存在于服务器中,这是通过查找 lua_scripts 字典来实现的,返回 1 与 0 表示存在与否。
3、SCRIPT LOAD:这个命令所做的事情和 EVAL 命令执行脚本时所做的前两步完全一样,即在 Lua 环境中定义函数,然后将脚本保存到 lua_scripts 字典。在这之后,客户端就可以使用 EVALSHA 命令来执行先前载入的脚本了。
4、SCRIPT KILL:如果服务器设置了 lua-time-limit 配置选项,那么在每次执行 Lua 脚本之前,服务器都会在 Lua 环境里面设置一个超时处理钩子。这个钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦发现运行时间超过了 lua-time-limit 选项设置的时长,它将定期在脚本运行的间隙中,查看是否有 SCRIPT KILL 命令或者 SHUTDOWN 命令到达服务器。如果超时运行的脚本未执行过任何写入操作,那么客户端就可以通过 SCRIPT KILL 命令来指示服务器停止执行这个脚本,并向客户端发送一个错误回复,之后服务器会继续运行。而如果脚本已经执行过写入操作,那么客户端只能用 SHUTDOWN NOSAVE 命令来停止服务器,以防止不合法的数据被写入到数据库中。
脚本复制
与其他普通 Redis 命令一样,当服务器运行在复制模式下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括 EVAL、EVALSHA、SCRIPT FLUSH 和 SCRIPT LOAD 命令。其中,EVALSHA 命令之外的其他三个命令的复制方法同复制其他普通 Redis 命令的方法一样:当主服务器执行完这三个命令中的其中一个时,会直接将这个命令传播给所有的从服务器执行。
而对于 EVALSHA 命令,因为主服务器与从服务器载入 Lua 脚本的情况可能有所不同,一个在主服务器上可以成功执行的 EVALSHA 命令,在从服务器上执行时却可能会出现脚本未找到错误,所以主服务器不能直接将其传播给所有的从服务器。比如,一个新的从服务器是在主服务器执行完 SCRIPT LOAD 命令后才开始复制主服务器,则它就没有对应的 SHA1,此时执行 EVALSHA 就会出错。
因此,Redis 要求主服务器在传播 EVALSHA 命令时,必须确保 EVALSHA 命令要执行的脚本已经被所有从服务器载入过,如果不能确保这一点的话,主服务器就会将之转换成一个等价的 EVAL 命令,然后通过传播 EVAL 命令来代替。这两种情况都需要用到服务器状态 redisServer 结构的 lua_scripts 和 repl_scriptcache_dict 字典属性。
主服务器使用 repl_scriptcache_dict 字典记录自己已经将哪些脚本(使用 EVAL 或 SCRIPT LAOD 命令执行过的)传播给了所有从服务器,该字典的键是一个 Lua 脚本的 SHA1 校验和,字典的值则全部是 NULL。当一个校验和出现在 repl_scriptcache_dict 中时,说明它所对应的 Lua 脚本已经传播给了所有从服务器,所以主服务器可以直接向从服务器传播包含这个 SHA1 的 EVALSHA 命令,而不必担心从服务器会出现脚本未找到错误。否则,主服务器就会将其转换成等价的 EVAL 命令来代替。
此外,每当主服务器新添加一个从服务器时,主服务器都会清空自己的 repl_scriptcache_dict 字典,因为此时该字典里面记录的脚本已经不再被所有从服务器载入过了。
通过使用 EVALSHA 命令指定的校验和,以及 lua_scripts 字典保存的脚本,服务器总可以将一个 EVALSHA 命令转换成一个等价的 EVAL 命令,因为两者的其他参数都是相同的,只需要将校验和改写成 lua_scripts 中对应的脚本即可。在传播完该 EVAL 命令后,服务器也会将这个校验和添加到 repl_scriptcache_dict 字典,这样下次遇到时就不必再进行转换了。
参考书籍:
1、《Redis设计与实现》第 20 章—— Lua 脚本。
redis> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 "msg" "hello world" OK redis> GET msg "hello world"
本文将对这整个过程进行介绍。
创建并修改 Lua 环境
为了在 Redis 服务器中执行 Lua 脚本,Redis 服务器内嵌了一个 Lua 环境,并对其进行了一系列修改,以确保其可以满足 Redis 服务器的需要。Redis 服务器按照以下步骤创建并修改 Lua 环境:
1、创建一个基础的 Lua 环境,之后的所有修改都是针对这个环境进行。
2、载入多个 Lua 函数库(如基础库、字符串库、数学库等)到 Lua 环境中,让 Lua 脚本可以用于数据操作。
3、创建全局变量 redis 表格,其中包含了以下函数:
1)用于在 Lua 脚本中执行 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 函数。
4、使用 Redis 自制的随机函数替换 Lua 原有的带有副作用的随机函数。这是为了保证相同的脚本可以在不同的机器上产生相同的结果,因为之前载入的 Lua 的数学库 math 中,用于生成随机数的 math.random 和 math.randomseed 函数都是带有副作用的,替换后,除非在脚本中使用 math.randomseed 显示地修改了 seed,否则每次 Lua 环境都使用固定的 math.randomseed(0) 语句来初始化 seed。如下面的代码片段所示:
-- random-with-default-seed.lua -- math.randomseed(10086) -- 取消这行注释表示使用指定的 seed 10086 local i = 4 local sed = {} while (i > 0) do seq[i] = math.random(i) i = i - 1 end return seq
执行结果多次都会得到相同的结果:
$ redis-cli --eval random-with-default-seed.lua 1) (integer) 1 2) (integer) 1 3) (integer) 3 4) (integer) 1
5、创建排序辅助函数,以便 Lua 环境用来对一部分 Redis 命令的结果进行排序,从而消除这些命令结果的不确定性。这些“带有不确定性的命令”包括:SINTER、SUNION、SDIFF、SMEMBERS、HKEYS、HVALS 和 KEYS,因为这些命令即使包含完全相同的元素,但只是因为元素添加顺序不同,就可能输出顺序不一致的结果。
6、创建 redis.pcall 函数的错误报告辅助函数,以提供更详细的出错信息。
7、对 Lua 环境中的全局环境进行保护,防止用户在执行 Lua 脚本的过程中,不会因为忘记使用 local 关键字而将额外的全局变量添加到 Lua 环境中(不过 Redis 并未禁止用户修改已存在的全局变量,如 redis 表格,所以在执行 Lua 脚本时务必小心,以免错误地修改了已存在的全局变量)。
8、将完成修改的 Lua 环境保存到服务器状态结构 redisServer 的 lua 属性中,等待执行服务器传来的 Lua 脚本。因为 Redis 使用串行化的方式来执行 Redis 命令,所以在任何特定时间里,最多只会有一个脚本能够被放进 Lua 环境里执行,因此,整个 Redis 服务器只需要创建一个 Lua 环境即可。
Lua 环境协作组件
除了创建并修改 Lua 环境,Redis 服务器还创建了以下两个用于与 Lua 环境进行协作的组件:
1、伪客户端。因为执行 Redis 命令必须要有相应的客户端状态,所以为了执行 Lua 脚本中 redis.call 或者 redis.pcall 函数里面包含的 Redis 命令,Redis 服务器专门为 Lua 环境创建了一个伪客户端。
2、lua_scripts 字典。RedisServer 结构的 lua_scripts 指针属性就指向这个字典,它的键为某个 Lua 脚本的 SHA1 校验和,字典的值则是对应的 Lua 脚本。Redis 服务器会将所有被 EVAL 命令执行过或者被 SCRIPT LOAD 命令载入过的 Lua 脚本都保存到这个字典里面。这个字典有两个作用,一个是实现 SCRIPT EXISTS 命令,另一个是实现 Lua 脚本复制功能,详情见下文内容。
EVAL 命令的实现
Redis 命令 EVAL 的执行过程可分为以下三个步骤:
1、根据客户端给定的 Lua 脚本,在 Lua 环境中定义一个 Lua 函数,其中,Lua 函数的名字由“f_”前缀加上该脚本的 SHA1 校验和(四十个字符长度)组成,而函数体则是脚本本身。使用函数来保存客户端传入的脚本主要有以下好处:
1)通过函数的局部性来让 Lua 环境保持清洁,减少了垃圾回收的工作量,并且避免使用了全局变量。
2)如果某个脚本所对应的函数在 Lua 环境中已定义过一次,则只要记得其所对应的 SHA1 校验和,服务器就可以在不知道脚本本身的情况下,直接调用 Lua 函数来执行脚本,这正是 EVALSHA 命令的实现原理。
2、将客户端给定的脚本保存到 lua_scripts 字典,以便将来进一步使用。
3、执行刚刚在 Lua 环境中定义的函数,以此来执行客户端给定的 Lua 脚本。不过在正式开始执行之前,服务器还需要进行一些设置钩子、传入参数之类的准备工作,整个准备和执行脚本的过程如下:
1)将 EVAL 命令中传入的键名参数和脚本参数(如果有的话)分别保存到 KEYS 数组和 ARGS 数组,然后将这两个数组作为全局变量传入到 Lua 环境里面。
2)为 Lua 环境装载超时处理钩子,以便脚本在出现超时运行的情况时,客户端可以通过 SCRIPT KILL 命令停止脚本,或者通过 SHUTDOWN 命令直接关闭服务器。
3)执行脚本函数。
4)移除之前装载的超时钩子。
5)将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端。
6)对 Lua 环境执行垃圾回收操作。
脚本管理命令的实现
除了 EVAL 和 EVALSHA 命令外,Redis 中与 Lua 脚本有关的命令还有以下四个:
1、SCRIPT FLUSH:这个命令用于清除服务器中所有和 Lua 脚本有关的信息,它会释放并重建 lua_scripts 字典,关闭现有的 Lua 环境并重新创建一个新的 Lua 环境。
2、SCRIPT EXISTS:这个命令可以根据输入的多个 SHA1 校验和来检查对应的 Lua 脚本是否存在于服务器中,这是通过查找 lua_scripts 字典来实现的,返回 1 与 0 表示存在与否。
3、SCRIPT LOAD:这个命令所做的事情和 EVAL 命令执行脚本时所做的前两步完全一样,即在 Lua 环境中定义函数,然后将脚本保存到 lua_scripts 字典。在这之后,客户端就可以使用 EVALSHA 命令来执行先前载入的脚本了。
4、SCRIPT KILL:如果服务器设置了 lua-time-limit 配置选项,那么在每次执行 Lua 脚本之前,服务器都会在 Lua 环境里面设置一个超时处理钩子。这个钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦发现运行时间超过了 lua-time-limit 选项设置的时长,它将定期在脚本运行的间隙中,查看是否有 SCRIPT KILL 命令或者 SHUTDOWN 命令到达服务器。如果超时运行的脚本未执行过任何写入操作,那么客户端就可以通过 SCRIPT KILL 命令来指示服务器停止执行这个脚本,并向客户端发送一个错误回复,之后服务器会继续运行。而如果脚本已经执行过写入操作,那么客户端只能用 SHUTDOWN NOSAVE 命令来停止服务器,以防止不合法的数据被写入到数据库中。
脚本复制
与其他普通 Redis 命令一样,当服务器运行在复制模式下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括 EVAL、EVALSHA、SCRIPT FLUSH 和 SCRIPT LOAD 命令。其中,EVALSHA 命令之外的其他三个命令的复制方法同复制其他普通 Redis 命令的方法一样:当主服务器执行完这三个命令中的其中一个时,会直接将这个命令传播给所有的从服务器执行。
而对于 EVALSHA 命令,因为主服务器与从服务器载入 Lua 脚本的情况可能有所不同,一个在主服务器上可以成功执行的 EVALSHA 命令,在从服务器上执行时却可能会出现脚本未找到错误,所以主服务器不能直接将其传播给所有的从服务器。比如,一个新的从服务器是在主服务器执行完 SCRIPT LOAD 命令后才开始复制主服务器,则它就没有对应的 SHA1,此时执行 EVALSHA 就会出错。
因此,Redis 要求主服务器在传播 EVALSHA 命令时,必须确保 EVALSHA 命令要执行的脚本已经被所有从服务器载入过,如果不能确保这一点的话,主服务器就会将之转换成一个等价的 EVAL 命令,然后通过传播 EVAL 命令来代替。这两种情况都需要用到服务器状态 redisServer 结构的 lua_scripts 和 repl_scriptcache_dict 字典属性。
主服务器使用 repl_scriptcache_dict 字典记录自己已经将哪些脚本(使用 EVAL 或 SCRIPT LAOD 命令执行过的)传播给了所有从服务器,该字典的键是一个 Lua 脚本的 SHA1 校验和,字典的值则全部是 NULL。当一个校验和出现在 repl_scriptcache_dict 中时,说明它所对应的 Lua 脚本已经传播给了所有从服务器,所以主服务器可以直接向从服务器传播包含这个 SHA1 的 EVALSHA 命令,而不必担心从服务器会出现脚本未找到错误。否则,主服务器就会将其转换成等价的 EVAL 命令来代替。
此外,每当主服务器新添加一个从服务器时,主服务器都会清空自己的 repl_scriptcache_dict 字典,因为此时该字典里面记录的脚本已经不再被所有从服务器载入过了。
通过使用 EVALSHA 命令指定的校验和,以及 lua_scripts 字典保存的脚本,服务器总可以将一个 EVALSHA 命令转换成一个等价的 EVAL 命令,因为两者的其他参数都是相同的,只需要将校验和改写成 lua_scripts 中对应的脚本即可。在传播完该 EVAL 命令后,服务器也会将这个校验和添加到 repl_scriptcache_dict 字典,这样下次遇到时就不必再进行转换了。
参考书籍:
1、《Redis设计与实现》第 20 章—— Lua 脚本。
下一篇: redis 客户端实现