redis lua脚本实践

11k 词

redis lua简介

Lua 脚本功能是Reids2.6版本的最大亮点, 通过内嵌对Lua环境的支持,Redis解决了长久以来不能高效地处理CAS(check-and-set)命令的缺点,并且可以通过组合使用多个命令,轻松实现以前很难实现或者不能高效实现的模式。

基本命令

EVAL与EVALSHA

通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。

  • script参数是一段Lua 5.1脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。
  • numkeys 参数用于指定键名参数的个数。

键名参数 key [key …] 从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组用1为起始所有的形式访问(KEYS[1],KEYS[2],以此类推)。
在命令的最后是那些不是键名参数的附加参数 arg [arg …],可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似(ARGV[1],ARGV[2],诸如此类)。

EVAL命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。Redis有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。

为了减少带宽的消耗, Redis实现了EVALSHA命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)。EVALSHA 命令的表现如下:

  • 如果服务器还记得给定的 SHA1 校验和所指定的脚本,那么执行这个脚本
  • 如果服务器不记得给定的 SHA1 校验和所指定的脚本,那么它返回一个特殊的错误,提醒用户使用 EVAL 代替 EVALSHA

使用样例:

1
2
3
4
5
6
7
127.0.0.1:8379> EVAL 'return "return String KEYS1: "..KEYS[1].." KEYS2: ".." "..KEYS[2].." ARGV1: "..ARGV[1].." ARGV2: "..ARGV[2]' 3 KEYS1Str KEYS2Str KEYS3Str ARGV1Str ARGV2Str ARGV3Str ARGV4Str
"return String KEYS1: KEYS1Str KEYS2: KEYS2Str ARGV1: ARGV1Str ARGV2: ARGV2Str"

127.0.0.1:8379> SCRIPT LOAD "return redis.call('GET','evalShell')"
"c870035beb27b1c404c19624c50b5e451ecf1623"
127.0.0.1:6379> EVALSHA c870035beb27b1c404c19624c50b5e451ecf1623 0
"shellTest"
redis.call()与redis.pcall()

可以使用两个不同函数来执行Redis命令,redis.call()和redis.pcall()两个函数的参数可以是任何格式良好(well formed)的 Redis 命令:

使用样例:

1
2
3
4
5
6
7
#最后的1如果为0代表的是没有keys是必须的 
127.0.0.1:8379> EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1 testLuaSet luaSetValue
OK
127.0.0.1:8379> GET testLuaSet
"luaSetValue"
127.0.0.1:8379> EVAL "return redis.call('GET',KEYS[1])" 1 testLuaSet
"luaSetValue"

这两个函数的唯一区别在于它们使用不同的方式处理执行命令所产生的错误,

  • redis.call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因
  • redis.pcall() 出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误(这样与命令行客户端直接操作返回相同):
1
2
3
4
127.0.0.1:8379> EVAL "return redis.call('GET','evalShell','a')" 0
(error) ERR Error running script (call to f_8730e9f52481d51b1aadfd2960f8bc324ec56e58): @user_script:1: @user_script: 1: Wrong number of args calling Redis command From Lua script
127.0.0.1:8379> EVAL "return redis.pcall('GET','evalShell','a')" 0
(error) @user_script: 1: Wrong number of args calling Redis command From Lua script

redis lua数据类型映射

当 Lua 通过 call() 或 pcall() 函数执行 Redis 命令的时候,命令的返回值会被转换成 Lua 数据结构。同样地,当 Lua 脚本在 Redis 内置的解释器里运行时,Lua 脚本的返回值也会被转换成 Redis 协议(protocol),然后由 EVAL 将值返回给客户端。

redis->lua

  • Redis 整数转换成 Lua numbers
  • Redis bulk 回复转换成 Lua strings
  • Redis 多条 bulk 回复转换成 Lua tables,tables 内可能有其他别的 Redis 数据类型
  • Redis 状态回复转换成 Lua tables, tables 内的 ok 域包含了状态信息
  • Redis 错误回复转换成 Lua tables ,tables 内的 err 域包含了错误信息
  • Redis 的 Nil 回复和 Nil 多条回复转换成 Lua 的 booleans false

