Redis - GEO 与自定义数据类型:从 LBS 场景到底层扩展
文章目录

引言
LBS(Location-Based Service)应用是日常生活里使用频率最高的一类业务:搜索附近的餐馆、打车叫车、外卖配送,背后都需要对海量经纬度数据做高效的范围查询。如果用 Redis 的基础类型来支撑这种需求,会发现单纯的 Hash、Set、Sorted Set 都不够顺手。Redis 提供的 GEO 扩展类型正是为这类场景设计的,它没有发明新的底层结构,而是巧妙地复用了 Sorted Set,关键在于一种叫 GeoHash 的编码方法。
理解了 GEO 的设计思路,再扩展一步,就是 Redis 的自定义数据类型机制。当现有类型组合也满足不了特殊业务时,可以通过修改 RedisObject、增加新的 type 与 encoding,开发出贴合业务的扩展类型。
为什么 Hash 装不下 LBS 数据
以叫车应用为例分析数据访问模式。每辆网约车有一个编号(比如 33),车辆持续上报自己的经纬度(比如 116.034579,39.000452)。乘客发起叫车时,应用根据乘客当前位置(116.054579,39.030452)查找附近的车辆并匹配。

数据本身的结构很简单:一辆车 ID 对应一组经纬度,且经纬度会随车辆移动而变化。这是典型的 key-value 模式,自然会想到 Hash。Hash 的 HSET 也确实能高效地更新经纬度。
但 LBS 还有第二个核心需求:根据某个位置查找附近的车辆。这是一个范围查询。Hash 是无序的,做范围查询只能全量扫描,性能完全不可接受。
Sorted Set 倒是支持按 score 排序和范围查询。如果把车辆 ID 作为 element,把经纬度作为 score,理论上就能解决范围查询的问题。问题是 Sorted Set 的 score 是一个浮点数,而经纬度是两个值,怎么把两个值塞进一个浮点数里且保持空间相邻性?答案就是 GeoHash。

GeoHash 编码:二分区间,区间编码
GeoHash 的核心思想是把整个二维地理空间递归地切成方格,每个方格用一个二进制串编码。距离相近的方格,编码值通常也相近,这样基于编码值的范围查询就能近似映射回地理空间的范围查询。
经度编码。经度范围是 [-180, 180],先做一次二分:[-180, 0) 是左区间,[0, 180] 是右区间。落在左区间编 0,右区间编 1。要编码 116.37 这个经度,第一次二分后落在右区间 [0, 180],编码位是 1。再对 [0, 180] 做二分得到 [0, 90) 和 [90, 180],116.37 还在右边,编码位仍是 1。继续对 [90, 180] 二分得到 [90, 135) 和 [135, 180],116.37 落到左边,编码位是 0。如此做 N 次二分就得到 N 位编码。做 5 次后 116.37 被定位在 [112.5, 123.75],编码值是 11010。
纬度编码同理,区间是 [-90, 90]。比如 39.86 经过 5 次二分得到的编码是 10111。注意严格意义上 Redis 的纬度有效范围是 [-85.05112878, 85.05112878],超过这个范围会报错。
经纬度合并。把经度的 5 位和纬度的 5 位按位交叉合成 10 位编码:偶数位放经度位,奇数位放纬度位。最终经度 11010 和纬度 10111 合成 1110011101。这个 10 位整数就可以作为 Sorted Set 的 score 了。

经纬度划分的方格越多(编码位数越多),定位精度就越高。把所有方格的编码值映射到一维序列上,相邻编码值在地图上通常也是相邻方格。但有个边界情况:相邻编码可能跨越大块切分边界,对应的方格其实距离很远,比如 0111 和 1000 在数值上接近,地图上却隔得不近。所以工程实现上常做"九宫格查询",把目标方格周围的 8 个方格也一并查询,再在客户端二次过滤掉真实距离过远的结果。




GEO 类型的命令使用
Redis 把上面这套机制封装进 GEO 类型,对外提供两个核心命令。
# 把车辆 33 的经纬度写入名为 cars:locations 的 GEO 集合
GEOADD cars:locations 116.034579 39.030452 33
# 查询以指定经纬度为中心、5 公里范围内的车辆,按距离从近到远排序,最多返回 10 条
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
GEORADIUS 的 ASC 与 COUNT 选项很实用。距离排序避免了客户端再次 sort,COUNT 限制返回数量则可以显著节省网络带宽——5 公里范围内可能有几百辆车,但乘客界面上只会展示十几辆。
车辆位置是不断变化的,更新位置直接覆盖即可,本质就是 Sorted Set 的 ZADD 行为。GEOADD 对已有成员会覆盖原有 score。
自定义数据类型:扩展的终极形式
GEO、Bitmap、HyperLogLog 这三个扩展类型有个共同点:底层都没引入全新的数据结构,而是基于 String 或 Sorted Set 做了一层语义封装。这条思路覆盖了大部分扩展需求,但当业务真的需要一个全新的结构(比如同时要支持单键 O(1) 查询和有序范围查询的混合结构),就需要往更底层走,开发新的数据类型。

