作者简介:万汨,饿了么资深开发工程师。iOS,Go,Java均有涉猎。目前主攻大数据开发。喜欢骑行、爬山。

前言:针对“附近的人”这一位置服务领域的应用场景,常见的可使用PG、MySQL和MongoDB等多种DB的空间索引进行实现。而Redis另辟蹊径,结合其有序队列zset以及geohash编码,实现了空间搜索功能,且拥有极高的运行效率。

本文将从源码角度对其算法原理进行解析,并推算查询时间复杂度。

要提供完整的“附近的人”服务,最基本的是要实现“增”、“删”、“查”的功能。以下将分别进行介绍,其中会重点对查询功能进行解析。

操作命令

自Redis 3.2开始,Redis基于geohash和有序集合提供了地理位置相关功能。Redis Geo模块包含了以下6个命令:

  • GEOADD: 将给定的位置对象(纬度、经度、名字)添加到指定的key;

  • GEOPOS: 从key里面返回所有给定位置对象的位置(经度和纬度);

  • GEODIST: 返回两个给定位置之间的距离;

  • GEOHASH: 返回一个或多个位置对象的Geohash表示;

  • GEORADIUS: 以给定的经纬度为中心,返回目标集合中与中心的距离不超过给定最大距离的所有位置对象;

  • GEORADIUSBYMEMBER: 以给定的位置对象为中心,返回与其距离不超过给定最大距离的所有位置对象。

  • 另外,公众 号Java精选,回复java面试,获取面试资料,支持在线刷题。

其中,组合使用GEOADD和GEORADIUS可实现“附近的人”中“增”和“查”的基本功能。

要实现微信中“附近的人”功能,可直接使用GEORADIUSBYMEMBER命令。其中“给定的位置对象”即为用户本人,搜索的对象为其他用户。

不过本质上,GEORADIUSBYMEMBER = GEOPOS + GEORADIUS,即先查找用户位置再通过该位置搜索附近满足位置相互距离条件的其他用户对象。

以下会从源码角度入手对GEOADD和GEORADIUS命令进行分析,剖析其算法原理。

Redis geo操作中只包含了“增”和“查”的操作,并没有专门的“删除”命令。主要是因为Redis内部使用有序集合(zset)保存位置对象,可用zrem进行删除。

在Redis源码geo.c的文件注释中,只说明了该文件为GEOADD、GEORADIUS和GEORADIUSBYMEMBER的实现文件(其实在也实现了另三个命令)。从侧面看出其他三个命令为辅助命令。

GEOADD

使用方式

GEOADD key longitude latitude member [longitude latitude member ...]

将给定的位置对象(纬度、经度、名字)添加到指定的key。面试宝典:https://www.yoodb.com

其中,key为集合名称,member为该经纬度所对应的对象。在实际运用中,当所需存储的对象数量过多时,可通过设置多key(如一个省一个key)的方式对对象集合变相做sharding,避免单集合数量过多。

成功插入后的返回值:

(integer) N

其中N为成功插入的个数。

源码分析

/* GEOADD key long lat name [long2 lat2 name2 ... longN latN nameN] */
void geoaddCommand(client *c) {//参数校验/* Check arguments number for sanity. */if ((c->argc - 2) % 3 != 0) {/* Need an odd number of arguments if we got this far... */addReplyError(c, "syntax error. Try GEOADD key [x1] [y1] [name1] ""[x2] [y2] [name2] ... ");return;}//参数提取Redisint elements = (c->argc - 2) / 3;int argc = 2+elements*2; /* ZADD key score ele ... */robj **argv = zcalloc(argc*sizeof(robj*));argv[0] = createRawStringObject("zadd",4);argv[1] = c->argv[1]; /* key */incrRefCount(argv[1]);//参数遍历+转换/* Create the argument vector to call ZADD in order to add all* the score,value pairs to the requested zset, where score is actually* an encoded version of lat,long. */int i;for (i = 0; i < elements; i++) {double xy[2];//提取经纬度if (extractLongLatOrReply(c, (c->argv+2)+(i*3),xy) == C_ERR) {for (i = 0; i < argc; i++)if (argv[i]) decrRefCount(argv[i]);zfree(argv);return;}//将经纬度转换为52位的geohash作为分值 & 提取对象名称/* Turn the coordinates into the score of the element. */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[2 + i * 3 + 2];//设置有序集合的对象元素名称和分值argv[2+i*2] = score;argv[3+i*2] = val;incrRefCount(val);}//调用zadd命令,存储转化好的对象/* Finally call ZADD that will do the work for us. */replaceClientCommandVector(c,argc,argv);zaddCommand(c);
}

通过源码分析可以看出Redis内部使用有序集合(zset)保存位置对象,有序集合中每个元素都是一个带位置的对象,元素的score值为其经纬度对应的52位的geohash值。

double类型精度为52位;
geohash是以base32的方式编码,52bits最高可存储10位geohash值,对应地理区域大小为0.6*0.6米的格子。换句话说经Redis geo转换过的位置理论上会有约0.3*1.414=0.424米的误差。

算法小结

简单总结下GEOADD命令都干了啥:
1、参数提取和校验;
2、将入参经纬度转换为52位的geohash值(score);
3、调用ZADD命令将member及其对应的score存入集合key中。


GEORADIUS

使用方式

GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [ASC|DESC] [COUNT count] [STORE key] [STORedisT key]

以给定的经纬度为中心,返回目标集合中与中心的距离不超过给定最大距离的所有位置对象。

范围单位:m | km | ft | mi --> 米 | 千米 | 英尺 | 英里

额外参数:

  • WITHDIST:在返回位置对象的同时,将位置对象与中心之间的距离也一并返回。距离的单位和用户给定的范围单位保持一致。

  • WITHCOORD:将位置对象的经度和维度也一并返回。

  • WITHHASH:以 52 位有符号整数的形式,返回位置对象经过原始 geohash 编码的有序集合分值。这个选项主要用于底层应用或者调试,实际中的作用并不大。

  • ASC|DESC:从近到远返回位置对象元素 | 从远到近返回位置对象元素。- COUNT count:选取前N个匹配位置对象元素。(不设置则返回所有元素) - STORE key:将返回结果的地理位置信息保存到指定key。- STORedisT key:将返回结果离中心点的距离保存到指定key。

由于 STORE 和 STORedisT 两个选项的存在,GEORADIUS 和 GEORADIUSBYMEMBER 命令在技术上会被标记为写入命令,从而只会查询(写入)主实例,QPS过高时容易造成主实例读写压力过大。

为解决这个问题,在 Redis 3.2.10 和 Redis 4.0.0 中,分别新增了 GEORADIUS_RO 和 GEORADIUSBYMEMBER_RO两个只读命令。

不过,在实际开发中笔者发现 在java package Redis.clients.jedis.params.geo 的 GeoRadiusParam 参数类中并不包含 STORE 和 STORedisT 两个参数选项,在调用georadius时是否真的只查询了主实例,还是进行了只读封装。感兴趣的朋友可以自己研究下。

成功查询后的返回值:

不带WITH限定,返回一个member list,如:

["member1","member2","member3"]

带WITH限定,member list中每个member也是一个嵌套list,如:

[["member1", distance1, [longitude1, latitude1]]["member2", distance2, [longitude2, latitude2]]
]

源码分析

此段源码较长,看不下去的可直接看中文注释,或直接跳到小结部分。