lua->redis

  • Lua numbers 转换成 Redis 整数
  • Lua strings 换成 Redis bulk 回复
  • Lua tables (array) 转换成 Redis 多条 bulk 回复
  • 一个带单个 ok 域的 Lua tables,转换成 Redis 状态回复
  • 一个带单个 err 域的 Lua tables ,转换成 Redis 错误回复
  • Lua 的 booleans false 转换成 Redis 的 Nil bulk 回复
  • Lua booleans true 转换成 Redis 整数回复中的 1 —- 额外的
1
2
3
4
5
6
7
8
# redis 中与 lua 各种类型转换
127.0.0.1:8379> EVAL "return {1,3.1415,'luaStrings',true,false}" 0
1) (integer) 1
2) (integer) 3
3) "luaStrings"
4) (integer) 1
5) (nil)
127.0.0.1:8379>

Helper函数返回Redis类型

从Lua返回Redis类型有两个 Helper 函数。

  • redis.error_reply(error_string)返回错误回复。此函数只返回一个字段表,其中err字段设置为指定的字符串。
  • redis.status_reply(status_string)返回状态回复。此函数只返回一个字段表,其中ok字段设置为指定的字符串。

使用 Helper 函数或直接以指定的格式返回表之间没有区别,因此以下两种形式是等效的:

1
2
return {err="My Error"}
return redis.error_reply("My Error")

script脚本缓存

Redis保证所有被运行过的脚本都会被永久保存在脚本缓存当中,这意味着,当EVAL命令在一个Redis实例上成功执行某个脚本之后,随后针对这个脚本的所有EVALSHA命令都会成功执行。

刷新脚本缓存的唯一办法是显式地调用SCRIPT FLUSH 命令,这个命令会清空运行过的所有脚本的缓存。通常只有在云计算环境中,Redis 实例被改作其他客户或者别的应用程序的实例时,才会执行这个命令。

缓存可以长时间储存而不产生内存问题的原因是,它们的体积非常小,而且数量也非常少,即使脚本在概念上类似于实现一个新命令,即使在一个大规模的程序里有成百上千的脚本,即使这些脚本会经常修改,即便如此,储存这些脚本的内存仍然是微不足道的。

事实上,用户会发现 Redis 不移除缓存中的脚本实际上是一个好主意。比如说,对于一个和 Redis 保持持久化链接(persistent connection)的程序来说,它可以确信,执行过一次的脚本会一直保留在内存当中,因此它可以在 pipline中使用EVALSHA命令而不必担心因为找不到所需的脚本而产生错误。

script命令

Redis提供了以下几个SCRIPT命令,用于对脚本子系统(scripting subsystem)进行控制:

SCRIPT LOAD script

1
2
自2.6.0可用。
时间复杂度:O(N) , N 为脚本的长度(以字节为单位)。

说明:
清除所有 Lua 脚本缓存。

返回值:
给定 script 的 SHA1 校验和

SCRIPT DEBUG YES|SYNC|NO

1
2
自3.2.0可用。
时间复杂度:O(1)。

说明:

Redis包括一个完整的 Lua 调试器,代号 LDB,可用于使编写复杂脚本的任务更简单。在调试模式下,Redis 充当远程调试服务器,客户端 redis-cli 可以逐步执行脚本,设置断点,检查变量等 。
应避免施工生产机器进行调试!

LDB可以以两种模式之一启用:异步或同步。在异步模式下,服务器创建一个不阻塞的分支调试会话,并且在会话完成后,数据的所有更改都将回滚,因此可以使用相同的初始状态重新启动调试。同步调试模式在调试会话处于活动状态时阻塞服务器,并且数据集在结束后会保留所有更改。

  • YES。启用Lua脚本的非阻塞异步调试(更改将被丢弃)。
  • SYNC。启用阻止Lua脚本的同步调试(保存对数据的更改)。
  • NO。禁用脚本调试模式。

返回值:
总是返回 OK

SCRIPT FLUSH

1
2
自2.6.0可用。
时间复杂度:O(N) , N 为缓存中脚本的数量。

说明:
清除所有 Lua 脚本缓存。

返回值:
总是返回 OK

SCRIPT EXISTS sha1 [sha1 …]

1
2
自2.6.0可用。
时间复杂度:O(N) , N 为给定的 SHA1 校验和的数量。

说明:

给定一个或多个脚本的 SHA1 校验和,返回一个包含 0 和 1 的列表,表示校验和所指定的脚本是否已经被保存在缓存当中。

返回值:

一个列表,包含 0 和 1 ,前者表示脚本不存在于缓存,后者表示脚本已经在缓存里面了。

列表中的元素和给定的 SHA1 校验和保持对应关系,比如列表的第三个元素的值就表示第三个 SHA1 校验和所指定的脚本在缓存中的状态。

SCRIPT KILL

1
2
自2.6.0可用。
时间复杂度:O(1)。

说明:
杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。

这个命令主要用于终止运行时间过长的脚本,比如一个因为 BUG 而发生无限 loop 的脚本,诸如此类。

SCRIPT KILL 执行之后,当前正在运行的脚本会被杀死,执行这个脚本的客户端会从 EVAL 命令的阻塞当中退出,并收到一个错误作为返回值。

另一方面,假如当前正在运行的脚本已经执行过写操作,那么即使执行 SCRIPT KILL ,也无法将它杀死,因为这是违反 Lua 脚本的原子性执行原则的。在这种情况下,唯一可行的办法是使用 SHUTDOWN NOSAVE 命令,通过停止整个 Redis 进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。

返回值:

执行成功返回 OK ,否则返回一个错误。

SCRIPT相关样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 加载一个脚本到缓存
127.0.0.1:8379> SCRIPT LOAD "return redis.call('SET',KEYS[1],ARGV[1])"
"cf63a54c34e159e75e5a3fe4794bb2ea636ee005"
# EVALSHA 在后面会讲解,这里就是调用一个脚本缓冲
127.0.0.1:8379> EVALSHA cf63a54c34e159e75e5a3fe4794bb2ea636ee005 1 ttestScript evalSHATest
OK
127.0.0.1:8379> GET ttestScript
"evalSHATest"
127.0.0.1:8379> SCRIPT EXISTS cf63a54c34e159e75e5a3fe4794bb2ea636ee005
1) (integer) 1
# 这里有三个 SHA 第一第三是随便输入的,检测是否存在脚本缓存
127.0.0.1:8379> SCRIPT EXISTS nonsha cf63a54c34e159e75e5a3fe4794bb2ea636ee005 abc
1) (integer) 0
2) (integer) 1
3) (integer) 0
# 清空脚本缓存
127.0.0.1:8379> SCRIPT FLUSH
OK
127.0.0.1:8379> SCRIPT EXISTS cf63a54c34e159e75e5a3fe4794bb2ea636ee005
1) (integer) 0
# 清除脚本缓存后再次执行就找不到该脚本了
127.0.0.1:8379> SCRIPT KILL
(error) NOTBUSY No scripts in execution right now.

redis lua脚本注意

纯函数限制

在编写脚本方面,脚本应该被写成纯函数(pure function)。也就脚本应该具有以下属性:

对于同样的数据集输入,给定相同的参数,脚本执行的 Redis 写命令总是相同的。脚本执行的操作不能依赖于任何隐藏(非显式)数据,不能依赖于脚本在执行过程中、或脚本在不同执行时期之间可能变更的状态,并且它也不能依赖于任何来自 I/O 设备的外部输入。
使用系统时间(system time),调用像 RANDOMKEY 那样的随机命令,或者使用 Lua 的随机数生成器,类似以上的这些操作,都会造成脚本的求值无法每次都得出同样的结果。

为了确保脚本符合上面所说的属性,redis做了以下工作:

Lua 没有访问系统时间或者其他内部状态的命令

  • Redis 会返回一个错误,阻止这样的脚本运行: 这些脚本在执行随机命令之后(比如 RANDOMKEY 、 SRANDMEMBER 或 TIME 等),还会执行可以修改数据集的 Redis 命令。如果脚本只是执行只读操作,那么就没有这一限制。注意,随机命令并不一定就指那些带 RAND 字眼的命令,任何带有非确定性的命令都会被认为是随机命令,比如 TIME 命令就是这方面的一个很好的例子。

  • 每当从 Lua 脚本中调用那些返回无序元素的命令时,执行命令所得的数据在返回给 Lua 之前会先执行一个静默(slient)的字典序排序(lexicographical sorting)。举个例子,因为 Redis 的 Set 保存的是无序的元素,所以在 Redis 命令行客户端中直接执行 SMEMBERS ,返回的元素是无序的,但是,假如在脚本中执行 redis.call(“smembers”, KEYS[1]) ,那么返回的总是排过序的元素。

  • 对 Lua 的伪随机数生成函数 math.random 和 math.randomseed 进行修改,使得每次在运行新脚本的时候,总是拥有同样的 seed 值。这意味着,每次运行脚本时,只要不使用 math.randomseed,那么 math.random 产生的随机数序列总是相同的。

