基于 Redis Geo 实现地理位置服务(LBS)中查找附近 XXX 的功能

2021-01-22 11:03:39 浏览数 (1)

从 LBS 应用聊起

在移动互联网如火如荼的今天,各种 LBS(Location Based Service,基于地理位置服务)应用遍地开花,其核心要素是利用定位技术获取当前移动设备(手机)所在的位置,然后通过移动互联网获取与当前位置相关的资源和信息,典型的 LBS 应用比如高德地图定位当前位置和附近的建筑、微信查找附近的人、陌陌等陌生人社交应用、滴滴打车查询附近的车、大众点评查找附近的餐馆等等,今天学院君将带领大家来探究类似的「查找附近 XXX」的功能是如何实现的。

在此之前,学院君在基于 Laravel Vue 构建前后端分离应用 这个项目中就已经实现过类似的 LBS 服务 —— 定位当前用户所在的城市然后显示该城市所有的咖啡店:

基于数据库进行地理位置查询

不过在那里我们是通过查询高德地图 API 实现的地理位置查询,对于这种比较简单的、数据量不大的应用,还可以基于数据库进行查询,假设当前用户所在位置的经度是 u_longitude,纬度是 u_latitude,要查找距离最近的城市,可以使用如下这个 SQL 语句:

代码语言:javascript复制
SELECT
    id,
    (6371 * acos(
        cos(radians(u_latitude)) 
        * cos(radians(latitude)) 
        * cos(radians(longitude) - radians(u_longitude)) 
          sin(radians(u_latitude)) 
        * sin(radians(latitude))
    )) AS distance
FROM
    cities
ORDER BY
    distance
LIMIT 1;

参考 Find features within given coordinates and distance using MySQL 编写对应的 SQL 查询语句。

然后,我们以上面获取到的城市坐标 (c_longitude, c_latitude) 为中心查询 50 公里范围内的所有咖啡店:

代码语言:javascript复制
SELECT
    id,
    city,
    (6371 * acos(
        cos(radians(c_latitude)) 
        * cos(radians(latitude)) 
        * cos(radians(longitude) - radians(c_longitude)) 
          sin(radians(c_latitude)) 
        * sin(radians(latitude))
    )) AS distance
FROM
    cafes
HAVING
    distance < 50
ORDER BY
    distance

对于数据量不大的系统,使用数据库查询没问题,但是如果数据量很大,比如大众点评这种覆盖全国咖啡店的系统,使用 SQL 查询性能就很差了,因为经纬度字段上使用了函数,无法充分利用索引进行优化,即使引入了函数索引或者虚拟生成列,性能也并不能提高多少,如果引入缓存,那么以经纬度为键名,这个存储成本太高了。

那有没有更好的解决方案呢?

为了实现类似这种地理位置的高性能查询,Redis 引入了 Geo 这种数据结构,通过 Geo,可以轻松搞定在海量数据中查找附近 XXX 的功能。

Geo 指令的使用介绍

Redis Geo 提供了如下八个指令:

基本使用

我们可以通过 GEOADD 指令添加元素到 Geo 集合:

第一个参数是键名,然后是经度、维度和元素值,我们按照这个约定添加如下几个咖啡店及对应经纬度坐标到代表咖啡店集合的 cafes Geo 结构中:

Geo 底层使用的数据结构是 ZSET(有序集合),所以你可以在 Geo 上使用任何 ZSET 指令:

要删除某个 Geo 集合,使用 ZREM 指令即可,所以 Geo 就没有单独提供删除指令。

接下来,我们就可以通过 Geo 提供的 GEODIST 指令计算咖啡店之间的距离了(最后面的参数是距离单位):

还可以通过 GEOPOS 指令获取指定元素的坐标位置:

或者位置的哈希值:

你可以在 geohash.org 这个网站通过哈希值查询其对应的地理位置:

圆形区域查询

接下来,我们可以通过 GEORADIUSBYMEMBER 指令来查询指定坐标附近的元素:

