随着移动互联网的兴起,越来越多的App中加入了LBS的元素。而在各种LBS应用中,查找附近的地点是一种最基本也是最常见的形式。前段时间项目中加入了一个新的特性,需要根据用户所在的位置,查找附近的用户和用户发表的广播。本文将对此项目中使用的一些关键技术和遇到的问题做个简单的介绍。
在进行具体的技术介绍之前,需要先从产品层面做一些基本的条件设定。首先,地理位置信息是有时效性的,用户A一个月前来过某个地点,一个月后再向出现在同一个地点的其他用户推荐A就没有太大的意义了。所以我们需要根据实际情况对数据进行定期清理,例如只保存最近一周或者最近15天内的数据,这样不仅能够给用户提供最“有效”的数据,而且能够控制总的数据量,减少server端的压力。其次,LBS应用对于精度的要求没有那么高,通过移动设备进行定位本身就有一定的误差,因此server端在进行算法设计和实现时也并不一定要求100%的准确(实际上也很难做到),只要能把误差控制在一定的范围内即可。
关于查找附近地点的算法和实现,网上有不少相关的文章,下面重点介绍一下在本项目开发过程中尝试和研究过的各种方案,以及最终选择的实现方式。
1. geohash
geohash是一种地址编码方式,它可以把用经纬度表示的地理位置信息编码成一个字符串,例如银科大厦所在位置的经纬度为(116.306785,39.981998),对应的geohash编码为wx4eqws0。Geohash的编码算法很简单,我们就以(116.306785,39.981998)为例进行简单的介绍。首先将纬度范围(-90,90)平分为(-90,0)和(0,90)两个区间,如果目标纬度位于前一个区间,则编码为0,否则编码为1。由于39.981998属于(0,90),所以编码为1。然后再将(0,90)分为(0,45)和(45,90)两个区间,而39.981998位于(0,45),所以编码为0。以此类推,直到精度符合要求为止,得到纬度编码为1011 1000 1101 1101 0000。用同样的方法对经度进行编码(经度的取值范围为(-180,180)),得到116.306785的编码为1101 0010 1011 0101 0000。然后将经度和纬度的编码合并,奇数位为纬度,偶数位为经度,得到编码11100 11101 00010 01101 10110 11100 11000 00000。***用0-9、b-z(去掉a,i,l和o)进行base32编码,得到最终的geohash编码wx4eqws0。
从以上的计算过程可以看出,geohash表示的是一个格子(矩形区域)而不是一个点,geohash长度越长,这个格子就越小;具有相同前缀的geohash编码会落在同一个格子内,相同的前缀越长,格子越小,在地理位置上就越接近,利用这一特点,可以实现我们想要的搜索附近的地点的功能。不过geohash编码算法也有一些缺点,首先是位于格子边界两侧的点虽然十分接近,但是编码会完全不同。因此在实际的应用中,我们不仅要搜索当前格子,还要搜索当前格子周围的8个格子。除此之外,如果我们想要搜索特定范围内的地点,比如要查找周围1Km内的人,geohash无法实现这种控制,只能在返回的结果集里再进行一次距离计算,过滤掉这个范围之外的结果。
2. 基于球面距离公式的算法
查找附近地点算法的难点在于,数据库中保存的是地点的坐标信息,搜索条件是地点坐标和给定坐标之间的距离,两者之间无法直接建立联系。如果我们能把搜索的条件转换为坐标范围的话,接下来的事情就比较简单了。一般我们需要搜索一定范围内的地点,这个范围实际上是一个圆,我们可以把搜索的范围扩大到这个圆的外接正方形,求出这个正方形对应的经纬度范围,这样就可以在数据库中进行查找了。因为实际的搜索范围有所扩大,所以可能需求对返回的结果进行一次过滤,去除掉不符合条件的结果。要进行上述的计算,我们要用到Haversine公式。Haversine公式的定义为:
haversine(d/R) = haversine(lat2-lat1) + cos(lat2)cos(lat1)haversine(lng2-lng1)
其中
haversine(a) = (1-cos(a))/2
d为两点间的距离,R为地球半径,取平均值6371km,lat1和lat2为两点的纬度,lng1和lng2为两点的经度。通过这个公式,我们可以根据任意两点的经纬度计算出两点间的球面距离。
利用Haversine公式,我们还可以反推出计算经纬度搜索范围的计算。在Haversin公式中,令lat1=lat2,可以得到
delta(lng)=2arcsin(sin(d/2R)/cos(lat1))
令lng1=lng2,可以得到
delta(lat)=d/R
根据当前点的坐标和经纬度的范围,就可以得出搜索范围了。
这个算法的特点是简单明了,搜索范围可控,精度也可以接受。但是在实际使用中发现,即使在经度和纬度列上建立索引(使用MySQL),数据量大的时候效率会比较差,达不到性能上的要求。
3. 基于Sptial Indexing的方案
方案2中基于球面距离公式的算法本身没有太大的问题,瓶颈在于数据库的效率。这里要介绍的Spatial Indexing——空间索引,就是要解决这个问题。不同于我们常用的BTree索引,基于RTree的空间索引可以在二维空间上进行高效的查询,非常适合类似查找附近的地点这样的需求。主流的数据库,包括MySQL,都在不同的程度上支持空间索引。MySQL中有一个类型Point,可以用来存储每个位置对应的经纬度。在这一列上建立空间索引,按照方案2中得出的经纬度范围划定一个矩形,利用MySQL提供的空间扩展函数判断某个点是否在这个矩形内,就可以实现查找附近地点的目的了。
以上介绍了在整个项目开发过程中进行的各种尝试以及每种方案的优缺点,最终采用的方案是以上各个方案的一个综合:在数据库中建立空间索引,对于任意一个请求,根据经纬度(lng,lat)和查找范围(d)以及Haversin公式计算出经纬度范围(delta(lng),delta(lat)),然后利用MySQL的空间扩展函数在数据库中进行查找,得到的结果除了排序、过滤并返回以外还要保存在cache中,cache的key为经纬度(lng,lat)的geohash编码。geohash编码的长度是可以配置的,本项目中我们选择了长度为7的geohash编码,原因是7位geohash编码表示的格子的大小大约在150m左右,也就是说150m范围内的搜索可以返回相同的结果,这基本满足我们对于精度的要求。