在日常生活中搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。
LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。
叫车服务为例,分析 LBS 应用中经纬度的存取特点:
一辆车(或一个用户)对应一组经纬度,并且随着车(或用户)的位置移动, 相应的经纬度也会变化。
这种数据记录模式属于一个 key(例如车 ID)对应一个 value(一组经纬度)。当有很多车辆信息要保存时,需要有一个集合来保存一系列的 key 和 value。
Hash 集合类型可以快速存取一系列的 key 和 value,正好可以用来记录一系列车辆 ID 和经纬度的对应关系,可以把不同车辆的 ID 和它们对应的经纬度信息存在 Hash 集合中。
Hash 类型的 HSET 操作命令,会根据 key 来设置相应的 value 值,用它来快速地更新车辆变化的经纬度信息。
Hash 类型看起来是一个不错的选择。LBS 应用除了记录经纬度信息,还需要根据用户的经纬度信息在车辆的 Hash 集合中进行范围查询。一旦涉及到范围查询,就意味着集合中的元素需要有序,但 Hash 类型的元素是无序的,显然不能满足要求。
Sorted Set 类型也支持一个 key 对应一个 value 的记录模式,key 就是 Sorted Set 中的元素,而 value 则是元素的权重分数。Sorted Set 可以根据元素的权重分数排序,支持范围查询。能满足 LBS 服务中查找相邻位置的需求。
GEO 类型的底层数据结构就是用 Sorted Set 来实现的。用 Sorted Set 来保存车辆的经纬度信息时,Sorted Set 的元素是车辆 ID,元素的权重分数是经纬度信息。
Sorted Set 元素的权重分数是一个浮点数,而一组经纬度包含的是经度和纬度两个值,是没法直接保存为一个浮点数的。
为了能高效地对经纬度进行比较,Redis 采用了业界广泛使用的 GeoHash 编码方法:“二分区间,区间编码”。
要对一组经纬度进行 GeoHash 编码:
经度和纬度的单独编码:
假设要编码的经度值是 116.37,用 5 位编码值(也就是 N=5,做 5 次分区)。先做第一次二分区操作,把经度区间[-180,180]分成了左分区[-180,0) 和右分区 [0,180],经度值 116.37 是属于右分区[0,180],用 1 表示第一次二分区后的编码值。
接下来做第二次二分区:把经度值 116.37 所属的[0,180]区间,分成[0,90) 和[90, 180]。经度值 116.37 还是属于右分区[90,180],第二次分区后的编码值仍然为 1。等到第三次对[90,180]进行二分区,经度值 116.37 落在了分区后的左分区[90, 135) 中,第三次分区后的编码值就是 0。
按照这种方法,做完 5 次分区后,把经度值 116.37 定位在[112.5, 123.75]这个区间,并且得到了经度值的 5 位编码值,即 11010。
纬度的编码方式和对经度的一样,只是纬度的范围是[-90,90],下面这张表显示了对 纬度值 39.86 的编码过程:
当一组经纬度值都编完码后,再把它们的各自编码值组合在一起,组合的规则是:
最终编码值的偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值,偶数位 从 0 开始,奇数位从 1 开始。
计算的经纬度(116.37,39.86)的各自编码值是 11010 和 10111,组合之后:
GeoHash 编码后,原来无法用一个权重分数表示的一组经纬度(116.37,39.86)就可以用 1110011101 这一个值来表示,就可以保存为 Sorted Set 的权重分数了。
GeoHash 编码后,相当于把整个地理空间划分成了一个个方格,每个方格对应了 GeoHash 中的一个分区。
例如,把经度区间[-180,180]做一次二分区,把纬度区间[-90,90]做一次二分区, 就会得到 4 个分区。它们的经度和纬度范围以及对应的 GeoHash 组合编码:
这 4 个分区对应了 4 个方格,每个方格覆盖了一定范围内的经纬度值,分区越多,每个方格能覆盖到的地理空间就越小,也就越精准。把所有方格的编码值映射到一维空间时,相邻方格的 GeoHash 编码值基本也是接近的。
Sorted Set 范围查询得到的相近编码值,在实际的地理空间上,也是相邻的方格,可以实现 LBS 应用“搜索附近的人或物”的功能了。
有的编码值虽然在大小上接近,但实际对应的方格却距离比较远。
例如,用 4 位来做 GeoHash 编码,把经度区间[-180,180]和纬度区间[-90,90]各分成了 4 个分区,一共 16 个分区,对应了 16 个方格。编码值为 0111 和 1000 的两个方格就离得比较远。
为了避免查询不准确问题,可以同时查询给定经纬度所在的方格周围的 4 个或 8 个方格。
GEO 类型是把经纬度所在的区间编码作为 Sorted Set 中元素的权重分数,把和经纬度相关的车辆 ID 作为 Sorted Set 中元素本身的值保存下来,相邻经纬度的查询就可以通过编码值的大小范围查询来实现了。
使用 GEO 类型经常用到两个命令 GEOADD 和 GEORADIUS:
假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:
GEOADD cars:locations 116.034579 39.030452 33
例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 (可自定义)公里内的车辆信息,并返回给 LBS 应用。
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
虽然有了 5 种基本类型和 3 种扩展数据类型,但有些场景下对数据类型会有特殊需求,例如需要一个数据类型既能像 Hash 那样支持快速的单键查询,又能像 Sorted Set 那样支持范围查询,之前学习的这些数据类型就无法满足需求了。
Redis 键值对中的每一个值都是用 RedisObject 保存的。RedisObject 包括元数据和指针:
Redis 的基本对象结构:
RedisObject 的内部组成包括了 type,、encoding,、lru 、 refcount 4 个元数据,以及 1 个*ptr指针。
RedisObject 结构借助*ptr指针可以指向不同的数据类型,例如,*ptr指向一个 SDS 或一个跳表,表示键值对中的值是 String 类型或 Sorted Set 类型。
定义了新的数据类型后,也要在 RedisObject 中设置好新类型的 type 和 encoding,用*ptr指向新类型的实现。
开发一个新的数据类型:
首先需要为新数据类型定义好它的底层结构、type 和 encoding 属性值,再实现新数据类型的创建、释放函数和基本命令。
开发叫作 NewTypeObject 的新数据类型为例,来解释下具体的 4 个操作步骤。
第一步:定义新数据类型的底层结构
用 newtype.h 文件来保存新类型的定义:
struct NewTypeObject {
struct NewTypeNode *head;
size_t len;
}NewTypeObject;
NewTypeNode 结构就是自定义的新类型的底层结构。为底层结构设计两个成员变量:Long 类型的 value 值,用来保存实际数据;*next指针,指向下一个 NewTypeNode 结构。
struct NewTypeNode {
long value;
struct NewTypeNode *next;
};
NewTypeObject 类型的底层结构其实就是一个 Long 类型的单向链表。可以根据需求,把 NewTypeObject 的底层结构定义为其他类型。 例如,想要 NewTypeObject 的查询效率比链表高,可以把它的底层结构设计成一颗 B+ 树。
第二步:在 RedisObject 的 type 属性中增加新类型的定义:
#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;
}
createNewTypeObject 分别调用了 newtypeNew 和 createObject 两个函数:
newtypeNew 函数是用来为新数据类型初始化内存结构的。初始化过程主要是用 zmalloc 做底层结构分配空间,以便写入数据。
NewTypeObject *newtypeNew(void){
NewTypeObject *n = zmalloc(sizeof(*n));
n->head = NULL;
n->len = 0;
return n;
}
newtypeNew 函数涉及到新数据类型的具体创建,而 Redis 默认会为每个数据类型定义一 个单独文件,实现这个类型的创建和命令操作,例如,t_string.c 和 t_list.c 分别对应 String 和 List 类型。按照 Redis 的惯例把 newtypeNew 函数定义在名为 t_newtype.c 的文件中。
createObject 是 Redis 本身提供的创建 RedisObject 的函数,参数是数据类型的 type 和指向数据类型实现的指针*ptr。 给 createObject 函数中传入了两个参数,分别是新类型的 type 值 OBJ_NEWTYPE, 以及指向一个初始化过的 NewTypeObjec 的指针。创建的 RedisObject 就能指向自定义的新数据类型了。
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->ptr = ptr;
...
return o;
}
对于释放函数来说,它是创建函数的反过程,是用 zfree 命令把新结构的内存空间释放掉。
第四步:开发新类型的命令操作 ;
增加相应的命令操作的过程分成三小步:
void ntinsertCommand(client *c){
//基于客户端传递的参数,实现在NewTypeObject链表头插入元素 }
void ntinsertCommand(client *c)
struct redisCommand redisCommandTable[] = {
...
{"ntinsert",ntinsertCommand,2,"m",...}
}
GEO 可以记录经纬度形式的地理位置信息,被广泛地应用在 LBS 服务中。
GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。
GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,两个关键机制是对二维地图做区间划分,以及对区间进行编码。一组经纬度落在某个区间后,用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。这样就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
GEO 属于 Redis 提供的扩展数据类型。
扩展数据类型有两种实现途径: