*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-tree 数据结构,我的名字是 antirez,我将把我的类型命名为 btree1-az。当保存类型时,该名称被转换为 64 位整数并存储在 RDB 文件中,并且在加载 RDB 数据时将用于解析哪个模块可以加载数据。如果 Redis 找不到匹配的模块,该整数将被转换回名称,以便向用户提供关于缺少哪个模块才能加载数据的一些线索。

类型名称也用作 TYPE 命令的回复,当使用持有注册类型的键调用时。

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

最后一个参数是一个结构体,用于将类型方法传递给注册函数:rdb_loadrdb_saveaof_rewritedigestfree 以及 mem_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 字节类型] [键] [类型特定的值]

1 字节类型标识字符串、列表、集合等。在模块数据的情况下,它被设置为 module data 的特殊值,但当然这还不够,我们需要将特定值与能够加载和处理它的特定模块类型链接起来所需的信息。

因此,当我们保存关于模块的 type specific value 时,我们用一个 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 位,并在模块类型缓存中搜索匹配的模块。找到匹配的模块后,将调用加载 RDB 文件值的方法,并将 10 位编码版本作为参数传递,以便模块知道要加载哪个版本的数据布局,如果它可以支持多个版本。

现在有趣的是,如果模块类型无法解析,因为没有加载的模块具有此签名,我们可以将 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) {
    /* ... 执行某些操作 ... */
}

然而,为了使调用做正确的事情,我们需要检查键是否为空,是否包含正确类型的值,等等。因此实现写入我们原生类型的命令的惯用代码大致如下:

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);
}

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

/* 如果键当前为空,则创建一个空的值对象。 */
struct some_private_struct *data;
if (type == REDISMODULE_KEYTYPE_EMPTY) {
    data = createMyDataStructure();
    RedisModule_ModuleTypeSetValue(key,MyType,data);
} else {
    data = RedisModule_ModuleTypeGetValue(key);
}
/* 对 '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 double_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) {
        /* 我们实际上应该在这里记录一个错误,或者尝试实现
           加载旧版本数据结构的能力。 */
        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 会直接 panic。

*AOF 重写

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

*处理多种编码

工作进行中

*内存分配

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

这不仅有助于 Redis 能够统计模块使用的内存,还有更多优势:

  • Redis 使用 jemalloc 分配器,这通常可以防止使用 libc 分配器可能导致的碎片问题。
  • 当从 RDB 文件加载字符串时,原生类型 API 能够返回使用 RedisModule_Alloc() 直接分配的字符串,因此模块可以直接将此内存链接到数据结构表示中,避免了对数据的无用复制。

即使你正在使用外部库来实现你的数据结构,模块 API 提供的分配函数也与 malloc()realloc()free()strdup() 完全兼容,因此转换库以使用这些函数应该是微不足道的。

如果你有一个使用 libc malloc() 的外部库,并且你想避免手动替换所有调用为 Redis 模块 API 调用,一种方法可以是使用简单的宏来替换 libc 调用为 Redis API 调用。类似这样的方法可能有效:

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

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