20 Lua脚本
Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,设计的目的是为了嵌入应用程序中,为应用程序提供灵活的扩展和定制功能。
- EVAL命令可以直接对输入的脚本进行求值
- EVALSHA命令则可以根据脚本的SHA1校验和来对脚本进行求值,这个命令要求校验和对应的脚本至少被EVAL执行过一次
20.1 Redis服务器初始化Lua环境
创建并修改Lus环境的整个过程:
- 创建一个基础的Lua环境
- 载入多个函数库到Lua环境中,Lua可以使用函数库进行数据操作
- 创建全局表格redis,这个表格包含了对redis进行操作的函数,比如用于在Lua脚本中执行Redis命令的redis.call函数
- 使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,从而避免在脚本中引入副作用
- 创建排序辅助函数,Lua环境使用改辅助函数对redis命令的结果进行排序,从而消除这些命令的不确定性
- 创建redis.pcall函数的错误报告辅助函数,这个函数可以提供更详细的出错信息
- 对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全局变量添加到Lua环境中
- 将完成修改的Lua环境保存到服务器状态的lua属性中,等待服务器传来Lua脚本
20.1.1 创建Lua环境
- 服务器调用Lua的C API函数lua_open, 创建一个新的Lua环境
20.1.2 载入函数库
- 将以下函数库载入Lua环境中
- 基础库base library:包含Lua的核心core函数,比如assert、error、pairs、tostring、pcall等。为了防止用户从外部文件中引入不安全代码,库中的loadfile会被删除
- 表格库table library:包含处理表格的通用函数,比如table.concat、table.insert、table.remove、table.sort等
- 字符串库string library:包含用于处理字符串的通用函数,比如字符串查找的string.find函数,对字符串进行格式化的string.format函数,对字符串取长的string.len,对字符串进行翻转的string.reverse函数
- 数字库math library:标准C语言数学库的接口,包含计算绝对值的math.abs、math.max、math.min、math.sqrt、math.log等
- 调试库debug library:提供了对程序进行调试所需的函数,比如对程序设置钩子和取得钩子debug.sethook、debug.gethook、debug.getinfo、debug.setmetatable、debug.getmetatable函数
- Lua CJSON库:用于处理UTF-8编码的JSON格式,其中cjson.decode函数将JSON格式的字符串转换成Lua值,而cjson.encode将一个Lua值序列化成JSON格式的字符串
- Struct库:用于Lua值和C结构struct之间进行转换,struct.pack将多个Lua值打包成一个类结构struct-like字符串,struct.unpack从一个类结构字符串中解包出多个Lua值
- Lua cmsgpack:用于处理MessagePack格式的数据,cmsgpack.pack将Lua值转换成MessagePack数据;而cmsgpack.unpack将MessagePack数据转换成Lua值
20.1.3 创建redis全局表格
- 服务器将在Lua环境中创建一个redis表格,并把它设置为全局变量
- 用于执行Redis命令的redis.call、redis.pcall(可以直接在Lua脚本中执行Redis命令)
- 用于记录Redis日志的redis.log函数,以及相应的日志级别的常量:redis.LOG_DEBUG、redis.LOG_VERBOSE、redis.LOG_NOTICE、redis.LOG_WARNING
- 用于计算SHA1校验和的redis.sha1hex函数
- 用于返回错误信息的redis.error_reply和redis.status_reply
20.1.4 使用Redis自制的随机函数替换Lua原有的随机函数
- 保证相同的脚本在不同的机器上产生相同的效果,Redis要求所有传入服务器的Lua脚本和Lua环境中的所有函数,都是无副作用的纯函数
- 之前载入Lua环境的math函数库,用于生成随机数的math.random和math.randomseed都有副作用,在不同机器运行结果可能不同
- Redis采用了自制的函数替换了math库的math.random和math.randomseed
- 相同的seed,math.random总是产生相同的随机数序列
- 除非在脚本中显式修改了seed,否则每次运行脚本的时候,Lua都使用固定的math.randomseed(0)初始化seed
--code0 random-with-default-seed.lua
local i = 10
local seq = {}
--math.randomseed(10089)
while (i > 0) do
seq[i] = math.random(i)
i = i - 1
end
return seq
--command run: ./redis-cli --eval random-with-default-seed.lua
20.1.5 创建排序辅助函数
- 在相同的数据集上可能产生不同输出的命令称之为“带有不确定性的命令”
- SINTER
- SUNION
- SDIFF
- SMEMBERS
- HKEYS
- HVALS
- KEYS
- 为了消除命令带来的不确定性,服务器为Lua环境创建一个排序辅助函数__redis__compare__helper,当Lua脚本执行完一个带有不确定性的命令之后,程序会使用__redis__compare__helper作为对比函数,自动调用table.sort函数对命令的返回值做一次排序,保证相同的数据集产生相同的输出
20.1.6 创建redis.pcall函数的错误报告辅助函数
- 服务器将为Lua环境创建一个名为__redis__err__handler的错误处理函数,当脚本调用redis.pcall执行redis命令时,被执行的命令出现错误,__redis__err__handler会打印代码的来源和发生错误的行数
--code1
local i = 10
local seq = {}
math.randomseed(10089)
while (i > 0) do
seq[i] = math.random(i)
i = i - 1
end
return redis.pcall("seta name hello")
-- $./redis-cli --eval random-with-default-seed.lua
-- (error) @user_script: 10: Unknown Redis command called from Lua script
20.1.7 保护Lua的全局环境
- 没有使用local关键字标记Lua全局变量的变量,不会被加到Lua环境中
- redis是本身存在的全局变量
- 当一个脚本试图创建一个全局变量的时候,服务器会报告一个错误
- 当一个脚本试图获取一个不存在的全局变量的时候,服务器也会报告一个错误
127.0.0.1:6379> 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'
127.0.0.1:6379> eval "x = 10; return x" 0
(error) ERR Error running script (call to f_adef9e507f6bbef03993f1d95c69453573b9c9b9): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'x'
127.0.0.1:6379> eval "return redis" 0
(empty array)
127.0.0.1:6379> eval "local x = 10; return x" 0
(integer) 10
127.0.0.1:6379>
20.1.8 将Lua环境保存到服务器状态的Lua属性中
//code2 server.h
struct redisServer {
/* General */
//...
/* Scripting */
lua_State *lua; /* The Lua interpreter. We use just one for all clients */
client *lua_client; /* The "fake client" to query Redis from Lua */
client *lua_caller; /* The client running EVAL right now, or NULL */
char* lua_cur_script; /* SHA1 of the script currently running, or NULL */
dict *lua_scripts; /* A dictionary of SHA1 -> Lua scripts */
unsigned long long lua_scripts_mem; /* Cached scripts' memory + oh */
mstime_t lua_time_limit; /* Script timeout in milliseconds */
mstime_t lua_time_start; /* Start time of script, milliseconds time */
int lua_write_dirty; /* True if a write command was called during the
execution of the current script. */
int lua_random_dirty; /* True if a random command was called during the
execution of the current script. */
int lua_replicate_commands; /* True if we are doing single commands repl. */
int lua_multi_emitted;/* True if we already proagated MULTI. */
int lua_repl; /* Script replication flags for redis.set_repl(). */
int lua_timedout; /* True if we reached the time limit for script
execution. */
int lua_kill; /* Kill the script if true. */
int lua_always_replicate_commands; /* Default replication type. */
int lua_oom; /* OOM detected when script start? */
//...
}
20.2 Lua环境协作的两个组件
20.2.1 执行Lua脚本的伪客户端
- 执行Redis命令需要由相应的客户端状态,为了执行Lua脚本中的Redis命令,Redis服务器为Lua环境创建了一个伪客户端,有这个伪客户端负责处理Lua脚本中包含的所有Redis命令
- Lua脚本使用redis.call和redis.pcall运行redis命令
- Lua环境将redis.call和redis.pcall想要执行的命令传给伪客户端
- 伪客户端将脚本想要执行的命令传给命令执行器
- 命令执行器执行伪客户端传来的命令,把结果传给伪客户端
- 伪客户端把结果返回Lua环境
- Lua环境收到命令结果后,将结果返回给redis.call/pcall
- redis.call或者redis.pcall会将命令结果作为函数返回值返回给脚本的调用者
20.2.2 lua_scripts脚本字典
- key是某个Lua脚本的SHA1校验和,字典的值是SHA1校验和对应的Lua脚本
- lua_scripts:保存所有被EVAL命令执行过的Lua脚本,所有被SCRIPT LOAD命令载入过的Lua脚本
- 用途:实现SCRIPT EXISTS;脚本复制
20.3 EVAL和EVALSHA的实现原理
20.3.1 定义脚本函数
- 客户端向服务器发送EVAL命令,在执行某个Lua脚本,服务器要给这个脚本定一个相对应的Lua函数(f_SHA1校验和()),函数体是脚本本身
- example见下code
- 优点
- 执行脚本步骤简单,只要调用脚本相对应的函数
- 通过函数的局部性要Lua环境保持清洁,减少了垃圾回收的工作量,避免使用全局变量
- 如果某个脚本在Lua环境中被定义过至少一次,只要记得校验和,服务器就可以不知道脚本本身的情况下,直接调用Lua执行(EVALSHA原理)
127.0.0.1:6379> script load "return 'this is a wonderful world'"
"b136ece6abd5a9ea2aa44309d9a4c49d1f08f6e2"
function f_b136ece6abd5a9ea2aa44309d9a4c49d1f08f6e2()
return 'this is a wonderful world'
end
20.3.2 保存到lua_scripts
- lua_scripts:新增键值对,键为Lua脚本的SHA1校验和,值为Lua本身
20.3.3 执行脚本函数
- 还需进行:设置钩子、传参等准备动作,才能正式开始执行脚本
- 过程:
- EVAL命令中传入的键名和脚本参数分别保存在KEYS和ARGV数组中
- 为Lua环境装载超时处理钩子hook,钩子的作用在脚本出现超时运行情况,客户端可以通过SCRIPT KILL命令停止脚本,或者SHUTDOWN直接关闭服务器
- 执行脚本函数
- 移除之前装载的超时钩子
- 将脚本函数的结果保存到客户端的输出缓冲区,等待服务器将结果返回给客户端
- 对Lua环境执行垃圾回收操作
20.4 EVELSHA命令
- 根据校验和(key)在lua_scripts字典中找,如存在,则执行value对应的脚本
20.5 脚本管理命令的实现
- 管理脚本的四个命令:SCRIPT FLUSH、SCRIPT EXISTS、SCRIPT LOAD、SCRIPT KILL
20.5.1 SCRIPT FLUSH
- 清除服务器中所有和Lua脚本有关的信息,这个命令会释放并重建lua_scripts字典,关闭现有的Lua环境并创建一个新的Lua环境
20.5.2 SCRIPT EXISTS
- 根据输入的SHA1校验和,检验校验和对应的脚本是否存在于服务器中,存在返回1,不存在返回0。查询lua_scripts字典
20.5.3 SCRIPT LOAD
- 首先在Lua环境中为脚本创建相对应的函数,再将脚本保存到lua_scripts里面
20.5.4 SCRIPT KILL
- 服务器设置了lua-time-limit配置,每次执行Lua脚本之前,服务器都会在Lua环境里面设置一个超时处理的钩子
- 超时处理的钩子在脚本运行期间,会检查运行时间,一旦超过时间,钩子将定期在脚本执行的间隙中,查看是否有SCRIPT KILL命令或者SHUTDOWN命令到达服务器
- 没有执行写入,客户端可以通过SCRIPT KILL来指示服务器停止运行脚本
- 执行写入,客户端只可以通过SHUTDOWN nosave来停止服务器,防止不合法数据写入
20.6 脚本复制
- 服务器在复制模式下,具有写性质的脚本命令也会被复制到从服务器,这些命令包含EVAL、EVALSHA、SCRIPT FLUSH、SCRIPT LOAD命令
20.6.1 复制EVAL、SCRIPT FLUSH、SCRIPT LOAD命令
- 当主服务器执行完EVAL、SCRIPT FLUSH、SCRIPT LOAD这三个命令中的其中一个时,主服务器会直接将被执行的命令传播给所有从服务器
- EVAL:主服务器执行EVAL后,向从服务器传播这条EVAL命令,从服务器会接收并执行这条EVAL
- SCRIPT FLUSH:最终主从服务器均会重置自己的Lua环境,并清空自己的脚本字典
- SCRIPT LOAD:最终主从服务器都会载入相同的Lua脚本
20.6.1 复制EVALSHA命令
- 相对复杂:相同的EVALSHA命令在主服务器上被成功执行,但是在从服务器上可能会出现脚本未找到的错误
- 判断传播EVALSHA命令是否安全的方法
- 主服务器使用服务器状态的repl_scriptcache_dict来记录将哪些脚本传播给了所有从服务器
- repl_scriptcache_dict键是Lua脚本的SHA1校验和,字典的值是NULL。
- 当一个校验和出现在repl_scriptcache_dict字典,说明这个校验和对应的Lua脚本已经传播给所有的从服务器,主服务器可以直接向从服务器传播SHA1校验和的EVALSHA命令
- 当一个校验和存在lua_scripts,但是不存在于repl_scriptcache_dict,主服务器将EVALSHA命令转换成等价的EVAL命令发送给从服务器
- 每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典
//code3 server.h
struct redisServer {
/* General */
//...
/* Replication script cache. */
dict *repl_scriptcache_dict; /* SHA1 all slaves are aware of. */
//...
}