文档:函数

此页面正在审核中。 该页面可能不正确,包含无效链接,和/或需要技术审查。未来它可能会大幅更改或完全移除。


Valkey 函数是一种用于管理要在服务器上执行的代码的 API。此功能是对 EVAL 脚本的补充。

EVAL 有什么问题?

EVAL 没有问题,但 EVAL 脚本和函数之间存在一些差异。使用 EVAL 命令时,脚本会被发送到服务器立即执行。EVAL 脚本的核心用例是在 Valkey 内部高效且原子地执行部分应用程序逻辑。此类脚本可以对多个键执行条件更新,可能组合多种不同的数据类型。

使用 EVAL 要求应用程序每次都发送整个脚本进行执行。由于这会导致网络和脚本编译开销,Valkey 提供了一种优化形式,即 EVALSHA 命令。通过首先调用 SCRIPT LOAD 获取脚本的 SHA1,应用程序之后只需使用其摘要即可重复调用它。

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

这种方法适用于许多轻量级脚本使用场景,但一旦应用程序变得复杂并更严重地依赖脚本,就会引入一些困难,即:

  1. 所有客户端应用程序实例都必须维护所有脚本的副本。这意味着需要某种机制,将脚本更新应用于应用程序的所有实例。
  2. 事务上下文中调用缓存的脚本会增加事务因脚本缺失而失败的可能性。更容易失败使得使用缓存脚本作为工作流的构建块吸引力较低。
  3. SHA1 摘要对于人类来说不可读,使得系统调试变得困难(例如在 MONITOR 会话中)。
  4. 如果使用不当,EVAL 会助长一种反模式,即客户端应用程序渲染逐字脚本,而不是负责任地使用 KEYSARGV Lua API
  5. 由于它们是临时性的,一个脚本不能调用另一个脚本。这使得脚本之间共享和重用代码几乎不可能,除非进行客户端预处理。

为了解决这些需求,同时避免对已建立且广受欢迎的临时脚本引入破坏性更改,函数在 7.0 版本中被引入。

什么是 Valkey 函数?

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

临时脚本被认为是应用程序领域的一部分,而函数则通过用户提供的逻辑扩展了数据库服务器本身。它们可以在启动时加载,并由各种应用程序和客户端重复使用。函数也持久化到 AOF 文件并从主节点复制到副本节点,因此它们与数据本身一样持久。当 Valkey 用作临时缓存时,需要额外的机制(如下所述)来使函数更持久。

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

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

函数是用 Lua 5.1 编写的。Valkey 函数可以使用 Lua 提供给临时脚本的所有可用功能,唯一的例外是 Valkey Lua 脚本调试器

加载库和函数

让我们通过一些具体示例和 Lua 代码片段来探索 Valkey 函数。

此时,如果您不熟悉 Lua,特别是 Valkey 中的 Lua,您可能会受益于查阅 Eval 脚本简介Lua API 页面中的一些示例,以更好地掌握该语言。

每个 Valkey 函数都属于一个库。使用 FUNCTION LOAD 命令将库加载到数据库中。库的源代码必须以 Shebang 行开头,该行提供有关库的元数据,例如语言(始终为 "lua")和库名称。Shebang 格式为:

#!lua name=<library name>

让我们尝试加载一个空库

127.0.0.1:6379> FUNCTION LOAD "#!lua name=mylib\n"
(error) ERR No functions registered

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

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

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

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

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

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

127.0.0.1:6379> FUNCTION LOAD "#!lua name=mylib\nserver.register_function('knockknock', function() return 'Who\\'s there?' end)"
mylib
127.0.0.1:6379> FCALL knockknock 0
"Who's there?"

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

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

我们将立即解释键名和额外参数如何可用于函数。由于这个简单的示例不涉及键,我们暂时使用 0。

输入键和常规参数

在我们进入下一个示例之前,理解 Valkey 对键名参数和非键名参数的区分至关重要。

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

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

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

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

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

Valkey 函数的 Lua API 使这些输入可作为函数回调的第一个和第二个参数。回调的第一个参数是一个 Lua 表,其中填充了函数的所有键名输入。类似地,回调的第二个参数包含所有常规参数。

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

#!lua name=mylib

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

server.register_function('my_hset', my_hset)

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

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

我们已在调用 FUNCTION LOAD 时添加了 REPLACE 修饰符,以告诉 Valkey 我们要覆盖现有的库定义。否则,Valkey 将会报错,抱怨库已存在。

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

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

在这种情况下,我们调用 FCALL 时,键名参数的数量为 1。这意味着函数的第一个输入参数是键名(因此包含在回调的 keys 表中)。在此第一个参数之后,所有后续输入参数都被视为常规参数,并构成传递给回调的第二个参数的 args 表。

扩展库

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

我们将向库中添加两个新函数以实现这些目标:

  1. my_hgetall Valkey 函数将从给定的哈希键名返回所有字段及其对应的值,不包括元数据(即 _last_modified_ 字段)。
  2. my_hlastmodified Valkey 函数将返回给定哈希键名的修改时间戳。

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

#!lua name=mylib

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

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

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

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

虽然上述所有内容都应该很简单,但请注意 my_hgetall 还调用了 server.setresp(3)。这意味着该函数在调用 server.call() 后期望 RESP3 回复,与默认的 RESP2 协议不同,RESP3 将回复作为映射(关联数组)返回。这样做允许函数从回复中删除(或设置为 nil,如 Lua 表的情况)特定字段,在我们的例子中是 _last_modified_ 字段。

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

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

加载后,您可以使用 FCALL 调用库的函数:

127.0.0.1:6379> FCALL my_hgetall 1 myhash
1) "myfield"
2) "some value"
3) "another_field"
4) "another value"
127.0.0.1:6379> FCALL my_hlastmodified 1 myhash
"1640772721"

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

127.0.0.1:6379> 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
    server.log(server.LOG_WARNING, error);
    return server.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 = server.call('TIME')[1]
  return server.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

  server.setresp(3)
  local hash = keys[1]
  local res = server.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 server.call('HGET', keys[1], '_last_modified_')
end

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

在 Valkey 中用上述代码替换库后,您可以立即尝试新的错误处理机制:

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

您的 Valkey 日志文件应包含类似于以下内容的行:

...
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

集群中的函数

如上所述,Valkey 自动处理已加载函数到副本的传播。在集群中,有必要将函数加载到所有主节点。

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

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

函数和临时 Valkey 实例

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

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

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

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

函数标志

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

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

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

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

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

Valkey 返回此错误,因为函数理论上可以对数据库执行读写操作。作为一种保护措施,Valkey 默认假定函数同时执行读写操作,因此它会阻止其执行。服务器将在以下情况下回复此错误:

  1. 使用 FCALL 对只读副本执行函数。
  2. 使用 FCALL_RO 执行函数。
  3. 检测到磁盘错误(Valkey 无法持久化,因此拒绝写入)。

在这些情况下,您可以将 no-writes 标志添加到函数的注册中,禁用保护措施并允许它们运行。要使用标志注册函数,请使用 server.register_function命名参数变体。

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

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

一旦我们替换了库,Valkey 允许在只读副本上使用 FCALL_RO 运行 my_hgetallmy_hlastmodified

127.0.0.1:6379> FCALL_RO my_hgetall 1 myhash
1) "myfield"
2) "some value"
3) "another_field"
4) "another value"
127.0.0.1:6379> FCALL_RO my_hlastmodified 1 myhash
"1640772721"

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