像微信 "附近的人",美团 "附近的餐厅",支付宝共享单车 "附近的车" 是怎么设计实现的呢?

一、使用数据库实现查找附近的人

我们都知道,地球上的任何一个位置都可以使用二维的 经纬度 来表示,经度范围 [-180, 180],纬度范围 [-90, 90],纬度正负以赤道为界,北正南负,经度正负以本初子午线 (英国格林尼治天文台) 为界,东正西负。比如说,北京人民英雄纪念碑的经纬度坐标就是 (39.904610, 116.397724),都是正数,因为中国位于东北半球。

所以,当我们使用数据库存储了所有人的 经纬度 信息之后,我们就可以基于当前的坐标节点,来划分出一个矩形的范围,来得知附近的人,如下图:

所以,我们很容易写出下列的伪 SQL 语句:

  1. SELECT id FROM positions WHERE x0 - r < x < x0 + r AND y0 - r < y < y0 + r

如果我们还想进一步地知道与每个坐标元素的距离并排序的话,就需要一定的计算。

当两个坐标元素的距离不是很远的时候,我们就可以简单利用 勾股定理 就能够得出他们之间的 距离。不过需要注意的是,地球不是一个标准的球体,经纬度的密度不一样 的,所以我们使用勾股定理计算平方之后再求和时,需要按照一定的系数 加权 再进行求和。当然,如果不准求精确的话,加权也不必了。

参考下方 参考资料 2 我们能够差不多能写出如下优化之后的 SQL 语句来:(仅供参考)

  1. SELECT
  2. *
  3. FROM
  4. users_location
  5. WHERE
  6. latitude > '.$lat.' - 1
  7. AND latitude < '.$lat.' + 1 AND longitude > '.$lon.' - 1
  8. AND longitude < '.$lon.' + 1
  9. ORDER BY
  10. ACOS(
  11. SIN( ( '.$lat.' * 3.1415 ) / 180 ) * SIN( ( latitude * 3.1415 ) / 180 ) + COS( ( '.$lat.' * 3.1415 ) / 180 ) * COS( ( latitude * 3.1415 ) / 180 ) * COS( ( '.$lon.' * 3.1415 ) / 180 - ( longitude * 3.1415 ) / 180 )
  12. ) * 6380 ASC
  13. LIMIT 10 ';

为了满足高性能的矩形区域算法,数据表也需要把经纬度坐标加上 双向复合索引 (x, y),这样可以满足最大优化查询性能。

二、GeoHash 算法简述

这是业界比较通用的,用于 地理位置距离排序 的一个算法,Redis 也采用了这样的算法。GeoHash 算法将 二维的经纬度 数据映射到 一维 的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算 「附近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。

它的核心思想就是把整个地球看成是一个 二维的平面,然后把这个平面不断地等分成一个一个小的方格,每一个 坐标元素都位于其中的 唯一一个方格 中,等分之后的 方格越小,那么坐标也就 越精确,类似下图:

经过划分的地球,我们需要对其进行编码:

经过这样顺序的编码之后,如果你仔细观察一会儿,你就会发现一些规律:

  • 横着的所有编码中,第 2 位和第 4 位都是一样的,例如第一排第一个 0101 和第二个 0111,他们的第 2 位和第 4 位都是 1
  • 竖着的所有编码中,第 1 位和第 3 位是递增的,例如第一排第一个 0101,如果单独把第 1 位和第 3 位拎出来的话,那就是 00,同理看第一排第二个 0111,同样的方法第 1 位和第 3 位拎出来是 01,刚好是 00 递增一个;

通过这样的规律我们就把每一个小方块儿进行了一定顺序的编码,这样做的 好处 是显而易见的:每一个元素坐标既能够被 唯一标识 在这张被编码的地图上,也不至于 暴露特别的具体的位置,因为区域是共享的,我可以告诉你我就在公园附近,但是在具体的哪个地方你就无从得知了。

总之,我们通过上面的思想,能够把任意坐标变成一串二进制的编码了,类似于 11010010110001000100 这样 (注意经度和维度是交替出现的哦..),通过这个整数我们就可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程序就越小。对于 "附近的人" 这个功能来说,损失的一点经度可以忽略不计。

