EVAL script numkeys key [key ...] arg [arg ...]

*EVAL 介绍

EVALEVALSHA 用于执行使用 Redis 内置 Lua 解释器的脚本,该功能从 Redis 2.6.0 版本开始提供。

EVAL 的第一个参数是 Lua 5.1 脚本。脚本不需要(也不应该)定义一个 Lua 函数。它只是一个将在 Redis 服务器上下文中运行的 Lua 程序。

EVAL 的第二个参数是脚本后面(从第三个参数开始)代表 Redis 键名的参数个数。Lua 可以通过 KEYS 全局变量以 1 为基数的数组形式访问这些参数(即 KEYS[1]KEYS[2]、...)。

所有额外的参数不应代表键名,可以通过 ARGV 全局变量访问,这与键的访问方式非常相似(即 ARGV[1]ARGV[2]、...)。

以下示例应能阐明上述内容:

> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

注意:如你所见,Lua 数组作为 Redis 的多 bulk 回复返回,这是一种 Redis 返回类型,你的客户端库可能会将其转换为编程语言中的数组类型。

可以通过两种不同的 Lua 函数从 Lua 脚本中调用 Redis 命令:

  • redis.call()
  • redis.pcall()

redis.call()redis.pcall() 类似,唯一的区别是:如果 Redis 命令调用导致错误,redis.call() 将引发 Lua 错误,这反过来会强制 EVAL 向命令调用者返回错误,而 redis.pcall() 会捕获错误并返回一个表示错误的 Lua 表。

redis.call()redis.pcall() 函数的参数都是格式正确的 Redis 命令的所有参数:

> eval "return redis.call('set','foo','bar')" 0
OK

上面的脚本将键 foo 设置为字符串 bar。然而它违反了 EVAL 命令的语义,因为脚本使用的所有键都应该通过 KEYS 数组传递:

> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK

所有 Redis 命令必须在执行前进行分析,以确定命令将操作哪些键。为了使 EVAL 满足这一点,必须显式传递键。这在很多方面都很有用,尤其是确保 Redis Cluster 可以将你的请求转发到适当的集群节点。

请注意,此规则并非强制执行,以便为用户提供滥用 Redis 单实例配置的机会,但代价是编写与 Redis Cluster 不兼容的脚本。

Lua 脚本可以返回一个值,该值使用一组转换规则从 Lua 类型转换为 Redis 协议。

*Lua 与 Redis 数据类型之间的转换

当 Lua 使用 call()pcall() 调用 Redis 命令时,Redis 返回值会转换为 Lua 数据类型。同样,在调用 Redis 命令以及 Lua 脚本返回值时,Lua 数据类型会转换为 Redis 协议,这样脚本就可以控制 EVAL 将返回给客户端的内容。

这种数据类型转换的设计方式是:如果 Redis 类型转换为 Lua 类型,然后将结果转换回 Redis 类型,结果与初始值相同。

换句话说,Lua 和 Redis 类型之间存在一对一的转换。下表显示了所有转换规则:

Redis 到 Lua 转换表

  • Redis 整数回复 -> Lua 数字
  • Redis bulk 回复 -> Lua 字符串
  • Redis multi bulk 回复 -> Lua 表(可能嵌套其他 Redis 数据类型)
  • Redis 状态回复 -> 带有一个 ok 字段的 Lua 表,该字段包含状态信息
  • Redis 错误回复 -> 带有一个 err 字段的 Lua 表,该字段包含错误信息
  • Redis Nil bulk 回复和 Nil multi bulk 回复 -> Lua false 布尔类型

Lua 到 Redis 转换表

  • Lua 数字 -> Redis 整数回复(数字转换为整数)
  • Lua 字符串 -> Redis bulk 回复
  • Lua 表(数组)-> Redis multi bulk 回复(如果 Lua 数组中有 nil,则截断到第一个 nil 之前)
  • 带有一个 ok 字段的 Lua 表 -> Redis 状态回复
  • 带有一个 err 字段的 Lua 表 -> Redis 错误回复
  • Lua 布尔值 false -> Redis Nil bulk 回复

还有一个额外的 Lua 到 Redis 转换规则,没有对应的 Redis 到 Lua 转换规则:

  • Lua 布尔值 true -> 值为 1 的 Redis 整数回复

最后,需要注意三条重要规则:

  • Lua 只有一个数值类型,即 Lua 数字。整数和浮点数之间没有区别。因此,我们总是将 Lua 数字转换为整数回复,如果有小数部分则将其去除。如果你想从 Lua 返回浮点数,应该将其作为字符串返回,就像 Redis 本身所做的那样(例如 ZSCORE 命令)。
  • 在 Lua 数组中无法简单地包含 nil,这是 Lua 表语义的结果,因此当 Redis 将 Lua 数组转换为 Redis 协议时,如果遇到 nil,转换就会停止。
  • 当 Lua 表包含键(及其值)时,转换后的 Redis 回复将不包含它们。

RESP3 模式转换规则:注意 Lua 引擎可以使用 Redis 6 的新协议以 RESP3 模式工作。在这种情况下,有额外的转换规则,并且与 RESP2 模式相比,某些转换也被修改了。有关更多信息,请参阅本文档的 RESP3 部分。

以下是一些转换示例:

> eval "return 10" 0
(integer) 10

> eval "return {1,2,{3,'Hello World!'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 3
   2) "Hello World!"

> eval "return redis.call('get','foo')" 0
"bar"

最后一个示例展示了如何从 Lua 接收 redis.call()redis.pcall() 的精确返回值,就像直接调用命令时返回的一样。

在下面的示例中,我们可以看到浮点数以及包含 nil 和键的数组是如何处理的:

> eval "return {1,2,3.3333,somekey='somevalue','foo',nil,'bar'}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "foo"

如你所见,3.333 被转换为 3,somekey 被排除,而字符串 bar 永远不会返回,因为在此之前有一个 nil。

*返回 Redis 类型的辅助函数

有两个辅助函数可以从 Lua 返回 Redis 类型。

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

使用辅助函数与直接返回指定格式的表没有区别,因此以下两种形式是等价的:

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

*脚本的原子性

Redis 使用相同的 Lua 解释器来运行所有命令。Redis 还保证脚本以原子方式执行:在执行脚本时,不会执行其他脚本或 Redis 命令。这种语义类似于 MULTI / EXEC。从所有其他客户端的角度来看,脚本的效果要么仍然不可见,要么已经完成。

然而,这也意味着执行慢脚本不是一个好主意。创建快速脚本并不难,因为脚本开销非常低,但如果你打算使用慢脚本,你应该意识到,在脚本运行时,没有其他客户端可以执行命令。

*错误处理

如前所述,调用 redis.call() 导致 Redis 命令错误将停止脚本的执行并返回错误,这种方式清楚地表明错误是由脚本生成的:

> del foo
(integer) 1
> lpush foo a
(integer) 1
> eval "return redis.call('get','foo')" 0
(error) ERR Error running script (call to f_6b1bf486c81ceb7edf3c093f4c48582e38c0e791): ERR Operation against a key holding the wrong kind of value

使用 redis.pcall() 不会引发错误,但会按照上面指定的格式返回一个错误对象(作为一个带有 err 字段的 Lua 表)。脚本可以通过返回 redis.pcall() 返回的错误对象将确切的错误传递给用户。

*在低内存条件下运行 Lua

当 Redis 中的内存使用超过 maxmemory 限制时,Lua 脚本中遇到的第一个使用额外内存的写命令将导致脚本中止(除非使用了 redis.pcall)。但是,这里需要注意的一点是,如果第一个写命令不使用额外内存,例如 DEL、LREM 或 SREM 等,Redis 将允许它运行,并且 Lua 脚本中的所有后续命令将为了原子性而执行完成。如果脚本中的后续写操作产生额外内存,Redis 内存使用可能会超过 maxmemory

Lua 脚本导致 Redis 内存使用超过 maxmemory 的另一种可能情况是:当脚本执行开始时,Redis 略低于 maxmemory,因此脚本中的第一个写命令被允许执行。随着脚本的执行,后续的写命令继续产生内存,导致 Redis 服务器超过 maxmemory

在这些情况下,建议配置 maxmemory-policy 不使用 noeviction。此外,Lua 脚本应该很短,这样可以在 Lua 脚本之间进行内存淘汰。

*带宽与 EVALSHA

EVAL 命令迫使你一次又一次地发送脚本体。Redis 不需要每次都重新编译脚本,因为它使用了内部缓存机制,但在许多情况下,支付额外的带宽成本可能不是最优的。

