Redis 函数
Redis 7 及更高版本中的脚本编程
Redis 函数是用于管理将在服务器上执行的代码的 API。此功能在 Redis 7 中可用,取代了先前版本中 EVAL 的使用。
前言(或者,Eval 脚本有什么问题?)
Redis 的先前版本仅通过 EVAL
命令提供脚本编程功能,该命令允许将 Lua 脚本发送到服务器执行。Eval 脚本的核心用例是在 Redis 内部高效、原子地执行应用程序逻辑的一部分。此类脚本可以跨多个键执行条件更新,并可能结合使用多种不同的数据类型。
使用 EVAL
要求应用程序每次都发送整个脚本进行执行。由于这会导致网络和脚本编译开销,Redis 以 EVALSHA
命令的形式提供了一种优化措施。通过首先调用 SCRIPT LOAD
获取脚本的 SHA1 值,应用程序之后可以通过其摘要重复调用该脚本。
根据设计,Redis 只缓存已加载的脚本。这意味着脚本缓存可能随时丢失,例如调用 SCRIPT FLUSH
后、重启服务器后或故障转移到副本时。如果缺少任何脚本,应用程序负责在运行时重新加载。基本假设是脚本是应用程序的一部分,而不是由 Redis 服务器维护的。
这种方法适用于许多轻量级的脚本编程用例,但一旦应用程序变得复杂并更严重地依赖脚本编程,就会引入一些困难,即
- 所有客户端应用程序实例必须维护所有脚本的副本。这意味着需要某种机制将脚本更新应用到应用程序的所有实例。
- 在事务的上下文中调用缓存脚本会增加事务因缺少脚本而失败的可能性。更容易失败使得使用缓存脚本作为工作流的构建块吸引力下降。
- SHA1 摘要是无意义的,这使得调试系统变得极其困难(例如,在
MONITOR
会话中)。 - 当不加思索地使用
EVAL
时,会助长一种反模式,即客户端应用程序直接渲染脚本,而不是负责任地使用KEYS
和ARGV
Lua API。 - 由于它们是临时的,一个脚本不能调用另一个脚本。这使得在脚本之间共享和重用代码几乎不可能,除非进行客户端预处理(见第一点)。
为了解决这些需求,同时避免对已建立且受欢迎的临时脚本造成破坏性更改,Redis v7.0 引入了 Redis 函数。
什么是 Redis 函数?
Redis 函数是临时脚本编程的演进。
函数提供了与脚本相同核心功能,但它们是数据库的一等软件制品。Redis 将函数作为数据库的组成部分进行管理,并通过数据持久化和复制来确保其可用性。由于函数是数据库的一部分,并在使用前声明,因此应用程序无需在运行时加载它们,也不会面临事务中断的风险。使用函数的应用程序只依赖于它们的 API,而不是依赖于数据库中嵌入的脚本逻辑。
临时脚本被认为是应用程序域的一部分,而函数则使用用户提供的逻辑扩展了数据库服务器本身。它们可以用于公开由核心 Redis 命令组成的更丰富的 API,类似于模块,一次开发,在启动时加载,并由各种应用程序/客户端重复使用。每个函数都有一个唯一的、用户定义名称,这使得调用和跟踪其执行更加容易。
Redis 函数的设计也试图区分用于编写函数的编程语言及其由服务器进行的管理。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 格式如下:
#!<engine name> name=<library name>
让我们尝试加载一个空库
redis> FUNCTION LOAD "#!lua name=mylib\n"
(error) ERR No functions registered
这是预期中的错误,因为加载的库中没有函数。每个库都需要包含至少一个注册函数才能成功加载。注册函数有名称,并作为库的入口点。当目标执行引擎处理 FUNCTION LOAD
命令时,它会注册库中的函数。
Lua 引擎在加载时会编译和评估库的源代码,并期望通过调用 redis.register_function()
API 来注册函数。
以下代码片段展示了一个简单的库,注册了一个名为 knockknock 的函数,该函数返回一个字符串回复
#!lua name=mylib
redis.register_function(
'knockknock',
function() return 'Who\'s there?' end
)
在上面的示例中,我们向 Lua 的 redis.register_function()
API 提供了关于函数的两个参数:其注册名称和一个回调函数。
我们可以加载我们的库并使用 FCALL
调用注册的函数
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 Cluster 的基础。
重要提示:为了确保 Redis 函数在单机和集群部署中都能正确执行,函数访问的所有键名都必须显式地作为输入键参数提供。
任何不是键名的函数输入都是常规输入参数。
现在,假设我们的应用程序将其部分数据存储在 Redis 哈希 (Hashes) 中。我们想要一种类似 HSET
的方式来设置和更新这些哈希中的字段,并将最后修改时间存储在一个名为 _last_modified_
的新字段中。我们可以实现一个函数来完成这一切。
我们的函数将调用 TIME
命令获取服务器时钟读数,并使用新字段的值和修改的时间戳更新目标哈希。我们将实现的函数接受以下输入参数:哈希的键名和要更新的字段-值对。
Redis 函数的 Lua API 将这些输入作为函数回调的第一个和第二个参数提供。回调的第一个参数是一个 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 的新文件,其中包含库的定义,我们可以像这样加载它(无需去除源代码中有用的空白字符)
$ cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE
我们向调用 FUNCTION LOAD
添加了 REPLACE
修饰符,以告知 Redis 我们要覆盖现有的库定义。否则,我们将收到 Redis 的错误,抱怨库已存在。
现在,库的更新代码已加载到 Redis,我们可以继续并调用我们的函数
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 作为键名参数的数量。这意味着函数的第一个输入参数是键名(因此包含在回调的 keys
表中)。在此第一个参数之后,所有后续输入参数都被视为常规参数,并构成作为第二个参数传递给回调的 args
表。
扩展库
我们可以向库中添加更多函数,以使应用程序受益。我们添加到哈希中的附加元数据字段在访问哈希数据时不应包含在响应中。另一方面,我们确实希望提供获取给定哈希键修改时间戳的方式。
我们将在库中添加两个新函数来实现这些目标
my_hgetall
Redis 函数将返回给定哈希键名中的所有字段及其对应值,但不包括元数据(即_last_modified_
字段)。my_hlastmodified
Redis 函数将返回给定哈希键名的修改时间戳。
该库的源代码可能如下所示
#!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 协议不同,RESP3 提供字典(关联数组)回复。这样做允许函数从回复中删除(或像 Lua 表一样设置为 nil
)特定字段,在本例中是 _last_modified_
字段。
假设您已将库的实现保存在 mylib.lua 文件中,您可以将其替换为
$ cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE
加载后,您可以使用 FCALL
调用库中的函数
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
命令获取库的详细信息
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()
验证输入 keys 表是否只有一个键。成功时返回 nil
,否则返回错误回复。
更新后的库源代码如下
#!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 中用上述代码替换库后,您可以立即尝试新的错误处理机制
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 Cluster 中,还需要将函数加载到所有集群节点。Redis Cluster 不会自动处理此事,需要由集群管理员处理(例如模块加载、配置设置等)。
由于函数的目标之一是独立于客户端应用程序存在,因此这不应成为 Redis 客户端库的职责。相反,可以使用 redis-cli --cluster-only-masters --cluster call host:port FUNCTION LOAD ...
命令在所有主节点上执行加载命令。
此外,请注意,redis-cli --cluster add-node
会自动处理从现有节点之一向新节点传播已加载函数。
函数和临时 Redis 实例
在某些情况下,可能需要启动一个新的 Redis 服务器,并预加载一组函数。常见原因包括
- 在新环境中启动 Redis
- 重启使用函数的临时(仅缓存)Redis 实例
在这种情况下,我们需要确保在 Redis 接受入站用户连接和命令之前,预加载的函数已可用。
为此,可以使用 redis-cli --functions-rdb
命令从现有服务器中提取函数。这会生成一个 RDB 文件,Redis 可以在启动时加载它。
函数标志
Redis 需要了解函数执行时的行为信息,以便正确实施资源使用策略并维护数据一致性。
例如,Redis 需要知道某个函数是只读的,然后才允许使用 FCALL_RO
在只读副本上执行它。
默认情况下,Redis 假定所有函数都可能执行任意读写操作。函数标志 (Function Flags) 使得在注册时声明更具体的函数行为成为可能。让我们看看它是如何工作的。
在我们之前的示例中,我们定义了两个只读取数据的函数。我们可以尝试使用 FCALL_RO
在只读副本上执行它们。
redis > FCALL_RO my_hgetall 1 myhash
(error) ERR Can not execute a function with write flag using fcall_ro.
Redis 返回此错误是因为理论上函数可以在数据库上执行读写操作。作为一种安全措施,默认情况下,Redis 假定函数会执行这两种操作,因此会阻止其执行。服务器将在以下情况下回复此错误
在这些情况下,您可以将 no-writes
标志添加到函数的注册中,禁用该安全措施并允许它们运行。要使用标志注册函数,请使用 redis.register_function
的命名参数变体。
库中更新后的注册代码片段如下
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
。
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"
有关完整的标志文档,请参阅脚本标志。