使用 Lua 脚本

在 Redis 中执行 Lua

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

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

脚本提供了在许多情况下都很有价值的几个属性。这些包括

  • 通过在数据所在的位置执行逻辑来提供局部性。数据局部性减少了整体延迟并节省了网络资源。
  • 阻塞语义,确保脚本的原子执行。
  • 启用简单功能的组合,这些功能要么在 Redis 中缺失,要么过于细化而无法成为 Redis 的一部分。

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

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

虽然服务器执行了它们,但 Eval 脚本被视为客户端应用程序的一部分,这就是它们没有命名、版本化或持久化的原因。因此,如果丢失(服务器重启、故障转移到副本等),所有脚本可能需要由应用程序随时重新加载。

入门

我们将使用 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 集群的基础。

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

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

在上面的示例中,HelloParameterization! 都是脚本的常规输入参数。由于脚本不触及任何键,因此我们使用数值参数 0 来指定没有键名参数。执行上下文通过 KEYSARGV 全局运行时变量将参数提供给脚本。KEYS 表在脚本执行之前预先填充了提供给脚本的所有键名参数,而 ARGV 表也具有类似的功能,但用于常规参数。

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

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_evalnumber_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 散列校验和作为参数,此命令返回一个由 10 组成的数组。1 表示特定 SHA1 被识别为已经存在于脚本缓存中的脚本。0 的意思是,具有此 SHA1 的脚本之前从未加载过(或者至少从未在最新调用 SCRIPT FLUSH 之后加载过)。

  • SCRIPT LOAD script:此命令将指定的脚本注册到 Redis 脚本缓存中。在所有我们想要确保 EVALSHA 不失败(例如,在管道中或从 MULTI/EXEC 事务 中调用,而无需执行脚本)的上下文中,此命令都是一个有用的命令。

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

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

脚本复制

在独立部署中,单个称为主服务器的 Redis 实例管理整个数据库。 集群部署 至少有三个主服务器管理分片数据库。Redis 使用 复制 来维护给定主服务器的一个或多个副本,或完全副本。

由于脚本可以修改数据,Redis 确保脚本执行的所有写操作也发送到副本,以保持一致性。在脚本复制方面,有两种概念性方法。

  1. 逐字复制:主服务器将脚本的源代码发送到副本。副本然后执行脚本并应用写操作效果。在短脚本生成许多命令的情况下(例如,for 循环),此模式可以节省复制带宽。但是,这种复制模式意味着副本会重复执行主服务器完成的相同工作,这是浪费的。更重要的是,它还要求所有写脚本都是确定性的
  2. 效果复制:仅复制脚本的修改数据命令。副本然后运行这些命令,而不执行任何脚本。虽然在网络流量方面可能更长,但这种复制模式本质上是确定性的,因此不需要特殊考虑。

逐字脚本复制是直到 Redis 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 时会很可惜。在这种情况下,最好只复制脚本的效果。
  • 启用脚本效果复制后,非确定性函数的限制将被移除。例如,您可以在脚本中的任何位置自由地使用 TIMESRANDMEMBER 命令。
  • 此模式下的 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 文件时),因此它对数据库的更改必须是可重现的。

发送脚本的原因是它通常比发送脚本生成的多个命令快得多。如果客户端向主服务器发送了许多脚本,将脚本转换为副本/AOF 的单个命令会导致复制链接或仅追加文件(AOF)的带宽过大(而且 CPU 过载,因为处理通过网络接收的命令对于 Redis 来说比处理由 Lua 脚本调用的命令的工作量要大得多)。

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

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

整个脚本复制方法的主要缺点是脚本需要具有以下属性:脚本在给定相同输入数据集的情况下始终必须使用相同的 Redis 命令执行相同的参数。脚本执行的操作不能依赖于任何隐藏的(非显式的)信息或状态,这些信息或状态可能会随着脚本执行的进行或在脚本的不同执行之间而改变。它也不能依赖于来自 I/O 设备的任何外部输入。

诸如使用系统时间、调用返回随机值的 Redis 命令(例如,RANDOMKEY)或使用 Lua 的随机数生成器之类的操作,会导致脚本无法一致地评估。

为了强制执行脚本的确定性行为,Redis 会执行以下操作。

  • Lua 不会导出命令来访问系统时间或其他外部状态。
  • 如果脚本在 Redis 随机命令(如 RANDOMKEYSRANDMEMBERTIME)之后调用能够更改数据集的 Redis 命令,Redis 将阻止该脚本并显示错误。这意味着不修改数据集的只读脚本可以调用这些命令。请注意,随机命令并不一定意味着使用随机数的命令:任何非确定性命令都被视为随机命令(在这方面最好的例子是 TIME 命令)。
  • 在 Redis 4.0 版中,可能以随机顺序返回元素的命令(例如 SMEMBERS(因为 Redis 集合是无序的))在从 Lua 调用时表现出不同的行为,并在返回数据到 Lua 脚本之前经过无声的词法排序过滤器。因此 redis.call("SMEMBERS",KEYS[1]) 将始终以相同的顺序返回集合元素,而由普通客户端调用的相同命令即使键包含完全相同的元素,也可能返回不同的结果。但是,从 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 的种子作为参数之一发送。给定相同的参数(我们的要求),脚本输出将始终相同,但我们在每次调用时都会更改一个参数,生成客户端端的随机种子。该种子将作为参数之一在复制链接和仅追加文件(AOF)中传播,确保在重新加载 AOF 或副本处理脚本时生成相同的更改。

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

调试 Eval 脚本

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

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

低内存条件下的执行

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

但是,上述情况有一个例外,即当脚本的第一个写命令不使用额外内存时,例如(DELLREM)。在这种情况下,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 看到 #! 注释,它就会将脚本视为声明了标志,即使没有定义任何标志,它仍然与没有 #! 行的脚本具有不同的默认值集。

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

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

RATE THIS PAGE
Back to top ↑