另一方面,通过特殊命令或通过 redis.conf 定义命令会带来一些问题:

  • 不同的实例可能对某个命令有不同的实现。
  • 如果必须确保所有实例都包含给定的命令,部署会很困难,尤其是在分布式环境中。
  • 阅读应用程序代码时,完整的语义可能不清楚,因为应用程序调用的是服务器端定义的命令。

为了避免这些问题,同时避免带宽损失,Redis 实现了 EVALSHA 命令。

EVALSHA 的工作方式与 EVAL 完全相同,但它的第一个参数不是脚本,而是脚本的 SHA1 摘要。其行为如下:

  • 如果服务器仍然记得具有匹配 SHA1 摘要的脚本,则执行该脚本。
  • 如果服务器不记得具有此 SHA1 摘要的脚本,则返回一个特殊错误,告诉客户端改用 EVAL

示例:

> set foo bar
OK
> eval "return redis.call('get','foo')" 0
"bar"
> evalsha 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 0
"bar"
> evalsha ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script. Please use EVAL.

客户端库的实现可以总是在底层乐观地发送 EVALSHA,即使客户端实际调用的是 EVAL,希望服务器已经见过该脚本。如果返回 NOSCRIPT 错误,则改用 EVAL

在这种情况下,将键和参数作为额外的 EVAL 参数传递也非常有用,因为脚本字符串保持不变,可以被 Redis 高效地缓存。

*脚本缓存语义

已执行的脚本保证永久存在于 Redis 实例的特定执行的脚本缓存中。这意味着,如果对 Redis 实例执行了一次 EVAL,所有后续的 EVALSHA 调用都将成功。

脚本可以长期缓存的原因是,一个编写良好的应用程序不太可能有足够多的不同脚本而导致内存问题。每个脚本在概念上都类似于一个新命令的实现,即使是一个大型应用程序也可能只有几百个脚本。即使应用程序被多次修改并且脚本会改变,所使用的内存也是微不足道的。

刷新脚本缓存的唯一方法是显式调用 SCRIPT FLUSH 命令,该命令将完全刷新脚本缓存,删除迄今为止执行的所有脚本。

这通常仅在云环境中为另一个客户或应用程序实例化实例时才需要。

此外,如前所述,重启 Redis 实例会刷新脚本缓存,该缓存不是持久化的。然而,从客户端的角度来看,只有两种方法可以确保 Redis 实例在两个不同命令之间没有重启。

  • 我们与服务器的连接是持久的,并且到目前为止从未关闭。
  • 客户端显式检查 INFO 命令中的 runid 字段,以确保服务器没有重启并且仍然是同一个进程。

实际上,对于客户端来说,更好的做法是简单地假设在给定连接的上下文中,缓存的脚本保证存在,除非管理员显式调用了 SCRIPT FLUSH 命令。

用户可以依赖 Redis 不删除脚本这一事实,这在管道上下文中在语义上很有用。

例如,一个与 Redis 有持久连接的应用程序可以确信,如果一个脚本被发送过一次,它将仍然在内存中,因此可以在管道中对这些脚本使用 EVALSHA,而不会因未知脚本而产生错误的风险(我们稍后会详细讨论这个问题)。

一个常见的模式是调用 SCRIPT LOAD 来加载将出现在管道中的所有脚本,然后直接在管道中使用 EVALSHA,无需检查因脚本哈希未被识别而导致的错误。

*SCRIPT 命令

Redis 提供了 SCRIPT 命令,可用于控制脚本子系统。SCRIPT 目前接受三种不同的命令:

  • SCRIPT FLUSH

    此命令是强制 Redis 刷新脚本缓存的唯一方法。在云环境中,同一个实例可以重新分配给不同的用户,这非常有用。它对于测试客户端库的脚本功能实现也很有用。

  • SCRIPT EXISTS sha1 sha2 ... shaN

    给定一个 SHA1 摘要列表作为参数,此命令返回一个由 1 和 0 组成的数组,其中 1 表示特定的 SHA1 被识别为已存在于脚本缓存中的脚本,而 0 表示具有此 SHA1 的脚本以前从未见过(或者至少在最近的 SCRIPT FLUSH 命令之后从未见过)。

  • SCRIPT LOAD script

    此命令在 Redis 脚本缓存中注册指定的脚本。在我们希望确保 EVALSHA 不会失败的所有上下文中(例如在管道或 MULTI/EXEC 操作期间),此命令很有用,而无需实际执行脚本。

  • SCRIPT KILL

    此命令是中断达到配置的最大脚本执行时间的长时间运行脚本的唯一方法。SCRIPT KILL 命令只能用于在执行期间未修改数据集的脚本(因为停止只读脚本不会违反脚本引擎的原子性保证)。有关长时间运行脚本的更多信息,请参阅下一节。

