Skip to content

脚本编程

使用 EVAL 命令编写 Lua 脚本

介绍

Redis 的 Lua 脚本是一种强大的功能,允许你在 Redis 服务器上执行复杂的操作,同时保证这些操作的原子性。这意味着一旦 Lua 脚本开始执行,Redis 将确保脚本中的所有命令按顺序执行,而不会被其他客户端的命令打断。这对于实现事务、复杂的逻辑操作非常有用。

以下是一些关于 Redis Lua 脚本的基本信息和示例:

基本语法

Redis Lua 脚本通常使用标准的 Lua 语言语法。在 Redis 中,你可以通过 EVAL 命令来执行 Lua 脚本。EVAL 命令的基本语法如下:

EVAL script numkeys key [key ...] arg [arg ...]
  • script 是你要执行的 Lua 脚本。
  • numkeys 是后续参数中 key 的数量。
  • key 是 Lua 脚本中会用到的 Redis 键。
  • arg 是传递给 Lua 脚本的附加参数。

示例

示例 1:简单的键值操作

假设你想通过 Lua 脚本设置一个键的值并获取它:

lua
-- Lua 脚本
local value = redis.call('SET', KEYS[1], ARGV[1])
return redis.call('GET', KEYS[1])

在 Redis CLI 中执行这个脚本:

sh
EVAL "local value = redis.call('SET', KEYS[1], ARGV[1]); return redis.call('GET', KEYS[1]);" 1 mykey myvalue

这里,1 表示 KEYS 数组有一个元素(mykey),myvalue 是传递给 ARGV 数组的第一个参数。

示例 2:实现原子性增加和获取

假设你有一个计数器,想要原子性地增加它的值并获取增加后的值:

lua
-- Lua 脚本
local current = tonumber(redis.call('GET', KEYS[1]) or "0")
current = current + tonumber(ARGV[1])
redis.call('SET', KEYS[1], tostring(current))
return current

在 Redis CLI 中执行这个脚本:

sh
EVAL "local current = tonumber(redis.call('GET', KEYS[1]) or \"0\"); current = current + tonumber(ARGV[1]); redis.call('SET', KEYS[1], tostring(current)); return current;" 1 mycounter 1

这里,1 表示 KEYS 数组有一个元素(mycounter),1 是传递给 ARGV 数组的第一个参数,表示增加的值。

注意事项

  1. 错误处理:在 Lua 脚本中,你可以使用 redis.callredis.pcall 来执行 Redis 命令。redis.call 在命令失败时会抛出错误,而 redis.pcall 则会捕获错误并返回错误信息。
  2. 脚本缓存:Redis 会缓存 Lua 脚本,以便在相同的脚本再次执行时,不需要重新编译。脚本的缓存是通过脚本的 SHA1 校验和来识别的。
  3. 执行时间:Lua 脚本的执行时间应该尽可能短,因为长时间的执行会阻塞 Redis 服务器,影响其他客户端的操作。

通过使用 Lua 脚本,你可以充分利用 Redis 的性能和功能,实现复杂且高效的数据操作。

编程的基础

官方参考链接

入门示例

bash
> EVAL "return 'Hello, scripting!'" 0
"Hello, scripting!"
  • 在此示例中,EVAL 接受两个参数。第一个参数是一个由脚本的 Lua 源代码组成的字符串。该脚本不需要包含任何 Lua 函数的定义。它只是一个将在 Redis 引擎上下文中运行的 Lua 程序。
  • 第二个参数是脚本主体后面的参数 key 数量,从第三个参数开始,表示 Redis 键名。在此示例中,我们使用值 0,因为我们没有为脚本提供任何参数,无论是否是键名。

脚本参数化

bash
> EVAL "return ARGV[1]" 0 Hello
"Hello"
> EVAL "return ARGV[1]" 0 Parameterization!
"Parameterization!"
  • 在上面的示例中,Hello 和 Parameterization! 都是脚本的常规输入参数。由于脚本不接触任何键,我们使用数值参数 0 来指定没有键名参数。执行上下文通过 KEYS 和 ARGV 全局运行时变量向脚本提供参数。KEYS 表预先填充了在执行脚本之前提供给脚本的所有键名参数,而 ARGV 表的用途类似,但用于常规参数。

下面尝试演示脚本 KEYS 和 ARGV 运行时全局变量之间的输入参数分布:

