文档:使用 Lua 进行脚本编写

本页面正在审核中。本页面可能不正确、包含无效链接和/或需要技术审查。未来它可能会大幅更改或完全删除。


Valkey 允许用户在服务器上上传和执行 Lua 脚本。脚本可以采用编程控制结构,并在执行时使用大多数命令来访问数据库。由于脚本在服务器中执行,因此从脚本读写数据非常高效。

Valkey 保证脚本的原子性执行。在脚本执行期间,所有服务器活动在其整个运行时都会被阻塞。这些语义意味着脚本的所有效果要么尚未发生,要么已经发生。

脚本编写提供了在许多情况下都很有价值的几个特性。其中包括

  • 通过在数据所在位置执行逻辑来提供局部性。数据局部性减少了整体延迟并节省了网络资源。
  • 确保脚本原子性执行的阻塞语义。
  • 能够组合 Valkey 中缺失的或过于小众而无法成为其一部分的简单功能。

Lua 允许您在 Valkey 内部运行部分应用程序逻辑。此类脚本可以跨多个键执行条件更新,可能原子性地组合多种不同的数据类型。

脚本由嵌入式执行引擎在 Valkey 中执行。目前,Valkey 支持单一的脚本引擎,即 Lua 5.1 解释器。有关完整的文档,请参阅Valkey Lua API 参考页面。

尽管服务器执行它们,但 Eval 脚本被视为客户端应用程序的一部分,这就是它们没有命名、版本化或持久化的原因。因此,如果脚本缺失(例如,在服务器重启、故障转移到副本后),应用程序可能需要随时重新加载所有脚本。从 7.0 版本开始,Valkey 函数提供了一种替代的可编程性方法,允许服务器本身通过额外的编程逻辑进行扩展。

开始

我们将通过使用 EVAL 命令开始在 Valkey 中进行脚本编写。

这是我们的第一个例子

> EVAL "return 'Hello, scripting!'" 0
"Hello, scripting!"

在此示例中,EVAL 接受两个参数。第一个参数是包含脚本 Lua 源代码的字符串。脚本不需要包含任何 Lua 函数的定义。它只是一个将在 Valkey 引擎上下文中运行的 Lua 程序。

第二个参数是脚本主体后面(从第三个参数开始)的参数数量,表示 Valkey 键名。在此示例中,我们使用了值 0,因为我们没有向脚本提供任何参数,无论是键名还是其他。

脚本参数化

应用程序可以根据其需求动态生成脚本源代码,尽管这种做法非常不推荐。例如,应用程序可以发送这两个完全不同但同时又完全相同的脚本

127.0.0.1:6379> EVAL "return 'Hello'" 0
"Hello"
127.0.0.1:6379> EVAL "return 'Scripting!'" 0
"Scripting!"

尽管 Valkey 不会阻止这种操作模式,但由于脚本缓存的考虑(更多内容见下文),它是一种反模式。与其让应用程序生成相同脚本的细微变体,不如对它们进行参数化,并传入执行它们所需的任何参数。

以下示例演示了如何通过参数化实现与上述相同的效果

127.0.0.1:6379> EVAL "return ARGV[1]" 0 Hello
"Hello"
127.0.0.1:6379> EVAL "return ARGV[1]" 0 Parameterization!
"Parameterization!"

此时,了解 Valkey 对作为键名的输入参数和非键名输入参数之间的区别至关重要。

虽然 Valkey 中的键名只是字符串,但与任何其他字符串值不同,它们代表数据库中的键。键名是 Valkey 中的一个基本概念,也是操作 Valkey Cluster 的基础。

重要提示:为确保脚本在独立部署和集群部署中正确执行,脚本访问的所有键名都必须明确作为输入键参数提供。脚本应只访问其名称作为输入参数提供的键。脚本绝不应访问通过程序生成名称的键,或基于数据库中存储的数据结构内容的键。

任何不是键名的函数输入都是常规输入参数。

在上面的示例中,HelloParameterization! 都是脚本的常规输入参数。因为脚本没有触及任何键,我们使用数字参数 0 来指定没有键名参数。执行上下文通过 KEYSARGV 全局运行时变量使参数可供脚本使用。KEYS 表在脚本执行前已预先填充了所有提供的键名参数,而 ARGV 表则用于常规参数,作用类似。

以下尝试演示脚本 KEYSARGV 运行时全局变量之间输入参数的分布

127.0.0.1:6379> 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 数组回复返回,因此您的客户端库很可能会将其转换为您的编程语言中的原生数组数据类型。请参阅有关数据类型转换的规则以获取更多相关信息。

