es区域查找底层原理
Api如何调用
- 映射
PUT /test
{
"mappings":{
"properties":{
"name":{
"type":"text"
},
"location":{
"type":"geo_point"
}
}
}
}
- 插入数据
PUT /test/_doc/1
## 字符串形式
{
"name":"NetEase",
"location":"40.715,74.011"
}
## 对象形式
{
"name":"Sina",
"location":{
"lat":40.722,
"lon":73.989
}
}
## 数组形式
{
"name":"Baidu",
"location":[
73.983,
40.719
]
}
字符串形式以半角逗号分割,如 “lat,lon”
对象形式显式命名为 lat 和 lon
数组形式表示为 [lon,lat]
- 查询
过滤器 | 作用 |
---|---|
geo_bounding_box | 找出落在指定矩形框中的点 |
geo_distance | 找出与指定位置在给定距离内的点 |
geo_distance_range | 找出与指定点距离在给定最小距离和最大距离之间的点 |
geo_polygon | 找出落在多边形中的点。 |
- 常用查询
- geo_bounding_box
指定一个矩形的顶部 ,底部 , 左边界和右边界,然后过滤器只需判断坐标的经度是否在左右边界之间,纬度是否在上下边界之间
{
"query":{
"bool":{
"must":{
"match_all":{
}
},
"filter":{
"geo_bounding_box":{
"location":{
"top_left":{
"lat":40.73,
"lon":71.12
},
"bottom_right":{
"lat":40.01,
"lon":74.1
}
}
}
}
}
}
}
- geo_distance
过滤仅包含与地理位置相距特定距离内的匹配的文档。
{
"query":{
"bool":{
"must":{
"match_all":{
}
},
"filter":{
"geo_distance":{
"distance":"200km",
"location":{
"lat":40,
"lon":70
}
}
}
}
}
}
原理分析
kd树思想
什么是kd树
- k-dimensional(维度)树
- 分割k维数据空间的数据结构
- 是一颗二叉树
- 切分维度上,左子数值小于右子树
kd树是怎么构建的
地点 | 经度 | 维度 |
---|---|---|
自如 | 3 | 199 |
贝壳 | 2 | 132 |
链家 | 5 | 87 |
德祐 | 65 | 12 |
望京soho | 127 | 14 |
将台 | 114 | 3 |
步骤一:
数据维度k=2
划分维度= 0
1.对划分维度的数据进行排序
[2,132],[3,199],[5,87],[65,12],[114,3],[127,14]
2.取中位数
index = 6/2 = 3
[65,12]
步骤二:
数据维度k=2
划分维度=(0+1%2)=1
步骤三:
数据维度k=2
划分维度=(1+1%2)=0
换种方式理解kd树的构建
在一个二维的平面当中分布着若干个点。
我们首先选择一个维度将这些数据一分为二,比如我们选择x轴。我们对所有数据按照x轴的值排序,选出其中的中点进行一分为二。
在这根线左右两侧的点被分成了两棵子树,对于这两个部分的数据来说,我们更换一个维度,也就是选择y轴进行划分。一样,我们先排序,然后找到中间的点,再次一分为二。我们可以得到:
我们重复上述过程,一直将点分到不能分为止
转成树就变成了这样
kd树怎么搜索的
以查询(2.1,3.1)为例:
- 二叉树搜索:先从(7,2)点开始进行二叉查找,然后到达(5,4),最后到达(2,3),此时搜索路径中的节点为<(7,2),(5,4),(2,3)>,首先以(2,3)作为当前最近邻点,计算其到查询点(2.1,3.1)的距离为0.1414,
- 回溯查找:在得到(2,3)为查询点的最近点之后,回溯到其父节点(5,4),并判断在该父节点的其他子节点空间中是否有距离查询点更近的数据点。以(2.1,3.1)为圆心,以0.1414为半径画圆,如下图所示。发现该圆并不和超平面y = 4交割,因此不用进入(5,4)节点右子空间中(图中灰色区域)去搜索;
- 最后,再回溯到(7,2),以(2.1,3.1)为圆心,以0.1414为半径的圆更不会与x = 7超平面交割,因此不用进入(7,2)右子空间进行查找。至此,搜索路径中的节点已经全部回溯完,结束整个搜索,返回最近邻点(2,3),最近距离为0.1414。
一个复杂点了例子如查找点为(2,4.5),具体步骤依次如下:
- 同样先进行二叉查找,先从(7,2)查找到(5,4)节点,在进行查找时是由y = 4为分割超平面的,由于查找点为y值为4.5,因此进入右子空间查找到(4,7),形成搜索路径<(7,2),(5,4),(4,7)>,但(4,7)与目标查找点的距离为3.202,而(5,4)与查找点之间的距离为3.041,所以(5,4)为查询点的最近点;
- 以(2,4.5)为圆心,以3.041为半径作圆,如下图所示。可见该圆和y = 4超平面交割,所以需要进入(5,4)左子空间进行查找,也就是将(2,3)节点加入搜索路径中得<(7,2),(2,3)>;于是接着搜索至(2,3)叶子节点,(2,3)距离(2,4.5)比(5,4)要近,所以最近邻点更新为(2,3),最近距离更新为1.5;
- 回溯查找至(5,4),直到最后回溯到根结点(7,2)的时候,以(2,4.5)为圆心1.5为半径作圆,并不和x = 7分割超平面交割,如下图所示。至此,搜索路径回溯完,返回最近邻点(2,3),最近距离1.5。
- 再举个例子,思考下这种情况。寻找s最近的点,是个怎样的过程。
Redis是如何实现区域查找的?
Redis区域查找相关API
- geoadd:添加经纬度坐标和对应地理位置名称。
- geopos:获取地理位置的经纬度坐标。
- geodist:计算两个地理位置的距离。
- georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
- georadiusbymember:根据储存在位置集合里面的某个地点获取指定范围内的地理位置集合。
- geohash:计算一个或者多个经纬度坐标点的geohash值。
Api案例
geoadd
geoadd 用于存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
geoadd 语法格式如下:
GEOADD key longitude latitude member [longitude latitude member …]
redis> GEOADD Sicily 13.361389 38.115556 "BeiJing" 15.087269 37.502669 "ShangHai"
(integer) 2
redis> GEODIST Sicily BeiJing ShangHai
"166274.1516"
redis> GEORADIUS Sicily 15 37 100 km
1) "BeiJing"
redis> GEORADIUS Sicily 15 37 200 km
1) "BeiJing"
2) "ShangHai"
redis>
geopos
geopos 用于从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
geopos 语法格式如下:
GEOPOS key member [member …]
redis> GEOADD Sicily 13.361389 38.115556 "BeiJing" 15.087269 37.502669 "ShangHai"
(integer) 2
redis> GEOPOS Sicily BeiJing ShangHai NonExisting
1) 1) "13.36138933897018433"
2) "38.11555639549629859"
2) 1) "15.08726745843887329"
2) "37.50266842333162032"
3) (nil)
redis>
geodist
geodist 用于返回两个给定位置之间的距离。
geodist 语法格式如下:
GEODIST key member1 member2 [m|km|ft|mi]
member1 member2 为两个地理位置。
最后一个距离单位参数说明:
m :米,默认单位。
km :千米。
mi :英里。
ft :英尺
redis> GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
(integer) 2
redis> GEODIST Sicily Palermo Catania
"166274.1516"
redis> GEODIST Sicily Palermo Catania km
"166.2742"
redis> GEODIST Sicily Palermo Catania mi
"103.3182"
redis> GEODIST Sicily Foo Bar
(nil)
redis>
georadius、georadiusbymember
georadius 以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。
georadiusbymember 和 GEORADIUS 命令一样, 都可以找出位于指定范围内的元素, 但是 georadiusbymember 的中心点是由给定的位置元素决定的, 而不是使用经度和纬度来决定中心点。
georadius 与 georadiusbymember 语法格式如下:
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD]
[WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
redis> GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
(integer) 2
redis> GEORADIUS Sicily 15 37 200 km WITHDIST
1) 1) "Palermo"
2) "190.4424"
2) 1) "Catania"
2) "56.4413"
redis> GEORADIUS Sicily 15 37 200 km WITHCOORD
1) 1) "Palermo"
2) 1) "13.36138933897018433"
2) "38.11555639549629859"
2) 1) "Catania"
2) 1) "15.08726745843887329"
2) "37.50266842333162032"
redis> GEORADIUS Sicily 15 37 200 km WITHDIST WITHCOORD
1) 1) "Palermo"
2) "190.4424"
3) 1) "13.36138933897018433"
2) "38.11555639549629859"
2) 1) "Catania"
2) "56.4413"
3) 1) "15.08726745843887329"
2) "37.50266842333162032"
redis>
参数说明:
m :米,默认单位。
km :千米。
mi :英里。
ft :英尺。
WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。
WITHCOORD: 将位置元素的经度和维度也一并返回。
WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
COUNT 限定返回的记录数。
ASC: 查找结果根据距离从近到远排序。
DESC: 查找结果根据从远到近排序。
redis> GEOADD Sicily 13.583333 37.316667 "Agrigento"
(integer) 1
redis> GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
(integer) 2
redis> GEORADIUSBYMEMBER Sicily Agrigento 100 km
1) "Agrigento"
2) "Palermo"
redis>
GeoHash
geohash是2008年Gustavo Niemeye发明用来编码经纬度信息的一种编码方式,比如北京市中心的经纬度坐标是116.404844,39.912279,通过12位geohash编码后就变成了wx4g0cg3vknd,它究竟是如何实现的?其实原理非常简单,就是二分,整个编码过程可以分为如下几步
- 转二进制
上过初中地理的我们都知道,地球上如何一个点就可以标识为某个经纬度坐标,经度的取值范围是东经0-180度和西经0-180度,维度的取值范围是北纬0到90和南纬0-90度。去掉东西南北,可以分别认为经度和维度的取值范围为[-180,180]和[-90,90]。
我们先来看经度,[-180,180]可以简单分成两个部分[-180,0]和[0,180],对于给定的一个具体值,我们用一个bit来标识是在[-180,0]还是[0,180]区间里。然后我们可以对这两个子区间继续细分,用更多的bit来标识是这个值是在哪个子区间里。就好比用二分查找,记录下每次查找的路径,往左就是0往右是1,查找完后我们就会得到一个0101的串,这个串就可以用来标识这个经度值。
同理维度也是一样,只不过他的取值返回变成了[-90,90]而已。通过这两种方式编码完成后,任意经纬度我们都可以得到两个由0和1组成的串。
比如还是北京市中心的经纬度坐标 116.404844,39.912279,我们先对116.404844做编码,得到其二进制为:
11010010110001101101
然后我们对维度39.912279编码得到二进制为
10111000110000111001
- 经纬度二进制合并
接下来我们只需要将上述二进制交错合并成一个即可,这里注意经度占偶数位,纬度占奇数位,得到最终的二进制。
1101101110000200111100000001111011010011
- 将合并后的二进制做base32编码
最后我们将合并后的二进制做base32编码,将连续5位转化为一个0-31的十进制数,然后用对应的字符代替,将所有二进制位处理完后我们就完成了base32编码。编码表如下:
最终得到geohash值wx4g0cg3vknd。
geohash是将空间不断的二分,然后将二分的路径转化为base32编码,最后保存下来,从原理可以看出,geohash表示的是一个区间,而不是一个点,geohash值越长,这个区间就越小,标识的位置也就越精确,下图是维基百科中不同长度geohash下的经纬度误差(lat:维度,lng:经度)
geohash的用途及问题
geohash成功的将一个二维信息编码成了一个一维信息,这样编码我觉得有两个好处:1. 编码后数据长度变短,利于节省存储。2. 利于使用前缀检索。我们来详细说下第二点。
从上文中geohash的实现来看,只要两个坐标点的geohash有共同的前缀,你们我们就可以肯定这两个点在同一个区域内 (区域大小取决于共同前缀的长度)。这种特性给我们带来的好处就是,我们可以把所有坐标点按geohash做增序索引,然后查找的时候按前缀筛选,大幅提升检索的性能。
举个例子,假设我要找北京国贸附近3公里内的餐馆,已知国贸的geohash是wx4g41,那我也很轻易就可以计算出来我需要扫描哪些区域内的点。但有个点需要注意,上文我已经提到过,geohash值实际上是代表一个区域,而不是一个点,找到一批候选点之后还需要遍历一次计算下精确距离。
Redis Geo源码
geoadd
/* GEOADD key [CH] [NX|XX] long lat name [long2 lat2 name2 ... longN latN nameN] */
void geoaddCommand(client *c) {
int xx = 0, nx = 0, longidx = 2;
int i;
/* 解析可选参数 */
while (longidx < c->argc) {
char *opt = c->argv[longidx]->ptr;
if (!strcasecmp(opt,"nx")) nx = 1;
else if (!strcasecmp(opt,"xx")) xx = 1;
else if (!strcasecmp(opt,"ch")) {}
else break;
longidx++;
}
if ((c->argc - longidx) % 3 || (xx && nx)) {
/* 解析所有的经纬度值和member,并对其个数做校验 */
addReplyErrorObject(c,shared.syntaxerr);
return;
}
/* 构建zadd的参数数组 */
int elements = (c->argc - longidx) / 3;
int argc = longidx+elements*2; /* ZADD key [CH] [NX|XX] score ele ... */
robj **argv = zcalloc(argc*sizeof(robj*));
argv[0] = createRawStringObject("zadd",4);
for (i = 1; i < longidx; i++) {
argv[i] = c->argv[i];
incrRefCount(argv[i]);
}
/* 以3个参数为一组,将所有的经纬度和member信息从参数列表里解析出来,并放到zadd的参数数组中 */
for (i = 0; i < elements; i++) {
double xy[2];
if (extractLongLatOrReply(c, (c->argv+longidx)+(i*3),xy) == C_ERR) {
for (i = 0; i < argc; i++)
if (argv[i]) decrRefCount(argv[i]);
zfree(argv);
return;
}
/* 将经纬度坐标转化成score信息 */
GeoHashBits hash;
geohashEncodeWGS84(xy[0], xy[1], GEO_STEP_MAX, &hash);
GeoHashFix52Bits bits = geohashAlign52Bits(hash);
robj *score = createObject(OBJ_STRING, sdsfromlonglong(bits));
robj *val = c->argv[longidx + i * 3 + 2];
argv[longidx+i*2] = score;
argv[longidx+1+i*2] = val;
incrRefCount(val);
}
/* 转化成zadd命令所需要的参数格式*/
replaceClientCommandVector(c,argc,argv);
zaddCommand(c);
}
georadius
void georadiusGeneric(client *c, int srcKeyIndex, int flags) {
robj *storekey = NULL;
int storedist = 0; /* 0 for STORE, 1 for STOREDIST. */
/* 根据key找找到对应的zojb */
robj *zobj = NULL;
if ((zobj = lookupKeyReadOrReply(c, c->argv[srcKeyIndex], shared.emptyarray)) == NULL ||
checkType(c, zobj, OBJ_ZSET)) {
return;
}
/* 解析请求中的经纬度值 */
int base_args;
GeoShape shape = {0};
if (flags & RADIUS_COORDS) {
/*
* 各种必选参数的解析,省略细节代码,主要是解析坐标点信息和半径
*/
}
/* 解析所有的可选参数. */
int withdist = 0, withhash = 0, withcoords = 0;
int frommember = 0, fromloc = 0, byradius = 0, bybox = 0;
int sort = SORT_NONE;
int any = 0; /* any=1 means a limited search, stop as soon as enough results were found. */
long long count = 0; /* Max number of results to return. 0 means unlimited. */
if (c->argc > base_args) {
/*
* 各种可选参数的解析,省略细节代码
*/
}
/* Get all neighbor geohash boxes for our radius search
* 获取到要查找范围内所有的9个geo邻域 */
GeoHashRadius georadius = geohashCalculateAreasByShapeWGS84(&shape);
/* 创建geoArray存储结果列表 */
geoArray *ga = geoArrayCreate();
/* 扫描9个区域中是否有满足条的点,有就放到geoArray中 */
membersOfAllNeighbors(zobj, georadius, &shape, ga, any ? count : 0);
/* 如果没有匹配结果,返回空对象 */
if (ga->used == 0 && storekey == NULL) {
addReply(c,shared.emptyarray);
geoArrayFree(ga);
return;
}
long result_length = ga->used;
long returned_items = (count == 0 || result_length < count) ?
result_length : count;
long option_length = 0;
/*
* 后续一些参数逻辑,比如处理排序,存储……
*/
// 释放geoArray占用的空间
geoArrayFree(ga);
}
Redis Geo流程总结
- 解析请求参数。
- 计算目标坐标所在的geohash和8个邻居。
- 在zset中查找这9个区域中满足距离限制的所有点集。
- 处理排序等后续逻辑。
- 清理临时存储空间。