bash
> 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"

通过脚本与 Redis 交互

可以通过 redis.call() 或 redis.pcall() 从 Lua 脚本调用 Redis 命令。

这两个函数几乎完全相同。如果命令格式正确,这两个函数都会执行 Redis 命令及其提供的参数。但是,这两个函数的区别在于处理运行时错误(例如语法错误)的方式。调用 redis.call() 函数时引发的错误会直接返回给执行该函数的客户端。相反,调用 redis.pcall() 函数时遇到的错误会返回到脚本的执行上下文,以便进行可能的处理。

bash
> 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 摘要执行缓存的脚本。

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

bash
> SCRIPT LOAD "return 'Immabe a cached script'"
"c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f"
> EVALSHA c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f 0
"Immabe a cached script"

缓存波动性

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

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

bash
> EVALSHA ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script. Please use EVAL.

在这种情况下,应用程序应首先使用 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 脚本:此命令在 Redis 脚本缓存中注册指定的脚本。在我们希望确保 EVALSHA 不会失败的所有情况下(例如,在管道中或从 MULTI/EXEC 事务调用时,无需执行脚本),这是一个有用的命令。
  • SCRIPT KILL:此命令是中断长时间运行的脚本(又称慢速脚本)的唯一方法,无需关闭服务器。一旦脚本的执行时间超过配置的最大执行时间阈值,则该脚本被视为慢速脚本。SCRIPT KILL 命令只能用于在执行期间未修改数据集的脚本(因为停止只读脚本不会违反脚本引擎保证的原子性)。
  • SCRIPT DEBUG:控制内置 Redis Lua 脚本调试器的使用。

脚本复制

在独立部署中,单个 Redis 实例(称为 master)管理整个数据库。集群部署至少有三个 master 管理分片数据库。Redis 使用复制来为任何给定 master 维护一个或多个副本(或精确副本)。

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

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

逐字脚本复制是 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 时重新计算脚本是令人遗憾的。在这种情况下,最好只复制脚本的影响。
  • 当启用脚本影响复制时,非确定性函数的限制将被删除。例如,您可以在脚本中的任何地方自由使用 TIME 或 SRANDMEMBER 命令。
  • 此模式下的 Lua PRNG 在每次调用时都会随机播种。

除非服务器的配置或默认值已经启用(Redis 7.0 之前),否则需要在脚本执行写入之前发出以下 Lua 命令:

bash
redis.replicate_commands()

如果脚本启用了复制,则 redis.replicate_commands() 函数返回 true ;否则,如果在脚本已经调用写入命令之后调用该函数,则返回 false ,并使用正常的整个脚本复制。

此函数自 Redis 7.0 开始已被弃用,尽管您仍然可以调用它,但它始终会成功。

具有确定性写入的脚本

注意:从 Redis 5.0 开始,脚本复制默认基于效果,而不是逐字复制。在 Redis 7.0 中,逐字脚本复制已被完全删除。以下部分仅适用于未使用基于效果的脚本复制的 Redis 7.0 以下版本。

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

发送脚本的原因是,它通常比发送脚本生成的多个命令要快得多。如果客户端向主服务器发送许多脚本,则将脚本转换为副本/ AOF 的单个命令会导致复制链接或仅附加文件占用过多带宽(并且还会占用过多的 CPU,因为与调度 Lua 脚本调用的命令相比,调度通过网络接收的命令对 Redis 来说要费力得多)。

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

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

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

使用系统时间、调用返回随机值的 Redis 命令(例如 RANDOMKEY)或使用 Lua 的随机数生成器等行为可能会导致脚本无法一致地进行评估。

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

  • Lua 不导出访问系统时间或其他外部状态的命令。
  • 如果脚本在 Redis 随机命令(如 RANDOMKEY、SRANDMEMBER、TIME)之后调用能够更改数据集的 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 中的初始实现可能如下所示:

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)])

每次运行此代码时,生成的列表都会恰好包含以下元素:

bash
> 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 伪随机数生成器的种子。新脚本如下:

ruby
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.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 脚本时,服务器不知道它如何访问数据库。默认情况下,Redis 假定所有脚本都会读取和写入数据。但是,从 Redis 7.0 开始,有一种方法可以在创建脚本时声明标志,以告诉 Redis 它应该如何表现。

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

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

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

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

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

集群中 Lua 脚本

RedisTemplate 在 Redis 集群中使用 Lua 脚本本站参考示例

使用 cli 测试

shell
# 使用eval函数执行lua脚本,1表示有1个key参数,redis 对应 KEYS[1], world 对应 ARGV[1]
# https://www.jianshu.com/p/4558689c13be
eval 'return "hello " .. KEYS[1] .. " " .. ARGV[1]' 1 redis world

# 使用redis.call调用redis命令,0 表示没有参数
eval 'return redis.call("set", "k1", "sv1")' 0
get "k1"

# 调用lua脚本文件
# 1.lua脚本文件内容如下:
return 'hello ' .. KEYS[1]
# 调用1.lua脚本文件,返回值hello redis
redis-cli -a 123456 --eval ./1.lua 1 redis

函数编程

官方参考

介绍

Redis 函数是用于管理要在服务器上执行的代码的 API。此功能在 Redis 7 中推出,取代了 Redis 早期版本中对 EVAL 的使用。

Eval 脚本有什么问题?

在之前的 Redis 版本中,脚本只能通过 EVAL 命令使用,该命令允许将 Lua 脚本发送至服务器执行。Eval 脚本的核心用例是在 Redis 内部高效且原子地执行部分应用程序逻辑。此类脚本可以跨多个键执行条件更新,并可能组合多种不同的数据类型。

使用 EVAL 要求应用程序每次都发送整个脚本以供执行。由于这会导致网络和脚本编译开销,Redis 以 EVALSHA 命令的形式提供了优化。通过首先调用 SCRIPT LOAD 获取脚本的 SHA1,应用程序随后可以仅使用其摘要反复调用该命令。

Redis 的设计是仅缓存已加载的脚本。这意味着脚本缓存可能随时丢失,例如调用 SCRIPT FLUSH 后、服务器重启后或故障转移到副本时。应用程序负责在运行时重新加载丢失的脚本。其基本假设是,脚本是应用程序的一部分,不由 Redis 服务器维护。

这种方法适合许多轻量级脚本用例,但一旦应用程序变得复杂并且更加依赖脚本,就会带来一些困难,即:

  1. 所有客户端应用程序实例都必须维护所有脚本的副本。这意味着需要某种机制将脚本更新应用于所有应用程序实例。
  2. 在事务上下文中调用缓存脚本会增加事务因缺少脚本而失败的概率。更高的失败概率使得使用缓存脚本作为工作流构建块变得不那么有吸引力。
  3. SHA1 摘要毫无意义,使得系统调试极其困难(例如,在 MONITOR 会话中)。
  4. 当单纯使用时,EVAL 会促进一种反模式,其中客户端应用程序会逐字呈现脚本,而不是负责任地使用 KEYS 和 ARGV Lua API。
  5. 由于它们是短暂的,一个脚本无法调用另一个脚本。这使得脚本之间共享和重用代码几乎不可能,除非进行客户端预处理(参见第一点)。

为了满足这些需求,同时避免对已经建立且广受欢迎的临时脚本进行重大更改,Redis v7.0 引入了 Redis 函数。

什么是 Redis 函数?

Redis 函数是短暂脚本进化的一步。

函数提供与脚本相同的核心功能,但它们是数据库的一流软件构件。Redis 将函数作为数据库的组成部分进行管理,并通过数据持久化和复制确保其可用性。由于函数是数据库的一部分,因此在使用前声明,应用程序无需在运行时加载它们,也无需承担事务中止的风险。使用函数的应用程序仅依赖于其 API,而无需依赖数据库中嵌入的脚本逻辑。

临时脚本被视为应用程序域的一部分,而函数则使用用户提供的逻辑扩展数据库服务器本身。它们可用于公开由核心 Redis 命令组成的更丰富的 API,类似于模块,只需开发一次,在启动时加载,并可由各种应用程序/客户端重复使用。每个函数都有一个唯一的用户定义名称,使其调用和跟踪其执行更加容易。

Redis Functions 的设计也试图区分用于编写函数的编程语言和服务器对函数的管理。Lua 是 Redis 目前唯一支持的嵌入式执行引擎语言解释器,其设计理念是简单易学。然而,选择 Lua 作为语言仍然给许多 Redis 用户带来了挑战。