最后就是一个 Base32 (0~9, a~z, 去掉 a/i/l/o 四个字母) 的编码操作,让它变成一个字符串,例如上面那一串儿就变成了 wx4g0ec1

Redis 中,经纬度使用 52 位的整数进行编码,放进了 zset 里面,zset 的 value 是元素的 keyscoreGeoHash52 位整数值。zset 的 score 虽然是浮点数,但是对于 52 位的整数值来说,它可以无损存储。

三、在 Redis 中使用 Geo

下方内容引自 参考资料 1 - 《Redis 深度历险》

在使用 Redis 进行 Geo 查询 时,我们要时刻想到它的内部结构实际上只是一个 zset(skiplist)。通过 zset 的 score 排序就可以得到坐标附近的其他元素 (实际情况要复杂一些,不过这样理解足够了),通过将 score 还原成坐标值就可以得到元素的原始坐标了。

Redis 提供的 Geo 指令只有 6 个,很容易就可以掌握。

增加

geoadd 指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组。

  1. 127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin
  2. (integer) 1
  3. 127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
  4. (integer) 1
  5. 127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
  6. (integer) 1
  7. 127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
  8. (integer) 2

不过很奇怪.. Redis 没有直接提供 Geo 的删除指令,但是我们可以通过 zset 相关的指令来操作 Geo 数据,所以元素删除可以使用 zrem 指令即可。

距离

geodist 指令可以用来计算两个元素之间的距离,携带集合名称、2 个名称和距离单位。

  1. 127.0.0.1:6379> geodist company juejin ireader km
  2. "10.5501"
  3. 127.0.0.1:6379> geodist company juejin meituan km
  4. "1.3878"
  5. 127.0.0.1:6379> geodist company juejin jd km
  6. "24.2739"
  7. 127.0.0.1:6379> geodist company juejin xiaomi km
  8. "12.9606"
  9. 127.0.0.1:6379> geodist company juejin juejin km
  10. "0.0000"

我们可以看到掘金离美团最近,因为它们都在望京。距离单位可以是 mkmmlft,分别代表米、千米、英里和尺。

获取元素位置

geopos 指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。

  1. 127.0.0.1:6379> geopos company juejin
  2. 1) 1) "116.48104995489120483"
  3. 2) "39.99679348858259686"
  4. 127.0.0.1:6379> geopos company ireader
  5. 1) 1) "116.5142020583152771"
  6. 2) "39.90540918662494363"
  7. 127.0.0.1:6379> geopos company juejin ireader
  8. 1) 1) "116.48104995489120483"
  9. 2) "39.99679348858259686"
  10. 2) 1) "116.5142020583152771"
  11. 2) "39.90540918662494363"

我们观察到获取的经纬度坐标和 geoadd 进去的坐标有轻微的误差,原因是 Geohash 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。对于 「附近的人」 这种功能来说,这点误差根本不是事。

获取元素的 hash 值

geohash 可以获取元素的经纬度编码字符串,上面已经提到,它是 base32 编码。 你可以使用这个编码值去 http://geohash.org/${hash} 中进行直接定位,它是 Geohash 的标准编码值。

  1. 127.0.0.1:6379> geohash company ireader
  2. 1) "wx4g52e1ce0"
  3. 127.0.0.1:6379> geohash company juejin
  4. 1) "wx4gd94yjn0"

让我们打开地址 http://geohash.org/wx4g52e1ce0,观察地图指向的位置是否正确:

很好,就是这个位置,非常准确。

附近的公司

georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其它元素,它的参数非常复杂。

  1. # 范围 20 公里以内最多 3 个元素按距离正排,它不会排除自身
  2. 127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
  3. 1) "ireader"
  4. 2) "juejin"
  5. 3) "meituan"
  6. # 范围 20 公里以内最多 3 个元素按距离倒排
  7. 127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc
  8. 1) "jd"
  9. 2) "meituan"
  10. 3) "juejin"
  11. # 三个可选参数 withcoord withdist withhash 用来携带附加参数
  12. # withdist 很有用,它可以用来显示距离
  13. 127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc
  14. 1) 1) "ireader"
  15. 2) "0.0000"
  16. 3) (integer) 4069886008361398
  17. 4) 1) "116.5142020583152771"
  18. 2) "39.90540918662494363"
  19. 2) 1) "juejin"
  20. 2) "10.5501"
  21. 3) (integer) 4069887154388167
  22. 4) 1) "116.48104995489120483"
  23. 2) "39.99679348858259686"
  24. 3) 1) "meituan"
  25. 2) "11.5748"
  26. 3) (integer) 4069887179083478
  27. 4) 1) "116.48903220891952515"
  28. 2) "40.00766997707732031"

除了 georadiusbymember 指令根据元素查询附近的元素,Redis 还提供了根据坐标值来查询附近的元素,这个指令更加有用,它可以根据用户的定位来计算「附近的车」,「附近的餐馆」等。它的参数和 georadiusbymember 基本一致,除了将目标元素改成经纬度坐标值:

  1. 127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 asc
  2. 1) 1) "ireader"
  3. 2) "0.0000"
  4. 2) 1) "juejin"
  5. 2) "10.5501"
  6. 3) 1) "meituan"
  7. 2) "11.5748"

注意事项

在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用 RedisGeo 数据结构,它们将 全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。

所以,这里建议 Geo 的数据使用 单独的 Redis 实例部署,不使用集群环境。

如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。

相关阅读

  1. Redis(1)——5种基本数据结构 - https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/
  2. Redis(2)——跳跃表 - https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/
  3. Redis(3)——分布式锁深入探究 - https://www.wmyskxz.com/2020/03/01/redis-3/
  4. Reids(4)——神奇的HyperLoglog解决统计问题 - https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/
  5. Redis(5)——亿级数据过滤和布隆过滤器 - https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/

参考资料

  1. 《Redis 深度历险》 - 钱文品/ 著 - https://book.douban.com/subject/30386804/
  2. mysql经纬度查询并且计算2KM范围内附近用户的sql查询性能优化实例教程 - https://www.cnblogs.com/mgbert/p/4146538.html
  3. Geohash算法原理及实现 - https://www.jianshu.com/p/2fd0cf12e5ba
  4. GeoHash算法学习讲解、解析及原理分析 - https://zhuanlan.zhihu.com/p/35940647
  • 本文已收录至我的 Github 程序员成长系列 【More Than Java】,学习,不止 Code,欢迎 star:https://github.com/wmyskxz/MoreThanJava
  • 个人公众号 :wmyskxz,个人独立域名博客:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长!

非常感谢各位人才能 看到这里,如果觉得本篇文章写得不错,觉得 「我没有三颗心脏」有点东西 的话,求点赞,求关注,求分享,求留言!

创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!