/* GEORADIUS key x y radius unit [WITHDIST] [WITHHASH] [WITHCOORD] [ASC|DESC]*                               [COUNT count] [STORE key] [STORedisT key]* GEORADIUSBYMEMBER key member radius unit ... options ... */
void georadiusGeneric(client *c, int flags) {robj *key = c->argv[1];robj *storekey = NULL;int stoRedist = 0; /* 0 for STORE, 1 for STORedisT. *///根据key获取有序集合robj *zobj = NULL;if ((zobj = lookupKeyReadOrReply(c, key, shared.null[c->resp])) == NULL ||checkType(c, zobj, OBJ_ZSET)) {return;}//根据用户输入(经纬度/member)确认中心点经纬度int base_args;double xy[2] = { 0 };if (flags & RADIUS_COORDS) {……}//获取查询范围距离double radius_meters = 0, conversion = 1;if ((radius_meters = extractDistanceOrReply(c, c->argv + base_args - 2,&conversion)) < 0) {return;}//获取可选参数 (withdist、withhash、withcoords、sort、count)int withdist = 0, withhash = 0, withcoords = 0;int sort = SORT_NONE;long long count = 0;if (c->argc > base_args) {... ...}//获取 STORE 和 STORedisT 参数if (storekey && (withdist || withhash || withcoords)) {addReplyError(c,"STORE option in GEORADIUS is not compatible with ""WITHDIST, WITHHASH and WITHCOORDS options");return;}//设定排序if (count != 0 && sort == SORT_NONE) sort = SORT_ASC;//利用中心点和半径计算目标区域范围GeoHashRadius georadius =geohashGetAreasByRadiusWGS84(xy[0], xy[1], radius_meters);//对中心点及其周围8个geohash网格区域进行查找,找出范围内元素对象geoArray *ga = geoArrayCreate();membersOfAllNeighbors(zobj, georadius, xy[0], xy[1], radius_meters, ga);//未匹配返空/* If no matching results, the user gets an empty reply. */if (ga->used == 0 && storekey == NULL) {addReplyNull(c);geoArrayFree(ga);return;}//一些返回值的设定和返回……geoArrayFree(ga);
}

上文代码中最核心的步骤有两个,一是“计算中心点范围”,二是“对中心点及其周围8个geohash网格区域进行查找”。

对应的是geohashGetAreasByRadiusWGS84membersOfAllNeighbors两个函数。

我们依次来看:

  • 计算中心点范围:

// geohash_helper.c

GeoHashRadius geohashGetAreasByRadiusWGS84(double longitude, double latitude,double radius_meters) {return geohashGetAreasByRadius(longitude, latitude, radius_meters);
}//返回能够覆盖目标区域范围的9个geohashBox
GeoHashRadius geohashGetAreasByRadius(double longitude, double latitude, double radius_meters) {
//一些参数设置,公众 号Java精选,回复java面试,获取面试资料GeoHashRange long_range, lat_range;GeoHashRadius radius;GeoHashBits hash;GeoHashNeighbors neighbors;GeoHashArea area;double min_lon, max_lon, min_lat, max_lat;double bounds[4];int steps;//计算目标区域外接矩形的经纬度范围(目标区域为:以目标经纬度为中心,半径为指定距离的圆)geohashBoundingBox(longitude, latitude, radius_meters, bounds);min_lon = bounds[0];min_lat = bounds[1];max_lon = bounds[2];max_lat = bounds[3];//根据目标区域中心点纬度和半径,计算带查询的9个搜索框的geohash精度(位)
//这里用到latitude主要是针对极地的情况对精度进行了一些调整(纬度越高,位数越小)steps = geohashEstimateStepsByRadius(radius_meters,latitude);//设置经纬度最大最小值:-180<=longitude<=180, -85<=latitude<=85geohashGetCoordRange(&long_range,&lat_range);//将待查经纬度按指定精度(steps)编码成geohash值geohashEncode(&long_range,&lat_range,longitude,latitude,steps,&hash);//将geohash值在8个方向上进行扩充,确定周围8个Box(neighbors)geohashNeighbors(&hash,&neighbors);//根据hash值确定area经纬度范围geohashDecode(long_range,lat_range,hash,&area);//一些特殊情况处理……//构建并返回结果    radius.hash = hash;radius.neighbors = neighbors;radius.area = area;return radius;
}
  • 对中心点及其周围8个geohash网格区域进行查找:

// geo.c