可以看到这个指令的基本参数包括键名、元素名、查询半径、距离单位,然后是非常多的可选项,具体细节阅读官方文档,这里我们简单演示几个查询场景:

我们还可以通过 COUNT 选项限定返回的结果数,以及 DESC 按照距离远近逆序排列(默认是 ASC,即由近及远):

如果想要返回距离值的话,可以添加 WITHDIST 选项:

注:其他 WITHXXX 选项功能类似,不一一列举了。

最核心的当属 GEORADIUS 指令了,我们可以通过它来查询指定坐标附近的元素,要实现「查询附近 XXX」功能,正是需要借助这个指令完成,比如当前在西湖音乐喷泉(120.167734,30.25965),想要去附近咖啡店喝杯咖啡,可以这么查询:

GEORADIUSGEORADIUSBYMEMBER 指令相比,除了将元素名替换成查询坐标,其他参数都一样,上述运行结果返回了最近的 3 家咖啡店及其位置和坐标等详细信息,通过这些信息,可以进一步在地图上标识,以及为用户做出路径规划。

底层实现算法

Geo 查询底层使用了 GeoHash 算法,该算法是一个地址编码算法,会将二维的经纬度坐标数据编码成一维的整数值,然后再对这个整数做 Base32 编码,将其转化为一个字符串(哈希值)。

存放到底层 ZSET 集合的元素键值和 Geo 的元素键值对应,score 字段存放的则是 GeoHash 对坐标编码后的 52 位整数值,在使用 Geo 进行查询时,先通过对 ZSET 的 score 字段排序得到坐标附近的其它元素,再通过将 score 还原成坐标值就可以得到对应元素的原始坐标。

矩形区域查询

Redis 6.2 版本为 Geo 新增了 GEOSEARCHGEOSEARCHSTORE 指令,这是由阿里云贡献的,随着社区团购、电子单车围栏等 LBS 业务的发展,传统的圆形区域搜索逐渐不能满足用户的需求,于是,阿里云 Tair 团队将阿里云 Redis 企业版 Tair 性能增强型中包含的矩形搜索能力贡献给了 Redis 社区,也就是 GEOSEARCHGEOSEARCHSTORE 指令所做的事情。

关于这两个指令的使用细节可参考 Redis 6.2 发布,地理位置功能增强了什么? 这篇教程。

通过 Geo 实现查找附近咖啡店功能

基于以上的介绍,想必你已经对如何在应用代码中实现「查找附近的XXX」功能胸有成竹了,以咖啡店应用为例,我们需要在新增咖啡店时将咖啡店名称及坐标信息维护到一个 Geo 集合中,这里将键名设置为 xueyuanjun.cafes

代码语言:javascript复制
Redis::geoAdd('xueyuanjun.cafes', [$longitude, $latitude, $name]);

然后在查询附近咖啡店时,先通过高德(或者百度)地图开放平台提供的定位 API获取用户的坐标位置信息,然后将这个坐标作为参数传递到 Redis 的 GEORADIUS 指令(这里使用圆形区域搜索):

代码语言:javascript复制
Redis::geoRadius('xueyuanjun.cafes', [$longitude, $latitude, $radius, 'km', 'WITHDIST', 'WITHCOORD']);

这里我们返回了用户周边 $radius 公里内所有的咖啡店,并包含距离和坐标信息,最后再通过高德提供的地图 API 将位置映射到地图上渲染出来,并且通过路径规划 API 完成路径推荐,这样,就完成了一个查找附近咖啡店的功能闭环。同理,实现其他查找附近 XXX 的功能思路也是类似。

需要注意的是,在 LBS 应用中,无论是车、餐馆、还是人,数量可能都是以千万、亿级计,每个维度的数据和坐标信息存放在一个键中,会导致单个键值特别大,如果超过亿级规模,则需要键做拆分,比如国家、省,以降低单个键的大小,避免迁移或者重启 Redis 服务时恢复时间过长。

本系列教程首发在Laravel学院(laravelacademy.org)

0 人点赞