Redis 函数

使用 Redis 7 及更高版本进行脚本编写

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 会导致一种反模式,在这种模式下,客户端应用程序会渲染逐字脚本,而不是负责任地使用 KEYSARGV Lua API
  5. 由于脚本是临时的,因此脚本无法调用其他脚本。这使得在脚本之间共享和重用代码变得几乎不可能,除非进行客户端预处理(参见第一点)。

为了解决这些需求,同时避免对已经建立且广受欢迎的临时脚本进行重大更改,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 中),您可能需要回顾一下 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 LISTFUNCTION DELETE

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

我们将立即解释键名称和附加参数如何对函数可用。由于此简单示例不涉及键,因此我们现在只使用 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"

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

扩展库

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

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

  1. my_hgetall Redis 函数将返回给定哈希键名称的所有字段及其各自的值,不包括元数据(即 _last_modified_ 字段)。
  2. 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 协议不同,该协议提供字典(关联数组)回复。这样做允许函数从回复中删除(或设置为 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 假设函数执行了这两项操作,因此阻止了它的执行。服务器将在以下情况下返回此错误:

  1. 使用 FCALL 在只读副本上执行函数。
  2. 使用 FCALL_RO 执行函数。
  3. 检测到磁盘错误(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_hgetallmy_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"

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

RATE THIS PAGE
Back to top ↑