//在9个hashBox中获取想要的元素
int membersOfAllNeighbors(robj *zobj, GeoHashRadius n, double lon, double lat, double radius, geoArray *ga) {GeoHashBits neighbors[9];unsigned int i, count = 0, last_processed = 0;int debugmsg = 0;//获取9个搜索hashBoxneighbors[0] = n.hash;……neighbors[8] = n.neighbors.south_west;//在每个hashBox中搜索目标点for (i = 0; i < sizeof(neighbors) / sizeof(*neighbors); i++) {if (HASHISZERO(neighbors[i])) {if (debugmsg) D("neighbors[%d] is zero",i);continue;}//剔除可能的重复hashBox (搜索半径>5000KM时可能出现)if (last_processed &&neighbors[i].bits == neighbors[last_processed].bits &&neighbors[i].step == neighbors[last_processed].step){continue;}//搜索hashBox中满足条件的对象    count += membersOfGeoHashBox(zobj, neighbors[i], ga, lon, lat, radius);last_processed = i;}return count;
}int membersOfGeoHashBox(robj *zobj, GeoHashBits hash, geoArray *ga, double lon, double lat, double radius) {
//获取hashBox内的最大、最小geohash值(52位)GeoHashFix52Bits min, max;scoresOfGeoHashBox(hash,&min,&max);//根据最大、最小geohash值筛选zobj集合中满足条件的点return geoGetPointsInRange(zobj, min, max, lon, lat, radius, ga);
}int geoGetPointsInRange(robj *zobj, double min, double max, double lon, double lat, double radius, geoArray *ga) {//搜索Range的参数边界设置(即9个hashBox其中一个的边界范围)zrangespec range = { .min = min, .max = max, .minex = 0, .maxex = 1 };size_t origincount = ga->used;sds member;//搜索集合zobj可能有ZIPLIST和SKIPLIST两种编码方式,这里以SKIPLIST为例,逻辑是一样的if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {……} else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {zset *zs = zobj->ptr;zskiplist *zsl = zs->zsl;zskiplistNode *ln;//获取在hashBox范围内的首个元素(跳表数据结构,效率可比拟于二叉查找树),没有则返0if ((ln = zslFirstInRange(zsl, &range)) == NULL) {/* Nothing exists starting at our min.  No results. */return 0;}//从首个元素开始遍历集合while (ln) {sds ele = ln->ele;//遍历元素超出range范围则break/* Abort when the node is no longer in range. */if (!zslValueLteMax(ln->score, &range))break;//元素校验(计算元素与中心点的距离)ele = sdsdup(ele);if (geoAppendIfWithinRadius(ga,lon,lat,radius,ln->score,ele)== C_ERR) sdsfree(ele);ln = ln->level[0].forward;}}return ga->used - origincount;
}int geoAppendIfWithinRadius(geoArray *ga, double lon, double lat, double radius, double score, sds member) {double distance, xy[2];//解码错误, 返回errorif (!decodeGeohash(score,xy)) return C_ERR; /* Can't decode. *///最终距离校验(计算球面距离distance看是否小于radius)if (!geohashGetDistanceIfInRadiusWGS84(lon,lat, xy[0], xy[1],radius, &distance)){return C_ERR;}//构建并返回满足条件的元素geoPoint *gp = geoArrayAppend(ga);gp->longitude = xy[0];gp->latitude = xy[1];gp->dist = distance;gp->member = member;gp->score = score;return C_OK;
}

算法小结

抛开众多可选参数不谈,简单总结下GEORADIUS命令是怎么利用geohash获取目标位置对象的:

1、参数提取和校验;

