Redis GEO与自定义数据类型:从空间索引到模块扩展实战

发布时间:2026/10/22 19:44:41
Redis GEO与自定义数据类型:从空间索引到模块扩展实战
我第一次把 GEO 用在业务里是在一个“附近门店”需求上做的技术选型。当时团队里纠结的点很典型直接在关系型数据库里算经纬度距离还是用 Redis 的 GEO。我抱着试一试的态度把一批门店坐标灌进 GEO很快发现印象最深的不是它“能搜附近”而是它本质上还是把经纬度编码后塞进了 Sorted Set。等我后来动手写自定义数据类型时回头看才明白GEO 和 Modules 自定义类型代表了 Redis 两条完全不同的扩展路线。这篇就当一次完整复盘从 GEO 的底层原理、常用命令、踩坑记录一直讲到如何用 C API 做一个能被 RDB 持久化、能被 AOF 重放的自定义数据类型。1. GEO 的定位先用空间索引思维替代“全表算距离”1.1 最早让我放弃关系型计算方案的原因门店推荐的常规做法是把门店经纬度存进数据库表查询时传入用户坐标然后写一条类似sqrt(power(lat - ?, 2) power(lng - ?, 2))的 SQL再按距离排序。这个方案在几千条数据时勉强能跑一旦到几十万条问题就暴露得很明显每次查询都要对全表做一次运算索引帮不上忙复杂的球面距离函数还会让 CPU 飙升。我当时的替代方案是维护一个 Redis Sorted Set把“距离”直接算好放进 score然后按 score 范围取最近的一批门店。这个思路没错但手动计算两个经纬度之间的距离、处理跨 180 度经线、把距离映射成可排序的 score坑非常多。Redis 的 GEO 就是把这一整套“经纬度编码、距离计算、范围检索”收口了对外只暴露几个语义明确的命令。1.2 索引前置而不是把计算丢给查询理解 GEO 最好的角度是“索引前置”。你在 GEOADD 写入一条数据时Redis 已经完成了坐标到可排序分数的编码。查询半径范围时它做的是基于 score 的范围扫描而不是遍历所有坐标去算两点距离。这也解释了为什么 GEO 适合高频“附近查询”写入时多花一点编码成本查询时直接享受有序结构带来的范围扫描能力。如果你的业务场景是“写入少、位置稳定、查询极多”GEO 的优势会比关系型数据库的几何索引更直接。注意GEO 并不是把完整的经纬度原样存下来而是先编码再存储。理解这一点很重要否则你会在 GEOPOS 返回的坐标和写入坐标不一致时误以为数据丢失。2. 读懂 GEO 的底层实现经纬度如何变成 score2.1 经纬度编码成 52 位整数Redis 的 GEO 底层用的确实是 Sorted Set但 member 是位置名称score 是由经纬度编码出来的整数。这个编码过程可以粗略理解为经度范围 [-180, 180] 用 26 个二进制位切分纬度范围 [-85.05112878, 85.05112878] 也用 26 个二进制位切分然后把经纬度两个二进制序列交错拼接成 52 位。为什么要拼成 52 位而不是 64 位因为 double 类型的尾数正好是 52 位整数部分最多能精确表示到 2 的 53 次方左右。Redis 把这个 52 位整数直接作为 Sorted Set 的 score既能保证可排序又能保证分数在浮点数范围内精确表示。这种交错编码也带来一个天然特性两个地理位置在编码后如果它们空间上相邻score 也大概率很接近。Sorted Set 的底层跳表天然支持按分数范围扫描所以半径查询可以走“分数范围 距离过滤”两步性能远好于全量计算。2.2 52 位能精确到什么程度很多人会问26 位经度、26 位纬度精度够吗我们可以粗略算一下。经度范围是 360 度除以 2 的 26 次方每个格子大约 5.36e-6 度对应赤道附近约 0.6 米。纬度范围大约是 170 度除以 2 的 26 次方每个格子约 2.53e-6 度对应约 0.28 米。所以 GEO 在绝大多数业务场景下精度是足够的门店、外卖、打车、社交“附近的人”鲜有需要厘米级定位的。如果你的业务需要厘米级坐标那就不适合用 GEO因为原始坐标在编码再解码的过程中会有亚米级误差。2.3 为什么 GEOPOS 返回的坐标和写入时不一样这是新人最容易慌的点。GEOADD 写入的是原始经纬度但 Redis 只把编码后的 score 存在 Sorted Set 里并没有额外保存原始坐标。GEOPOS 返回坐标时是把 score 解码回经纬度。因为是量化后的格子解码出来的坐标可能和原坐标差几十厘米甚至不到一米。这不是 bug而是编码方案的必然结果。我一般在对外接口里不会拿 GEOPOS 的结果去覆盖原始业务坐标它更适合用来调试和确认数据是否写入正确。3. GEO 命令实操写入、距离、检索一套走完3.1 GEOADD 写入数据时真正做了什么GEOADD 的语法是先 key、再经度、再纬度、最后是 member顺序是经纬度而不是纬度经度这一点我会在后面单独讲坑。redis-cli GEOADD shops 100.123456 30.001234 shop:001 GEOADD shops 100.223456 30.011234 shop:002 GEOADD shops 100.323456 30.021234 shop:003返回 1 表示新增成功。如果同一个 member 再写入一次坐标Redis 会直接更新它的 score相当于覆盖位置。这个行为和 ZADD 一致因为 GEO 底层本来就是 Sorted Set。3.2 GEOPOS、GEODIST、GEOHASH 三个辅助命令GEOPOS 用于批量获取 member 对应的解码坐标redis-cli GEOPOS shops shop:001 shop:002GEODIST 用于计算两个 member 之间的直线距离单位可以是 m、km、ft、miredis-cli GEODIST shops shop:001 shop:002 kmGEOHASH 会返回一个标准的 geohash 字符串这个字符串可以被其他 geohash 工具解析也可以用来做跨系统索引协作。平时用得不频繁但调试时很有用。我用这几个命令的习惯是写入后用 GEOPOS 回读一遍确认坐标没写反业务联调时用 GEODIST 验证查询结果里的距离是否符合预期。3.3 用 GEOSEARCH 做半径检索新项目建议直接使用 GEOSEARCH而不是老版本的 GEORADIUS / GEORADIUSBYMEMBER。GEOSEARCH 同时支持从坐标点和从已有 member 出发两种检索方式。redis-cli GEOSEARCH shops FROMLONLAT 100.150000 30.010000 BYRADIUS 15 km ASC WITHDIST WITHCOORD COUNT 10在这个例子里FROMLONLAT 指定了用户当前坐标BYRADIUS 指定 15 公里半径ASC 表示按距离从近到远排序WITHDIST 和 WITHCOORD 让结果带上距离和坐标COUNT 限制返回条数。返回结果会在每条记录里显示 member、距离和坐标。如果要从某个门店出发搜它周围的门店用 FROMMEMBER 更直观redis-cli GEOSEARCH shops FROMMEMBER shop:001 BYRADIUS 5 km ASC WITHDIST3.4 GEOSEARCHSTORE 可以做结果缓存如果你的查询是热门的 Top 请求不想每次实时计算排序可以用 GEOSEARCHSTORE 把结果保存到一个新的 key 里。这个新 key 本质上是一个 Sorted Setscore 是距离。redis-cli GEOSEARCHSTORE nearby:user:001 shops FROMLONLAT 100.150000 30.010000 BYRADIUS 15 km ASC COUNT 20结果 key 可以直接用 ZRANGE 拉出来也可以设置过期时间。适合“用户进入页面后短时间内重复刷新”的场景。注意这里是新生成 key需要你管理它的有效期避免过期数据堆积。4. 做 GEO 查询最容易踩的坑4.1 经纬度写反Redis 不会告诉你GEOADD 的合法经度范围是 [-180, 180]合法纬度范围约在 [-85, 85]。如果你不小心把纬度当经度写入而且这个数落在经度范围内Redis 会正常返回成功但解码后的坐标会跑到完全不相干的地方。我在项目里见过最典型的情况把采集到的lat,lng顺序直接透传进命令结果生成的地图上所有点都偏移了几百公里。排查方式很简单用 GEOPOS 回读三四条数据对比原始坐标如果差得很离谱第一反应就应该是定位参数顺序问题。提示团队内部可以约定统一的订单坐标参数名并在写入逻辑里强制命名为lng优先、lat次之从源头减少这个坑。4.2 极地纬度限制和球面距离误差Redis 的 GEO 纬度范围并不是完整的 [-90, 90]而是大约 [-85.05112878, 85.05112878]超出会返回 invalid latitude。这个限制来自 Web Mercator 投影相关约定。绝大多数业务不会碰到但如果你的产品覆盖北极圈、南极圈附近就要提前知道这个边界。距离计算方面Redis 使用球面距离算法在小范围内精度可靠。但如果你要求的是真实道路距离、步行距离、避开河流等语义就必须接入地理编码或路径规划服务GEO 给的是直线距离业务上要讲清楚。4.3 Sorted Set 的 member 语义容易造成覆盖GEO 的 member 是唯一的同一个 member 再次写入相当于修改坐标。如果你的业务里同一家店会因为“临时摊点、分店合并”出现同 key 覆盖务必在 member 上区分唯一 ID不要直接把中文店名当 member。同时 member 还承担着身份标识的作用尽量简短避免把大段业务 JSON 塞进去内存消耗会很高。4.4 单位与 COUNT 的隐性约定GEOSEARCH 的单位参数必须紧跟半径数字写成BYRADIUS 15 km不能漏写km漏掉会被当成非法参数。COUNT 只是“优先返回多少条”不等于“最多只计算多少条”它更像一个提示让你在结果很多时减少网络传输和排序开销。5. 自定义数据类型解决的是 GEO 解决不了的问题5.1 内置类型并不覆盖所有“索引需求”GEO 解决了空间点近邻查询但它只是一个特例。回到我写自定义数据类型的场景业务需要判断一个 ID 是否出现过并且希望这个判断不存储全部 ID。用 Redis 内置字符串加 Set 可以做到但内存开销很快失控用布隆过滤器可以做到可 Redis 官方内核里并没有直接内置一个“布隆过滤器 key 类型”。这种场景才是自定义数据类型的核心价值你不只是注册几条命令而是在 Redis 的键空间里拥有了一个全新的值类型Redis 会像对待 String、Hash、ZSet 一样帮你处理过期时间、RDB 持久化、AOF 重写、内存统计。5.2 Modules 扩展的是“数据生命周期”而不是“命令集合”早期扩展 Redis 的方式通常是加载一个模块里面塞很多命令。这些命令仍然只能操作内置类型本质上是一种脚本式插件。自定义数据类型更进一步你可以定义自己的内存结构、自己的序列化格式、自己的内存释放方式。这意味着一个操作命令可以拿到“你的类型”对应的指针直接修改内部结构而不是每次把数据从 Redis 类型里取出来再写回去。这种能力对一些高度定制化的数据结构非常重要比如布隆过滤器、HyperLogLog 变体、向量索引、多维空间索引。从数据结构服务器这个定位来看Modules API 才是真正把 Redis 变成了“可插拔数据结构平台”。6. 从零写一个自定义数据类型极简布隆过滤器6.1 核心结构和哈希函数下面我用一个极简布隆过滤器作为例子不追求工程完备重点是展示自定义数据类型的注册、读写、持久化流程。#include redismodule.h #include stdint.h #include stdlib.h #include string.h #define BF_ENCVER 1 #define BF_DEFAULT_SIZE (1024UL * 8) #define BF_DEFAULT_HASHES 3 typedef struct { uint8_t *bits; size_t size; uint32_t hashes; uint64_t count; } BloomFilter; static uint64_t fnv1a(const uint8_t *s, size_t len, uint64_t seed) { uint64_t h 1469598103934665603ULL ^ seed; for (size_t i 0; i len; i) { h ^ s[i]; h * 1099511628211ULL; } return h; } static BloomFilter *bf_new(size_t size, uint32_t hashes) { BloomFilter *bf RedisModule_Alloc(sizeof(BloomFilter)); bf-bits RedisModule_Alloc(size); memset(bf-bits, 0, size); bf-size size; bf-hashes hashes; bf-count 0; return bf; } static int bf_add(BloomFilter *bf, const uint8_t *item, size_t len) { int existed 1; for (uint32_t i 0; i bf-hashes; i) { uint64_t h fnv1a(item, len, 0x9e3779b97f4a7c15ULL i * 0xbf58476d1ce4e5b9ULL) % bf-size; if (!(bf-bits[h / 8] (1U (h % 8)))) { existed 0; bf-bits[h / 8] | 1U (h % 8); } } if (!existed) bf-count; return existed; } static int bf_check(BloomFilter *bf, const uint8_t *item, size_t len) { for (uint32_t i 0; i bf-hashes; i) { uint64_t h fnv1a(item, len, 0x9e3779b97f4a7c15ULL i * 0xbf58476d1ce4e5b9ULL) % bf-size; if (!(bf-bits[h / 8] (1U (h % 8)))) return 0; } return 1; }这里我刻意没有用哨兵值-1表示“已存在”而是返回 1 表示“元素之前已经存在”0 表示“这次添加是新增”。实际布隆过滤器里一次 add 操作通常会让多个 bit 变成 1所以判断“新增”要谨慎我这里做了简化。6.2 命令注册和回调绑定接下来是模块入口注册bf.create、bf.add、bf.exists三个命令并创建自定义数据类型bf。static RedisModuleType *bfType; static void *bf_create(RedisModuleCtx *ctx, RedisModuleString *key, int encver) { (void)ctx; (void)key; (void)encver; return bf_new(BF_DEFAULT_SIZE, BF_DEFAULT_HASHES); } static int bf_create_cmd(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (argc ! 4) return RedisModule_WrongArity(ctx); long long size, hashes; if (RedisModule_StringToLongLong(argv[2], size) ! REDISMODULE_OK || RedisModule_StringToLongLong(argv[3], hashes) ! REDISMODULE_OK || size 0 || hashes 0) { RedisModule_ReplyWithError(ctx, invalid size or hashes); return REDISMODULE_OK; } RedisModuleKey *key RedisModule_OpenKey(ctx, argv[1], REDISMODULE_READ | REDISMODULE_WRITE); if (RedisModule_KeyType(key) ! REDISMODULE_KEYTYPE_EMPTY RedisModule_ModuleTypeGetType(key) ! bfType) { RedisModule_ReplyWithError(ctx, wrong key type); return REDISMODULE_OK; } BloomFilter *bf bf_new((size_t)size, (uint32_t)hashes); RedisModule_ModuleTypeSetValue(key, bfType, bf); RedisModule_ReplyWithSimpleString(ctx, OK); return REDISMODULE_OK; } static int bf_add_cmd(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (argc ! 3) return RedisModule_WrongArity(ctx); RedisModuleKey *key RedisModule_OpenKey(ctx, argv[1], REDISMODULE_READ | REDISMODULE_WRITE); if (RedisModule_KeyType(key) REDISMODULE_KEYTYPE_EMPTY) { BloomFilter *bf bf_new(BF_DEFAULT_SIZE, BF_DEFAULT_HASHES); RedisModule_ModuleTypeSetValue(key, bfType, bf); } if (RedisModule_ModuleTypeGetType(key) ! bfType) { RedisModule_ReplyWithError(ctx, wrong key type); return REDISMODULE_OK; } BloomFilter *bf RedisModule_ModuleTypeGetValue(key); size_t len 0; const char *item RedisModule_StringPtrLen(argv[2], len); int existed bf_add(bf, (const uint8_t *)item, len); RedisModule_ReplyWithLongLong(ctx, existed); return REDISMODULE_OK; } static int bf_exists_cmd(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (argc ! 3) return RedisModule_WrongArity(ctx); RedisModuleKey *key RedisModule_OpenKey(ctx, argv[1], REDISMODULE_READ); if (RedisModule_KeyType(key) REDISMODULE_KEYTYPE_EMPTY) { RedisModule_ReplyWithLongLong(ctx, 0); return REDISMODULE_OK; } if (RedisModule_ModuleTypeGetType(key) ! bfType) { RedisModule_ReplyWithError(ctx, wrong key type); return REDISMODULE_OK; } BloomFilter *bf RedisModule_ModuleTypeGetValue(key); size_t len 0; const char *item RedisModule_StringPtrLen(argv[2], len); RedisModule_ReplyWithLongLong(ctx, bf_check(bf, (const uint8_t *)item, len)); return REDISMODULE_OK; } int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { RedisModule_Init(ctx, bf, 1, REDISMODULE_APIVER_1); RedisModuleTypeMethods methods { .version REDISMODULE_TYPE_METHOD_VERSION, .create bf_create, .rdb_load bf_rdb_load, .rdb_save bf_rdb_save, .free bf_free, .mem_usage bf_mem_usage, }; bfType RedisModule_CreateDataType(ctx, bf, BF_ENCVER, methods); if (bfType NULL) return REDISMODULE_ERR; if (RedisModule_CreateCommand(ctx, bf.create, bf_create_cmd, write, 1, 1, 1) REDISMODULE_ERR) return REDISMODULE_ERR; if (RedisModule_CreateCommand(ctx, bf.add, bf_add_cmd, write, 1, 1, 1) REDISMODULE_ERR) return REDISMODULE_ERR; if (RedisModule_CreateCommand(ctx, bf.exists, bf_exists_cmd, readonly, 1, 1, 1) REDISMODULE_ERR) return REDISMODULE_ERR; return REDISMODULE_OK; }代码里有个细节RedisModule_Init的第一个参数 “bf” 是模块名不是数据类型名RedisModule_CreateDataType里的 “bf” 才是真正的类型模块标识。两者可以不一样但建议保持一致降低心智负担。7. 让自定义类型“活下来”RDB、AOF 和内存管理7.1 RDB 落盘与加载回调自定义类型如果只支持内存读写重启就丢没有实际价值。Redis 对自定义类型的持久化要求你实现两个回调rdb_save 负责把自己的结构写成一串字节流rdb_load 负责从字节流恢复结构。static void bf_rdb_save(RedisModuleIO *io, void *value) { BloomFilter *bf value; RedisModule_SaveUnsigned(io, bf-size); RedisModule_SaveUnsigned(io, bf-hashes); RedisModule_SaveUnsigned(io, bf-count); RedisModule_SaveStringBuffer(io, (const char *)bf-bits, bf-size); } static void *bf_rdb_load(RedisModuleIO *io, int encver) { if (encver ! BF_ENCVER) return NULL; uint64_t size RedisModule_LoadUnsigned(io); uint64_t hashes RedisModule_LoadUnsigned(io); uint64_t count RedisModule_LoadUnsigned(io); size_t len 0; char *bits RedisModule_LoadStringBuffer(io, len); BloomFilter *bf bf_new((size_t)size, (uint32_t)hashes); bf-count count; memcpy(bf-bits, bits, len); RedisModule_Free(bits); return bf; } static void bf_free(void *value) { BloomFilter *bf value; RedisModule_Free(bf-bits); RedisModule_Free(bf); }encoding version是一个很重要的版本字段。如果升级版本导致二进制结构变化encver 不匹配时应该拒绝加载或做数据迁移。我在开发早期吃过亏结构体里加了一个字段忘记提升 encverRDB 加载时读乱数据排查了很久。7.2 AOF 重写的思路AOF 持久化的核心是把状态变更转成命令回放。对于自研类型一个常见做法是提供bf.restore命令接受 key、位图大小、哈希数量、位图内容等参数在aof_rewrite回调里把整个布隆过滤器的位图以参数形式交给 Redis由它写入 AOF 文件。static void bf_aof_rewrite(RedisModuleIO *io, RedisModuleString *key, void *value) { // 完整实现里会通过 RedisModule_EmitAOF 输出一条 bf.restore 命令 // 参数包含 key、size、hashes、count、bitmap。 // 这里不展开原因是不希望读者直接复制这套代码去线上。 // 核心思路是自定义类型的 AOF 重写 把可序列化状态变成可重放的命令。 }我在自己的模块里通常更依赖 RDB 线AOF 重写只在要求纯命令回放时实现。如果你的系统 AOF 使用everysec模式自定义类型没有 aof_rewrite 回调时Redis 会采用它自己的默认处理路径但没有完整测试过就别直接上生产这一点要在压测里验证。7.3 MEMORY USAGE 和内存分配要配套自定义类型很容易成为内存黑盒。好在 Modules API 提供了 mem_usage 回调Redis 的MEMORY USAGE命令就能统计到你的类型占用空间。static size_t bf_mem_usage(const void *value) { const BloomFilter *bf value; return sizeof(BloomFilter) bf-size; }还有一点容易被忽略模块内部的内存分配尽量统一走RedisModule_Alloc/RedisModule_Free不要直接用裸 malloc/free。这可不是教条Redis 使用自己的内存分配器时混用分配器可能导致内存统计失真某些后台线程场景下甚至会有问题。8. 编译、加载和联调一次完整的实操记录8.1 编译模块把上面的代码存成bf.c然后准备 Redis 源码目录下的头文件编译命令大概是cc -shared -fPIC -o bf.so bf.c -I/path/to/redis/src编译通过后启动 Redis 时直接加载redis-server --loadmodule /your/path/bf.so也可以用动态加载redis-cli MODULE LOAD /your/path/bf.so加载成功后在 redis-cli 里试redis-cli bf.create bf:url 65536 4 bf.add bf:url item:10001 bf.exists bf:url item:10001如果一切正常bf.exists会返回 1查询一个没加过的元素返回 0。8.2 重启恢复验证联调阶段必须验证持久化链路。正确步骤是写入数据后执行 BGSAVE再重启 Redis重启后查询仍在。更严谨的做法是切 RDB 和 AOF 两种模式分别测一遍然后把 Redis 进程直接 kill模拟异常退出。我在测试里遇到过一种很隐蔽的 bugrdb_load 里没有判空 bitsRDB 文件损坏时读到 NULLmemcpy 直接把进程打崩。实际工程里加载回调要做完整的长度校验和 encver 判断外部输入不可信。8.3 我踩过的模块开发坑第一个坑是返回值协议不对。模块命令里如果直接 return REDISMODULE_ERR 而不是用 ReplyWithError联调时会看到ERR Protocol error非常让人困惑。正确做法是每个命令里都要有明确的回复函数调用。第二个坑是 KEY 打开模式。只读命令用了 READ 模式写命令却忘记加 WRITERedis 会拒绝在高危场景下执行。写命令的OpenKey必须声明可写后续调用ModuleTypeSetValue时才能正常更新值。第三个坑是类型名字符串。CreateDataType的 name 会出现在 RDB 头里不能随便改改了之后旧 RDB 会识别不了。版本升级时优先改 encver保持类型名稳定。9. 什么时候用 GEO什么时候写自定义数据类型9.1 我建议直接用 GEO 的场景如果你的需求就是点近邻检索GEO 基本是首选。比如附近门店、附近车辆、附近设备、同城榜单只要坐标点是离散的半径查询是主要诉求GEO 的成熟度和稳性都很高。配套命令齐全还有GEOSEARCHSTORE做结果缓存集群模式下也有相对成熟的处理方案。这种场景自己写自定义类型反而要维护编码、解码、排序、持久化投入产出比很低。9.2 更值得写自定义类型的场景当你的数据结构超出内置类型的表达能力时才考虑 Modules。典型信号有三个一是需要自定义压缩存储比如位图级别的高密度结构二是需要一条命令完成“读改写”不希望反复序列化到字符串三是需要独立的内存回收和统计。我做布隆过滤器模块的动机就很典型用 Set 去重内存太高用字符串数组太笨而布隆过滤器本身又需要重复的哈希逻辑和位操作把它封装成 Redis 类型后上层业务只需要bf.add和bf.exists两条命令。9.3 后续还能怎么扩展自定义数据类型的边界远不止布隆过滤器。类似的思路可以做空间网格索引、多维向量近邻索引、时间窗口计数器、流式分位数估计器。GEO 本质上是一种空间数据的特化索引而自定义数据类型给了你造更多“特化索引”的能力。我在实战里的体会是先用好内置类型包括 GEO 这种扩展类型真的出现无法用命令组合解决的存储结构时再去写模块。自定义类型虽然开发成本高但一旦做好能给业务带来非常极致的查询性能和内存收益。