Redis 函数功能对实现语言没有任何假设。函数定义中包含的执行引擎负责执行函数。理论上,只要遵循一些规则(例如能够终止正在执行的函数),引擎就可以执行任何语言的函数。

如上所述,目前 Redis 仅搭载一个嵌入式 Lua 5.1 引擎。未来计划支持更多引擎。Redis 函数可以使用 Lua 的所有功能来处理临时脚本,唯一的例外是 Redis Lua 脚本调试器。

函数还可以通过代码共享来简化开发。每个函数都属于一个库,并且任何给定的库都可以包含多个函数。库的内容是不可变的,并且不允许选择性地更新其函数。相反,库会作为一个整体进行更新,所有函数都会在一次操作中同时更新。这允许从同一库中的其他函数调用函数,或者通过在库内部方法中使用通用代码在函数之间共享代码,这些方法也可以接受语言原生参数。

如上所述,函数旨在更好地支持通过逻辑模式维护数据实体的一致视图的用例。因此,函数与数据本身一起存储。函数还会持久化到 AOF 文件,并从主服务器复制到副本服务器,因此它们与数据本身一样持久。当 Redis 用作临时缓存时,需要额外的机制(如下所述)来提高函数的持久性。

与 Redis 中的所有其他操作一样,函数的执行具有原子性。函数的执行会在整个过程中阻塞所有服务器活动,类似于事务的语义。这些语义意味着脚本的所有效果要么尚未发生,要么已经发生。已执行函数的阻塞语义始终适用于所有连接的客户端。由于运行函数会阻塞 Redis 服务器,因此函数应该快速完成执行,因此您应该避免使用长时间运行的函数。

加载库和函数

让我们通过一些具体的例子和 Lua 片段来探索 Redis 函数。

此时,如果您不熟悉 Lua,特别是 Redis 中的 Lua,您可以通过查看 Eval 脚本简介和 Lua API 页面中的某些示例来更好地掌握该语言。

每个 Redis 函数都属于一个加载到 Redis 的库。使用 FUNCTION LOAD 命令将库加载到数据库。该命令将库的有效负载作为输入,库有效负载必须以 Shebang 语句开头,该语句提供有关库的元数据(例如要使用的引擎和库名称)。Shebang 格式如下:

bash
#!<engine name> name=<library name>

让我们尝试加载一个空库:

bash
redis> FUNCTION LOAD "#!lua name=mylib\n"
(error) ERR No functions registered

此错误是预料之中的,因为加载的库中没有函数。每个库都需要至少包含一个已注册的函数才能成功加载。已注册的函数有名称,并充当库的入口点。当目标执行引擎处理 FUNCTION LOAD 命令时,它会注册库中的函数。

Lua 引擎在加载时会编译并评估库源代码,并期望通过调用 redis.register_function() API 来注册函数。

以下代码片段演示了一个简单的库,它注册了一个名为knockknock的函数,并返回一个字符串回复:

lua
#!lua name=mylib
redis.register_function(
  'knockknock',
  function() return 'Who\'s there?' end
)

在上面的例子中,我们向 Lua 的 redis.register_function() API 提供了有关该函数的两个参数:其注册名称和回调。

我们可以加载我们的库并使用 FCALL 调用注册的函数:

bash
redis> FUNCTION LOAD "#!lua name=mylib\nredis.register_function('knockknock', function() return 'Who\\'s there?' end)"
mylib
redis> FCALL knockknock 0
"Who's there?"

请注意,FUNCTION LOAD 命令返回已加载库的名称,此名称稍后可用于 FUNCTION LIST 和 FUNCTION DELETE。

我们为 FCALL 提供了两个参数:函数的注册名称和数值 0。此数值表示其后的键名数量(与 EVAL 和 EVALSHA 的工作方式相同)。

我们将立即解释如何在函数中使用键名和其他参数。由于这个简单的例子不涉及键,所以我们暂时使用 0。

输入键和常规参数

在我们转到下面的示例之前,必须了解 Redis 对键名参数和非键名参数之间的区别。

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

重要提示:为了确保 Redis 函数在独立部署和集群部署中正确执行,函数访问的所有键的名称都必须明确提供为输入键参数。

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

