原生类型模块 API

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

Redis 模块可以通过调用 Redis 命令来访问 Redis 内置数据结构,也可以通过直接操作数据结构来访问,这些操作都是高级别的。

通过使用这些功能来构建基于现有 Redis 数据结构的新抽象,或者通过使用字符串 DMA 将模块数据结构编码为 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 字节类型标识字符串、列表、集合等等。在模块数据的情况下,它被设置为 module data 的特殊值,但当然这还不够,我们需要将特定值与能够加载和处理它的特定模块类型关联起来的信息。

因此,当我们保存关于模块的类型特定值时,会在其前面加上一个 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 Modules 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()

RATE THIS PAGE
Back to top ↑