使用Lua进行脚本编写

在Redis中执行Lua

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

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

脚本提供了许多在多种情况下非常有价值的属性。这些包括:

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

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

脚本在Redis中通过嵌入式执行引擎执行。 目前,Redis支持单一的脚本引擎,即Lua 5.1解释器。 请参阅Redis Lua API参考页面以获取完整的文档。

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

入门指南

我们将通过使用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)的带宽过大(而且由于通过网络接收的命令分发对Redis来说比Lua脚本调用的命令分发要耗费更多的CPU,因此也会导致CPU使用过多)。

通常情况下,复制脚本而不是脚本的效果是有意义的,但并非在所有情况下都是如此。 因此,从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或副本处理脚本时生成相同的更改。

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

调试Eval脚本

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

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

在低内存条件下执行

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

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

另一个脚本可能导致内存使用超过maxmemory阈值的情况是,当Redis略低于maxmemory时开始执行脚本,因此脚本的第一个写命令被允许。 随着脚本的执行,后续的写命令消耗更多内存,导致服务器使用的RAM超过配置的maxmemory指令。

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

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

评估标志

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

实现这一点的方法是在脚本的第一行使用Shebang语句,如下所示:

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

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

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

请参考Script flags了解各种脚本及其默认设置。

RATE THIS PAGE
Back to top ↑