现在,假设我们的应用程序将部分数据存储在 Redis 哈希表中。我们希望使用类似 HSET 的方式来设置和更新这些哈希表中的字段,并将上次修改时间存储在一个名为 last_modified 的新字段中。我们可以实现一个函数来完成所有这些操作。

我们的函数将调用 TIME 来获取服务器的时钟读数,并使用新字段的值和修改的时间戳来更新目标哈希。我们将实现的函数接受以下输入参数:哈希的键名和要更新的字段值对。

Redis 函数的 Lua API 将这些输入作为函数回调函数的第一个和第二个参数进行访问。回调函数的第一个参数是一个 Lua 表,其中包含函数的所有键名输入。同样,回调函数的第二个参数由所有常规参数组成。

以下是我们的函数及其库注册的可能实现:

lua
#!lua name=mylib

local function my_hset(keys, args)
  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

redis.register_function('my_hset', my_hset)

如果我们创建一个名为 mylib.lua 的新文件,其中包含库的定义,我们可以像这样加载它(不剥离源代码中的有用空格):

bash
$ cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE

我们在 FUNCTION LOAD 调用中添加了 REPLACE 修饰符,以告知 Redis 我们想要覆盖现有的库定义。否则,Redis 会报错,提示该库已存在。

现在库的更新代码已加载到 Redis,我们可以继续调用我们的函数:

bash
redis> FCALL my_hset 1 myhash myfield "some value" another_field "another value"
(integer) 3
redis> HGETALL myhash
1) "_last_modified_"
2) "1640772721"
3) "myfield"
4) "some value"
5) "another_field"
6) "another value"

在本例中,我们调用了 FCALL,并将键名参数的数量设为 1。这意味着函数的第一个输入参数是一个键名(因此包含在回调函数的键值表中)。在第一个参数之后,所有后续输入参数都被视为常规参数,并构成传递给回调函数的 args 表,作为其第二个参数。

扩展库

我们可以向库中添加更多函数,以优化我们的应用程序。我们添加到哈希中的附加元数据字段不应在访问哈希数据时包含在响应中。另一方面,我们确实希望提供获取给定哈希键的修改时间戳的方法。

我们将向库中添加两个新功能来实现这些目标:

  • my_hgetall Redis 函数将返回给定哈希键名的所有字段及其各自的值,但不包括元数据(即 last_modified 字段)。
  • my_hlastmodified Redis 函数将返回给定哈希键名的修改时间戳。

该库的源代码可能如下所示:

lua
#!lua name=mylib

local function my_hset(keys, args)
  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

local function my_hgetall(keys, args)
  redis.setresp(3)
  local hash = keys[1]
  local res = redis.call('HGETALL', hash)
  res['map']['_last_modified_'] = nil
  return res
end

local function my_hlastmodified(keys, args)
  local hash = keys[1]
  return redis.call('HGET', hash, '_last_modified_')
end

redis.register_function('my_hset', my_hset)
redis.register_function('my_hgetall', my_hgetall)
redis.register_function('my_hlastmodified', my_hlastmodified)

虽然以上所有操作都很简单,但请注意,my_hgetall 还会调用 redis.setresp(3)。这意味着该函数在调用 redis.call() 后需要接收 RESP3 的回复。与默认的 RESP2 协议不同,redis.call() 提供的是字典(关联数组)回复。这样做允许该函数从回复中删除(或像 Lua 表那样将其设置为 nil)特定字段,在本例中是 last_modified 字段。

假设您已将库的实现保存在 mylib.lua 文件中,则可以将其替换为:

bash
$ cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE

一旦加载,您就可以使用 FCALL 调用库的函数:

bash
redis> FCALL my_hgetall 1 myhash
1) "myfield"
2) "some value"
3) "another_field"
4) "another value"
redis> FCALL my_hlastmodified 1 myhash
"1640772721"

您还可以使用 FUNCTION LIST 命令获取库的详细信息:

bash
redis> FUNCTION LIST
1) 1) "library_name"
   2) "mylib"
   3) "engine"
   4) "LUA"
   5) "functions"
   6) 1) 1) "name"
         2) "my_hset"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      2) 1) "name"
         2) "my_hgetall"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      3) 1) "name"
         2) "my_hlastmodified"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)

您可以看到,使用新功能更新我们的库很容易。

重用库中的代码

