热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Redis到底是怎么实现“附近的人”这个功能的呢?

【编者的话】针对“附近的人”这一位置服务领域的应用场景,常见的可使用PG、MySQL和MongoDB等多种DB的空间索引进行实现。而Redis另辟蹊径,结合其有序队列zset以及geohash编码,实现了空间搜索功能,且拥有极高的运行效率。本文将从源码角度对其算法原理进行解析,并推算查询时间复杂度。要提供完整的“附近的人”服务,最基本的是要实现“增”、“删”、“查”的功能。以下将分别进行介绍,其中会重点对查询功能进行解析。自Redis3.2开始,Redis基于

【编者的话】针对“附近的人”这一位置服务领域的应用场景,常见的可使用PG、 MySQL 和 MongoDB 等多种DB的空间索引进行实现。而 Redis 另辟蹊径,结合其有序队列zset以及geohash编码,实现了空间搜索功能,且拥有极高的运行效率。本文将从源码角度对其算法原理进行解析,并推算查询时间复杂度。

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

操作命令

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

  • GEOADD:将给定的位置对象(纬度、经度、名字)添加到指定的key;
  • GEOPOS:从key里面返回所有给定位置对象的位置(经度和纬度);
  • GEODIST:返回两个给定位置之间的距离;
  • GEOHASH:返回一个或多个位置对象的Geohash表示;
  • GEORADIUS:以给定的经纬度为中心,返回目标集合中与中心的距离不超过给定最大距离的所有位置对象;
  • GEORADIUSBYMEMBER:以给定的位置对象为中心,返回与其距离不超过给定最大距离的所有位置对象。

其中,组合使用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。

其中,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;

}



//参数提取Redis

