原生类型的模块 API

如何在 Redis 模块中使用原生类型

Redis 模块可以通过调用 Redis 命令在高级别访问 Redis 内置数据结构,也可以通过直接操作数据结构在低级别访问 Redis 内置数据结构,从而构建新的抽象。

通过使用这些功能在现有的 Redis 数据结构之上构建新的抽象,或使用字符串 DMA 将模块数据结构编码到 Redis 字符串中,可以创建感觉上像导出新数据类型的模块。但是,对于更复杂的问题,这还不够,需要在模块中实现新的数据结构。

我们将 Redis 模块实现感觉上像原生 Redis 数据结构的新数据结构的能力称为原生类型支持。本文档描述了 Redis 模块系统导出的 API,以便创建新的数据结构并处理 RDB 文件中的序列化、AOF 中的重写过程、通过 TYPE 命令进行类型报告,等等。

原生类型的概述

导出原生类型的模块由以下主要部分组成

  • 某种新数据结构及其在新数据结构上运行的命令的实现。
  • 处理以下内容的一组回调:RDB 保存、RDB 加载、AOF 重写、释放与键关联的值、计算要与 DEBUG DIGEST 命令一起使用的值摘要(哈希)。
  • 一个 9 个字符的名称,该名称对于每个模块原生数据类型都是唯一的。
  • 编码版本,用于将模块特定数据版本持久化到 RDB 文件中,以便模块能够从 RDB 文件加载旧表示形式。

虽然处理 RDB 加载、保存和 AOF 重写乍一看似乎很复杂,但模块 API 提供了非常高级别的函数来处理所有这些,而无需用户处理读/写错误,因此从实际意义上讲,为 Redis 编写新的数据结构是一项简单的任务。

在 Redis 分发中的 /modules/hellotype.c 文件中提供了非常容易理解但完整的本机类型实现示例。鼓励读者通过查看此示例实现来阅读文档,了解如何在实践中应用这些内容。

注册新数据类型

为了将新的本机类型注册到 Redis 核心,模块需要声明一个全局变量,该变量将保存对数据类型的引用。注册数据类型的 API 将返回一个数据类型引用,该引用将存储在全局变量中。

static RedisModuleType *MyType;
#define MYTYPE_ENCODING_VERSION 0

int RedisModule_OnLoad(RedisModuleCtx *ctx) {
RedisModuleTypeMethods tm = {
    .version = REDISMODULE_TYPE_METHOD_VERSION,
    .rdb_load = MyTypeRDBLoad,
    .rdb_save = MyTypeRDBSave,
    .aof_rewrite = MyTypeAOFRewrite,
    .free = MyTypeFree
};

    MyType = RedisModule_CreateDataType(ctx, "MyType-AZ",
	MYTYPE_ENCODING_VERSION, &tm);
    if (MyType == NULL) return REDISMODULE_ERR;
}

如上例所示,只需一个 API 调用即可注册新类型。但是,将一些函数指针作为参数传递。某些是可选的,而某些是必需的。必须传递上述方法集,而 .digest.mem_usage 是可选的,并且目前实际上不受模块内部支持,因此现在可以忽略它们。

ctx 参数是我们 OnLoad 函数中接收的上下文。类型 name 是一个 9 个字符的名称,其字符集包括 A-Za-z0-9,加上下划线 _ 和减号 - 字符。

请注意,此名称对于 Redis 生态系统中的每个数据类型必须是唯一的,因此请发挥创造力,如果合理,同时使用小写和大写字母,并尝试使用将类型名称与模块作者名称混合的约定,以创建 9 个字符的唯一名称。

注意:名称恰好为 9 个字符非常重要,否则类型的注册将失败。阅读更多内容以了解原因。

例如,如果我正在构建一个b 树数据结构,而我的名字是antirez,我将把我的类型称为btree1-az。该名称转换为 64 位整数后,在保存类型时存储在 RDB 文件中,并在加载 RDB 数据时使用,以解析哪个模块可以加载数据。如果 Redis 找不到匹配的模块,则该整数将转换回名称,以便为用户提供一些线索,说明缺少哪个模块才能加载数据。

