在Redis的官方文档里,字符串、哈希、列表、集合和有序集合被列为五种基础数据结构。它们的设计足够通用,但很多业务模型天然不适合映射到这五种类型上。比如你需要存储一个带有过期时间的位置信息,同时要支持按距离排序;或者你想要一个可以原子性增减的浮点计数器,但Redis的INCRBY只支持整数。遇到这种情况,多数人会选择在应用层拼接数据结构,比如用Hash模拟队列、用Sorted Set实现滑动窗口,代价是命令次数增多、原子性变差。Redis 4.0引入的模块系统给出了另一个答案:你可以用C语言写一个动态链接库,在Redis启动时加载,从而注册全新的数据类型和对应命令。

模块系统的设计核心是RedisModuleType结构体。这个结构体里保存了类型名称、编码版本、内存释放函数指针、RDB保存与加载函数指针等十多个回调。当客户端向一个新类型的键写入数据时,Redis会调用模块定义的mem_alloc来分配内存,调用rdb_save把值序列化到磁盘。这种设计和Redis内部原生类型完全平行,原生字符串、哈希也是通过类似的函数指针表实现的。模块开发者的工作就是填满这个回调表,然后通过RedisModule_CreateDataType把类型注册到全局字典中。注册之后,新类型就能像原生类型一样参与过期、持久化、复制和集群键迁移。
从零编写一个支持自定义类型的模块
拿一个最简单的场景举例:实现一个timevalue类型,值包含一个Unix时间戳和一个双精度浮点数。客户端可以执行TSET key timestamp value写入,执行TGET key读出一个JSON字符串。先创建C源文件,包含redismodule.h头文件。这个头文件由Redis官方提供,在编译时链接-lredis并不存在,因为模块不直接链接Redis二进制,而是在运行时由Redis进程动态加载并注入函数表。所以编写模块时只需要声明正确的函数签名,不需要链接任何额外的静态库。加载模块的命令是loadmodule /path/to/module.so,可以放在redis.conf里,也可以通过MODULE LOAD在线执行。
模块的入口函数必须命名为RedisModule_OnLoad,返回值为REDISMODULE_OK或REDISMODULE_ERR。在这个函数里,先调用RedisModule_Init初始化模块上下文,然后定义一个名为timevalue的类型。类型回调表中,需要实现rdb_save和rdb_load,否则一旦开启RDB持久化,带有该类型键的数据库会拒绝快照保存。内存释放函数free同样不可省略,不然每次覆盖或删除键都会造成内存泄漏。下面的代码演示了如何注册类型并创建一个写入命令:
#include "redismodule.h"
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef struct TimeValue {
long long timestamp;
double value;
} TimeValue;
void *TimeValueRdbLoad(RedisModuleIO *io, int encver) {
TimeValue *tv = RedisModule_Alloc(sizeof(TimeValue));
tv->timestamp = RedisModule_LoadSigned(io);
tv->value = RedisModule_LoadDouble(io);
return tv;
}
void TimeValueRdbSave(RedisModuleIO *io, void *ptr) {
TimeValue *tv = ptr;
RedisModule_SaveSigned(io, tv->timestamp);
RedisModule_SaveDouble(io, tv->value);
}
void TimeValueFree(void *value) {
RedisModule_Free(value);
}
int TSetCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
if (argc != 4) return RedisModule_WrongArity(ctx);
RedisModuleKey *key = RedisModule_OpenKey(ctx, argv[1], REDISMODULE_WRITE);
TimeValue *tv = RedisModule_Alloc(sizeof(TimeValue));
if (RedisModule_StringToLongLong(argv[2], &tv->timestamp) != REDISMODULE_OK ||
RedisModule_StringToDouble(argv[3], &tv->value) != REDISMODULE_OK) {
RedisModule_Free(tv);
RedisModule_CloseKey(key);
return RedisModule_ReplyWithError(ctx, "ERR invalid number");
}
RedisModule_ModuleTypeSetValue(key, timevalue_type, tv);
RedisModule_CloseKey(key);
RedisModule_ReplicateVerbatim(ctx);
return RedisModule_ReplyWithSimpleString(ctx, "OK");
}
int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
if (RedisModule_Init(ctx, "timevalue", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR)
return REDISMODULE_ERR;
RedisModuleTypeMethods tm = {
.version = REDISMODULE_TYPE_METHOD_VERSION,
.rdb_load = TimeValueRdbLoad,
.rdb_save = TimeValueRdbSave,
.aof_rewrite = NULL,
.mem_usage = NULL,
.free = TimeValueFree,
.digest = NULL
};
timevalue_type = RedisModule_CreateDataType(ctx, "timevalue", 0, &tm);
if (timevalue_type == NULL) return REDISMODULE_ERR;
if (RedisModule_CreateCommand(ctx, "tset", TSetCommand, "write deny-oom", 1, 1, 1) == REDISMODULE_ERR)
return REDISMODULE_ERR;
return REDISMODULE_OK;
}
上述代码中,RedisModule_ModuleTypeSetValue负责把自定义结构体指针与键关联,Redis只存储这个指针,不关心结构体内部内容。这带来一个好处:你可以用malloc管理任意复杂的内存布局,但必须通过RedisModule_Alloc分配,因为Redis需要跟踪模块分配的内存,执行maxmemory淘汰策略时才能正确计算。释放时也必须调用RedisModule_Free,否则可能破坏内存记账信息。
模块命令与原生命令的差异和陷阱
注册命令时可以指定多种标志,比如write表示会修改数据,deny-oom表示内存不足时拒绝执行而不是牺牲别的键,fast表示命令在O(1)时间内完成,Redis集群会据此决定是否允许跨槽转发。模块命令的执行上下文和原生命令完全一样,都在主线程中运行,因此任何耗时的计算都会阻塞整个Redis实例。如果你的自定义类型包含排序、搜索或大数组操作,务必把这些逻辑拆分为轻量的请求处理,将重活交给后台线程。Redis模块API提供RedisModule_BlockClient和RedisModule_UnblockClient来实现异步响应,但需要配合RedisModule_CreateThreadPool或自己管理的pthread。
另一个常见误区是模块版本与Redis版本的兼容性。从4.0到7.x,模块API的版本号一直在演进,老的API仍然可用,但会有性能损失,比如未定义mem_usage回调时,Redis会调用RedisModule_Alloc分配的内存默认按size_t字节估算,很可能与真实内存占用偏差很大。所以升级Redis后,必须重新编译模块,并用MODULE LIST查看每个模块报告的API版本。不匹配的版本不会导致启动失败,但可能触发内存超用告警或者RDB格式错误。
持久化和复制的细节也非常关键。自定义类型的rdb_save回调必须保证幂等,因为主从复制的全量同步阶段,从节点会重放RDB文件。如果保存函数带有随机性,比如依赖当前系统时间,主从数据就会不一致。AOF方面,默认情况下模块命令不会自动记录到AOF,除非在命令注册时声明getkeys-api并提供键提取函数,或者在写命令末尾显式调用RedisModule_ReplicateVerbatim。上面示例中的TSET就调用了这个函数,确保主节点执行后把完整命令传播给从节点和AOF。如果忘了调用,主从之间会出现数据丢失,而且仅在故障切换后才会暴露,排查成本极高。
何时使用自定义模块,何时应该绕行
模块带来的最大收益是原子性和一致性。比如你用两个键分别存储时间戳和数值,应用层要保证两者同时更新,就必须引入事务或Lua脚本。而自定义类型把所有字段封装到一个键值里,一次命令就能完成原子写入,代码也更简洁。但模块的学习曲线陡峭,需要用C语言处理内存管理、崩溃恢复和并发边界。很多团队高估了自己维护C代码的能力,模块一旦上线,就需要跟随Redis大版本升级持续编译验证,否则会出现奇奇怪怪的崩溃。相比之下,Redis 5.0引入的Streams、Redis 6.2引入的HyperLogLog扩展,已经覆盖了不少原本需要模块实现的场景。
如果你的数据类型只是组合多个现有结构,优先考虑用Hash配合多个字段、或者用Sorted Set的member做索引。如果一定要做全新建模,可以先用Node.js或Python通过Redis协议编写一个轻量级的代理层,在代理内维护扩展结构,再映射成Redis原生命令。这种方式牺牲了一定的原子性,但避免了模块C代码的维护负担。只有当你对响应时间要求在微秒级、需要严格保证单个命令的原子性,并且团队里有熟练的C工程师时,才值得投入开发Redis模块。
最后提醒一点:模块是Redis内核的一部分,加载后无法热卸载,任何内存越界都会拖垮整个实例。开发阶段务必在独立的Redis实例中测试,并开启sanitize编译选项检查内存泄漏。生产环境部署模块前,用redis-benchmark和valgrind压测至少一周,观察内存曲线和RDB文件的稳定性。自定义数据类型扩展是Redis高级能力中最有力也最危险的工具,谨慎使用才能发挥它的价值。