除了将函数捆绑到数据库管理的软件构件中之外,库还可以促进代码共享。我们可以在库中添加一个由其他函数调用的错误处理辅助函数。辅助函数 check_keys() 用于验证输入键表是否包含单个键。成功时返回 nil,否则返回错误信息。

更新后的库的源代码将是:

lua
#!lua name=mylib

local function check_keys(keys)
  local error = nil
  local nkeys = table.getn(keys)
  if nkeys == 0 then
    error = 'Hash key name not provided'
  elseif nkeys > 1 then
    error = 'Only one key name is allowed'
  end

  if error ~= nil then
    redis.log(redis.LOG_WARNING, error);
    return redis.error_reply(error)
  end
  return nil
end

local function my_hset(keys, args)
  local error = check_keys(keys)
  if error ~= nil then
    return error
  end

  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

local function my_hgetall(keys, args)
  local error = check_keys(keys)
  if error ~= nil then
    return error
  end

  redis.setresp(3)
  local hash = keys[1]
  local res = redis.call('HGETALL', hash)
  res['map']['_last_modified_'] = nil
  return res
end

local function my_hlastmodified(keys, args)
  local error = check_keys(keys)
  if error ~= nil then
    return error
  end

  local hash = keys[1]
  return redis.call('HGET', keys[1], '_last_modified_')
end

redis.register_function('my_hset', my_hset)
redis.register_function('my_hgetall', my_hgetall)
redis.register_function('my_hlastmodified', my_hlastmodified)

用上述内容替换 Redis 中的库后,您可以立即尝试新的错误处理机制:

bash
127.0.0.1:6379> FCALL my_hset 0 myhash nope nope
(error) Hash key name not provided
127.0.0.1:6379> FCALL my_hgetall 2 myhash anotherone
(error) Only one key name is allowed

并且您的 Redis 日志文件中应该包含类似以下内容的行:

...
20075:M 1 Jan 2022 16:53:57.688 # Hash key name not provided
20075:M 1 Jan 2022 16:54:01.309 # Only one key name is allowed

集群中的函数

如上所述,Redis 会自动将已加载的函数传播到副本。在 Redis 集群中,还需要将函数加载到所有集群节点。Redis 集群不会自动处理此操作,需要由集群管理员处理(例如模块加载、配置设置等)。

由于函数的目标之一是与客户端应用程序分离,因此这不应成为 Redis 客户端库的职责。相反,可以使用 redis-cli --cluster-only-masters --cluster call host:port FUNCTION LOAD ... 在所有主节点上执行 load 命令。

另请注意,redis-cli --cluster add-node 会自动将已加载的函数从现有节点之一传播到新节点。

函数和临时 Redis 实例

在某些情况下,可能需要启动一个预先加载了一组函数的全新 Redis 服务器。常见的原因如下:

  • 在新环境中启动 Redis
  • 重新启动使用函数的临时(仅缓存)Redis

在这种情况下,我们需要确保在 Redis 接受入站用户连接和命令之前预加载的功能可用。

为此,可以使用 redis-cli --functions-rdb 从现有服务器中提取函数。这将生成一个 RDB 文件,Redis 启动时可以加载该文件。

函数标志

Redis 需要了解函数执行时的行为方式,以便正确执行资源使用策略并维护数据一致性。

例如,Redis 需要知道某个函数是只读的,然后才允许它在只读副本上使用 FCALL_RO 执行。

默认情况下,Redis 假定所有函数都可以执行任意的读写操作。函数标志可以在注册时声明更具体的函数行为。让我们看看它是如何工作的。

在之前的示例中,我们定义了两个仅读取数据的函数。我们可以尝试使用 FCALL_RO 针对只读副本执行它们。

bash
redis > FCALL_RO my_hgetall 1 myhash
(error) ERR Can not execute a function with write flag using fcall_ro.

Redis 返回此错误是因为理论上函数可以同时对数据库执行读取和写入操作。出于安全考虑,默认情况下,Redis 会假定该函数同时执行读取和写入操作,因此会阻止其执行。服务器会在以下情况下回复此错误:

  • 针对只读副本使用 FCALL 执行函数。
  • 使用 FCALL_RO 执行一个函数。
  • 检测到磁盘错误(Redis 无法持久,因此拒绝写入)。

在这些情况下,您可以在函数注册中添加 no-writes 标志,禁用安全措施并允许其运行。要使用标志注册函数,请使用 redis.register_function 的命名参数变体。