当使用保存了已注册类型的键调用 TYPE 命令时,类型名称也用作答复。

encver 参数是模块用于在 RDB 文件中存储数据的编码版本。例如,我可以从编码版本 0 开始,但稍后当我发布模块的版本 2.0 时,我可以将编码切换为更好的版本。新模块将使用编码版本 1 注册,因此当它保存新的 RDB 文件时,新版本将存储在磁盘上。但是,在加载 RDB 文件时,即使找到了不同编码版本的数据(并且编码版本作为参数传递给 rdb_load),也会调用模块 rdb_load 方法,以便模块仍然可以加载旧的 RDB 文件。

最后一个参数是用于将类型方法传递给注册函数的结构:rdb_loadrdb_saveaof_rewritedigestfreemem_usage 都是具有以下原型和用途的回调

typedef void *(*RedisModuleTypeLoadFunc)(RedisModuleIO *rdb, int encver);
typedef void (*RedisModuleTypeSaveFunc)(RedisModuleIO *rdb, void *value);
typedef void (*RedisModuleTypeRewriteFunc)(RedisModuleIO *aof, RedisModuleString *key, void *value);
typedef size_t (*RedisModuleTypeMemUsageFunc)(void *value);
typedef void (*RedisModuleTypeDigestFunc)(RedisModuleDigest *digest, void *value);
typedef void (*RedisModuleTypeFreeFunc)(void *value);
  • rdb_load 在从 RDB 文件加载数据时调用。它加载的数据格式与 rdb_save 产生的格式相同。
  • rdb_save 在将数据保存到 RDB 文件时调用。
  • aof_rewrite 在重写 AOF 时调用,模块需要告诉 Redis 重新创建给定键的内容的命令序列。
  • digest 在执行 DEBUG DIGEST 并且找到持有此模块类型的键时调用。目前尚未实现此功能,因此可以将函数留空。
  • mem_usageMEMORY 命令询问特定键消耗的总内存时调用,用于获取模块值使用的字节数。
  • free 在通过 DEL 或任何其他方式删除具有模块原生类型的键时调用,以便让模块回收与此类值关联的内存。

好的,但是为什么模块类型需要 9 个字符的名称?

哦,我理解你需要理解这一点,所以这里有一个非常具体的解释。

当 Redis 持久化到 RDB 文件时,模块特定的数据类型也需要持久化。现在,RDB 文件是如下所示的键值对序列

[1 byte type] [key] [a type specific value]

1 字节类型标识字符串、列表、集合等。对于模块数据,它被设置为模块数据的特殊值,但当然这还不够,我们需要的信息是将特定值与能够加载和处理它的特定模块类型相关联。

因此,当我们保存有关模块的特定类型值时,我们用一个 64 位整数作为前缀。64 位足以存储查找可以处理该特定类型的模块所需的信息,但它足够短,我们可以在 RDB 中存储每个模块值,而不会使最终的 RDB 文件太大。同时,用 64 位签名作为前缀的这种解决方案不需要在 RDB 头部中定义模块特定类型的列表等奇怪的操作。一切都非常简单。

那么,为了可靠地标识给定模块,你可以在 64 位中存储什么?如果你构建一个包含 64 个符号的字符集,你可以轻松地存储 9 个 6 位字符,然后剩下 10 位,用于存储类型的编码版本,以便相同的类型可以在未来演变并为 RDB 文件提供不同且更有效或更新的序列化格式。

因此,存储在每个模块值之前的 64 位前缀如下

6|6|6|6|6|6|6|6|6|10

前 9 个元素是 6 位字符,最后 10 位是编码版本。

当 RDB 文件被重新加载时,它读取 64 位值,屏蔽最后 10 位,并在模块类型缓存中搜索匹配的模块。找到匹配项后,将使用 10 位编码版本作为参数调用加载 RDB 文件值的方法,以便模块知道要加载的数据布局的版本(如果它可以支持多个版本)。

