前阵子帮某本地生活平台排查一个“附近门店”功能的性能问题,当年几百万个门店坐标放在关系型数据库里,每次按距离排序都要扫一遍全表,接口响应直接飙到 3 秒开外。最终方案落到了 Redis 的 GEO 类型上——这也是我第二次在真实项目里大规模用 GEO。如果你以为 GEO 只是“存个经纬度再算算距离”,那你很可能会错过它背后精妙的编码设计;如果你以为 Redis 只能用它内置的几种数据结构,那自定义数据类型这个扩展机制很值得了解一下。
这篇文章我会从 GEO 的底层结构讲起,拆解它的编码方式、核心命令,再用一个“附近门店推荐”的完整案例带你走一遍从设计到落地的全过程。接着介绍如何通过 Redis Modules 打造自己的数据类型,手写一个最小可用的自定义类型骨架。最后整理几类我在实际项目中踩过的坑,附上定位和排查方法,希望帮你少走弯路。
1. GEO是什么:从“附近的人”需求说起
1.1 GEO解决的核心问题
“附近的人”“附近门店”“按距离排序”——这类需求几乎是每个 LBS 业务的标配。传统做法很简单:数据库表里存两个字段,lat 和 lng,查询时用半正矢公式(Haversine)计算距离。听起来没问题,但数据量一旦上来就露馅了:以“某市 300 万门店”为例,一次“找 3 公里内的门店并按距离排序”的查询,数据库要计算 300 万次距离公式,而且因为查询条件带函数计算,索引基本失效,只能全表扫。实测下来,单次查询耗时常常在 2 到 5 秒之间,这还没算并发。
GEO 类型解决的就是这个问题。它把经纬度坐标编码成一个数字,存进有序集合(ZSet),利用有序集合天然支持“按分数范围检索”的能力,先快速圈定一个矩形候选范围,再做精确距离过滤排序。把计算从“全表扫描”降成“局部扫描”,性能能提升几个数量级。我第一次用它是在一个出行项目里,几千万个司机位置,每次“找附近 5 公里”的查询响应控制在 10 毫秒以内,完全能支撑高并发。
1.2 底层数据结构设计:为什么用ZSet
很多人不知道,GEO 在 Redis 内部并不是一个全新结构,它本质上就是一个有序集合。你可以直接对一个 GEO key 执行 ZCARD、ZRANGE、ZREM 这些 ZSet 命令,因为它们本来就是同一个东西。
那坐标是怎么存成“分数”的?Redis 把地球想象成一个平面网格,经度范围是 [-180, 180],纬度范围是 [-85.05112878, 85.05112878]。注意这个纬度上限不是南北纬 90 度,而是 Web 墨卡托投影能表示的最大纬度,约等于 85.05 度,超过这个值 GEOADD 会直接报错。编码时,经度和纬度各被映射成一个 26 位的整数,然后交错拼成一个 52 位整数,作为 ZSet 的 score。这个 52 位编码的理论精度在赤道附近大概是 0.6 米左右,也就是说,存进去的坐标和取出来时会有亚米级的微小偏差,对绝大多数“附近搜索”业务完全够用。
为什么 Redis 要选 ZSet,而不是为 GEO 单独设计一套存储结构?我理解核心原因是复用。ZSet 底层在数据量小的时候用压缩列表,数据量大时用“跳表 + 哈希表”的组合,能同时做到按 score 排序、按 member 精确定位、按范围切片。GEO 只不过是把 score 换成了经纬度编码,就能直接继承这一整套成熟能力。另外,由于 GEO 就是 ZSet,它天然兼容持久化、主从复制、RDB/AOF 备份,运营成本极低。你不需要学一套新的备份恢复流程。
2. GEO核心命令详解与实战
2.1 GEOADD:写入经纬度坐标
先看写入命令:
GEOADD nearby:shop 116.397128 39.916527 "shop_a" GEOADD nearby:shop 121.473701 31.230416 "shop_b"这里有两个点必须反复强调。第一,参数顺序是“经度在前,纬度在后”,不是纬度在前。我见过太多同事写反,导致门店飞到非洲去了。第二,member(这里是门店 ID)是唯一的,同一个 member 再次 GEOADD 就是更新坐标,命令返回 0,不会新增。批量写入的效率很高,一次可以带多组“经度 纬度 member”,实测写入 10 万个点,耗时也就在几百毫秒量级。
写入后你可以好奇地用 ZSCORE 看看它内部存的分数:
ZSCORE nearby:shop "shop_a"会得到一个很大的整数,这就是经纬度交错编码后的 score。这时候如果你手贱直接对这个 key 执行 ZADD 写入一个自定义分数,那坐标就彻底错乱了,所以日常维护中应当只用 GEO 系列命令操作 GEO key,不要混用 ZSet 命令。
2.2 GEOPOS、GEODIST、GEOHASH:查询与换算
查坐标用 GEOPOS:
GEOPOS nearby:shop "shop_a"返回的是解码后的经度和纬度,和原始坐标可能有零点几米的偏移,这是编码精度决定的,不用慌。
算距离用 GEODIST:
GEODIST nearby:shop "shop_a" "shop_b" km单位支持 m(米,默认)、km(千米)、mi(英里)、ft(英尺)。这里特别注意:默认单位是米,如果你忘记指定单位,得到的数字会比你预想的“大”,而且不是千米。我在某计费项目里就见过因为单位搞错,导致用户被多扣了钱的故障。
获取标准 geohash 字符串用 GEOHASH:
GEOHASH nearby:shop "shop_a"返回的是 11 字符的 geohash 字符串,它和 Redis 内部那 52 位 score 不是一回事,但可以互相转换。这个字符串常用于和其他地图服务对账,或者通过前缀匹配做粗糙的邻近判断。
2.3 GEOSEARCH:真正的“附近的人”核心命令
Redis 6.2 开始推荐使用 GEOSEARCH,替代旧的 GEORADIUS。GEOSEARCH 的查询能力更丰富,语义也更清楚。
# 基于某个成员坐标,查半径 5km 内,按距离升序返回前 20 个,带坐标和距离 GEOSEARCH nearby:shop FROMMEMBER "shop_a" BYRADIUS 5 km ASC COUNT 20 WITHCOORD WITHDIST # 基于指定经纬度,查半径 3km 内 GEOSEARCH nearby:shop FROMLONLAT 116.397128 39.916527 BYRADIUS 3 km ASC COUNT 20 # 按矩形范围查询:宽 100km,高 50km GEOSEARCH nearby:shop FROMLONLAT 116.397128 39.916527 BYBOX 100 50 km ASC COUNT 20FROMMEMBER 表示“以某个已存在的成员作为中心点”,FROMLONLAT 表示“直接以某个经纬度作为中心点”。这两个子命令特别有用,业务上用户上报的位置往往不在门店集合里,用 FROMLONLAT 可以免去“先查坐标再查半径”两步操作。
旧的 GEORADIUS 和 GEORADIUSBYMEMBER 在 6.2 之后被标记为废弃命令,虽然还能用,但新项目建议直接上 GEOSEARCH。如果维护的是老版本 Redis,那就只能继续用旧命令,语法差别不大,主要留意旧命令的 COUNT 参数行为和排序组合。
2.4 为什么 GEO 查询可以这么快
GEOSEARCH 的底层过程可以理解为三步:
- 根据中心点和半径,计算出一个粗略的经纬度范围矩形;
- 用 ZRANGEBYSCORE 在 ZSet 中取出这个矩形范围内的候选成员;
- 对候选成员逐个用球面距离公式过滤,把矩形四角“超出圆形范围”的噪声点剔掉,然后排序返回。
也就是说,它先用“矩形粗筛”把全量数据缩小到一个很小的候选集,再做“圆形精排”。这个策略和我们日常做“先查城市再查街道”的索引思维是一样的。代价是,如果半径特别大,比如“全城找 50 公里”,候选集可能还是很大,排序和过滤的消耗就会上来。所以实际使用中,建议用 COUNT 限制返回数量,并且配合 ASC 拿到最近的 N 个。
3. 从命令到业务:附近门店推荐完整方案
3.1 数据模型设计
下面用我之前处理的“本地生活平台附近门店推荐”作为案例,完整拆一遍设计方案。
业务要求:用户打开 App,上传当前位置,返回 3 公里内正在营业的门店,按距离从近到远排序,分页拉取,门店量级在 300 万左右。
我的设计是这样的:
- Key 维度:按城市分片,命名为
geo:shop:{cityId}。为什么分片?如果 300 万门店全塞进一个 key,单个 key 的数据量太大,遇到热点城市会被集中读写,查询和写入都容易遇到性能瓶颈。按城市分片后,单 key 的成员数基本在几万到几十万,内存和访问热度都更均衡。 - Member 用门店 ID,不要塞冗长的 JSON。门店的详情(名称、评分、营业状态、品类标签)放在另一个 Hash key 里,比如
hash:shop:{shopId}。GEO 只负责“坐标索引”,详情查询走 Hash,这叫索引与数据分离,职责单一,也方便更新。 - 写入流程:门店创建或坐标变更时,执行一次 GEOADD 覆盖坐标即可。全量初始化时用管道或批量命令,效率高很多。
- 查询流程:用户传经纬度,先 GEOSEARCH 拿到候选门店 ID 列表,再批量从 Hash 里取门店详情,在应用层过滤掉“已打烊”的,最后组装返回。
如果你需要按品类过滤(比如只返回咖啡店),不要试图在 GEO 的 member 里拼类型标签,比如"shop_123_coffee"。这样会让一个门店因为多品类而存在多条坐标记录,维护成本极高。更合理的做法是先按距离召回 3 公里内的候选 ID,再在应用层用品类索引过滤。如果门店总量很大,也可以给热门品类单独建 GEO key,如geo:shop:coffee:{cityId},空间换时间。
3.2 边缘区域、坐标系和缓存:三个容易忽略的细节
第一个细节是城市边界问题。按城市分片后,用户如果正好站在两个城市的交界处,3 公里范围可能会覆盖到隔壁城市的门店,而查询只扫了本城市的 key,就会漏数据。我当时的处理方案是:对于坐标落在城市边界缓冲区(比如离边界 5 公里内)的用户,除了查本城市 key,还要并发查相邻几个城市的 GEO key,再合并结果重新排序。这个方案有点糙,但实现简单、效果稳定。更精细的做法是按经纬度网格分片,把地图切成 0.1°×0.1° 的小格子,门店按格子归属,查询时找出用户所在格子及周围 9 个格子的 key 再合并。代价是 key 数量多,管理复杂,但热点分散更彻底。
第二个细节是坐标系统一。国内大量设备上报的是 GCJ-02(火星坐标),而部分地图底图和 GPS 原始数据是 WGS-84,两者相差几十米到几百米不等。如果存进去的坐标体系不统一,算出来的距离就是错的,可能会把隔壁街道的店算成 100 米外。所以项目里要明确一个约定:入库前统一转成同一种坐标系,要不全部 GCJ-02,要不全部 WGS-84,永远不要在 GEO 里混用。
第三个细节是缓存策略。附近门店属于高并发读接口,几百万用户的刷新频率不低,如果每次都打 Redis GEO,虽然性能不错,但热 key 压力还是存在。我当时对聚合结果做了 30 秒的本地缓存,用户滑动分页时优先走缓存。实践下来的体感是:GEO 查询本身已经很快,但缓存能帮你扛住瞬时热点,比如饭点前后的大流量。
3.3 一次完整的查询命令流
为了方便你“抄作业”,我贴一段当时用的简化流程:
# 1. 初始化时批量写入门店坐标 GEOADD geo:shop:110000 116.397128 39.916527 10001 GEOADD geo:shop:110000 116.394856 39.910972 10002 GEOADD geo:shop:110000 121.473701 31.230416 10003 # 2. 用户查询:以经纬度为中心,3公里半径,按距离升序,返回前20个,附带距离和坐标 GEOSEARCH geo:shop:110000 FROMLONLAT 116.395000 39.915000 BYRADIUS 3 km ASC COUNT 20 WITHDIST # 3. 拿返回的ID列表去批量取详情 HGETALL hash:shop:10001 HGETALL hash:shop:10002这套流程上线后,“附近门店”接口的 P95 响应从 2.8 秒降到了 80 毫秒以内,GEO 查询本身占用的时间基本在 10 毫秒左右。剩余耗时主要花在详情查询和应用层组装上。
4. 自定义数据类型:Redis的“可插拔”扩展机制
4.1 为什么需要自定义数据类型
Redis 内置了 String、List、Hash、Set、ZSet 五种基础数据结构,但真实业务总有几种需求是原生结构拼不出来的:
- 布隆过滤器:判断“某个值是否一定不在集合里”,原生结构要不占内存太多,要不无法高效实现;
- 时间序列:按时间窗口聚合、降采样,需要连续存储和滑动窗口操作;
- 向量相似度检索:需要专门的内存索引和距离计算;
- 限流令牌桶:需要原子地维护一个随时间变化的计数器状态。
这些场景虽然可以用 Lua 脚本配合多个原生 key 模拟,但模拟方案往往存在内存布局不优、多次网络往返、无法整体持久化等问题。Redis Modules 机制就是为了解决这类问题而存在的:你写一个动态链接库,让 Redis 加载后拥有全新的数据类型,数据的内存布局、命令逻辑、持久化方式都由你定制。
在动手写模块之前,我建议先回顾一下自己的需求到底适不适合:如果只是需要“一组操作组合”,优先考虑 Lua 脚本;如果只是需要“压缩存储”,优先考虑 String 加自定义序列化;只有当你需要的是一个拥有独立内存结构、支持原子命令、并且要原生参与 RDB/AOF 的新数据类型时,才值得写模块。
4.2 认识Redis Modules:核心概念与API
Redis 4.0 正式引入了 Modules API,使用 C 语言编写。不过你不需要把整个 Redis 源码改造一遍,只需要包含redismodule.h头文件,编译出一个.so文件,然后在redis.conf里加一行loadmodule /path/to/yourmodule.so,或者运行时用MODULE LOAD加载,即可生效。
模块有几个核心概念:
- 模块初始化:入口函数
RedisModule_OnLoad,在这里注册命令和数据类型; - 命令注册:
RedisModule_CreateCommand把 C 函数绑定成 Redis 命令; - 数据类型注册:
RedisModule_CreateDataType声明一个新类型,并提供读写、序列化、复制、释放等回调; - 内存管理:使用
RedisModule_Alloc和RedisModule_Free,而不是裸的 malloc/free,这样才能被 Redis 统计和替换; - 回复客户端:使用
RedisModule_ReplyWith*系列函数。
社区里比较常见的现成模块有布隆过滤器、JSON、时序、图数据库等,GitHub 上搜 “redis module” 就能找到不少。多数场景其实不需要自己造轮子,但了解底层机制能帮你判断某个模块好不好、是否值得引入,以及出了问题怎么排查。
4.3 手写一个最小可用的自定义类型
下面我以“一个支持按索引原子累加的计数器数组”为例,展示自定义数据类型的骨架。不用纠结这个业务是否合理,重点是理解类型注册、命令注册和持久化回调这几个环节。
首先,定义数据结构体:
#include "redismodule.h" #include <stdint.h> #include <stdlib.h> typedef struct { uint64_t size; uint64_t *values; } CounterArray;然后实现 RDB 持久化回调。做自定义类型时,这一步经常被忽略,但很关键:如果没有 RDB save/load 回调,redis 重启后数据就丢了;如果没有实现复制回调,主从切换后数据对不上。
void *CounterArrayRdbLoad(RedisModuleIO *rdb, int encver) { if (encver > 0) return NULL; uint64_t size = RedisModule_LoadUnsigned(rdb); CounterArray *arr = RedisModule_Alloc(sizeof(CounterArray)); arr->size = size; arr->values = RedisModule_Alloc(sizeof(uint64_t) * size); for (uint64_t i = 0; i < size; i++) { arr->values[i] = RedisModule_LoadUnsigned(rdb); } return arr; } void CounterArrayRdbSave(RedisModuleIO *rdb, void *value) { CounterArray *arr = value; RedisModule_SaveUnsigned(rdb, arr->size); for (uint64_t i = 0; i < arr->size; i++) { RedisModule_SaveUnsigned(rdb, arr->values[i]); } }再实现释放、复制回调,并注册数据类型:
void CounterArrayFree(void *value) { CounterArray *arr = value; RedisModule_Free(arr->values); RedisModule_Free(arr); } void *CounterArrayCopy(void *value) { CounterArray *src = value; CounterArray *dst = RedisModule_Alloc(sizeof(CounterArray)); dst->size = src->size; dst->values = RedisModule_Alloc(sizeof(uint64_t) * src->size); memcpy(dst->values, src->values, sizeof(uint64_t) * src->size); return dst; } int CounterArrayTypeRegister(RedisModuleCtx *ctx) { RedisModuleTypeMethods tm = { .version = REDISMODULE_TYPE_METHOD_VERSION, .rdb_load = CounterArrayRdbLoad, .rdb_save = CounterArrayRdbSave, .free = CounterArrayFree, .copy = CounterArrayCopy, }; return RedisModule_CreateDataType(ctx, "carray\\0buffer", 0, &tm); }最后注册两个命令:一个是写入命令,一个是查询命令。
int CounterArrayIncr(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (argc != 4) { RedisModule_WrongArity(ctx); return REDISMODULE_ERR; } RedisModuleKey *key = RedisModule_OpenKey(ctx, argv[1], REDISMODULE_WRITE); long long index, incr; RedisModule_StringToLongLong(argv[2], &index); RedisModule_StringToLongLong(argv[3], &incr); CounterArray *arr = RedisModule_ModuleTypeGetValue(key); // 这里省略了类型检查、数组长度不足扩容等细节 arr->values[index] += incr; RedisModule_ReplyWithLongLong(ctx, arr->values[index]); RedisModule_ReplicateVerbatim(ctx); return REDISMODULE_OK; }需要注意的是,命令里我用RedisModule_ReplicateVerbatim告诉 Redis 这条写命令要被同步到从库和 AOF。任何写命令如果忘记做这一步,主从切换后就会数据不一致。这是写模块最容易踩的坑。
注册入口:
int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (RedisModule_Init(ctx, "carray", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) { return REDISMODULE_ERR; } if (CounterArrayTypeRegister(ctx) == REDISMODULE_ERR) return REDISMODULE_ERR; if (RedisModule_CreateCommand(ctx, "carray.incr", CounterArrayIncr, "write", 1, 1, 1) == REDISMODULE_ERR) { return REDISMODULE_ERR; } return REDISMODULE_OK; }这个骨架虽然还不能直接用于生产(缺少大量错误处理),但已经把事情讲清楚了:自定义类型需要你亲自负责内存布局、序列化、写传播和释放。也正是这些细节决定了模块的稳定性和性能上限。老话说得好,能力越大责任越大,自定义类型的每一分自由度,都需要相应的一分维护成本来换。
5. 实战中的坑与排查技巧
5.1 坐标精度与数据维护
GEO 返回的坐标和写入坐标存在亚米级误差,这是 52 位编码决定的,绝大多数场景不用管。但如果在做“同一个位置点多次写读”这类精细化业务,需要留意这个误差。另外,千万不要用 ZADD 直接改 GEO key 的 score,非要改的话先用 ZREM 删掉再重新 GEOADD。我在一次数据订正时就见过操作失误导致大量坐标错乱,最后只能根据业务日志重建索引。GEO key 本质上还是 ZSet,如果你对 ZSet 命令不够熟,最容易出现这类低级事故。
5.2 参数顺序和单位,最容易翻车
我把这几个高频翻车点总结成一张表,排查问题时可以直接对照:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| GEOADD 报 invalid longitude/latitude | 经度纬度写反 | 检查参数顺序,经度在前 |
| 查询结果坐标偏移几十米 | 坐标系统一问题 | 统一 WGS-84 或 GCJ-02 后再入库 |
| GEODIST 距离大得离谱 | 默认单位是米,误以为千米 | 显式指定 km |
| 查不到边界外的门店 | 按城市/网格分片未覆盖周边 | 对边界用户增加相邻分片查询 |
| GEOSEARCH 返回数量不准 | COUNT 不是 LIMIT,是候选上限 | 按业务场景预留余量或加缓存 |
5.3 模块升级与兼容性排查
自定义类型在持久化时有一个 encver(编码版本号),首次注册数据类型时设为 0。如果模块升级改了底层结构,encver 必须递增,否则旧 RDB 数据加载时会失败。我见过某团队升级模块后忘了处理 encver,所有 key 读不出来,最后只能回滚二进制。模块的 RDB 数据识别方式比较特殊,备份时如果 Redis 挂了,RDB 文件里包含模块数据,用常规 redis-check-rdb 工具可能提示异常。这一点在决定是否引入模块前要想清楚:运维同事是否愿意背上这个包袱。如果只是一个小场景,用原生命令组合有时更省心。
5.4 关于选型,我个人的实操体会
做LBS业务,如果你的 Redis 版本在 6.2 以上,GEO 几乎是无脑首选。它简单、可靠、运维成熟,和 ZSet 共用一套机制,团队学习成本低。只有当查询形状涉及复杂多边形、或者需要做轨迹回放这类需求,才需要考虑引入专业空间数据方案。
自定义数据类型则要克制。Redis Modules 确实强大,但每引入一个模块,就多一份升级兼容、RDB 恢复、主从复制验证的运维责任。我的习惯是:能组合原生结构就组合原生结构,能写 Lua 就写 Lua,实在解决不了再上模块。而且上模块之前,一定要准备一份“模块加载失败时的回滚预案”,先验证 RDB 备份恢复流程,再上生产。
踩过几次坑之后,我现在给团队定的规矩很简单:所有 GEO 命令涉及经纬度时,服务端代码里统一用“经度变量、纬度变量”命名,不做缩写;所有 GEO key 上线前必须确认坐标系;所有自定义模块必须配套一份 encver 变更记录文档。规矩不复杂,但能省掉九成以上的返工时间。