从脚本与 Valkey 交互

可以从 Lua 脚本中通过 server.call()server.pcall() 调用 Valkey 命令。

两者几乎相同。如果提供的参数构成了一个格式良好的命令,它们都会执行 Valkey 命令及其参数。然而,这两个函数之间的区别在于运行时错误(例如语法错误)的处理方式。调用 server.call() 函数引发的错误会直接返回给执行它的客户端。相反,调用 server.pcall() 函数时遇到的错误会返回到脚本的执行上下文,以便进行可能的处理。

例如,考虑以下内容

> EVAL "return server.call('SET', KEYS[1], ARGV[1])" 1 foo bar
OK

上面的脚本接受一个键名和一个值作为其输入参数。执行时,脚本调用 SET 命令将输入键 foo 设置为字符串值 "bar"。

脚本缓存

到目前为止,我们一直使用 EVAL 命令来运行我们的脚本。

每当我们调用 EVAL 时,我们也会在请求中包含脚本的源代码。重复调用 EVAL 来执行同一组参数化脚本,既浪费网络带宽,又在 Valkey 中产生一些开销。自然,节省网络和计算资源是关键,因此,Valkey 提供了一种脚本缓存机制。

您使用 EVAL 执行的每个脚本都存储在服务器维护的专用缓存中。缓存的内容按脚本的 SHA1 摘要和组织,因此脚本的 SHA1 摘要和在缓存中唯一标识它。您可以通过运行 EVAL 并在之后调用 INFO 来验证此行为。您会注意到 used_memory_scripts_evalnumber_of_cached_scripts 指标会随着每个新执行的脚本而增长。

如上所述,动态生成的脚本是一种反模式。在应用程序运行时生成脚本可能会(而且很可能)耗尽主机用于缓存它们的内存资源。相反,脚本应尽可能通用,并通过其参数提供定制执行。

通过调用 SCRIPT LOAD 命令并提供其源代码,可以将脚本加载到服务器的缓存中。服务器不会执行脚本,而只是编译并将其加载到服务器的缓存中。加载后,您可以使用服务器返回的 SHA1 摘要执行缓存的脚本。

以下是加载和执行缓存脚本的示例

127.0.0.1:6379> SCRIPT LOAD "return 'Immabe a cached script'"
"c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f"
127.0.0.1:6379> EVALSHA c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f 0
"Immabe a cached script"

缓存易失性

Valkey 脚本缓存始终是易失的。它不被视为数据库的一部分,也不进行持久化。当服务器重启、副本承担主角色时的故障转移,或通过 SCRIPT FLUSH 明确清除时,缓存可能会被清除。这意味着缓存脚本是短暂的,缓存的内容可能随时丢失。

使用脚本的应用程序应始终调用 EVALSHA 来执行它们。如果脚本的 SHA1 摘要不在缓存中,服务器将返回错误。例如

127.0.0.1:6379> EVALSHA ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script

在这种情况下,应用程序应首先使用 SCRIPT LOAD 加载它,然后再次调用 EVALSHA 以通过其 SHA1 和运行缓存的脚本。大多数 Valkey 客户端已经提供了自动执行此操作的实用 API。请查阅您的客户端文档以获取具体细节。

管道化上下文中的 EVALSHA

管道化请求的上下文环境中执行 EVALSHA 应特别小心。管道化请求中的命令按发送顺序运行,但其他客户端的命令可能会在这些命令之间交错执行。正因为如此,NOSCRIPT 错误可能从管道化请求中返回,但无法处理。

因此,客户端库的实现应在管道化上下文中使用普通参数化的 EVAL

脚本缓存语义

在正常操作期间,应用程序的脚本应无限期地保留在缓存中(即,直到服务器重启或缓存被清除)。其基本原理是,一个编写良好的应用程序的脚本缓存内容不太可能持续增长。即使是使用数百个缓存脚本的大型应用程序,在缓存内存使用方面也不应成为问题。

清除脚本缓存的唯一方法是显式调用 SCRIPT FLUSH 命令。运行该命令将完全清除脚本缓存,移除所有已执行的脚本。通常,这仅在实例将在云环境中为另一个客户或应用程序实例化时才需要。

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

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

实际上,对于客户端来说,更简单的方法是假设在给定连接的上下文中,除非管理员明确调用了 SCRIPT FLUSH 命令,否则缓存脚本肯定存在。用户可以信赖 Valkey 保留缓存脚本这一事实在管道化上下文中具有语义上的帮助。

