Redis学习笔记 - Lua脚本(2) - Lua脚本的实现

参考:<>

  • 注:这本书是基于Redis3.0版本写的,和后面的版本有点差异

Redis中Lua脚本相关命令介绍以及简单使用,参考博客:https://blog.csdn.net/mytt_10566/article/details/99715998

一、创建并修改Lua环境

为了在Redis服务器创建Lua脚本,Redis在服务器内嵌了一个Lua环境,并对Lua环境进行一系列修改,确保这个Lua环境满足Redis服务器的需要。

Redis服务器创建并修改Lua环境的整个过程由下面几个步骤组成:

  • 创建一个基础的Lua环境,之后的所有修改都是针对这个环境进行的
  • 载入多个函数库到Lua环境里,让Lua脚本可以使用这些函数库来进行数据操作
  • 创建全局表格redis,这个表格包含了对Redis进行操作的函数,比如用于在Lua脚本执行Redis命令的redis.call函数
  • 使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,避免在脚本中引入副作用
  • 创建排序辅助函数,Lua环境使用这个辅助函数对一部分Redis命令的结果进行排序,消除这些命令的不确定性
  • 创建redis.pcall函数的错误报告辅助函数,这个函数可以提供详细的出错信息
  • 对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全局变量条件到Lua环境
  • 将完成修改的Lua环境保存到服务器状态的lua属性中,等待执行服务器传来的Lua脚本

1.1 创建Lua环境

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

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

1.2 载入函数库

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

  • 基础库(base library):包含Lua的核心函数,如assert、error、pairs、tostring、pcall等。此外,防止用户从外部文件中引入不安全代码,库中的loadfile函数会被删除
  • 表格库(table library):包含用于处理表格的通用函数,如table.concattable.inserttable.removetable.sort
  • 字符串库(string library):包含处理字符串的通用函数,比如对字符串查找的string.find函数、对字符串格式化的string.format函数等
  • 数学库(math library):标准c语言数据库的接口,包括计算绝对值的math.abs函数、最大值最小值math.maxmath.min函数等
  • 调试库(debug library):提供了对程序进行调试所需的函数,如对程序设置钩子、获取钩子的debug.sethook函数和debug.gethook函数,返回给定函数相关信息的debug.getinfo函数等
  • Lua CJSON库:用于处理UTF-8编码的JSON
  • Struct库:用于Lua值和C结构(struct)之间进行转换
  • Lua csmgpack库:处理MessagePack格式的数据

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

1.3 创建Redis全局表格

这一步,服务器将在Lua环境中创建一个redis表格(table),并将它设为全局变量。这个Redis表格包含以下函数:

  • 用于执行Redis命令的redis.callredis.pcall函数
  • 用于记录日志(log)的redis.log函数,以及相应的日志级别(level)常量:redis.LOG_DEBUG、redis.LOG_VERBOSE、redis.LOG_NOTICE以及redis.LOG_WARNING
  • 用于计算SHA1校验和的redis.sha1hex函数
  • 用于返回错误信息的redis.error_reply函数和redis.status_reply函数

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函数,替换之后的两个函数有以下两个特征:

  • 对于相同的seed来说,math.random总产生相同的随机数序列,这个函数是一个纯函数
  • 除非在脚本中使用math.randomseed显式地修改seed,否则每次运行脚本时,Lua环境都使用固定的math.randomseed(0)语句来初始化seed

1.5 创建排序辅助函数

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

对于Lua脚本来说,另一个可能产生不一致数据的地方是那些带有不确定性质的命令。

比如一个集合键来说,因为集合元素的排列是无序的,所以即使两个集合的元素完全相同,它们的输出结果也可能不同。类似的命令有:

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

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

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

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

示例:执行一个不存在的命令

redis> eval "return redis.call('my command')" 0
(error) ERR Error running script (call to f_05c35ae0cb5f327782154229e9038fc8b2546c3b): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script
  • @user_script:说明这是一个用户定义函数
  • 1 :表示出错代码位于Lua脚本的第一行