Redis(6)——GeoHash查找附近的人的更多相关文章

  1. Redis 是怎么实现 “附近的人” 的?

    针对"附近的人"这一位置服务领域的应用场景,常见的可使用PG.MySQL和MongoDB等多种DB的空间索引进行实现. 而Redis另辟蹊径,结合其有序队列zset以及geohas ...

  2. Redis实战篇(四)基于GEO实现查找附近的人功能

    如果现在要开发一个功能: 要为一款交友App实现查找附近的人,并按距离进行排序. 让你来开发这个功能,你会如何实现? MySQL 不合适 你可能想到,把用户用户的经纬度坐标使用MySQL等关系数据库( ...

  3. redis 查找附近的人

    儿童定位手表,有个交友功能,查找附近的人,用redis的geo来实现比较简单,其实是一个ZSET(有序集合) redis 版本要大于3.2 查看redis 版本    /usr/bin/redis-s ...

  4. 使用PHP实现查找附近的人

    https://zhuanlan.zhihu.com/p/31380780 LBS(基于位置的服务) 查找附近的人有个更大的专有名词叫做LBS(基于位置的服务),LBS是指是指通过电信移动运营商的无线 ...

  5. 使用 Redis 如何实现查询附近的人?「视频版」——面试突击 003 期

    面试问题 Redis 如何实现查询附近的人? 涉及知识点 Redis 中如何操作位置信息? GEO 底层是如何实现的? 如何在程序实现查询附近的人? 在实际使用中需要注意哪些问题? 视频答案 视频地址 ...

  6. Golang 实现 Redis(9): 使用GeoHash 搜索附近的人

    本文是使用 golang 实现 redis 系列的第九篇,主要介绍如何使用 GeoHash 实现搜索附近的人. 搜索附近的POI是一个非常常见的功能,它的技术难点在于地理位置是二维的(经纬度)而我们常 ...

  7. Redis Keys 命令 - 查找所有符合给定模式( pattern)的 key

    Redis Keys 命令用于查找所有符合给定模式 pattern 的 key .. 语法 redis KEYS 命令基本语法如下: redis 127.0.0.1:6379> KEYS PAT ...

  8. redis之GeoHash

    Redis 提供的 Geo 指令只有 6 个,它只是一个普通的 zset 结构. 增加geoadd 指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组127.0.0.1:6379&g ...

  9. 终极二分查找--传说十个人写九个有bug

    之前写过一篇极为罗嗦的二分查找,非常得意地以为以后就可以避免踩坑了,但是今天才知道二分查找可以写的既简洁又鲁棒,唉!还是要多学习啊! 给一个按照从大到小的顺序排序好的数组a[]={1,2,3,4,7, ...

随机推荐

  1. shiro遇到的坑-重写sessionManager遇到的坑

    最近公司开发一个微信小程序项目加shiro的项目.因为微信小程序是不使用cookie,使用的是 storage .那么我们就不能使用传统的方式来保持登录状态了. 1.首先和网上的一样,先重写一个Ses ...

  2. Raspberrypi 装配笔记

    1 镜像烧制 2 基础配置 2.1 SSH 连接 2.2 修改管理员密码 2.3 Samba 3 功能配置 3.1 Homebridge 1 镜像烧制 从树莓派官网下载最新的 Raspbian 系统镜 ...

  3. C# SerialPort 读写三菱FX系列PLC

    1:串口初始化 com = , Parity.Even, , StopBits.One); 2:打开关闭串口 if (com.IsOpen) { com.Close();//关闭 } com.Open ...

  4. JS调用免费接口根据ip查询位置

    免费接口如下: 新浪的IP地址查询接口:http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=js 新浪多地域测试方法:http://in ...

  5. 题解:线性规划与网络流24题 T2 太空飞行计划问题

    太空飞行计划问题 问题描述 W教授正在为国家航天中心计划一系列的太空飞行.每次太空飞行可进行一系列商业性实验而获取利润.现已确定了一个可供选择的实验集合E={E1,E2,-,Em},和进行这些实验需要 ...

  6. SpringMVC基本使用步骤

    使用Spring MVC,第一步就是使用Spring提供的前置控制器,即Servlet的实现类DispatcherServlet拦截url:org.springframework.web.servle ...

  7. ES7中的async和await

    ES7中的async和await 在上一章中,使用Promise将原本的回调方式转换为链式操作,这就将一个个异步执行的操作串在一条同步线上了.下一次的操作必须等待当前操作的结束. 使用Promise的 ...

  8. Python 搭建webdriver环境遇到的问题总结

    安装过程是参考<selenium2Python自动化测试实战>中Pythonwebdriver环境搭建章节 在安装过程中,遇到了一些问题,总结一下,为日后自己再遇到相同问题做个笔记以便查看 ...

  9. poi报表导出4.1.0版本工具类 导出并下载

    这一段时间,由于项目上线基于稳定,所以我这边在基于我们一期迭代的分支上优化一部分我们之前没有做的功能,报表导出.本身之前用的是3.5的版本,但是由于同事要写导入,写的代码只有4.1.0的版本支持,所以 ...

  10. 吴裕雄--天生自然 Tensorflow卷积神经网络:花朵图片识别

    import os import numpy as np import matplotlib.pyplot as plt from PIL import Image, ImageChops from ...