库中更新的注册代码片段如下所示:

lua
redis.register_function('my_hset', my_hset)
redis.register_function{
  function_name='my_hgetall',
  callback=my_hgetall,
  flags={ 'no-writes' }
}
redis.register_function{
  function_name='my_hlastmodified',
  callback=my_hlastmodified,
  flags={ 'no-writes' }
}

一旦我们替换了库,Redis 就允许使用 FCALL_RO 对只读副本运行 my_hgetall 和 my_hlastmodified:

bash
redis> FCALL_RO my_hgetall 1 myhash
1) "myfield"
2) "some value"
3) "another_field"
4) "another value"
redis> FCALL_RO my_hlastmodified 1 myhash
"1640772721"

有关完整的文档标志,请参阅 脚本标志

使用 Jedis 测试

详细用法请参考https://gitee.com/dexterleslie/demonstration/tree/master/demo-redis/redis-jedis/demo-jedis-lua-script

java
public class Tests {
    @Test
    public void test() {
        try (Jedis jedis = JedisUtil.getInstance().getJedis()) {
            jedis.flushDB();

            String script = "return \"hello \" .. KEYS[1] .. \" \" .. ARGV[1]";
            Object object = jedis.eval(script, Collections.singletonList("redis"), Collections.singletonList("world"));
            Assert.assertEquals("hello redis world", object.toString());
        }
    }

    @Test
    public void testLuaScriptError() {
        try (Jedis jedis = JedisUtil.getInstance().getJedis()) {
            jedis.flushDB();

            // 脚本在hset处中断执行
            String script = "redis.call(\"set\", \"k1\", \"v1\")" +
                    "redis.call(\"set\", \"k2\", \"v2\")" +
                    "redis.call(\"hset\", \"k1\", \"v1\")" +
                    "redis.call(\"set\", \"k3\", \"v3\")";
            try {
                jedis.eval(script, Collections.EMPTY_LIST, Collections.EMPTY_LIST);
                Assert.fail("预期异常没有抛出");
            } catch (JedisDataException ignored) {
            }

            // 已经执行的命令依旧生效
            Assert.assertEquals("v1", jedis.get("k1"));
            Assert.assertEquals("v2", jedis.get("k2"));
            Assert.assertFalse(jedis.exists("k3"));
        }
    }
}

使用 RedisTemplate 测试

https://www.cnblogs.com/Howinfun/p/11803747.html

详细用法请参考示例https://gitee.com/dexterleslie/demonstration/tree/master/demo-redis/redistemplate/redistemplate-lua-script

java
@SpringBootTest(classes = {Application.class})
public class ApplicationTests {
    @Autowired
    StringRedisTemplate redisTemplate;

    @Test
    public void contextLoads() {
        // region 测试 Lua 脚本文件
        // 清空 db
        Objects.requireNonNull(this.redisTemplate.getConnectionFactory()).getConnection().flushDb();

        DefaultRedisScript<String> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("1.lua"));
        script.setResultType(String.class);
        String str = this.redisTemplate.execute(script, Arrays.asList("k1", "k2"), "v1", "v2");
        Assertions.assertEquals("k1 k2 v1 v2", str);
        Assertions.assertEquals("v1", this.redisTemplate.opsForValue().get("k1"));
        Assertions.assertEquals("v2", this.redisTemplate.opsForValue().get("k2"));

        // endregion

        // region 测试 Lua 脚本字符串

        // 清空 db
        Objects.requireNonNull(this.redisTemplate.getConnectionFactory()).getConnection().flushDb();

        script = new DefaultRedisScript<>();
        script.setScriptText("redis.call('set', KEYS[1], ARGV[1])\n" +
                "redis.call('set', KEYS[2], ARGV[2])\n" +
                "return KEYS[1] .. ' ' .. KEYS[2] .. ' ' .. ARGV[1] .. ' ' .. ARGV[2]");
        script.setResultType(String.class);
        str = this.redisTemplate.execute(script, Arrays.asList("k1", "k2"), "v1", "v2");
        Assertions.assertEquals("k1 k2 v1 v2", str);
        Assertions.assertEquals("v1", this.redisTemplate.opsForValue().get("k1"));
        Assertions.assertEquals("v2", this.redisTemplate.opsForValue().get("k2"));

        // endregion
    }
}