现在,所有这一切的有趣之处在于,如果无法解析模块类型,因为没有加载的模块具有此签名,我们可以将 64 位值转换回 9 个字符的名称,并向用户打印一条错误消息,其中包括模块类型名称!这样他或她就能立即意识到问题所在。

设置和获取键

RedisModule_OnLoad()函数中注册了我们的新数据类型后,我们还需要能够设置 Redis 键,其值为我们的本机类型。

这通常发生在将数据写入键的命令的上下文中。本机类型 API 允许将键设置为模块本机数据类型并获取键,并测试给定键是否已与特定数据类型的值关联。

API 使用常规模块 RedisModule_OpenKey() 低级键访问接口来处理此问题。这是将本机类型私有数据结构设置为 Redis 键的示例

RedisModuleKey *key = RedisModule_OpenKey(ctx,keyname,REDISMODULE_WRITE);
struct some_private_struct *data = createMyDataStructure();
RedisModule_ModuleTypeSetValue(key,MyType,data);

函数 RedisModule_ModuleTypeSetValue() 用于打开用于写入的键句柄,并获取三个参数:键句柄、在类型注册期间获得的本机类型的引用,以及最后包含实现模块本机类型的私有数据的 void* 指针。

请注意,Redis 根本不知道您的数据包含什么。它只会调用您在方法注册期间提供的回调,以便对类型执行操作。

同样,我们可以使用此函数从键中检索私有数据

struct some_private_struct *data;
data = RedisModule_ModuleTypeGetValue(key);

我们还可以测试键是否具有我们的本机类型作为值

if (RedisModule_ModuleTypeGetType(key) == MyType) {
    /* ... do something ... */
}

但是,为了使调用执行正确操作,我们需要检查键是否为空,是否包含正确类型的键值,等等。因此,实现写入本机类型的命令的惯用代码如下

RedisModuleKey *key = RedisModule_OpenKey(ctx,argv[1],
    REDISMODULE_READ|REDISMODULE_WRITE);
int type = RedisModule_KeyType(key);
if (type != REDISMODULE_KEYTYPE_EMPTY &&
    RedisModule_ModuleTypeGetType(key) != MyType)
{
    return RedisModule_ReplyWithError(ctx,REDISMODULE_ERRORMSG_WRONGTYPE);
}

然后,如果我们成功验证键不是错误类型,并且我们要写入键,我们通常希望在键为空时创建一个新的数据结构,或者在已经存在一个数据结构时检索与键关联的值的引用

/* Create an empty value object if the key is currently empty. */
struct some_private_struct *data;
if (type == REDISMODULE_KEYTYPE_EMPTY) {
    data = createMyDataStructure();
    RedisModule_ModuleTypeSetValue(key,MyTyke,data);
} else {
    data = RedisModule_ModuleTypeGetValue(key);
}
/* Do something with 'data'... */

免费方法

如前所述,当 Redis 需要释放持有本机类型值的键时,它需要模块的帮助来释放内存。这就是我们在类型注册期间传递 free 回调的原因

typedef void (*RedisModuleTypeFreeFunc)(void *value);

假设我们的数据结构由单个分配组成,那么免费方法的一个简单实现可以如下所示

void MyTypeFreeCallback(void *value) {
    RedisModule_Free(value);
}

然而,更实际的方法将调用执行更复杂内存回收的函数,方法是将 void 指针转换为某个结构并释放组成该值的全部资源。

RDB 加载和保存方法

RDB 保存和加载回调需要在磁盘上创建(并加载回)一种数据类型的表示形式。Redis 提供了一个高级 API,可以自动将以下类型存储在 RDB 文件中

  • 无符号 64 位整数。
  • 有符号 64 位整数。
  • 双精度浮点数。
  • 字符串。

由模块使用上述基本类型找到一个可行的表示形式。但是请注意,虽然整数和双精度浮点数值以与架构和字节序无关的方式存储和加载,但如果您使用原始字符串保存 API,例如将结构保存到磁盘上,则必须自己注意这些细节。

以下是执行 RDB 保存和加载的函数列表

