Lua脚本
2023-4-11
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property

Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
 

创建并修改 Lua 环境

为了在Redis服务器中执行Lua脚本, Redis在服务器内嵌了一个Lua环境, 并对这个Lua环境进行了一系列修改, 从而确保这个Lua环境可以满足Redis服务器的需要。
Redis 服务器创建并修改 Lua 环境的整个过程由以下步骤组成:
  1. 创建一个基础的Lua环境, 之后的所有修改都是针对这个环境进行的。
    1. 服务器首先调用LuaC API函数lua_open , 创建一个新的Lua环境。lua_open函数创建的只是一个基本的Lua环境, 为了让这个Lua环境可以满足Redis的操作要求, 接下来服务器将对这个Lua环境进行一系列修改。
  1. 载入多个函数库到Lua环境里面, 让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 值。
      通过使用这些功能强大的函数库, Lua 脚本可以直接对执行 Redis 命令获得的数据进行复杂的操作。
  1. 创建全局表格 redis , 这个表格包含了对 Redis 进行操作的函数, 比如用于在 Lua 脚本中执行 Redis 命令的 redis.call 函数。
    1. 这个 redis 表格包含以下函数:
      • 用于执行Redis命令的 redis.call 和 redis.pcall 函数
      • 用于记录Redis日志(log)的redis.log函数, 以及相应的日志级别(level)常量:redis.LOG_DEBUGredis.LOG_VERBOSEredis.LOG_NOTICE, 以及redis.LOG_WARNING 
      • 用于计算 SHA1 校验和的 redis.sha1hex 函数
      • 用于返回错误信息的 redis.error_reply 函数和 redis.status_reply 函数
      在这些函数里面, 最常用也最重要的要数redis.call函数和redis.pcall函数 —— 通过这两个函数, 用户可以直接在Lua脚本中执行Redis命令:
  1. 使用 Redis 自制的随机函数来替换 Lua 原有的带有副作用的随机函数, 从而避免在脚本中引入副作用。
    1. 为了保证相同的脚本可以在不同的机器上产生相同的结果, Redis 要求所有传入服务器的 Lua 脚本, 以及 Lua 环境中的所有函数, 都必须是无副作用的纯函数。
      但是, 在之前载入到 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 。
      比如说, 使用以下脚本, 可以打印 seed 值为 0 时, math.random 对于输入 10 至 1 所产生的随机序列,无论执行这个脚本多少次, 产生的值都是相同的:
      但是, 如果在另一个脚本里面, 调用 math.randomseed 将 seed 修改为 10086 ,那么这个脚本生成的随机数序列将和使用默认 seed 值 0 时生成的随机序列不同:
  1. 创建排序辅助函数, Lua 环境使用这个辅佐函数来对一部分 Redis 命令的结果进行排序, 从而消除这些命令的不确定性。
    1. 为了防止带有副作用的函数令脚本产生不一致的数据, Redis 对 math 库的 math.random 函数和 math.randomseed 函数进行了替换。
      对于 Lua 脚本来说, 另一个可能产生不一致数据的地方是那些带有不确定性质的命令。
      比如对于一个集合键来说, 因为集合元素的排列是无序的, 所以即使两个集合的元素完全相同, 它们的输出结果也可能并不相同。考虑下面这个集合例子:
      fruit集合和another-fruit 集合包含的元素是完全相同的, 只是因为集合添加元素的顺序不同, SMEMBERS 命令的输出就产生了不同的结果。RedisSMEMBERS这种在相同数据集上可能会产生不同输出的命令称为“带有不确定性的命令”, 这些命令包括:
      • SINTER
      • SUNION
      • SDIFF
      • SMEMBERS
      • HKEYS
      • HVALS
      • KEYS
      为了消除这些命令带来的不确定性, 服务器会为 Lua 环境创建一个排序辅助函数 __redis__compare_helper , 当 Lua 脚本执行完一个带有不确定性的命令之后, 程序会使用 __redis__compare_helper 作为对比函数, 自动调用 table.sort 函数对命令的返回值做一次排序, 以此来保证相同的数据集总是产生相同的输出。
       
      如果在 Lua 脚本中对 fruit 集合和 another-fruit 集合执行 SMEMBERS 命令, 那么两个脚本将得出相同的结果 —— 因为脚本已经对 SMEMBERS 命令的输出进行过排序了:
  1. 创建 redis.pcall 函数的错误报告辅助函数, 这个函数可以提供更详细的出错信息。
    1. 服务器将为 Lua 环境创建一个名为 __redis__err__handler 的错误处理函数, 当脚本调用 redis.pcall 函数执行 Redis 命令, 并且被执行的命令出现错误时, __redis__err__handler 就会打印出错代码的来源和发生错误的行数, 为程序的调试提供方便。
      如果客户端要求服务器执行以下 Lua 脚本,那么服务器将向客户端返回一个错误:
      @user_script说明这是一个用户定义的函数, 而之后的4则说明出错的代码位于 Lua 脚本的第四行。
  1. 对 Lua 环境里面的全局环境进行保护, 防止用户在执行 Lua 脚本的过程中, 将额外的全局变量添加到了 Lua 环境里面。
    1. 服务器将对 Lua 环境中的全局环境进行保护, 确保传入服务器的脚本不会因为忘记使用 local 关键字而将额外的全局变量添加到了 Lua 环境里面。因为全局变量保护的原因, 当一个脚本试图创建一个全局变量时, 服务器将报告一个错误:
      试图获取一个不存在的全局变量也会引发一个错误:
      不过 Redis 并未禁止用户修改已存在的全局变量, 所以在执行 Lua 脚本的时候, 必须非常小心, 以免错误地修改了已存在的全局变量:
  1. 将完成修改的 Lua 环境保存到服务器状态的 lua 属性里面, 等待执行服务器传来的 Lua 脚本。
    1. 服务器会将 Lua 环境和服务器状态的 lua属性关联起来:
      notion image
      因为 Redis 使用串行化的方式来执行 Redis 命令, 所以在任何特定时间里, 最多都只会有一个脚本能够被放进 Lua 环境里面运行, 因此, 整个 Redis 服务器只需要创建一个 Lua 环境即可。
 
 

