使用 Lua 脚本
在 Redis 中执行 Lua
Redis 允许用户在服务器上上传和执行 Lua 脚本。脚本可以使用程序控制结构,并在执行时使用大部分命令来访问数据库。由于脚本在服务器中执行,因此从脚本读写数据非常高效。
Redis 保证脚本的原子执行。在执行脚本期间,所有服务器活动在其整个运行时都会被阻塞。这些语义意味着脚本的所有效果要么尚未发生,要么已经发生。
脚本提供了在许多情况下非常有价值的几个特性,包括
- 通过在数据所在位置执行逻辑来提供局部性。数据局部性降低了整体延迟并节省了网络资源。
- 确保脚本原子执行的阻塞语义。
- 能够组合 Redis 缺失或过于小众而无法成为其一部分的简单功能。
Lua 允许你在 Redis 内部运行部分应用程序逻辑。此类脚本可以跨多个键执行条件更新,可能原子地组合多种不同的数据类型。
脚本在 Redis 中由嵌入式执行引擎执行。目前,Redis 支持单个脚本引擎,即 Lua 5.1 解释器。完整的文档请参考Redis Lua API 参考页面。
虽然服务器执行它们,但 Eval 脚本被视为客户端应用程序的一部分,因此它们没有名称、版本或持久化。因此,如果脚本丢失(例如服务器重启、故障转移到副本后),应用程序可能需要在任何时候重新加载所有脚本。自版本 7.0 起,Redis 函数提供了一种替代的可编程性方法,允许使用额外的编程逻辑扩展服务器本身。
入门
我们将通过使用EVAL
命令来开始使用 Redis 脚本。
这是我们的第一个例子
> EVAL "return 'Hello, scripting!'" 0
"Hello, scripting!"
在此示例中,EVAL
接受两个参数。第一个参数是一个字符串,包含脚本的 Lua 源代码。脚本不需要包含任何 Lua 函数的定义。它只是一个将在 Redis 引擎上下文中运行的 Lua 程序。
第二个参数是脚本主体后面参数的数量,从第三个参数开始,代表 Redis 键名。在此示例中,我们使用了值 0,因为我们没有为脚本提供任何参数,无论是键名还是其他参数。
脚本参数化
尽管非常不建议这样做,但应用程序可以根据需要动态生成脚本源代码。例如,应用程序可以发送这两个完全不同但同时又完全相同的脚本
redis> EVAL "return 'Hello'" 0
"Hello"
redis> EVAL "return 'Scripting!'" 0
"Scripting!"
虽然 Redis 没有阻止这种操作模式,但由于脚本缓存方面的考虑(更多内容见下文),这是一种反模式。应用程序不应该生成相同脚本的细微变体,而是应该对其进行参数化,并传递执行所需的任何参数。
以下示例演示了如何通过参数化实现上述相同的效果
redis> EVAL "return ARGV[1]" 0 Hello
"Hello"
redis> EVAL "return ARGV[1]" 0 Parameterization!
"Parameterization!"
此时,理解 Redis 对作为键名的输入参数与非键名的输入参数之间的区分至关重要。
虽然 Redis 中的键名只是字符串,但与任何其他字符串值不同,它们代表数据库中的键。键名是 Redis 中的基本概念,也是操作 Redis Cluster 的基础。
重要提示:为了确保脚本在单机和集群部署中的正确执行,脚本访问的所有键名都必须显式地作为输入键参数提供。脚本只能访问作为输入参数提供的键名所对应的键。脚本绝不能访问通过程序生成名称或根据数据库中存储的数据结构内容生成的键。
任何不是键名的函数输入都是常规输入参数。
在上面的示例中,Hello 和 Parameterization! 都是脚本的常规输入参数。因为脚本没有触及任何键,所以我们使用数字参数 0 来指定没有键名参数。执行上下文通过 KEYS 和 ARGV 全局运行时变量使参数对脚本可用。在脚本执行之前,KEYS 表预先填充了提供给脚本的所有键名参数,而 ARGV 表的作用类似,但用于常规参数。
以下尝试演示脚本的 KEYS 和 ARGV 运行时全局变量之间的输入参数分配
redis> EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"
注意:如上所示,Lua 的表数组作为 RESP2 数组回复返回,因此您的客户端库很可能会将其转换为您的编程语言中的原生数组数据类型。有关更多相关信息,请参阅管理数据类型转换的规则。
从脚本与 Redis 交互
可以通过 redis.call()
或 redis.pcall()
从 Lua 脚本调用 Redis 命令。
两者几乎完全相同。如果提供的参数代表一个格式正确的命令,两者都会执行一个 Redis 命令及其提供的参数。然而,两者的区别在于如何处理运行时错误(例如语法错误)。从调用 redis.call()
函数引发的错误直接返回给执行该脚本的客户端。相反,调用 redis.pcall()
函数时遇到的错误会返回到脚本的执行上下文,以便进行可能的处理。
例如,考虑以下内容
> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 foo bar
OK
上面的脚本接受一个键名和一个值作为输入参数。执行时,脚本调用 SET
命令将输入键 foo 设置为字符串值 "bar"。
脚本缓存
到目前为止,我们使用 EVAL
命令来运行我们的脚本。
每当我们调用 EVAL
时,我们也会在请求中包含脚本的源代码。重复调用 EVAL
来执行同一组参数化脚本会浪费网络带宽,并且在 Redis 中也会产生一些开销。当然,节省网络和计算资源是关键,因此,Redis 提供了一种脚本缓存机制。
您使用 EVAL
执行的每个脚本都存储在服务器维护的专用缓存中。缓存的内容按脚本的 SHA1 摘要总和组织,因此脚本的 SHA1 摘要总和在缓存中唯一标识它。您可以通过运行 EVAL
然后调用 INFO
来验证此行为。您会注意到 used_memory_scripts_eval 和 number_of_cached_scripts 指标会随着每个新执行的脚本而增长。
如上所述,动态生成的脚本是一种反模式。在应用程序运行时生成脚本可能会(并且很可能)耗尽主机用于缓存它们的内存资源。相反,脚本应该尽可能通用,并通过其参数提供定制的执行。
通过调用 SCRIPT LOAD
命令并提供其源代码,脚本被加载到服务器的缓存中。服务器不执行脚本,而只是编译并将其加载到服务器的缓存。加载后,您可以使用服务器返回的 SHA1 摘要执行缓存的脚本。
这是一个加载然后执行缓存脚本的示例
redis> SCRIPT LOAD "return 'Immabe a cached script'"
"c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f"
redis> EVALSHA c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f 0
"Immabe a cached script"
缓存的易失性
Redis 脚本缓存总是易失的。它不被视为数据库的一部分,也不被持久化。当服务器重启时、故障转移时副本接管主角色时,或者通过 SCRIPT FLUSH
显式清除时,缓存可能会被清空。这意味着缓存的脚本是临时的,缓存的内容随时可能丢失。
使用脚本的应用程序应始终调用 EVALSHA
来执行它们。如果脚本的 SHA1 摘要不在缓存中,服务器会返回错误。例如
redis> EVALSHA ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script
在这种情况下,应用程序应该首先使用 SCRIPT LOAD
加载它,然后再次调用 EVALSHA
来运行该 SHA1 和对应的缓存脚本。大多数 Redis 客户端已经提供了自动执行此操作的实用 API。有关具体细节,请查阅您的客户端文档。
流水线上下文中的 EVALSHA
在流水线请求的上下文中执行 EVALSHA
时应特别小心。流水线请求中的命令按发送顺序运行,但其他客户端的命令可能会在这些命令之间交错执行。因此,NOSCRIPT
错误可能会从流水线请求中返回,但无法处理。
因此,客户端库的实现应在流水线上下文中回退到使用普通 EVAL
对参数化脚本进行求值。
脚本缓存语义
在正常操作期间,应用程序的脚本旨在无限期地保留在缓存中(即直到服务器重启或缓存被清空)。其根本原因在于,一个精心编写的应用程序的脚本缓存内容不太可能持续增长。即使是使用数百个缓存脚本的大型应用程序,在缓存内存使用方面也不应该成为问题。
清空脚本缓存的唯一方法是显式调用 SCRIPT FLUSH
命令。运行该命令将完全清空脚本缓存,移除所有已执行的脚本。通常,只有当实例要在云环境中为其他客户或应用程序实例化时才需要这样做。
此外,正如已经提到的,重启 Redis 实例会清空非持久化的脚本缓存。然而,从 Redis 客户端的角度来看,只有两种方法可以确保 Redis 实例在两个不同命令之间没有重启
- 我们与服务器的连接是持久的,并且到目前为止从未关闭。
- 客户端在
INFO
命令中显式检查run_id
字段,以确保服务器没有重启并且仍然是同一个进程。
实际上,对于客户端来说,更简单的方式是在给定连接的上下文中,假设缓存的脚本是保证存在的,除非管理员显式调用了 SCRIPT FLUSH
命令。用户可以依赖 Redis 保留缓存脚本这一事实在流水线上下文中具有语义上的帮助。
SCRIPT
命令
Redis SCRIPT
命令提供了多种控制脚本子系统的方法。这些方法是
-
SCRIPT FLUSH
:此命令是强制 Redis 清空脚本缓存的唯一方法。在同一 Redis 实例被重新分配用于不同用途的环境中,它最有用。它也有助于测试客户端库对脚本功能的实现。 -
SCRIPT EXISTS
:给定一个或多个 SHA1 摘要作为参数,此命令返回一个由 1 和 0 组成的数组。1 表示该 SHA1 被识别为脚本缓存中已存在的脚本。0 表示具有此 SHA1 的脚本之前未加载(或至少自上次调用SCRIPT FLUSH
以来未加载)。 -
SCRIPT LOAD script
:此命令在 Redis 脚本缓存中注册指定的脚本。在所有希望确保EVALSHA
不会失败的上下文中,这是一个有用的命令(例如,在流水线中或从MULTI
/EXEC
事务中调用时),而无需执行脚本本身。 -
SCRIPT KILL
:此命令是中断长时间运行脚本(即慢脚本)的唯一方法,除非关闭服务器。一旦脚本的执行时长超过配置的最大执行时间阈值,就被视为慢脚本。SCRIPT KILL
命令只能用于执行期间未修改数据集的脚本(因为停止只读脚本不会违反脚本引擎的原子性保证)。 -
SCRIPT DEBUG
:控制内置Redis Lua 脚本调试器的使用。
脚本复制
在单机部署中,一个称为 master 的 Redis 实例管理整个数据库。集群部署至少有三个 master 管理分片数据库。Redis 使用复制为任何给定的 master 维护一个或多个副本,即精确的拷贝。
由于脚本可以修改数据,Redis 确保脚本执行的所有写入操作也发送到副本以保持一致性。关于脚本复制有两种概念性方法
- 逐字复制(Verbatim replication):master 将脚本的源代码发送给副本。副本然后执行脚本并应用写入效果。在短脚本生成许多命令(例如,for 循环)的情况下,此模式可以节省复制带宽。然而,这种复制模式意味着副本重复了 master 所做的工作,这是浪费的。更重要的是,它还需要所有写入脚本都是确定性的。
- 效果复制(Effects replication):只复制脚本的数据修改命令。副本然后运行这些命令,而不执行任何脚本。虽然在网络流量方面可能更长,但这种复制模式根据定义是确定性的,因此不需要特殊考虑。
逐字脚本复制是 Redis 3.2 之前唯一支持的模式,在 3.2 中添加了效果复制。可以使用 lua-replicate-commands 配置指令和 redis.replicate_commands()
Lua API 来启用它。
在 Redis 5.0 中,效果复制成为默认模式。自 Redis 7.0 起,不再支持逐字复制。
复制命令而不是脚本
从 Redis 3.2 开始,可以选择一种替代的复制方法。不是复制整个脚本,而是复制脚本生成的写入命令。我们将此称为脚本效果复制。
注意:从 Redis 5.0 开始,脚本效果复制是默认模式,无需显式启用。
在这种复制模式下,当 Lua 脚本执行时,Redis 会收集 Lua 脚本引擎执行的所有实际修改数据集的命令。脚本执行完成后,脚本生成的命令序列会被包装成一个 MULTI
/EXEC
事务并发送给副本和 AOF。
根据用例的不同,这在几个方面很有用
- 当脚本计算缓慢,但效果可以用少量写入命令概括时,在副本上或重新加载 AOF 时重新计算脚本是很可惜的。在这种情况下,最好只复制脚本的效果。
- 启用脚本效果复制后,对非确定性函数的限制就被取消了。例如,您可以在脚本中的任何地方自由使用
TIME
或SRANDMEMBER
命令。 - 在此模式下,Lua PRNG 在每次调用时都随机初始化种子。
除非已由服务器配置或默认值启用(Redis 7.0 之前),否则在脚本执行写入操作之前,您需要发出以下 Lua 命令
redis.replicate_commands()
如果脚本效果复制已启用,redis.replicate_commands()
函数返回 true;否则,如果在脚本已经调用写入命令之后调用此函数,它将返回 false,并使用正常的整个脚本复制。
此函数自 Redis 7.0 起已被弃用,虽然您仍然可以调用它,但它将始终成功。
具有确定性写入的脚本
注意:从 Redis 5.0 开始,脚本复制默认基于效果复制,而不是逐字复制。在 Redis 7.0 中,逐字脚本复制已被完全移除。以下部分仅适用于未使用效果复制的 Redis 7.0 以下版本。
脚本编写的一个重要部分是编写只以确定性方式更改数据库的脚本。在 Redis 实例中执行的脚本,在 5.0 版本之前默认是通过发送脚本本身——而不是结果命令——传播到副本和 AOF 文件。由于脚本将在远程主机(或重新加载 AOF 文件时)重新运行,因此它对数据库的更改必须是可重现的。
发送脚本的原因是,这通常比发送脚本生成的多个命令要快得多。如果客户端向 master 发送许多脚本,将脚本转换为副本/AOF 的单个命令会导致复制链接或 Append Only File 占用过多的带宽(以及过多的 CPU,因为与 Lua 脚本调用的命令相比,Redis 处理通过网络接收的命令要花费更多的工作)。
通常复制脚本而不是脚本效果是有意义的,但并非所有情况下都是如此。因此,从 Redis 3.2 开始,脚本引擎可以选择复制脚本执行产生的写入命令序列,而不是复制脚本本身。
在本节中,我们将假设通过发送整个脚本来逐字复制脚本。我们将此复制模式称为逐字脚本复制。
整个脚本复制方法的主要缺点是脚本必须具备以下属性:给定相同的输入数据集,脚本必须总是执行相同的 Redis 写入命令,并使用相同的参数。脚本执行的操作不能依赖于在脚本执行过程中或脚本不同执行之间可能变化的任何隐藏(非显式)信息或状态。也不能依赖于来自 I/O 设备的任何外部输入。
使用系统时间、调用返回随机值的 Redis 命令(例如,RANDOMKEY
)或使用 Lua 的随机数生成器等行为,都可能导致脚本无法一致地求值。
为了强制脚本的确定性行为,Redis 采取了以下措施
- Lua 不导出访问系统时间或其他外部状态的命令。
- 如果脚本在调用了
RANDOMKEY
、SRANDMEMBER
、TIME
等 Redis 随机命令之后又调用了可以修改数据集的 Redis 命令,Redis 将以错误阻塞脚本。这意味着不修改数据集的只读脚本可以调用这些命令。请注意,随机命令不一定意味着使用随机数的命令:任何非确定性命令都被视为随机命令(这方面最好的例子是TIME
命令)。 - 在 Redis 4.0 版本中,可能以随机顺序返回元素的命令,例如
SMEMBERS
(因为 Redis Set 是无序的),从 Lua 调用时会表现出不同的行为,并在将数据返回给 Lua 脚本之前经历一个静默的字典排序过滤器。因此redis.call(\"SMEMBERS\",KEYS[1])
将始终以相同的顺序返回 Set 元素,而普通客户端调用的相同命令即使键包含完全相同的元素,也可能返回不同的结果。但是,从 Redis 5.0 开始,不再执行这种排序,因为复制效果规避了这种类型的非确定性。总的来说,即使是为 Redis 4.0 开发,也永远不要假定 Lua 中的某些命令将是有序的,而应依赖于您调用的原始命令的文档来查看其提供的属性。 - Lua 的伪随机数生成函数
math.random
已被修改,并且每次执行都使用相同的种子。这意味着每次执行脚本时调用math.random
将始终生成相同的数字序列(除非使用了math.randomseed
)。
话虽如此,你仍然可以通过一个简单的技巧使用既写入又具有随机行为的命令。想象一下,你想编写一个 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)])
每次运行此代码时,生成的列表将正好包含以下元素
redis> 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 的种子作为其中一个参数发送。给定相同的参数,脚本输出将始终相同(这是我们的要求),但在每次调用时,我们都会更改其中一个参数,从而在客户端生成随机种子。该种子将作为参数之一在复制链接和 Append Only File 中传播,保证在重新加载 AOF 或副本处理脚本时生成相同的更改。
注意:此行为的一个重要部分是,Redis 实现的作为 math.random
和 math.randomseed
的 PRNG 保证无论运行 Redis 的系统架构如何,都具有相同的输出。32 位、64 位、大端和小端系统都将产生相同的输出。
调试 Eval 脚本
从 Redis 3.2 开始,Redis 支持原生 Lua 调试。Redis Lua 调试器是一个远程调试器,由服务器(即 Redis 本身)和客户端(默认是 redis-cli
)组成。
Lua 调试器在 Redis 文档的Lua 脚本调试部分中描述。
低内存条件下的执行
当 Redis 的内存使用超出 maxmemory
限制时,脚本中遇到的第一个使用额外内存的写入命令将导致脚本中止(除非使用了 redis.pcall
)。
然而,上述情况有一个例外,即当脚本的第一个写入命令不使用额外内存时(例如 DEL
和 LREM
)。在这种情况下,Redis 将允许脚本中的所有命令运行以确保原子性。如果脚本中的后续写入消耗额外内存,Redis 的内存使用量可能会超出 maxmemory
配置指令设置的阈值。
脚本可能导致内存使用量超过 maxmemory
阈值的另一种情况是,当执行开始时 Redis 的内存使用量略低于 maxmemory
,因此脚本的第一个写入命令被允许。随着脚本执行,后续写入命令消耗更多内存,导致服务器使用的 RAM 超过配置的 maxmemory
指令。
在这些场景下,您应该考虑将 maxmemory-policy
配置指令设置为除 noeviction
之外的任何值。此外,Lua 脚本应尽可能快,以便在执行之间可以触发驱逐。
请注意,您可以使用标志来改变此行为
Eval 标志
通常,当您运行 Eval 脚本时,服务器不知道它如何访问数据库。默认情况下,Redis 假设所有脚本都读写数据。然而,从 Redis 7.0 开始,有一种方法可以在创建脚本时声明标志,以便告诉 Redis 它应该如何行为。
做到这一点的方法是在脚本的第一行使用 Shebang 语句,如下所示
#!lua flags=no-writes,allow-stale
local x = redis.call('get','x')
return x
请注意,一旦 Redis 看到 #!
注释,即使没有定义标志,它也会将脚本视为已声明标志,与没有 #!
行的脚本相比,它仍然具有一组不同的默认值。
另一个区别是,没有 #!
的脚本可以运行访问属于不同集群哈希槽的键的命令,而有 #!
的脚本会继承默认标志,因此不能。
请参阅脚本标志以了解各种脚本和默认值。