*具有确定性写入的脚本

注意:从 Redis 5 开始,脚本总是作为效果进行复制,而不是逐字发送脚本。因此,以下部分主要适用于 Redis 4 或更早版本。

脚本编写的一个非常重要的部分是编写仅以确定性方式更改数据库的脚本。在 Redis 实例中执行的脚本,默认情况下,通过发送脚本本身(而不是生成的命令)传播到副本和 AOF 文件。由于脚本将在远程主机上重新运行(或在重新加载 AOF 文件时),它对数据库所做的更改必须是可重现的。

发送脚本的原因是它通常比发送脚本生成的多个命令快得多。如果客户端向主节点发送许多脚本,将脚本转换为副本/AOF 的单个命令将导致复制链接或追加只读文件出现过多带宽(并且也会消耗过多 CPU,因为与调度由 Lua 脚本调用的命令相比,通过网络接收的命令的调度对 Redis 来说要繁重得多)。

通常,复制脚本而不是脚本的效果是有意义的,但并非在所有情况下都如此。因此,从 Redis 3.2 开始,脚本引擎能够选择性地复制脚本执行产生的写命令序列,而不是复制脚本本身。有关更多信息,请参阅下一节。

在本节中,我们将假设通过发送整个脚本来复制脚本。我们将这种复制模式称为完整脚本复制

完整脚本复制方法的主要缺点是脚本需要具有以下属性:

  • 给定相同的输入数据集,脚本必须始终使用相同的参数执行相同的 Redis 命令。脚本执行的操作不能依赖于任何可能随着脚本执行过程或脚本不同执行之间而改变的隐藏(非显式)信息或状态,也不能依赖于来自 I/O 设备的任何外部输入。

使用系统时间、调用 Redis 随机命令(如 RANDOMKEY)或使用 Lua 的随机数生成器之类的事情,可能导致脚本不会总是以相同的方式求值。

为了在脚本中强制执行此行为,Redis 执行以下操作:

  • Lua 不导出访问系统时间或其他外部状态的命令。
  • 如果脚本在 Redis 随机命令(如 RANDOMKEYSRANDMEMBERTIME之后调用能够更改数据集的 Redis 命令,Redis 将阻止脚本并返回错误。这意味着,如果脚本是只读的并且不修改数据集,它可以自由地调用这些命令。请注意,随机命令不一定意味着使用随机数的命令:任何非确定性命令都被视为随机命令(这方面最好的例子是 TIME 命令)。
  • 在 Redis 4 中,可能以随机顺序返回元素的命令,如 SMEMBERS(因为 Redis 集合是无序的),当从 Lua 调用时具有不同的行为,并在将数据返回给 Lua 脚本之前经过一个静默的字典序排序过滤。因此,redis.call("smembers",KEYS[1]) 将始终以相同的顺序返回集合元素,而从普通客户端调用的相同命令,即使键包含完全相同的元素,也可能返回不同的结果。然而,从 Redis 5 开始,不再需要这样的排序步骤,因为 Redis 5 复制脚本的方式不再需要将非确定性命令转换为确定性命令。一般来说,即使在为 Redis 4 开发时,也不要假设 Lua 中的某些命令会被排序,而应依赖你调用的原始命令的文档来查看它提供的属性。
  • Lua 的伪随机数生成函数 math.random 被修改,以便每次执行新脚本时始终使用相同的种子。这意味着,如果不使用 math.randomseed,每次执行脚本时调用 math.random 将始终生成相同的数字序列。

然而,用户仍然可以使用以下简单的技巧编写具有随机行为的命令。假设我想编写一个 Redis 脚本,该脚本将使用 N 个随机整数填充列表。

我可以从这个小的 Ruby 程序开始:

require 'rubygems'
require 'redis'

r = Redis.new

RandomPushScript = <<EOF
    local i = tonumber(ARGV[1])
    local res
    while (i > 0) do
        res = redis.call('lpush',KEYS[1],math.random())
        i = i-1
    end
    return res
EOF

r.del(:mylist)
puts r.eval(RandomPushScript,[:mylist],[10,rand(2**32)])

每次执行此脚本时,生成的列表将恰好具有以下元素:

> lrange mylist 0 -1
 1) "0.74509509873814"
 2) "0.87390407681181"
 3) "0.36876626981831"
 4) "0.6921941534114"
 5) "0.7857992587545"
 6) "0.57730350670279"
 7) "0.87046522734243"
 8) "0.09637165539729"
 9) "0.74990198051087"