1.7 保护Lua的全局环境

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

因为全局变量保护:

  • (1)当一个脚本试图创建一个全局变量时,服务器会抛出一个错误:
redis> eval "x = 10" 0
(error) ERR Error running script (call to f_df1ad3745c2d2f078f0f41377a92bb6f8ac79af0): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'x'
  • (2)当一个脚本访问一个不存在的全局变量时,服务器也会抛出一个错误:
redis> eval "return x" 0
(error) ERR Error running script (call to f_03c387736bb5cc009ff35151572cee04677aa374): @enable_strict_lua:15: user_script:1: Script attempted to access nonexistent global variable 'x'

不过Redis也支持修改已经存在全局变量,不过需要谨慎点,以免错误修改了全局变量。

redis> eval "redis = 10086; return redis" 0
(integer) 10086

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

进过上面的一些列操作,Redis服务器对Lua环境的修改工作就结束了。

最后一步,服务器将Lua环境和服务器状态的lua属性关联起来,如下图所示:
Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第1张图片
因为Redis使用串行化方式执行Redis命令,所以在任何特定时间里,最多都只会有一个脚本能够放进Lua环境里运行,因此整个Redis服务器只需要创建一个Lua环境。

二、Lua环境协作组件

除了创建并修改Lua环境外,Redis服务器还创建了两个用于与Lua环境进行协作的组件:

  • 一个是负责执行Lua脚本的Redis命令的客户端
  • 用于保存Lua脚本的lua_scripts字典

2.1 伪客户端

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

Lua脚本使用redis.call函数或者redis.pcall函数执行一个Redis命令,需要以下步骤:

  • Lua环境将redis.call函数或者redis.pcall函数想要执行的命令传给伪客户端
  • 伪客户端将脚本想要执行的命令传给执行器
  • 命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端
  • 伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境
  • Lua环境在接收到命令结果回复之后,将该结果返回给redis.call函数或redis.pcall函数
  • 接收到结果的redis.call函数或redis.pcall函数会将命令结果作为函数返回值返回给脚本中的调用者

Lua脚本在调用redis.call函数时,Lua环境、伪客户端、命令执行器三者之间的通信过程如下图所示:
Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第2张图片

2.2 lua_scripts字典

Redis服务器为Lua环境创建另一个协作组件是lua_scripts字典:

  • 键:某个Lua脚本的SHA1校验和(checksum)
  • 值:SHA1校验和对应的Lua脚本

lua_scripts字典的两个作用:

  • 实现SCRIPT EXISTS命令
  • 实现脚本复制功能
struct redisServer {
	// ...
	dict *lua_scripts;
	// ...
};

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

示例:
如果客户端向服务器发送以下命令:

redis> script load "return 'hi'"
"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3"
redis> script load "return 1+1"
"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9"
redis> script load "return 2*2"
"4475bfb5919b5ad16424cb50f74d4724ae833e72"

那么服务器的lua_scripts字典包含被SCRIPT LOAD命令载入的三个Lua脚本,如下图所示:
Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第3张图片

三、EVAL命令的实现

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

  • 根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数
  • 将客户端给定的脚本保存到lua_scripts字典,等待进一步使用
  • 执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本

3.1 定义脚本函数

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

示例:

对于命令:EVAL "return 'hello world'" 0,服务器将在Lua环境中定义以下函数:

function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91
	return 'hello world'
end	

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

  • 执行脚本步骤简单,只要调用与脚本对应的函数即可
  • 通过函数的局部性让Lua环境保持清洁,减少了垃圾回收工作量,避免了使用全局变量
  • 如果某个脚本所对应的函数在Lua环境中被定义至少一次,那么只要记得这个脚本的SHA1和,服务器就可以直接通过SHA1直接调用脚本,而不需要知道脚本本身。这就是EVALSHA命令的实现原理。

3.2 将脚本保存到lua_scripts字典