void RedisModule_SaveUnsigned(RedisModuleIO *io, uint64_t value);
uint64_t RedisModule_LoadUnsigned(RedisModuleIO *io);
void RedisModule_SaveSigned(RedisModuleIO *io, int64_t value);
int64_t RedisModule_LoadSigned(RedisModuleIO *io);
void RedisModule_SaveString(RedisModuleIO *io, RedisModuleString *s);
void RedisModule_SaveStringBuffer(RedisModuleIO *io, const char *str, size_t len);
RedisModuleString *RedisModule_LoadString(RedisModuleIO *io);
char *RedisModule_LoadStringBuffer(RedisModuleIO *io, size_t *lenptr);
void RedisModule_SaveDouble(RedisModuleIO *io, double value);
double RedisModule_LoadDouble(RedisModuleIO *io);

这些函数不要求模块进行任何错误检查,模块始终可以假定调用成功。

例如,假设我有一个实现双精度浮点数数组的本机类型,其结构如下

struct double_array {
    size_t count;
    double *values;
};

我的 rdb_save 方法可能如下所示

void DoubleArrayRDBSave(RedisModuleIO *io, void *ptr) {
    struct dobule_array *da = ptr;
    RedisModule_SaveUnsigned(io,da->count);
    for (size_t j = 0; j < da->count; j++)
        RedisModule_SaveDouble(io,da->values[j]);
}

我们所做的是存储元素数,然后是每个双精度浮点数值。因此,当稍后我们必须在 rdb_load 方法中加载结构时,我们将执行类似以下操作

void *DoubleArrayRDBLoad(RedisModuleIO *io, int encver) {
    if (encver != DOUBLE_ARRAY_ENC_VER) {
        /* We should actually log an error here, or try to implement
           the ability to load older versions of our data structure. */
        return NULL;
    }

    struct double_array *da;
    da = RedisModule_Alloc(sizeof(*da));
    da->count = RedisModule_LoadUnsigned(io);
    da->values = RedisModule_Alloc(da->count * sizeof(double));
    for (size_t j = 0; j < da->count; j++)
        da->values[j] = RedisModule_LoadDouble(io);
    return da;
}

加载回调只是从我们存储在 RDB 文件中的数据中重建数据结构。

请注意,虽然在从磁盘写入和读取的 API 上没有错误处理,但加载回调仍然可以在读取的内容不正确的情况下返回 NULL。在这种情况下,Redis 将会崩溃。

AOF 重写

void RedisModule_EmitAOF(RedisModuleIO *io, const char *cmdname, const char *fmt, ...);

处理多种编码

WORK IN PROGRESS

分配内存

模块数据类型应尝试使用 RedisModule_Alloc() 函数系列来分配、重新分配和释放用于实现本机数据结构的堆内存(有关详细信息,请参阅其他 Redis 模块文档)。

这不仅对 Redis 来说很有用,以便能够计算模块使用的内存,而且还有更多优点

  • Redis 使用 jemalloc 分配器,它通常可以防止可能由使用 libc 分配器引起的碎片问题。
  • 从 RDB 文件加载字符串时,本机类型 API 能够直接使用 RedisModule_Alloc() 分配的字符串返回字符串,以便模块可以直接将此内存链接到数据结构表示形式中,避免无用的数据副本。

即使您使用外部库来实现数据结构,模块 API 提供的分配函数也与 malloc()realloc()free()strdup() 完全兼容,因此转换库以使用这些函数应该很简单。

如果您有一个使用 libc malloc() 的外部库,并且您想避免手动用 Redis 模块 API 调用替换所有调用,一种方法是使用简单的宏来用 Redis API 调用替换 libc 调用。类似以下内容可能会起作用

#define malloc RedisModule_Alloc
#define realloc RedisModule_Realloc
#define free RedisModule_Free
#define strdup RedisModule_Strdup

但是请记住,将 libc 调用与 Redis API 调用混合会导致问题和崩溃,因此,如果您使用宏替换调用,则需要确保所有调用都已正确替换,并且替换调用后的代码永远不会尝试使用 libc malloc() 分配的指针调用 RedisModule_Free()

为本页评分