Redis 函数
使用 Redis 7 及更高版本进行脚本编写
Redis Functions 是一个用于管理在服务器上执行的代码的API。这个功能在Redis 7中可用,取代了之前版本中EVAL的使用。
序言(或者说,Eval脚本有什么问题?)
Redis的早期版本仅通过EVAL
命令提供脚本功能,该命令允许将Lua脚本发送到服务器执行。
Eval Scripts的核心用例是在Redis内部高效且原子地执行部分应用程序逻辑。
此类脚本可以在多个键上执行条件更新,可能结合几种不同的数据类型。
使用EVAL
要求应用程序每次都要发送整个脚本进行执行。
由于这会导致网络和脚本编译的开销,Redis提供了EVALSHA
命令作为优化。通过首先调用SCRIPT LOAD
来获取脚本的SHA1,应用程序之后可以仅使用其摘要来重复调用它。
根据设计,Redis 只缓存已加载的脚本。
这意味着脚本缓存可能在任何时候丢失,例如在调用 SCRIPT FLUSH
后、服务器重启后或故障转移到副本时。
如果缺少任何脚本,应用程序负责在运行时重新加载脚本。
基本假设是脚本是应用程序的一部分,而不是由 Redis 服务器维护的。
这种方法适用于许多轻量级的脚本使用场景,但一旦应用程序变得复杂并更加依赖脚本时,就会引入一些困难,即:
- 所有客户端应用程序实例必须维护所有脚本的副本。这意味着需要某种机制将脚本更新应用到应用程序的所有实例。
- 在事务的上下文中调用缓存的脚本会增加由于脚本缺失而导致事务失败的概率。更有可能失败使得使用缓存的脚本作为工作流的构建块变得不那么吸引人。
- SHA1 摘要毫无意义,使得调试系统极其困难(例如,在
MONITOR
会话中)。 - 当不恰当地使用时,
EVAL
会助长一种反模式,即客户端应用程序直接渲染脚本,而不是负责任地使用KEYS
和ARGV
Lua API。 - 由于它们是短暂的,一个脚本不能调用另一个脚本。这使得在脚本之间共享和重用代码几乎不可能,除非进行客户端预处理(参见第一点)。
为了满足这些需求,同时避免对已经建立且受欢迎的临时脚本进行破坏性更改,Redis v7.0 引入了 Redis Functions。
什么是Redis函数?
Redis 函数是从临时脚本演变而来的一个步骤。
函数提供与脚本相同的核心功能,但它们是数据库的一等软件工件。 Redis 将函数作为数据库的组成部分进行管理,并通过数据持久化和复制确保其可用性。 由于函数是数据库的一部分,因此在使用前声明,应用程序无需在运行时加载它们,也不会面临事务中止的风险。 使用函数的应用程序仅依赖于它们的 API,而不是数据库中的嵌入式脚本逻辑。
虽然临时脚本被视为应用程序领域的一部分,但函数通过用户提供的逻辑扩展了数据库服务器本身。 它们可以用来暴露一个由核心Redis命令组成的更丰富的API,类似于模块,开发一次,启动时加载,并由各种应用程序/客户端重复使用。 每个函数都有一个唯一的用户定义名称,使得调用和跟踪其执行变得更加容易。
Redis 函数的设计还尝试区分用于编写函数的编程语言和服务器对它们的管理。 Lua 是 Redis 目前唯一支持作为嵌入式执行引擎的语言解释器,它旨在简单易学。 然而,选择 Lua 作为语言仍然给许多 Redis 用户带来了挑战。
Redis Functions 功能不对实现语言做出任何假设。 作为函数定义的一部分的执行引擎负责运行它。 理论上,只要遵守一些规则(例如能够终止正在执行的函数),引擎就可以用任何语言执行函数。
目前,如上所述,Redis附带了一个嵌入式的Lua 5.1引擎。 未来有计划支持更多的引擎。 Redis函数可以使用Lua的所有可用功能来编写临时脚本, 唯一的例外是Redis Lua脚本调试器。
函数还通过启用代码共享来简化开发。 每个函数都属于一个单一的库,任何给定的库都可以由多个函数组成。 库的内容是不可变的,不允许对其函数进行选择性更新。 相反,库作为一个整体进行更新,所有函数在一次操作中一起更新。 这允许从同一库中的其他函数调用函数,或通过在库内部方法中使用通用代码在函数之间共享代码,这些方法也可以接受语言原生参数。
函数的目的是为了更好地支持通过逻辑模式维护数据实体的一致视图的用例,如上所述。 因此,函数与数据本身一起存储。 函数也会持久化到AOF文件中,并从主节点复制到副本节点,因此它们与数据本身一样持久。 当Redis用作临时缓存时,需要额外的机制(如下所述)以使函数更加持久。
与Redis中的所有其他操作一样,函数的执行是原子性的。 函数的执行在其整个时间内会阻塞所有服务器活动,类似于事务的语义。 这些语义意味着脚本的所有效果要么尚未发生,要么已经发生。 执行函数的阻塞语义始终适用于所有连接的客户端。 由于运行函数会阻塞Redis服务器,因此函数应快速完成执行,因此应避免使用长时间运行的函数。
加载库和函数
让我们通过一些具体的例子和Lua代码片段来探索Redis函数。
此时,如果您对Lua语言以及特别是在Redis中的应用不熟悉,您可能会从查看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集群的基础。
重要提示: 为了确保Redis函数在独立和集群部署中的正确执行,函数访问的所有键名必须作为输入键参数明确提供。
函数的任何输入如果不是键名,则是一个常规的输入参数。
现在,假设我们的应用程序将其部分数据存储在Redis哈希中。
我们想要一种类似于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提供了字典(关联数组)的回复。
这样做允许函数从回复中删除(或设置为nil
,就像Lua表一样)特定字段,在我们的例子中,是_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 集群中,还需要将函数加载到所有集群节点。这不是由 Redis 集群自动处理的,需要由集群管理员处理(如模块加载、配置设置等)。
作为函数的目标之一是与客户端应用程序分离,这不应成为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 假设所有函数可能执行任意的读取或写入操作。函数标志使得在注册时声明更具体的函数行为成为可能。让我们看看这是如何工作的。
在我们之前的例子中,我们定义了两个只读取数据的函数。我们可以尝试在只读副本上使用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
标志添加到函数的注册中,禁用保护措施并允许它们运行。
要使用标志注册函数,请使用named arguments变体的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 允许在只读副本上使用 my_hgetall
和 my_hlastmodified
与 FCALL_RO
一起运行:
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"
有关完整的文档标志,请参阅Script flags。