环境协作组件

除了创建并修改Lua环境之外,Redis服务器还创建了两个用于与Lua环境进行协作的组件
  • 负责执行Lua脚本中的Redis命令的伪客户端
  • 用于保存Lua脚本的lua_scripts字典
 

伪客户端

因为执行Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中包含的Redis命令,Redis服务器专门为Lua环境创建了一个伪客户端,并由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。
Lua脚本使用redis.call函数或者redis.pcall函数执行一个Redis命令,需要完成以下步骤:
  1. Lua环境将redis.call函数或者redis.pcall函数想要执行的命令传给伪客户端
  1. 伪客户端将脚本想要执行的命令传给命令执行器
  1. 命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端
  1. 伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境
  1. Lua环境在接收到命令结果之后,将该结果返回给redis.call函数或者redis.pcall函数
  1. 接收到结果的redis.call函数或者redis.pcall函数会将命令结果作为函数返回值返回给脚本中的调用者
 
Lua脚本在调用redis.call函数时,Lua环境、伪客户端、命令执行器三者之间的通信过程:
notion image
 
notion image
 

lua_scripts字典

  • 字典的键为某个Lua脚本的SHA1校验和(checksum)
  • 字典的值则是SHA1校验和对应的Lua脚本
Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里。
lua_scripts字典有两个作用:一个是实现SCRIPT EXISTS命令,另一个是实现脚本复制功能
如果客户端向服务器发送以下命令:
notion image
那么服务器的lua_scripts字典将包含被SCRIPT LOAD命令载入的三个Lua脚本:
notion image
 

EVAL命令的实现

EVAL命令可以直接执行Lua脚本
EVAL命令的执行过程可以分为以下三个步骤:
  • 根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数
  • 将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用
  • 执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本

定义脚本函数

当客户端向服务器发送EVAL命令,要求执行某个Lua脚本的时候,服务器首先要做的就是在Lua环境中,为传入的脚本定义一个与这个脚本相对应的Lua函数,其中:
  • Lua函数的名字f_前缀加上脚本的SHA1校验和(四十个字符长)组成
  • 函数的体(body)则是脚本本身
 
对于命令EVAL “return ‘hello world’” 0来说, 服务器将在Lua环境中定义以下函数:
使用函数来保存客户端传入的脚本有以下好处:
  • 执行脚本的步骤非常简单,只要调用与脚本相对应的函数即可
  • 通过函数的局部性来Lua环境保持清洁,减少了垃圾回收的工作量,并且避免了使用全局变量
  • 如果某个脚本所对应的函数在Lua环境中被定义过至少一次,那么只要记得这个脚本的SHA1校验和,服务器就可以在不知道脚本本身的情况下,直接通过调用Lua函数来执行脚本(备注:这也是EVALSHA命令的实现原理)
 
 

将脚本保存到lua_scripts字典

将客户端传入的脚本保存到服务器的lua_scripts字典里面
对于命令:eval "return 'hello world'" 0
服务器将在lua_scripts字典中新添加一个键值对,其中键为Lua脚本的SHA1校验和:5332031c6b470dc5a0dd9b4bf2030dea6d65de91
而值则为Lua脚本本身:return 'hello world'
添加新键值对之后的lua_scripts字典:
notion image

执行脚本函数

在为脚本定义函数,并且将脚本保存到lua_scripts字典之后,服务器还需要进行一些设置钩子、传入参数之类的准备动作,才能正式开始执行脚本。整个准备和执行脚本的过程如下:
  1. EVAL命令中传入的键名(key name)参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里面
  1. Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况时, 让客户端通过SCRIPT KILL命令停止脚本,或者通过SHUTDOWN命令直接关闭服务器
  1. 执行脚本函数
  1. 移除之前装载的超时钩子
  1. 将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端
  1. Lua环境执行垃圾回收操作
 
 
