面向 LBS 应用的 GEO 数据类型
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中,我们来看一下它的底层结构。
GEO 的底层结构
一般来说,在设计一个数据类型的底层结构时,我们首先需要知道,要处理的数据有什么访问特点。所以,我们需要先搞清楚位置信息到底是怎么被存取的。
把不同车辆的 ID 和它们对应的经纬度信息存在 Hash 集合中:
![c2c9b534fb29813279041220c336dde3.png](https://i-blog.csdnimg.cn/blog_migrate/06883f98c5d63e2470406bddc54d8d54.jpeg)
一旦涉及到范围查询,就意味着集合中的元素需要有序,但 Hash 类型的元素是无序的
Sorted Set 类型也支持一个 key 对应一个 value 的记录模式,其中,key 就是 Sorted Set 中的元素,而 value 则是元素的权重分数。更重要的是,Sorted Set 可以根据元素的权重分数排序,支持范围查询。
GEO 类型的底层数据结构就是用 Sorted Set 来实现的
用 Sorted Set 来保存车辆的经纬度信息时,Sorted Set 的元素是车辆 ID,元素的权重分数是经纬度信息,如下图所示:
![6447f805a0a52388687d8ef6bd915e0b.png](https://i-blog.csdnimg.cn/blog_migrate/c0b91b20672b0d658c1d73bff0df8599.jpeg)
这时问题来了,Sorted Set 元素的权重分数是一个浮点数(float 类型),而一组经纬度包含的是经度和纬度两个值,是没法直接保存为一个浮点数的,那具体该怎么进行保存呢?
这就要用到 GEO 类型中的 GeoHash 编码了。
GeoHash 的编码方法
为了能高效地对经纬度进行比较,Redis 采用了业界广泛使用的 GeoHash 编码方法,这个方法的基本原理就是“二分区间,区间编码”。
当我们要对一组经纬度进行 GeoHash 编码时,我们要先对经度和纬度分别编码,然后再把经纬度各自的编码组合成一个最终编码。
做完 5 次分区后,我们把经度值 116.37 定位在[112.5, 123.75]这个区间,并且得到了经度值的 5 位编码值,即 11010。这个编码过程如下表所示:
![06a694fc5d89055ec838d372fc79410b.png](https://i-blog.csdnimg.cn/blog_migrate/b177da4fcce83322d2c8f015126340dd.jpeg)
对纬度的编码方式,和对经度的一样,只是纬度的范围是[-90,90],下面这张表显示了对纬度值 39.86 的编码过程。
![bd79131ef46d21f43c609fb8e507989d.png](https://i-blog.csdnimg.cn/blog_migrate/9116583abb4d028236e813eba059fdff.jpeg)
当一组经纬度值都编完码后,我们再把它们的各自编码值组合在一起,组合的规则是:最终编码值的偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值,其中,偶数位从 0 开始,奇数位从 1 开始。
![f87f7cbf34b3b0b9f78c3ef6ecb2f638.png](https://i-blog.csdnimg.cn/blog_migrate/1f8e24d8c1ec72a07d768fb6930afaf7.jpeg)
这 4 个分区对应了 4 个方格,每个方格覆盖了一定范围内的经纬度值,分区越多,每个方格能覆盖到的地理空间就越小,也就越精准。我们把所有方格的编码值映射到一维空间时,相邻方格的 GeoHash 编码值基本也是接近的,如下图所示:
![5ebc458a7470b825603cf8e683c7009f.png](https://i-blog.csdnimg.cn/blog_migrate/6438b4ecf31edb6dad3de85d9a79d083.jpeg)
所以,我们使用 Sorted Set 范围查询得到的相近编码值,在实际的地理空间上,也是相邻的方格,这就可以实现 LBS 应用“搜索附近的人或物”的功能了。
不过,我要提醒你一句,有的编码值虽然在大小上接近,但实际对应的方格却距离比较远。例如,我们用 4 位来做 GeoHash 编码,把经度区间[-180,180]和纬度区间[-90,90]各分成了 4 个分区,一共 16 个分区,对应了 16 个方格。编码值为 0111 和 1000 的两个方格就离得比较远,如下图所示:
![600c2799b3ecf7fc5908df671aa0880d.png](https://i-blog.csdnimg.cn/blog_migrate/96735856113392168bd4762332723f87.jpeg)
如何操作 GEO 类型?
- GEOADD: 添加
- GEORADIUS: 查找一定范围内的元素
# 顺序: 经度 维度 标记GEOADD cars:locations 116.034579 39.030452 33
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
如何自定义数据类型?
RedisObject
Redis 的基本对象结构
组成:
- type:表示值的类型,涵盖了我们前面学习的五大基本类型;
- encoding:是值的编码方式,用来表示 Redis 中实现各个基本类型的底层数据结构,例如 SDS、压缩列表、哈希表、跳表等;
- lru:记录了这个对象最后一次被访问的时间,用于淘汰过期的键值对;
- refcount:记录了对象的引用计数;
- *ptr:是指向数据的指针。
![2ef0a6faa4a5ed4882dff5d68ae3e92a.png](https://i-blog.csdnimg.cn/blog_migrate/5787607aaf0670eb53128b793d195246.jpeg)
我们在定义了新的数据类型后,也只要在 RedisObject 中设置好新类型的 type 和 encoding,再用*ptr指向新类型的实现,就行了。
开发一个新的数据类型
首先,我们需要为新数据类型定义好它的底层结构、type 和 encoding 属性值,然后再实现新数据类型的创建、释放函数和基本命令。
![973b846d30a7b168cda57cc3d64741c4.png](https://i-blog.csdnimg.cn/blog_migrate/ce78498d26d782fd386f032ce820199b.jpeg)
第一步:定义新数据类型的底层结构
newtype.h:
struct NewTypeObject { struct NewTypeNode *head; size_t len; }NewTypeObject;
NewTypeNode: 自定义的新类型的底层结构
struct NewTypeNode { long value; struct NewTypeNode *next;};
第二步:在 RedisObject 的 type 属性中,增加这个新类型的定义
定义是在 Redis 的 server.h 文件中, 增加一个叫作 OBJ_NEWTYPE 的宏定义,用来在代码中指代 NewTypeObject 这个新类型
#define OBJ_STRING 0 /* String object. */#define OBJ_LIST 1 /* List object. */#define OBJ_SET 2 /* Set object. */#define OBJ_ZSET 3 /* Sorted set object. */…#define OBJ_NEWTYPE 7
第三步:开发新类型的创建和释放函数
Redis 把数据类型的创建和释放函数都定义在了 object.c 文件中。所以,我们可以在这个文件中增加 NewTypeObject 的创建函数 createNewTypeObject,如下所示:
robj *createNewTypeObject(void){ NewTypeObject *h = newtypeNew(); robj *o = createObject(OBJ_NEWTYPE,h); return o;}
newtypeNew 函数: 为新数据类型初始化内存结构的
NewTypeObject *newtypeNew(void){ NewTypeObject *n = zmalloc(sizeof(*n)); n->head = NULL; n->len = 0; return n;}
createObject: 是 Redis 本身提供的 RedisObject 创建函数,它的参数是数据类型的 type 和指向数据类型实现的指针*ptr。
robj *createObject(int type, void *ptr) { robj *o = zmalloc(sizeof(*o)); o->type = type; o->ptr = ptr; ... return o;}
第四步:开发新类型的命令操作
分三小步:
- 在 t_newtype.c 文件中增加命令操作的实现。比如说,我们定义 ntinsertCommand 函数,由它实现对 NewTypeObject 单向链表的插入操作:
void ntinsertCommand(client *c){ //基于客户端传递的参数,实现在NewTypeObject链表头插入元素}
- 在 server.h 文件中,声明我们已经实现的命令,以便在 server.c 文件引用这个命令,例如:
void ntinsertCommand(client *c)
- 在 server.c 文件中的 redisCommandTable 里面,把新增命令和实现函数关联起来。例如,新增的 ntinsert 命令由 ntinsertCommand 函数实现,我们就可以用 ntinsert 命令给 NewTypeObject 数据类型插入元素了。
struct redisCommand redisCommandTable[] = { ...{"ntinsert",ntinsertCommand,2,"m",...}}