EVAL命令第二步:将客户端传入的脚本保存到服务器的lua_scripts字典中。

示例:

对于命令:EVAL "return 'hello world'" 0,服务器将在lua_scripts字典中新添加一个键值对:

  • 键:Lua脚本的SHA1校验和:5332031c6b470dc5a0dd9b4bf2030dea6d65de91
  • 值:Lua脚本本身:return 'hello world'

lua_scripts字典如下图所示:
lua_scripts字典

3.3 执行脚本函数

EVAL命令第三步:服务器设置钩子、传入参数等准备工作,才能正式开始执行脚本。

整个准备和执行脚本的过程如下:

  • EVAL命令中传入的键名(key name)参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里
  • 为Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行时,让客户端通过SCRIPT KILL命令停止脚本,或通过SHUTDOWN命令直接关闭服务器
  • 执行脚本函数
  • 移除之前装载的超时钩子
  • 将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里,等待服务器将结果返回给客户端

示例:

对于命令:EVAL "return 'hello world'" 0,服务器将执行以下动作:

  • 因为这个脚本没有给定任何键名或脚本参数,所以服务器会跳过传值到KEYS数组或ARGV数组这一步
  • 为Lua环境装载超时处理钩子
  • 在Lua环境中执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数
  • 移除超时钩子
  • 将执行函数所得的结果"hello world"保存到客户端状态的输出缓冲区里
  • 对Lua环境执行垃圾回收操作

之后只需要将保存在输出缓冲区里的执行结果返回给执行EVAL命令的客户端即可。

四、EVALSHA命令的实现

由第三节可知,每个被EVAL命令成功执行过的Lua脚本,在Lua环境里都有一个与这个脚本对应的Lua函数。所以客户端可以根据脚本的SHA1校验和来调用脚本对应的函数,从而执行脚本。这就是EVALSHA命令的实现原理。

示例:

服务器执行完EVAL "return 'hello world'" 0命令后,Lua环境里就定义了下面的函数:

function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91
	return 'hello world'
end	

当客户端执行EVALSHA命令时:

redis> evalsha 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
"hello world"

redis> evalsha sha1 0
(error) NOSCRIPT No matching script. Please use EVAL.
  • 服务器首先根据客户端输入的SHA1校验和,检查函数f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91是否在Lua环境中
  • 如果Lua环境存在该函数,则服务器执行该函数,并将结果返回给客户端
  • 如果Lua环境不存在该函数,返回一个错误

五、脚本管理命令的实现

脚本管理的四个命令:SCRIPT FLUSHSCRIPT EXISTSSCRIPT LOADSCRIPT KILL

5.1 SCRIPT FLUSH

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

步骤如下:

  • 释放脚本字典
  • 重建脚本字典
  • 关闭Lua环境
  • 初始化一个新的Lua环境

5.2 SCRIPT EXISTS

SCRIPT EXISTS命令:根据输入的SHA1校验和,检查校验和对应的脚本是否存在与服务器中。实际上就是检验给定的校验和是否在lua_scripts字典的键。

检查流程如下图所示:
Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第4张图片
示例:

redis> cript exists 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 abcdefghijklmnopqrst
1) (integer) 1
2) (integer) 0

由结果可知:

  • 5332031c6b470dc5a0dd9b4bf2030dea6d65de91校验和对应的脚本存在于lua_scripts字典表中
  • abcdefghijklmnopqrst不在。

5.3 SCRIPT LOAD

SCRIPT LOAD命令:将脚本 script 添加到脚本缓存中,但并不立即执行这个脚本。这个命令和EVAL命令执行脚本的前两个步骤一致:

  • 在Lua环境中为脚本创建对应的函数
  • 再将脚本保存到lua_scripts字典里

EVAL命令区别就是:

  • SCRIPT LOAD命令只是加载脚本到lua_scripts字典,而不执行;想要执行该脚本则可以通过EVALSHA命令来完成。

示例:

执行以下命令:

redis> script load "return 'hi'"
"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3"

