明天的你会感谢今天努力的你

举手之劳,加个关注

关注公众号 - 听枫逐日,以开源精神主导互联


简介

对于很多初学者来说,“附近的人”或者类似功能,在技术实现上还有点摸不着头脑。本文将简要的为你讲解“附近的人”的基本理论原理,并以Redis的GEO系列地理位置操作指令为例,理论联系实际地为你讲解它们是如何被高效实现的。

阅读提示:本文适合有一定Redis使用经验和经纬度知识的服务器后端开发人员阅读。

经纬度常识

  • 经线是纵的,经度是横的,用于表示不同的经线,纬线是横的,纬度是纵的,用于表示不同的纬线,如下图

  • 纬线:地球仪上的横线,lat,赤道是最大的纬线,从赤道开始分为北纬和南纬,都是0-90°,纬线是角度数值,并不是米;
  • 经线:地球仪上的竖线,lng,子午线为0°,分为西经和东经,都是0-180°,经线也是角度数值;
  • 经纬线和米的换算:经度或者纬度0.00001度,约等于1米,这个在GPS测算距离的时候可以体会到,GPS只要精确到小数点后五位,就是10米范围内的精度
  • 经度0度的位置为本初子午线,在180度的位置转为西经,数字由大到小依次经过北美洲到达西欧.纬度0度的位置为赤道
  • 为了便于理解,将地球看成一个基于经纬度线的坐标系。纬线就是平行于赤道平面的那些平面的周线,经线就是连接南北两极的大圆线的半圆弧。纬度分为北纬(正),南纬(负),赤道所在的纬度值为0。经度以本初子午线界(本初子午线经度为0),分为东经(正),西经(负)。故纬度范围可表示为[-90o, 0o),(0o, 90o],经度范围可表示为[-180o, 0o),(0o, 180o]

什么是geohash


  • GeoHash将二维的经纬度转换成字符串,比如下图展示了北京9个区域的GeoHash字符串,分别是WX4ER,WX4G2、WX4G3等等,每一个字符串代表了某一矩形区域。也就是说,这个矩形区域内所有的点(经纬度坐标)都共享相同的GeoHash字符串,这样既可以保护隐私(只表示大概区域位置而不是具体的点),又比较容易做缓存。
  • 不同的编码长度,表示不同的范围区间,字符串越长,表示的范围越精确
  • 字符串相似的表示距离相近(特殊情况后文阐述),这样可以利用字符串的前缀匹配来查询附近的POI信息。如下两个图所示,一个在城区,一个在郊区,城区的GeoHash字符串之间比较相似,郊区的字符串之间也比较相似,而城区和郊区的GeoHash字符串相似程度要低些
  • 总结:GeoHash就是一种将经纬度转换成字符串的方法,并且使得在大部分情况下,字符串前缀匹配越多的距离越近

GeoHash 算法


以经纬度值:(116.389550, 39.928167)进行算法说明,对纬度39.928167进行逼近编码 (地球纬度区间是[-90,90])

  1. 区间[-90,90]进行二分为[-90,0),[0,90],称为左右区间,可以确定39.928167属于右区间[0,90],给标记为1
  2. 接着将区间[0,90]进行二分为 [0,45),[45,90],可以确定39.928167属于左区间 [0,45),给标记为0
  3. 递归上述过程39.928167总是属于某个区间[a,b]。随着每次迭代区间[a,b]总在缩小,并越来越逼近39.928167
  4. 如果给定的纬度x(39.928167)属于左区间,则记录0,如果属于右区间则记录1,序列的长度跟给定的区间划分次数有关,如下图
  • 同理,地球经度区间是[-180,180],可以对经度116.389550进行编码
  • 通过上述计算,纬度产生的编码为1 1 0 1 0 0 1 0 1 1 0 0 0 1 0,经度产生的编码为1 0 1 1 1 0 0 0 1 1 0 0 0 1 1
  • 合并:偶数位放经度,奇数位放纬度,把2串编码组合生成新串如下图:
  • 首先将11100 11101 00100 01111 0000  01101转成十进制,对应着28、29、4、15,0,13 十进制对应的base32编码就是wx4g0e,如下图
  • Ø同理,将编码转换成经纬度的解码算法与之相反