理解 Redis 自定义数据类型,要先理解 RedisObject 这个基础对象。Redis 中所有的 value 都通过 RedisObject 封装,键也是 RedisObject。它的字段包括:
type:值的类型,对应五大基本类型加扩展类型,是顶层的类型标识encoding:底层编码方式,比如 SDS、ziplist、listpack、quicklist、hashtable、intset、skiplist 等lru:最后访问时间,用于 LRU 淘汰refcount:引用计数,用于共享对象(如小整数对象)的内存管理*ptr:指向具体数据结构的指针
新增数据类型的本质,就是定义一个新结构,让 RedisObject 的 type 多一种取值,ptr 能指向这个新结构。
开发新数据类型的四步骤

以一个名为 NewTypeObject 的新类型为例,它的底层是一个长整型单向链表。
第一步:定义底层结构。在新建的 newtype.h 文件里写两个结构体:
struct NewTypeNode {
long value;
struct NewTypeNode *next;
};
typedef struct NewTypeObject {
struct NewTypeNode *head;
size_t len;
} NewTypeObject;
底层用什么完全由业务决定。要快速范围查询可以换成跳表或 B+ 树,要节省内存可以用紧凑数组。
第二步:在 server.h 注册类型常量。Redis 用宏定义来区分各种 type:
#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
// ...
#define OBJ_NEWTYPE 7
第三步:实现创建与释放函数。Redis 在 object.c 集中管理 RedisObject 的生命周期,新类型的工厂函数也放这里:
robj *createNewTypeObject(void) {
NewTypeObject *h = newtypeNew();
robj *o = createObject(OBJ_NEWTYPE, h);
return o;
}
NewTypeObject *newtypeNew(void) {
NewTypeObject *n = zmalloc(sizeof(*n));
n->head = NULL;
n->len = 0;
return n;
}
createObject 是 Redis 自带的通用工厂,传入 type 和具体数据指针。新类型的具体初始化逻辑(newtypeNew)按惯例放在专属的 t_newtype.c 文件里,对应 t_string.c、t_list.c 等已有命名风格。
释放函数与之对称,调用 zfree 把链表节点和容器一一释放掉,内存交还给 Redis 的内存分配器。
第四步:实现命令并注册。命令实现仍然放在 t_newtype.c:
void ntinsertCommand(client *c) {
// 解析客户端参数,向 NewTypeObject 链表头插入元素
}
server.h 里声明这个函数,最后在 server.c 的 redisCommandTable 里把命令名和函数关联起来:
struct redisCommand redisCommandTable[] = {
// ...
{"ntinsert", ntinsertCommand, 2, "m", ...},
};
到这一步,一个全新的数据类型和它的命令就已经能用了。如果还需要持久化能力,得在 RDB 序列化和 AOF 重写两个模块里增加对 OBJ_NEWTYPE 的处理逻辑,否则重启数据就丢了。集群环境下还要考虑 key 的 slot 路由不会受新类型影响。
实战中的考量
修改 Redis 源码引入新类型,看起来强大,落地却要慎重。最大的工程风险是版本升级——新类型的代码要随主线版本一起维护,每次社区发布新版本都得重新合并 patch。维护成本高昂,所以业内更倾向于通过 Module 机制来扩展。Redis 4.0 之后开放的 Module API 允许编译为动态链接库后用 loadmodule 加载,新类型与官方代码解耦,RedisTimeSeries、RediSearch、RedisJSON 都是这个路线。
GEO 给我们的启示其实在另一个层面:用现有的有序结构 + 巧妙的编码方式,往往能解决看起来需要全新结构的问题。GeoHash 把二维空间映射到一维有序值,是将复杂问题降维的经典手法。日常工作中遇到看似无法直接用 Redis 类型表达的需求时,先想想能不能通过编码或组合解决,再考虑动手扩展,这是更经济的工程选择。
一些容易忽略的细节
GEO 的距离查询基于 GeoHash 编码的范围扫描,并不是真实欧氏距离,因此在编码边界附近会有"邻居跨界"的现象。Redis 内部已经做了周边方格扩展,但极端情况下仍可能漏掉跨大块边界的真正近邻。对距离精度敏感的业务,最好对结果做一次客户端的精确距离计算和过滤。
车辆持续移动,意味着每隔几秒就会有一次 GEOADD 写入。Sorted Set 的写入是 O(logN),N 是集合元素数。当一个城市同时在线 50 万辆车,单个 GEO key 太大,会让单条命令的耗时上升、跨实例迁移困难。生产实践通常按城市或行政区分片,每个区域一个 GEO key,既控制单 key 体积又便于水平扩展。
GEO 类型本身不存储车辆的其他元信息(车型、司机、车牌等),通常配合一个 Hash 一起使用:GEO 负责空间索引,Hash 负责详情查询。匹配到附近车辆 ID 后,再用 HMGET 拉详情。这种"索引 + 详情"的拆分模式在 Redis 工程实践里非常常见。

更多推荐

所有评论(0)