redis> evalsha 2f31ba2bb6d6a0f42cc159d2e2dad55440778de3 0
"hi"

上面的script load "return 'hi'"命令产生以下操作:

  • 服务器在Lua环境中创建以下函数:
function f_2f31ba2bb6d6a0f42cc159d2e2dad55440778de3()
	return 'hi'
end	
  • 然后将键为:2f31ba2bb6d6a0f42cc159d2e2dad55440778de3、值为:"return ‘hi’"的键值对添加到lua_scripts字典里:
    Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第5张图片

5.4 SCRIPT KILL

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

超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,假如超过了lua-time-limit配置项设置的值,钩子将定期在脚本运行的空隙,查看是否有SCRIPT KILLSHUTDOWN命令到达服务器。

  • 如果超时运行的脚本未执行过任何 写入 操作,那么客户端通过SCRIPT KILL命令来停止这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令后,服务器可以继续运行。
  • 如果脚本已经执行过写入操作,那么客户端只能用SHUTDWON nosave命令停止服务器,防止非法数据写入数据库。

带有超时处理钩子的脚本运行过程如下图所示:
Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第6张图片

六、脚本复制

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

6.1 复制EVAL、SCRIPT FLUSH和SCRIPT LOAD命令

Redis复制EVALSCRIPT FLUSHSCRIPT LOAD三个命令的方法和复制其他普通Redis命令的方法一样,当主服务器执行完以上三个命令其中之一时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器,如下图所示:
Redis学习笔记 - Lua脚本(2) - Lua脚本的实现_第7张图片

6.1.1 EVAL

在主服务器执行Lua脚本同样会在所有从服务器执行。

示例:

执行以下命令:

redis> eval "return redis.call('SET', KEYS[1], ARGV[1])" 1 "msg" "hello world"
OK

主服务器在执行完该命令后,向所有从服务器传播这条EVAL命令,从服务器会接收并执行这条EVAL命令。

最终,主从服务器都会将msg设置为"hello world",并将脚本"return redis.call('SET', KEYS[1], ARGV[1])"保存在脚本字典里。

6.1.2 SCRIPT FLUSH

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

6.1.3 SCRIPT LOAD

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

示例:

客户端向服务器发送以下命令:

redis> script load "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"

那么主服务器也会向所有从服务器传播同样的命令:script load "return 'hello world'"

最终,主从服务器都会载入脚本"return 'hello world'

6.2 复制EVALSHA脚本

EVALSHA命令是所有与Lua脚本有关的命令中,复制操作最复杂的。

因为主服务器与从服务器载入Lua脚本的情况可能不同,所以主服务器不能像复制EVALSCRIPT FLUSHSCRIPT LOAD命令那样,直接将命令传播给从服务器。

对于一个在主服务器成功执行的EVALSHA命令,相同的命令在从服务器执行时可能出现脚本未找到(not found)错误。

考虑下面几种情况:

  • 主服务器载入了某个脚本,由于主服务器还没有将载入命令传播给从服务器,这时候主服务器成功执行EVALSHA命令,并将命令传播给从服务器,从服务器会出现脚本未找到错误。
  • 或者有多个从服务器,载入命令只传播到了某个从服务器,这时候主服务执行EVALSHA命令,并将命令传播给所有从服务器,部分从服务器成功执行,部分从服务器会出现脚本未找到错误

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

传播EVALSHA命令或转换成EVAL命令,都需要用到服务器状态的lua_scripts字典和repl_scriptcache_dict字典。

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脚本已经被主服务器载入,但没有传播给所有从服务器。如果此时主服务器传播EVALSHA命令,那么至少有一个从服务器出现脚本未找到错误。
6.2.2 清空repl_scriptcache_dict字典

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

6.2.3 EVALSHA命令转换成EVAL命令

使用EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器总可以
将一个EVALSHA命令:EVAHSHA [key...] [arg...]
转换成一个等价的EVAL命令:EVAL

你可能感兴趣的:(redis)