10) "0.17082803611217"

为了使其具有确定性,但仍然确保脚本的每次调用都会产生不同的随机元素,我们可以简单地向脚本添加一个额外的参数,该参数将用于为 Lua 伪随机数生成器提供种子。新脚本如下:

RandomPushScript = <<EOF
    local i = tonumber(ARGV[1])
    local res
    math.randomseed(tonumber(ARGV[2]))
    while (i > 0) do
        res = redis.call('lpush',KEYS[1],math.random())
        i = i-1
    end
    return res
EOF

r.del(:mylist)
puts r.eval(RandomPushScript,1,:mylist,10,rand(2**32))

我们在这里所做的是将 PRNG 的种子作为参数之一发送。给定相同的参数,脚本输出将始终相同(我们的要求),但我们在每次调用时更改其中一个参数,在客户端生成随机种子。该种子将作为参数之一在复制链接和追加只读文件中传播,确保在重新加载 AOF 或副本处理脚本时生成相同的更改。

注意:这种行为的一个重要部分是,Redis 实现为 math.randommath.randomseed 的 PRNG 保证无论运行 Redis 的系统的体系结构如何,都具有相同的输出。32 位、64 位、大端和小端系统都将产生相同的输出。

*复制命令而不是脚本

注意:从 Redis 5 开始,本节描述的复制方法(脚本效果复制)是默认方法,不需要显式启用。

从 Redis 3.2 开始,可以选择另一种复制方法。我们可以只复制脚本生成的单个写命令,而不是复制整个脚本。我们称之为脚本效果复制

在这种复制模式下,当执行 Lua 脚本时,Redis 收集由 Lua 脚本引擎执行的所有实际修改数据集的命令。当脚本执行完成时,脚本生成的命令序列被包装在一个 MULTI/EXEC 事务中,并发送到副本和 AOF。

这根据用例的不同在几个方面很有用:

  • 当脚本计算缓慢,但效果可以用几个写命令概括时,在副本上或重新加载 AOF 时重新计算脚本是很可惜的。在这种情况下,只复制脚本的效果要好得多。
  • 当启用脚本效果复制时,对非确定性函数的限制被移除。例如,你可以在脚本中的任何位置自由使用 TIMESRANDMEMBER 命令。
  • 在此模式下,Lua PRNG 在每次调用时随机播种。

要启用脚本效果复制,你需要在脚本执行写操作之前发出以下 Lua 命令:

redis.replicate_commands()

如果脚本效果复制已启用,该函数返回 true;否则,如果在脚本已经调用写命令之后调用该函数,则返回 false,并使用正常的完整脚本复制。

*命令的选择性复制

当选择脚本效果复制时(请参阅上一节),可以更好地控制命令传播到副本和 AOF 的方式。这是一个非常高级的功能,因为误用可能会造成破坏,破坏主节点、副本和 AOF 必须包含相同逻辑内容的契约。

然而,这是一个有用的功能,因为有时我们只需要在主节点上执行某些命令,例如,为了创建中间值。

考虑一个 Lua 脚本,我们执行两个集合的交集。然后我们从交集中随机选取五个元素,并创建一个包含它们的新集合。最后,我们删除代表两个原始集合交集的临时键。我们想要复制的只是包含五个元素的新集合的创建。复制创建临时键的命令也是没有用的。

出于这个原因,Redis 3.2 引入了一个新命令,该命令仅在启用脚本效果复制时有效,并且能够控制脚本复制引擎。该命令称为 redis.set_repl(),如果在禁用脚本效果复制时调用,则会引发错误。

该命令可以使用四个不同的参数调用:

redis.set_repl(redis.REPL_ALL) -- 复制到 AOF 和副本。
redis.set_repl(redis.REPL_AOF) -- 仅复制到 AOF。
redis.set_repl(redis.REPL_REPLICA) -- 仅复制到副本(Redis >= 5)
redis.set_repl(redis.REPL_SLAVE) -- 用于向后兼容,与 REPL_REPLICA 相同。
redis.set_repl(redis.REPL_NONE) -- 根本不复制。

默认情况下,脚本引擎设置为 REPL_ALL。通过调用此函数,用户可以随时打开或关闭复制模式。

一个简单的示例如下:

redis.replicate_commands() -- 启用效果复制。
redis.call('set','A','1')
redis.set_repl(redis.REPL_NONE)
redis.call('set','B','2')
redis.set_repl(redis.REPL_ALL)
redis.call('set','C','3')

运行上述脚本后,结果是只有键 A 和 C 将在副本和 AOF 上创建。

*全局变量保护

Redis 脚本不允许创建全局变量,以避免将数据泄漏到 Lua 状态中。如果脚本需要在调用之间保持状态(一种相当罕见的需求),它应该使用 Redis 键代替。

当尝试访问全局变量时,脚本将被终止,并且 EVAL 返回一个错误:

redis 127.0.0.1:6379> eval 'a=10' 0
(error) ERR Error running script (call to f_933044db579a2f8fd45d8065f04a8d0249383e57): user_script:1: Script attempted to create global variable 'a'

访问不存在的全局变量会产生类似的错误。

使用 Lua 调试功能或其他方法(如更改用于实现全局保护的元表)来绕过全局保护并不困难。但是,意外地这样做很难。如果用户弄乱了 Lua 全局状态,则不能保证 AOF 和复制的一致性:不要这样做。

给 Lua 新手的提示:为了避免在脚本中使用全局变量,只需使用 local 关键字声明你将使用的每个变量。

*在脚本中使用 SELECT

可以像普通客户端一样在 Lua 脚本内部调用 SELECT,但 Redis 2.8.11 和 Redis 2.8.12 之间的行为有一个微妙的方面发生了变化。在 2.8.12 版本之前,Lua 脚本选择的数据库转移到调用脚本作为当前数据库。从 Redis 2.8.12 开始,Lua 脚本选择的数据库仅影响脚本本身的执行,但不会修改调用脚本的客户端选择的数据库。

补丁级别版本之间的语义更改是必要的,因为旧行为本质上与 Redis 复制层不兼容,并且是错误的原因。

*在 RESP3 模式下使用 Lua 脚本

从 Redis 6 版本开始,服务器支持两种不同的协议。一种称为 RESP2,是旧协议:所有到服务器的新连接都以此模式启动。但是,客户端可以使用 HELLO 命令协商新协议:这样连接就处于 RESP3 模式。在此模式下,某些命令(例如 HGETALL)会使用新的数据类型回复(在这种特定情况下是 Map 数据类型)。RESP3 协议在语义上更强大,但大多数脚本使用 RESP2 就可以了。

Lua 引擎在与 Redis 通信时始终假定以 RESP2 模式运行,因此无论调用 EVALEVALSHA 命令的连接是处于 RESP2 还是 RESP3 模式,Lua 脚本在默认情况下,在使用 redis.call() 内置函数调用命令时,仍然会看到它们过去从 Redis 看到的相同类型的回复。

然而,在 Redis 6 或更高版本中运行的 Lua 脚本能够切换到 RESP3 模式,并使用新的可用类型获取回复。类似地,Lua 脚本能够使用新类型回复客户端。在继续阅读本节之前,请确保理解 RESP3 的功能

要切换到 RESP3,脚本应调用此函数:

redis.setresp(3)

请注意,脚本可以通过使用参数 '3' 或 '2' 调用该函数来回切换 RESP3 和 RESP2。

此时,新的转换可用,特别是:

特定于 RESP3 的 Redis 到 Lua 转换表:

  • Redis map 回复 -> 带有一个 map 字段的 Lua 表,该字段包含一个表示 map 字段和值的 Lua 表。
  • Redis set 回复 -> 带有一个 set 字段的 Lua 表,该字段包含一个 Lua 表,将集合的元素表示为字段,值仅为 true
  • Redis 新的 RESP3 单 null 值 -> Lua nil。
  • Redis true 回复 -> Lua true 布尔值。
  • Redis false 回复 -> Lua false 布尔值。
  • Redis double 回复 -> 带有一个 score 字段的 Lua 表,该字段包含一个表示 double 值的 Lua 数字。
  • Redis big number 回复 -> 带有一个 big_number 字段的 Lua 表,该字段包含一个表示大数字值的 Lua 字符串。
  • Redis verbatim string 回复 -> 带有一个 verbatim_string 字段的 Lua 表,该字段包含一个具有两个字段 stringformat 的 Lua 表,分别表示逐字字符串和逐字格式。
  • 所有旧的 RESP2 转换仍然适用。

注意:大数字和逐字回复仅在 Redis 7 或更高版本中可用。此外,目前 RESP3 属性在 Lua 中不受支持。

特定于 RESP3 的 Lua 到 Redis 转换表:

  • Lua 布尔值 -> Redis 布尔值 true 或 false。请注意,与 RESP2 模式相比,这是一个变化,在 RESP2 模式中,从 Lua 返回 true 会向 Redis 客户端返回数字 1,而返回 false 过去返回 NULL。
  • 带有一个 map 字段(设置为字段-值 Lua 表)的 Lua 表 -> Redis map 回复。
  • 带有一个 set 字段(设置为字段-值 Lua 表)的 Lua 表 -> Redis set 回复,值被丢弃,可以是任何东西。
  • 带有一个 double 字段(设置为字段-值 Lua 表)的 Lua 表 -> Redis double 回复。
  • Lua null -> Redis RESP3 新的 null 回复(协议 "_\r\n")。
  • 除非上面另有规定,否则所有旧的 RESP2 转换仍然适用。

有一点需要理解:如果 Lua 以 RESP3 类型回复,但调用 Lua 的连接处于 RESP2 模式,Redis 将自动将 RESP3 协议转换为 RESP2 兼容协议,就像普通命令一样。例如,向处于 RESP2 模式的连接返回 map 类型将产生返回一个包含字段和值的扁平数组的效果。

*可用库

Redis Lua 解释器加载以下 Lua 库:

  • base
  • table
  • string
  • math
  • struct
  • cjson
  • cmsgpack
  • bitop
  • redis.sha1hex 函数
  • redis.breakpointredis.debug 函数(在 Redis Lua 调试器 的上下文中)

每个 Redis 实例都保证拥有上述所有库,因此你可以确信 Redis 脚本的环境始终相同。

struct、CJSON 和 cmsgpack 是外部库,所有其他库都是标准的 Lua 库。

*struct

struct 是一个用于在 Lua 中打包/解包结构的库。

有效格式:
> - 大端序
< - 小端序
![num] - 对齐
x - 填充
b/B - 有符号/无符号字节
h/H - 有符号/无符号短整数
l/L - 有符号/无符号长整数
T   - size_t
i/In - 大小为 `n' 的有符号/无符号整数(默认是 int 的大小)
cn - 由 `n' 个字符组成的序列(来自/去往字符串);打包时,n==0 表示整个字符串;解包时,n==0 表示使用先前读取的数字作为字符串长度
s - 以零结尾的字符串
f - 浮点数
d - 双精度浮点数
' ' - 被忽略

示例:

127.0.0.1:6379> eval 'return struct.pack("HH", 1, 2)' 0
"\x01\x00\x02\x00"
127.0.0.1:6379> eval 'return {struct.unpack("HH", ARGV[1])}' 0 "\x01\x00\x02\x00"
1) (integer) 1
2) (integer) 2
3) (integer) 5
127.0.0.1:6379> eval 'return struct.size("HH")' 0
(integer) 4

*CJSON

CJSON 库在 Lua 中提供了非常快速的 JSON 操作。

示例:

redis 127.0.0.1:6379> eval 'return cjson.encode({["foo"]= "bar"})' 0
"{\"foo\":\"bar\"}"
redis 127.0.0.1:6379> eval 'return cjson.decode(ARGV[1])["foo"]' 0 "{\"foo\":\"bar\"}"
"bar"

*cmsgpack

cmsgpack 库在 Lua 中提供了简单快速的 MessagePack 操作。

示例:

127.0.0.1:6379> eval 'return cmsgpack.pack({"foo", "bar", "baz"})' 0
"\x93\xa3foo\xa3bar\xa3baz"
127.0.0.1:6379> eval 'return cmsgpack.unpack(ARGV[1])' 0 "\x93\xa3foo\xa3bar\xa3baz"
1) "foo"
2) "bar"
3) "baz"

*bitop

Lua 位操作模块为数字添加了按位运算。自 Redis 2.8.18 版本起可用于脚本。

示例:

127.0.0.1:6379> eval 'return bit.tobit(1)' 0
(integer) 1
127.0.0.1:6379> eval 'return bit.bor(1,2,4,8,16,32,64,128)' 0
(integer) 255
127.0.0.1:6379> eval 'return bit.tohex(422342)' 0
"000671c6"

它还支持其他几个函数: bit.tobit, bit.tohex, bit.bnot, bit.band, bit.bor, bit.bxor, bit.lshift, bit.rshift, bit.arshift, bit.rol, bit.ror, bit.bswap。 所有可用函数在 Lua BitOp 文档 中有说明。

*redis.sha1hex

对输入字符串执行 SHA1。

示例:

127.0.0.1:6379> eval 'return redis.sha1hex(ARGV[1])' 0 "foo"
"0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33"

*从脚本发出 Redis 日志

可以使用 redis.log 函数从 Lua 脚本写入 Redis 日志文件。

redis.log(loglevel,message)

loglevel 是以下之一:

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARNING

它们直接对应于正常的 Redis 日志级别。只有使用等于或大于当前配置的 Redis 实例日志级别的日志级别发出的脚本日志才会被发出。

message 参数只是一个字符串。

示例:

redis.log(redis.LOG_WARNING,"Something is wrong with this script.")

将生成以下内容:

[32343] 22 Mar 15:21:39 # Something is wrong with this script.

*沙盒与最大执行时间

脚本不应尝试访问外部系统,如文件系统或任何其他系统调用。脚本只应操作 Redis 数据和传递的参数。

脚本也受最大执行时间(默认为五秒)的限制。这个默认超时很大,因为脚本通常应在毫秒内运行。该限制主要是为了处理开发过程中产生的意外无限循环。

可以通过 redis.conf 或使用 CONFIG GET / CONFIG SET 命令以毫秒精度修改脚本的最大执行时间。影响最大执行时间的配置参数称为 lua-time-limit

当脚本达到超时时,Redis 不会自动终止它,因为这会违反 Redis 与脚本引擎之间关于确保脚本原子性的契约。中断脚本意味着可能留下包含半写数据的数据库。出于这个原因,当脚本执行超过指定时间时,会发生以下情况:

  • Redis 记录一个脚本运行时间过长的日志。
  • 它开始再次接受来自其他客户端的命令,但会对所有发送普通命令的客户端回复 BUSY 错误。在此状态下允许的唯一命令是 SCRIPT KILLSHUTDOWN NOSAVE
  • 可以使用 SCRIPT KILL 命令终止仅执行只读命令的脚本。这不会违反脚本语义,因为脚本尚未向数据集写入任何数据。
  • 如果脚本已经调用了写命令,则唯一允许的命令变为 SHUTDOWN NOSAVE,它会在不将当前数据集保存到磁盘的情况下停止服务器(基本上服务器被中止)。

*管道上下文中的 EVALSHA

在管道请求的上下文中执行 EVALSHA 时应小心,因为即使在管道中,也必须保证命令的执行顺序。如果 EVALSHA 返回 NOSCRIPT 错误,则不能稍后重新发出该命令,否则会违反执行顺序。

客户端库实现应采用以下方法之一:

  • 在管道上下文中始终使用纯 EVAL
  • 累积要发送到管道的所有命令,然后检查 EVAL 命令,并使用 SCRIPT EXISTS 命令检查是否所有脚本都已定义。如果没有,根据需要将 SCRIPT LOAD 命令添加到管道顶部,并对所有 EVAL 调用使用 EVALSHA

*调试 Lua 脚本

从 Redis 3.2 开始,Redis 支持原生 Lua 调试。Redis Lua 调试器是一个远程调试器,由一个服务器(即 Redis 本身)和一个客户端(默认情况下是 redis-cli)组成。

Lua 调试器在 Redis 文档的 Lua 脚本调试 部分中描述。