GeoHash 原理


  • Geohash其实就是将整个地图或者某个分割所得的区域进行一次划分,由于采用的是base32编码方式,即Geohash中的每一个字母或者数字(如wx4g0e中的w)都是由5bits组成(2^5 = 32,base32),这5bits可以有32中不同的组合(0~31),这样我们可以将整个地图区域分为32个区域,通过00000 ~ 11111来标识这32个区域。第一次对地图划分后的情况如下图所示(每个区域中的编号对应于该区域所对应的编码):

  • Geohash的0、1串序列是经度0、1序列和纬度0、1序列中的数字交替进行排列的,偶数位对应的序列为经度序列,奇数位对应的序列为纬度序列,在进行第一次划分时,Geohash0、1序列中的前5个bits(11100),那么这5bits中有3bits是表示经度,2bits表示纬度,所以第一次划分时,是将经度划分成8个区段(2^3 = 8),将纬度划分为4个区段(2^2 = 4),这样就形成了32个区域。如下图

  • 同理,可以按照第一次划分所采用的方式对第一次划分所得的32个区域各自再次划分.

对照

附近的人,附近的加油站如何实现

它需要做以下两件事情:

1)在使用“附近的人”功能前提交自已的地理位置;

2)根据“我”的地理位置,计算出别人跟我的距离;

3)将第2步中计算出的距离由近及远,进行排序。

具体在产品技术上的实现原理和技术难点

1)现在移动端(ios、android等),通过系统的API很容易抓到用户当前的位置(即经纬度数据);

2)根据第1步中的经纬度数据,很容易计算出两个点之间的距离

3)对第2步中的计算结果排序就更简单了。

技术难点

1)如何高效地进行两点距离的计算

2)如何高效地进行地理围栏的圈定

下面是一个Java版实现:

public class GeoHash {
public static final double MINLAT = -90;
public static final double MAXLAT = 90;
public static final double MINLNG = -180;
public static final double MAXLNG = 180;private static int numbits = 3 * 5; //经纬度单独编码长度private static double minLat;
private static double minLng;private final static char[] digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8','9', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p','q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' };//定义编码映射关系
final static HashMap<Character, Integer> lookup = new HashMap<Character, Integer>();
//初始化编码映射内容
static {int i = 0;for (char c : digits)lookup.put(c, i++);
}public GeoHash(){setMinLatLng();
}public String encode(double lat, double lon) {BitSet latbits = getBits(lat, -90, 90);BitSet lonbits = getBits(lon, -180, 180);StringBuilder buffer = new StringBuilder();for (int i = 0; i < numbits; i++) {buffer.append( (lonbits.get(i))?'1':'0');buffer.append( (latbits.get(i))?'1':'0');}String code = base32(Long.parseLong(buffer.toString(), 2));//Log.i("okunu", "encode  lat = " + lat + "  lng = " + lon + "  code = " + code);return code;
}public ArrayList<String> getArroundGeoHash(double lat, double lon){//Log.i("okunu", "getArroundGeoHash  lat = " + lat + "  lng = " + lon);ArrayList<String> list = new ArrayList<>();double uplat = lat + minLat;double downLat = lat - minLat;double leftlng = lon - minLng;double rightLng = lon + minLng;String leftUp = encode(uplat, leftlng);list.add(leftUp);String leftMid = encode(lat, leftlng);list.add(leftMid);String leftDown = encode(downLat, leftlng);list.add(leftDown);String midUp = encode(uplat, lon);list.add(midUp);String midMid = encode(lat, lon);list.add(midMid);String midDown = encode(downLat, lon);list.add(midDown);String rightUp = encode(uplat, rightLng);list.add(rightUp);String rightMid = encode(lat, rightLng);list.add(rightMid);String rightDown = encode(downLat, rightLng);list.add(rightDown);//Log.i("okunu", "getArroundGeoHash list = " + list.toString());return list;
}//根据经纬度和范围,获取对应的二进制
private BitSet getBits(double lat, double floor, double ceiling) {BitSet buffer = new BitSet(numbits);for (int i = 0; i < numbits; i++) {double mid = (floor + ceiling) / 2;if (lat >= mid) {buffer.set(i);floor = mid;} else {ceiling = mid;}}return buffer;
}//将经纬度合并后的二进制进行指定的32位编码
private String base32(long i) {char[] buf = new char[65];int charPos = 64;boolean negative = (i < 0);if (!negative){i = -i;}while (i <= -32) {buf[charPos--] = digits[(int) (-(i % 32))];i /= 32;}buf[charPos] = digits[(int) (-i)];if (negative){buf[--charPos] = '-';}return new String(buf, charPos, (65 - charPos));
}private void setMinLatLng() {minLat = MAXLAT - MINLAT;for (int i = 0; i < numbits; i++) {minLat /= 2.0;}minLng = MAXLNG - MINLNG;for (int i = 0; i < numbits; i++) {minLng /= 2.0;}
}//根据二进制和范围解码
private double decode(BitSet bs, double floor, double ceiling) {double mid = 0;for (int i=0; i<bs.length(); i++) {mid = (floor + ceiling) / 2;if (bs.get(i))floor = mid;elseceiling = mid;}return mid;
}//对编码后的字符串解码
public double[] decode(String geohash) {StringBuilder buffer = new StringBuilder();for (char c : geohash.toCharArray()) {int i = lookup.get(c) + 32;buffer.append( Integer.toString(i, 2).substring(1) );}BitSet lonset = new BitSet();BitSet latset = new BitSet();//偶数位,经度int j =0;for (int i=0; i< numbits*2;i+=2) {boolean isSet = false;if ( i < buffer.length() )isSet = buffer.charAt(i) == '1';lonset.set(j++, isSet);}//奇数位,纬度j=0;for (int i=1; i< numbits*2;i+=2) {boolean isSet = false;if ( i < buffer.length() )isSet = buffer.charAt(i) == '1';latset.set(j++, isSet);}double lon = decode(lonset, -180, 180);double lat = decode(latset, -90, 90);return new double[] {lat, lon};
}public static void main(String[] args)  throws Exception{GeoHash geohash = new GeoHash();
//        String s = geohash.encode(40.222012, 116.248283);
//        System.out.println(s);geohash.getArroundGeoHash(40.222012, 116.248283);
//        double[] geo = geohash.decode(s);
//        System.out.println(geo[0]+" "+geo[1]);
}
}

核心算法获取任意两点距离