对于命令eval "return 'hello world'" 0,服务器将执行以下动作
  1. 因为这个脚本没有给定任何键名参数或者脚本参数,所以服务器会跳过传值到KEYS数组或ARGV数组这一步
  1. Lua环境装载超时处理钩子
  1. Lua环境中执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数
  1. 移除超时钩子
  1. 将执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数所得的结果"hello world"保存到客户端状态的输出缓冲区里面
  1. Lua环境执行垃圾回收操作
 

EVALSHA命令的实现

每个被EVAL命令成功执行过的Lua脚本,在Lua环境里面都有一个与这个脚本相对应的Lua函数,函数的名字由f_前缀加上40个字符长的 SHA1校验和组成,例如f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91
只要脚本对应的函数曾经在Lua环境里面定义过,那么即使不知道脚本的内容本身,客户端也可以根据脚本的SHA1校验和来调用脚本对应的函数,从而达到执行脚本的目的,这就是EVALSHA命令的实现原理。可以用伪代码来描述这一原理:
当服务器执行完以下EVAL命令之后:
Lua环境里面就定义了以下函数::
当客户端执行以下EVALSHA命令时:
服务器首先根据客户端输入的SHA1校验和,检查函数f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91是否存在于Lua环境中,得到的回应是该函数确实存在,于是服务器执行Lua环境中的f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数,并将结果"hello world"返回给客户端

脚本管理命令的实现

除了EVAL命令和EVALSHA命令之外,Redis中与Lua脚本有关的命令还有四个,它们分别是:SCRIPT FLUSH命令、SCRIPT EXISTS命令、SCRIPT LOAD命令、以及SCRIPT KILL命令。

SCRIPT FLUSH

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

SCRIPT EXISTS

SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中。
SCRIPT EXISTS命令是通过检查给定的校验和是否存在于lua_scripts字典来实现的。
伪代码:
对于下图所示的lua_scripts字典来说,可以进行以下测试:
notion image
notion image
实现SCRIPT EXISTS实际上并不需要lua_scripts字典的值。如果lua_scripts字典只用于实现SCRIPT EXISTS命令的话,那么字典只需要保存Lua脚本的SHA1校验和就可以了,并不需要保存Lua脚本本身。lua_scripts字典既保存脚本的SHA1校验和,又保存脚本本身的原因是为了实现脚本复制功能

SCRIPT LOAD

SCRIPT LOAD命令只加载/保存脚本,但是不执行脚本。
命令首先在Lua环境中为脚本创建相对应的函数,然后再将脚本保存到lua_scripts字典里面
 

SCRIPT KILL

如果服务器设置了lua-time-limit配置选项,那么在每次执行Lua脚本之前,服务器都会在Lua环境里面设置一个超时处理钩子(hook)。
超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长,钩子将定期在脚本运行的间隙中, 查看是否有SCRIPT KILL命令或者SHUTDOWN命令到达服务器。
带有超时处理钩子的脚本的运行过程:
  • 如果超时运行的脚本未执行过任何写入操作,那么客户端可以通过SCRIPT KILL命令来指示服务器停止执行这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令之后,服务器可以继续运行
  • 如果脚本已经执行过写入操作,那么客户端只能用SHUTDOWN nosave命令来停止服务器,从而防止不合法的数据被写入数据库中
notion image
 
 

脚本的复制

当服务器运行在复制模式之下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括EVAL命令、EVALSHA命令、SCRIPT FLUSH命令,以 及SCRIPT LOAD命令

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

Redis复制EVALSCRIPT FLUSHSCRIPT LOAD三个命令的方法和复制其他普通Redis命令的方法一样,当主服务器执行完以上三个命令的其中一个时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器:
notion image

复制EVALSHA命令

execsha命令的复制会比较复杂,因为execsha命令需要保证脚本函数已经被创建。如果master直接传播可能会出现master中已创建了脚本函数,但是从服务器没有创建过脚本函数,所以主服务可以执行execsha命令,但从服务器会报错。
 
Redis借助:lua_scripts字典和repl_scriptcache_dict字典实现execsha命令的传播
其中:lua_scripts字典存放sha1校验和与脚本, repl_scriptcache_dict字典存放所有从服务器已经同步的脚本,keysha1校验和,值为null
如果sha1校验和存在lua_scripts字典中,但是没有在repl_scriptcache_dict字典,说明这个脚本自己已经加载,但是没有被完全传播给从服务器。
所以当execsha命令发送到主服务器的时候,主服务器会检查这个sha1校验和是否已经传播给所有从服务器了,也就是查看repl_scriptcache_dict字典中是否存在。若存在则直接传播excesha命令,若不存在,则将execsha命令转换成exec命令进行传播。然后在repl_scriptcache_dict字典中加入这个脚本,表示已经被完全传播。
如果新连接了一个从服务器,那么主服务器会清空repl_scriptcache_dict字典,因为新连接的从服务器是没有任何脚本的,所以所有脚本都没有被传播到所有从服务器。
 
notion image
  • Redis
  • Lua 排序
    目录