SCRIPT 命令

Valkey SCRIPT 命令提供了几种控制脚本子系统的方法。它们是

  • SCRIPT FLUSH:此命令是强制 Valkey 清除脚本缓存的唯一方法。它在同一 Valkey 实例被重新分配给不同用途的环境中最有用。它也对测试客户端库的脚本功能实现有所帮助。

  • SCRIPT EXISTS:给定一个或多个 SHA1 摘要作为参数,此命令返回一个由 10 组成的数组。1 表示特定的 SHA1 被识别为脚本缓存中已存在的脚本。0 表示具有此 SHA1 的脚本之前未加载(或者至少自上次调用 SCRIPT FLUSH 以来从未加载过)。

  • SCRIPT LOAD script:此命令在 Valkey 脚本缓存中注册指定的脚本。在所有我们希望确保 EVALSHA 不会失败(例如,在管道化或从 MULTI/EXEC 事务调用时)的上下文环境中,这是一个有用的命令,而无需执行脚本。

  • SCRIPT SHOW:此命令显示存储在脚本缓存中的脚本的原始源代码。它有助于用户轻松地通过签名获取脚本。

  • SCRIPT KILL:此命令是中断长时间运行脚本(又称慢速脚本)的唯一方法,除非关闭服务器。一旦脚本的执行时间超过配置的最大执行时间阈值,它就被视为慢速脚本。SCRIPT KILL 命令只能用于在其执行期间未修改数据集的脚本(因为停止只读脚本不会违反脚本引擎保证的原子性)。

  • SCRIPT DEBUG:控制内置的 Valkey Lua 脚本调试器的使用。

脚本复制

在主从设置(见复制)中,脚本在主服务器上执行的写入命令也会发送到副本以保持一致性。当脚本执行完成后,脚本生成的一系列命令会被封装到一个 MULTI/EXEC 事务中,并发送到副本,如果使用了 AOF 文件,还会写入 AOF 文件。(见持久化。)这被称为效果复制

过去,也可以使用逐字复制,这意味着脚本作为一个整体被复制,但这在 7.0 版中已移除。

server.replicate_commands() 函数已弃用且无效,但它的存在是为了避免破坏现有脚本。

调试 Eval 脚本

Valkey 有一个内置的 Lua 调试器。Valkey Lua 调试器是一个远程调试器,由服务器(即 Valkey 本身)和客户端(默认情况下是 valkey-cli)组成。

Lua 调试器在 Valkey 文档的Lua 脚本调试部分中进行了描述。

低内存条件下的执行

当 Valkey 中的内存使用量超过 maxmemory 限制时,脚本中遇到的第一个使用额外内存的写入命令将导致脚本中止(除非使用了 server.pcall)。

然而,上述情况有一个例外,即当脚本的第一个写入命令不使用额外内存时(例如 DELLREM)。在这种情况下,Valkey 将允许脚本中的所有命令运行以确保原子性。如果脚本中后续的写入操作消耗额外的内存,Valkey 的内存使用量可能会超过由 maxmemory 配置指令设置的阈值。

脚本可能导致内存使用量超过 maxmemory 阈值的另一个场景是,当 Valkey 内存略低于 maxmemory 时开始执行,从而允许脚本的第一个写入命令。随着脚本的执行,后续的写入命令会消耗更多内存,导致服务器使用的 RAM 超过配置的 maxmemory 指令。

在这些场景中,您应该考虑将 maxmemory-policy 配置指令设置为除 noeviction 之外的任何值。此外,Lua 脚本应尽可能快,以便在执行之间可以触发逐出。

请注意,您可以通过使用标志来更改此行为

Eval 标志

通常,当您运行 Eval 脚本时,服务器不知道它是如何访问数据库的。默认情况下,Valkey 假定所有脚本都会读写数据。然而,从 Redis OSS 7.0 开始,有一种方法可以在创建脚本时声明标志,以告诉 Valkey 它应该如何行为。

实现这一目标的方法是在脚本的第一行使用一个 Shebang 声明,如下所示

#!lua flags=no-writes,allow-stale
local x = server.call('get','x')
return x

请注意,一旦 Valkey 看到 #! 注释,它就会将脚本视为声明了标志,即使没有定义任何标志,它与没有 #! 行的脚本相比仍然有一组不同的默认值。

另一个区别是,没有 #! 的脚本可以运行访问属于不同集群哈希槽的键的命令,而带有 #! 的脚本继承了默认标志,因此不能这样做。

请参阅脚本标志了解各种脚本和默认值。