int 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 argv+2)+(i*3),xy) == C_ERR) {

        for (i = 0; i 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网格区域进行查找”。对应的是geohashGetAreasByRadiusWGS84和membersOfAllNeighbors两个函数。我们依次来看:

计算中心点范围:

// 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) {

//一些参数设置

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<=85

geohashGetCoordRange(&long_range,⪫_range);



//将待查经纬度按指定精度(steps)编码成geohash值

geohashEncode(&long_range,⪫_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个搜索hashBox

neighbors[0] = n.hash;

……

neighbors[8] = n.neighbors.south_west;



//在每个hashBox中搜索目标点

for (i = 0; i 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范围内的首个元素(跳表数据结构,效率可比拟于二叉查找树),没有则返0

    if ((ln = zslFirstInRange(zsl, ⦥)) == 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, ⦥))

            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];



//解码错误, 返回error

if (!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网格等级(精度)以及对应的能够覆盖目标区域的九宫格位置;(后续会有详细说明)
  3. 对九宫格进行遍历,根据每个geohash网格的范围框选出位置对象。进一步找出与中心点距离小于输入半径的对象,进行返回。

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

Redis到底是怎么实现“附近的人”这个功能的呢?

Redis到底是怎么实现“附近的人”这个功能的呢?

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

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

算法分析

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

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

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

Redis到底是怎么实现“附近的人”这个功能的呢?

换句话说,geohash网格等级越高,所覆盖的地理位置范围就越小。 当我们根据输入半径和中心点位置计算出的能够覆盖目标区域的最高等级的九宫格(网格)时,就已经对九宫格外的元素进行了筛除。 这里之所以使用九宫格,而不用单个网格,主要原因还是为了避免边界情况,尽可能缩小查询区域范围。试想以0经纬度为中心,就算查1米范围,单个网格覆盖的话也得查整个地球区域。而向四周八个方向扩展一圈可有效避免这个问题。

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

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

Redis到底是怎么实现“附近的人”这个功能的呢?

其拥有类似二叉查找树的查询效率,操作平均时间复杂性为O(log(N))。且最底层的所有元素都以链表的形式按序排列。所以在查询时,只要找到集合中处在目标geohash网格中的第一个值,后续依次对比即可,不用多次查找。 九宫格不能一起查,要一个个遍历的原因也在于九宫格各网格对应的geohash值不具有连续性。只有连续了,查询效率才会高,不然要多做许多距离运算。

综上,我们从源码角度解析了Redis Geo模块中 “增(GEOADD)”和“查(GEORADIUS)”的详细过程。并可推算出Redis中GEORADIUS查找附近的人功能,时间复杂度为:O(N+log(M))。其中N为九宫格范围内的位置元素数量(要算距离);M是指定层级格子的数量,log(M)是跳表结构中找到每个格子首元素的时间复杂度(这个过程一般会进行9次)。结合Redis本身基于内存的存储特性,在实际使用过程中有非常高的运行效率。

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

原文链接: https://juejin.im/post/5da40462f265da5baf410a11


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 我们


推荐阅读
  • 阿里Treebased Deep Match(TDM) 学习笔记及技术发展回顾
    本文介绍了阿里Treebased Deep Match(TDM)的学习笔记,同时回顾了工业界技术发展的几代演进。从基于统计的启发式规则方法到基于内积模型的向量检索方法,再到引入复杂深度学习模型的下一代匹配技术。文章详细解释了基于统计的启发式规则方法和基于内积模型的向量检索方法的原理和应用,并介绍了TDM的背景和优势。最后,文章提到了向量距离和基于向量聚类的索引结构对于加速匹配效率的作用。本文对于理解TDM的学习过程和了解匹配技术的发展具有重要意义。 ... [详细]
  • XML介绍与使用的概述及标签规则
    本文介绍了XML的基本概念和用途,包括XML的可扩展性和标签的自定义特性。同时还详细解释了XML标签的规则,包括标签的尖括号和合法标识符的组成,标签必须成对出现的原则以及特殊标签的使用方法。通过本文的阅读,读者可以对XML的基本知识有一个全面的了解。 ... [详细]
  • Android系统移植与调试之如何修改Android设备状态条上音量加减键在横竖屏切换的时候的显示于隐藏
    本文介绍了如何修改Android设备状态条上音量加减键在横竖屏切换时的显示与隐藏。通过修改系统文件system_bar.xml实现了该功能,并分享了解决思路和经验。 ... [详细]
  • Java学习笔记之面向对象编程(OOP)
    本文介绍了Java学习笔记中的面向对象编程(OOP)内容,包括OOP的三大特性(封装、继承、多态)和五大原则(单一职责原则、开放封闭原则、里式替换原则、依赖倒置原则)。通过学习OOP,可以提高代码复用性、拓展性和安全性。 ... [详细]
  • 生成式对抗网络模型综述摘要生成式对抗网络模型(GAN)是基于深度学习的一种强大的生成模型,可以应用于计算机视觉、自然语言处理、半监督学习等重要领域。生成式对抗网络 ... [详细]
  • Commit1ced2a7433ea8937a1b260ea65d708f32ca7c95eintroduceda+Clonetraitboundtom ... [详细]
  • 阿,里,云,物,联网,net,core,客户端,czgl,aliiotclient, ... [详细]
  • 使用Ubuntu中的Python获取浏览器历史记录原文: ... [详细]
  • 本文介绍了Android 7的学习笔记总结,包括最新的移动架构视频、大厂安卓面试真题和项目实战源码讲义。同时还分享了开源的完整内容,并提醒读者在使用FileProvider适配时要注意不同模块的AndroidManfiest.xml中配置的xml文件名必须不同,否则会出现问题。 ... [详细]
  • Android开发实现的计时器功能示例
    本文分享了Android开发实现的计时器功能示例,包括效果图、布局和按钮的使用。通过使用Chronometer控件,可以实现计时器功能。该示例适用于Android平台,供开发者参考。 ... [详细]
  • Java在运行已编译完成的类时,是通过java虚拟机来装载和执行的,java虚拟机通过操作系统命令JAVA_HOMEbinjava–option来启 ... [详细]
  • 本文讨论了在openwrt-17.01版本中,mt7628设备上初始化启动时eth0的mac地址总是随机生成的问题。每次随机生成的eth0的mac地址都会写到/sys/class/net/eth0/address目录下,而openwrt-17.01原版的SDK会根据随机生成的eth0的mac地址再生成eth0.1、eth0.2等,生成后的mac地址会保存在/etc/config/network下。 ... [详细]
  • 本文介绍了在处理不规则数据时如何使用Python自动提取文本中的时间日期,包括使用dateutil.parser模块统一日期字符串格式和使用datefinder模块提取日期。同时,还介绍了一段使用正则表达式的代码,可以支持中文日期和一些特殊的时间识别,例如'2012年12月12日'、'3小时前'、'在2012/12/13哈哈'等。 ... [详细]
  • Python爬虫中使用正则表达式的方法和注意事项
    本文介绍了在Python爬虫中使用正则表达式的方法和注意事项。首先解释了爬虫的四个主要步骤,并强调了正则表达式在数据处理中的重要性。然后详细介绍了正则表达式的概念和用法,包括检索、替换和过滤文本的功能。同时提到了re模块是Python内置的用于处理正则表达式的模块,并给出了使用正则表达式时需要注意的特殊字符转义和原始字符串的用法。通过本文的学习,读者可以掌握在Python爬虫中使用正则表达式的技巧和方法。 ... [详细]
  • Android系统源码分析Zygote和SystemServer启动过程详解
    本文详细解析了Android系统源码中Zygote和SystemServer的启动过程。首先介绍了系统framework层启动的内容,帮助理解四大组件的启动和管理过程。接着介绍了AMS、PMS等系统服务的作用和调用方式。然后详细分析了Zygote的启动过程,解释了Zygote在Android启动过程中的决定作用。最后通过时序图展示了整个过程。 ... [详细]
author-avatar
sean
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有