redis lua脚本中不允许存在function

执行以下函数会报错

1
2
3
function fun()
--- 业务逻辑
end

全局变量保护

为了防止不必要的数据泄漏进Lua环境, Redis脚本不允许创建全局变量。如果一个脚本需要在多次执行之间维持某种状态,它应该使用 Redis key 来进行状态保存。

企图在脚本中访问一个全局变量(不论这个变量是否存在)将引起脚本停止, EVAL 命令会返回一个错误:

1
2
127.0.0.1:8379> EVAL "website='coderknock.com'" 0
(error) ERR Error running script (call to f_ad03e14e835e9880720cd43db8062256c089cd79): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'website'
  • 避免引入全局变量的一个诀窍是:将脚本中用到的所有变量都使用 local 关键字定义为局部变量。

redis lua依赖库

Redis 内置的 Lua 解释器加载了以下 Lua 库:
base,table,string,math,debug,cjson,cmsgpack

其中 cjson 库可以让 Lua 以非常快的速度处理 JSON 数据,除此之外,其他别的都是 Lua 的标准库。
每个 Redis 实例都保证会加载上面列举的库,从而确保每个 Redis 脚本的运行环境都是相同的。
下面展示cjson 的使用,Lua 脚本如下:

1
2
3
4
5
6
7
8
9
10
11
local json = cjson
local str = '{"key1":"value1"}'
# 反列化
local j = json.decode(str)
for k, v in pairs(j) do
print(k, v)
end

j['key2'] = 'value2'
# 序列化
return json.encode(j)

执行脚本:

1
2
3
sudo /home/redis-5.0.3/src/redis-cli -p 8379 --eval ~/cjson_lua.lua
# 返回
"{"key1":"value1","key2":"value2"}"

脚本散发日志

在 Lua 脚本中,可以通过调用 redis.log 函数来写 Redis 日志(log):

redis.log(loglevel, message)

其中, message 参数是一个字符串,而 loglevel 参数可以是以下任意一个值:

redis.LOG_DEBUG
redis.LOG_VERBOSE
redis.LOG_NOTICE
redis.LOG_WARNING
上面的这些等级(level)和标准 Redis 日志的等级相对应。

对于脚本散发(emit)的日志,只有那些和当前 Redis 实例所设置的日志等级相同或更高级的日志才会被散发。例子:

1
2
3
4
5
6
7
8
9
10
local json = cjson
local str = '{"key1":"value1"}'
local j = json.decode(str)
for k, v in pairs(j) do
print(k, v)
end
j['key2'] = 'value2'
# 日志打印,会在server端日志文件进行打印
redis.log(redis.LOG_WARNING, "lua脚本日志测试")
return json.encode(j)

沙箱(sandbox)和最大执行时间

脚本应该仅仅用于传递参数和对 Redis 数据进行处理,它不应该尝试去访问外部系统(比如文件系统),或者执行任何系统调用。

除此之外,脚本还有一个最大执行时间限制,它的默认值是 5 秒钟,一般正常运作的脚本通常可以在几分之几毫秒之内完成,花不了那么多时间,这个限制主要是为了防止因编程错误而造成的无限循环而设置的。

最大执行时间的长短由 lua-time-limit 选项来控制(以毫秒为单位),可以通过编辑 redis.conf 文件或者使用 CONFIG GET 和 CONFIG SET 命令来修改它。

当一个脚本达到最大执行时间的时候,它并不会自动被 Redis 结束,因为 Redis 必须保证脚本执行的原子性,而中途停止脚本的运行意味着可能会留下未处理完的数据在数据集(data set)里面。