/*** 计算地球上任意两点(经纬度)距离    *     * @param long1 第一点经度    * @param lat1 第一点纬度    * @param long2 第二点经度    * @param lat2 第二点纬度    * @return 返回距离 单位:米*/public static double Distance(double long1, double lat1, double long2, double lat2){double a, b, R;R = 6378137; // 地球半径        lat1 = lat1 * Math.PI / 180.0;lat2 = lat2 * Math.PI / 180.0;a = lat1 - lat2;b = (long1 - long2) * Math.PI / 180.0;double d;double sa2, sb2;sa2 = Math.sin(a / 2.0);sb2 = Math.sin(b / 2.0);d = 2* R * Math.asin(Math.sqrt(sa2 * sa2 + Math.cos(lat1) * Math.cos(lat2) * sb2 * sb2));return d;}

Redis里的GEO地理位置相关指令

针对“附近的人”这一位置服务领域的应用场景,服务端高性能场景下,常见的可使用PG、MySQL和MongoDB等多种DB的空间索引进行实现。

而Redis另辟蹊径,结合其有序队列zset以及geohash编码,实现了空间搜索功能,且拥有极高的运行效率。

要提供完整的“附近的人”这样的功能或服务,最基本的是要实现“增”、“删”、“查”的功能。本文余下的文字,以下将分别进行介绍,其中会重点对查询功能进行解析。并将从Redis源码角度对其算法原理进行解析,并推算查询时间复杂度。

Redis的GEO地理位置操作指令

自 Redis 3.2版 开始,Redis基于geohash和有序集合提供了地理位置相关功能。

Redis Geo模块的6个指令用途说明:

https://www.redis.net.cn/  中文网站

http://redisdoc.com/script/eval.html  中文网站

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

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

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

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

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

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

其中,组合使用GEOADD和GEORADIUS可实现“附近的人”中“增”和“查”的基本功能。要实现类似于微信中“附近的人”功能,可直接使用GEORADIUSBYMEMBER命令。

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

使用时的注意点:

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

2)在Redis源码geo.c的文件注释中,只说明了该文件为GEOADD、GEORADIUS和GEORADIUSBYMEMBER的实现;

3)从侧面看出其他三个命令为辅助命令。

Redis的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;}//参数提取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源码分析可以看出,Redis内部使用有序集合(zset)保存位置对象,有序集合中每个元素都是一个带位置的对象,元素的score值为其经纬度对应的52位的geohash值:

  1. double类型精度为52位;
  2. 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中。

Redis的GEORADIUS指令是如何高效实现的

使用方式

1GEORADIUS 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]]

]

8.2 源码分析

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

/* GEORADIUS key x y radius unit [WITHDIST] [WITHHASH] [WITHCOORD] [ASC|DESC]*                               [COUNT count] [STORE key] [STORedisT key]* GEORADIUSBYMEMBER key member radius unit ... options ... */voidgeoradiusGeneric(client *c, intflags) {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个geohashBoxGeoHashRadius 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<=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;}returncount;}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_torigincount = 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;}}returnga->used - origincount;}int geoAppendIfWithinRadius(geoArray *ga, double lon, double lat, double radius, double score, sds member) {double distance, xy[2];//解码错误, 返回errorif(!decodeGeohash(score,xy)) returnC_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网格的范围框选出位置对象。进一步找出与中心点距离小于输入半径的对象,进行返回。

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

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

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

8.4 算法分析

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

为什么要找到满足条件的最高的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))。

其中:

1)N为九宫格范围内的位置元素数量(要算距离);

2)M是指定层级格子的数量;

3)log(M)是跳表结构中找到每个格子首元素的时间复杂度(这个过程一般会进行9次)。

结合Redis本身基于内存的存储特性,在实际使用过程中有非常高的运行效率。

关注下我的公众号吧,无偿指导面试,只为开源精神

声明:本文来源于网络整理,已无法获知原始作者,故没有备注作者

