《redis设计与实现》第四部分 (第20章 Lua脚本)

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. */
    //...
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值