2、利用中心点和输入半径计算待查区域范围。这个范围参数包括满足条件的最高的geohash网格等级(精度) 以及 对应的能够覆盖目标区域的九宫格位置;(后续会有详细说明,面试宝典:https://www.yoodb.com)

3、对九宫格进行遍历,根据每个geohash网格的范围框选出位置对象。进一步找出与中心点距离小于输入半径的对象,进行返回。

直接描述不太好理解,我们通过如下两张图在对算法进行简单的演示:

图片

图片

令左图的中心为搜索中心,绿色圆形区域为目标区域,所有点为待搜索的位置对象,红色点则为满足条件的位置对象。

在实际搜索时,首先会根据搜索半径计算geohash网格等级(即右图中网格大小等级),并确定九宫格位置(即红色九宫格位置信息);再依次查找计算九宫格中的点(蓝点和红点)与中心点的距离,最终筛选出距离范围内的点(红点)。

算法分析

为什么要用这种算法策略进行查询,或者说这种策略的优势在哪,让我们以问答的方式进行分析说明。

为什么要找到满足条件的最高的geohash网格等级?为什么用九宫格?

这其实是一个问题,本质上是对所有的元素对象进行了一次初步筛选。 在多层geohash网格中,每个低等级的geohash网格都是由4个高一级的网格拼接而成(如图)。

图片

换句话说,geohash网格等级越高,所覆盖的地理位置范围就越小。当我们根据输入半径和中心点位置计算出的能够覆盖目标区域的最高等级的九宫格(网格)时,就已经对九宫格外的元素进行了筛除。

这里之所以使用九宫格,而不用单个网格,主要原因还是为了避免边界情况,尽可能缩小查询区域范围。试想以0经纬度为中心,就算查1米范围,单个网格覆盖的话也得查整个地球区域。而向四周八个方向扩展一圈可有效避免这个问题。

如何通过geohash网格的范围框选出元素对象?效率如何?

首先在每个geohash网格中的geohash值都是连续的,有固定范围。所以只要找出有序集合中,处在该范围的位置对象即可。以下是有序集合的跳表数据结构:

图片

其拥有类似二叉查找树的查询效率,操作平均时间复杂性为O(log(N))。且最底层的所有元素都以链表的形式按序排列。

所以在查询时,只要找到集合中处在目标geohash网格中的第一个值,后续依次对比即可,不用多次查找。

九宫格不能一起查,要一个个遍历的原因也在于九宫格各网格对应的geohash值不具有连续性。只有连续了,查询效率才会高,不然要多做许多距离运算。

综上,我们从源码角度解析了Redis Geo模块中 “增(GEOADD)” 和 “查(GEORADIUS)” 的详细过程。并可推算出Redis中GEORADIUS查找附近的人功能,时间复杂度为:O(N+log(M))

其中N为指定半径范围内的位置元素数量,而M则是被九宫格圈住计算距离的元素的数量。结合Redis本身基于内存的存储特性,在实际使用过程中有非常高的运行效率。

作者:阿里本地生活技术团队

https://juejin.cn/post/6844903966061363207

公众号“Java精选”所发表内容注明来源的,版权归原出处所有(无法查证版权的或者未注明出处的均来自网络,系转载,转载的目的在于传递更多信息,版权属于原作者。如有侵权,请联系,笔者会第一时间删除处理!
最近有很多人问,有没有读者交流群!加入方式很简单,公众号Java精选,回复“加群”,即可入群!Java精选面试题(微信小程序):3000+道面试题,包含Java基础、并发、JVM、线程、MQ系列、Redis、Spring系列、Elasticsearch、Docker、K8s、Flink、Spark、架构设计等,在线随时刷题!
------ 特别推荐 ------
特别推荐:专注分享最前沿的技术与资讯,为弯道超车做好准备及各种开源项目与高效率软件的公众号,「大咖笔记」,专注挖掘好东西,非常值得大家关注。点击下方公众号卡片关注。点击“阅读原文”,了解更多精彩内容!文章有帮助的话,点在看,转发吧!

简单几步,实现 Redis 查询 “附近的人”!相关推荐

  1. 如何用 Redis 查询 “附近的人” ?

    点击关注公众号:互联网架构师,后台回复 2T获取2TB学习资源! 上一篇:Alibaba开源内网高并发编程手册.pdf 来源:juejin.cn/post/6844903966061363207 作者 ...

  2. Redis进阶实践之十八 使用管道模式提高Redis查询的速度

    Redis进阶实践之十八 使用管道模式提高Redis查询的速度 原文:Redis进阶实践之十八 使用管道模式提高Redis查询的速度 一.引言 学习redis 也有一段时间了,该接触的也差不多了.后来 ...

  3. springboot controller 分页查询_Spring Boot实战分页查询附近的人:Redis+GeoHash+Lua

    前言 最近在做社交的业务,用户进入首页后需要查询附近的人: 项目状况:前期尝试业务阶段: 特点: 快速实现(不需要做太重,满足初期推广运营即可) 快速投入市场去运营 收集用户的经纬度: 用户在每次启动 ...

  4. 云信小课堂丨简单四步,快速搭建协同办公系统!

    Vol. 8 企业作为社会分工参与的主体,在自身发展以及内部协作中,势必会涉及到各类自有技术和商业信息的互动与交流,而这类信息由于有着高度的保密性和隐私性,所以如微信.QQ 等通用的即时通讯工具很难满 ...

  5. springboot controller 分页查询_Spring Boot实战分页查询附近的人: Redis+GeoHash+Lua

    您的支持是我不断创作巨大动力 CSDN博客地址(关注,点赞) 人工智能推荐 GitHub(Star,Fork,Watch) 前言 最近在做社交的业务,用户进入首页后需要查询附近的人: 项目状况:前期尝 ...

  6. “果粉”速递:简单9步轻松绕过iCloud 激活锁

    本文讲的是"果粉"速递:简单9步轻松绕过iCloud 激活锁,如何在iPhone 7(Plus).6S Plus.6S.6 Plus.6.5S.5C.4S.4以及iPad或iPod ...

  7. 简单三步轻松建站,全新小白也能搭建企业网站

    ​​​现在公众号上很多人咨询小编如何搭建一个个人/企业网站.放在小编上大学那时候局限的技能对于小编来说是一个巨困难的事情.现在科技进步飞速,搭建一个网站也是变得超级简单.现在跟着小编来简单五步搭建一个 ...

  8. 根据城市的三字代码查询经纬度_百度地图API简单应用——1.根据地址查询经纬度...

    这几天比较空闲,就接触了下百度地图的API(开发者中心链接地址:http://developer.baidu.com),发现调用还是挺方便的.只要简单几步注册下,就可以获得一个Key,就能直接调用(P ...

  9. StackExchange.Redis学习笔记(二) Redis查询 五种数据类型的应用

    StackExchange.Redis学习笔记(二) Redis查询 五种数据类型的应用 原文: StackExchange.Redis学习笔记(二) Redis查询 五种数据类型的应用 Connec ...

最新文章

  1. 渗透测试是否需要学习Linux
  2. 「尖峰对话」问题征集:向张宏江和John Hopcroft 近距离提问
  3. Oracle通用分页包
  4. 针对某游戏保护DebugPort清零的一次逆向
  5. html网络,HTML—构建网络
  6. Qt工作笔记-QGraphicsView框架容易忽视的坐标问题-“画布”QgraphicsScene到底放在了哪儿?
  7. JMeter源码集成到Eclipse
  8. oracle通过DBlink连接mysql
  9. C++之const关键字探究
  10. Java 集合(初稿)
  11. Java 异常处理的 20 个最佳实践,你知道几个?| CSDN 博文精选
  12. 玩生死狙击找不到服务器怎么办,生死狙击手游进不了游戏解决方法 生死狙击手游进游戏诀窍...
  13. 列表推导式 生成器表达式
  14. 最新麦子学院33G完整版Web前端Web前端开发从入门到精通
  15. 通俗理解什么是隐马尔科夫模型(hmm)
  16. 创建一个ROS工作空间(ROS Workspace)过程中遇到的问题
  17. Ubuntu系统垃圾清理、Debian系统垃圾清理
  18. Oracle SQL基础
  19. “读芯术”精华文章汇总:学术报告
  20. vuex四大核心元素

热门文章

  1. Android中的 eBPF 流量监控
  2. “利润定乾坤”的商业模式陷阱将令你的企业自掘坟墓
  3. Carthage入门篇-安装和使用
  4. 安装 m2e 插件(maven for eclipse) 插件的最新网址
  5. 齐鲁工业大学计算机科学与技术专业分数线,齐鲁工业大学计算机科学与技术专业2017年在山西理科高考录取最低分数线...
  6. Win11提示你的账户已被停用,请向系统管理员咨询解决方法
  7. 借助智慧数字经营怎么进行营销推广?
  8. 万物皆可盘,一起来盘点一下那些最实用的(前五)智能家居单品
  9. Nginx 又一牛X的功能!流量拷贝
  10. 【附源码】计算机毕业设计java学生档案管理系统设计与实现