Redis 如何实现附近的人相关推荐

  1. 基于Redis实现查找附近的人

    之前分享过一篇<使用geohash实现查找附近的人>,其实redis在3.2版本之后也提供了地理位置的能力. geoadd命令 该命令是添加地理位置信息,基本格式是: 基于Redis实现查 ...

  2. Redis实战 - 11 Redis GEO 实现附近的人功能

    各种社交软件里面都有附件的人的需求,在该应用中,我们查询附近1公里的食客,同时只需查询出20个即可. 文章目录 1. Redis GEO常用命令 2. 上传用户地理位置 1. RedisKeyCons ...

  3. redis存储微博点赞的人,如何存储?

    问题对人有帮助,内容完整,我也想知道答案 3 问题没有实际价值,缺少关键内容,没有改进余地 比如说有一个微博的TID是1. UID为1,2,3,4,5,6,7,8,9的用户都给这个微博点赞了.用red ...

  4. Redis实现微信“附近的人”功能?,这篇文章作用太大了

    回复[面试题]获取2021年最新java面试题合集- 前言:针对"附近的人"这一位置服务领域的应用场景,常见的可使用PG.MySQL和MongoDB等多种DB的空间索引进行实现.而 ...

  5. redis 经纬度_原来用Redis实现查找附近的人这么容易

    1. 前言 老板突然要上线一个需求,获取当前位置方圆一公里的业务代理点.明天上线!当接到这个需求的时候我差点吐血,这时间也太紧张了.赶紧去查相关的技术选型.经过一番折腾,终于在晚上十点完成了这个需求. ...

  6. 原来用Redis实现查找附近的人这么容易

    1. 前言 老板突然要上线一个需求,获取当前位置方圆一公里的业务代理点.明天上线!当接到这个需求的时候我差点吐血,这时间也太紧张了.赶紧去查相关的技术选型.经过一番折腾,终于在晚上十点完成了这个需求. ...

  7. PHP实现附近的人、按距离排序之Redis GEO方案

    [写在前面] 获取附近的人列表,首先要有用户的位置信息,做法是客户端调用一个接口传递用户的经纬度坐标.所在区域adcode等,把这些用户位置信息入库(当然前提是用户开启定位权限). 有了用户位置信息库 ...

  8. Java基于Redis实现“附近的人”(含源码下载)

    "附近的人"在社交类APP已成为标配的功能,Low一点的实现方式可以把坐标存至关系型数据库,通过计算的坐标点距离实现,这种计算可行但计算速度远不及内存操作级别的NoSql数据库. ...

  9. redis未能启动服务器,redis服务启动不了的解决办法(服务器应非法关机了)

    1.确认redis没运行.[[email protected] run]# /etc/init.d/kkmail_redis status redis is not running 2.删除pid文件 ...

最新文章

  1. OVS对VXLAN解封包(二)
  2. Oracle中使用Table()函数解决For循环中不写成 in (l_idlist)形式的问题
  3. (*长期更新)软考网络工程师学习笔记——Section 18 计算机硬件基本知识
  4. 中点和中值滤波的区别_桥式整流二极管及滤波电容如何选择
  5. matplotlib 设置水平线、垂直线
  6. 每个计算机主机有且只能有一块硬盘对不对,电子科技大学计算机基础试卷.doc...
  7. 自己写的Weblogic的poc
  8. java网络编程基础——TCP网络编程三
  9. 利用Web of Science创建引文跟踪、检索词跟踪
  10. 解析函数的孤立奇点-复变函数
  11. 硅谷高效工作法 -知乎大学
  12. 巨杉数据库sequoiadb
  13. APP界面设计规范:如何定义视觉规范
  14. 在hbase shell中执行list抛出异常:ERROR: No valid credentials provided
  15. linux yum仓库命令,linux 自定义yum仓库、repo文件 yum命令
  16. 在线教育的鲶鱼“肥瘦不均”
  17. Blender新手入门试水作品:Low poly版敲击兽
  18. 中国大学python程序设计答案_Python程序设计1-中国大学mooc-试题题目及答案
  19. PPP拨号和NDIS拨号的区别:
  20. html图片左右滚动播放,CSS3实现横向滚动播放

热门文章

  1. RT-Thread进阶之低功耗PM组件应用笔记
  2. 电子发票产品的三种服务模式
  3. 我玩某宝新模式2个月收λ15万,你觉得过气的行业,真的很赚钱!
  4. python爬虫学习 之 定向爬取 淘宝商品价格
  5. 单片机高手是这样炼成的!这样学习单片机事半功倍
  6. Yolov3模型使用教程
  7. 【电影推荐】20部生存启示录—灾难大片
  8. 表字段的处理(Sql Server)
  9. 【ssd】M.2的SATA,PCI-x2(Socket 2 ),PCI-x4(Socket 3)了解一下,老程序员都快被新硬件搞蒙圈了
  10. 小白看了直呼细节--CPP“引用”