因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:

  • Redis 记录一个脚本正在超时运行
  • Redis 开始重新接受其他客户端的命令请求,但是只有 SCRIPT KILL 和 SHUTDOWN NOSAVE 两个命令会被处理,对于其他命令请求, Redis 服务器只是简单地返回 BUSY 错误。
  • 可以使用 SCRIPT KILL 命令将一个仅执行只读命令的脚本杀死,因为只读命令并不修改数据,因此杀死这个脚本并不破坏数据的完整性
  • 如果脚本已经执行过写命令,那么唯一允许执行的操作就是 SHUTDOWN NOSAVE ,它通过停止服务器来阻止当前数据集写入磁盘

pipeline上下文(context)中的 EVALSHA

在 pipeline 请求的上下文中使用 EVALSHA 命令时,要特别小心,因为在 pipeline 中,必须保证命令的执行顺序。

一旦在 pipeline 中因为 EVALSHA 命令而发生 NOSCRIPT 错误,那么这个 pipeline 就再也没有办法重新执行了,否则的话,命令的执行顺序就会被打乱。

为了防止出现以上所说的问题,客户端库实现应该实施以下的其中一项措施:

  • 总是在 pipeline 中使用 EVAL 命令
  • 检查 pipeline 中要用到的所有命令,找到其中的 EVAL 命令,并使用 SCRIPT EXISTS 命令检查要用到的脚本是不是全都已经保存在缓存里面了。如果所需的全部脚本都可以在缓存里找到,那么就可以放心地将所有 EVAL 命令改成 EVALSHA 命令,否则的话,就要在pipeline 的顶端(top)将缺少的脚本用 SCRIPT LOAD 命令加上去。

知识点介绍参考资料:
redis官方资料
redis lua脚本资料
redis lua脚本原理

通过例子学习redis lua使用

通过一个业务场景来展示redis lua脚本的使用,业务场景如下:
redis string存储如下用户对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
key:user_1
value:
{
"id": 1,
"name": "zhangsan",
"age": 25,
"email": "zhangsan@redis.com"
}

key:user_2
value:
{
"id": 2,
"name": "lisi",
"age": 10,
"email": "lisi@redis.com"
}

key:user_3
value:
{
"id": 3,
"name": "wangwu",
"age": 30,
"email": "wangwu@redis.com"
}

通过redis lua实现一次性读取多个指定用户信息, 即通过指定user_1,user_2可以一次性返回用户列表数据。并转化为json数据输出

基础数据导入

1
2
3
4
5
6
7
8
127.0.0.1:8379> set user_1 '{"id":1,"name":"zhangsan","age":25,"email":"zhangsan@redis.com"}'
OK
127.0.0.1:8379> set user_2 '{"id":2,"name":"lisi","age":10,"email":"lisi@redis.com"}'
OK
127.0.0.1:8379> set user_3 '{"id":3,"name":"wangwu","age":30,"email":"wangwu@redis.com"}'
OK
127.0.0.1:8379> get user_1
"{"id":1,"name":"zhangsan","age":25,"email":"zhangsan@redis.com"}"

脚本设计

1
2
3
4
5
6
7
8
9
10
11
local json = cjson
local user_map = {}
for i, k in pairs(KEYS) do
-- 从redis获取数据
local str = redis.call('GET', k)
-- json反序列化
local user = json.decode(str)
user_map[k] = user
end
-- json序列化
return json.encode(user_map)

脚本执行以及输出

1
2
3
4
5
6
-- debug方式执行功能
[yongssu@l-ddr1.vc.dev.cn0 ~]$ sudo /home//redis-5.0.3/src/redis-cli -p 8379 --ldb --eval redis_lua.lua user_1 user_2
"{"user_1":{"id":1,"age":25,"name":"zhangsan","email":"zhangsan@redis.com"},"user_2":{"id":2,"age":10,"name":"lisi","email":"lisi@redis.com"}}"
-- 正常方式执行
[yongssu@l-ddr1.vc.dev.cn0 ~]$ sudo /home/redis-5.0.3/src/redis-cli -p 8379 --eval redis_lua.lua user_1 user_2
"{"user_1":{"id":1,"age":25,"name":"zhangsan","email":"zhangsan@redis.com"},"user_2":{"id":2,"age":10,"name":"lisi","email":"lisi@redis.com"}}"