Golang 实现 Redis(9): 使用GeoHash 搜索附近的人
本文是使用 golang 实现 redis 系列的第九篇,主要介绍如何使用 GeoHash 实现搜索附近的人。
搜索附近的POI是一个非常常见的功能,它的技术难点在于地理位置是二维的(经纬度)而我们常用的索引(无论是B树、红黑树还是跳表)都是一维的。GeoHash 算法的本质就是将二维的经纬度转换为一维的表示。
本文核心实现代码可以在Godis:lib/geohash中找到。也可以下载Godis来亲自体验。
兴趣点(Point Of Intererst, POI): 在电子地图中我们关心的各种地点被称为POI, 比如餐厅、超市、写字楼。POI 通常包含名称、经纬度、描述等信息。在搜索附近的人时,你也可以把附近的用户称为POI。
GeoHash 原理
我们知道经度的取值范围是[-180,180]
, 纬度取值范围是[-90,90]
。我们将经纬度分别二等分,那么可以将地球表面分为4个部分:
纬度[-90, 0]
用0表示,[0, 90]
用1表示;经度[-180, 0]
用0表示,[0, 180]
用1表示。经度在前纬度在后,这样四个部分都有了一个二进制编码。
我们对四个小矩形继续二等分:
两次等分后的矩形需要用 4bit 来表示。前两位是第一次等分后所在大矩形的编码,后两位表示第二次分割出的小矩形在大矩形中的位置。
对这些矩形进行进一步分割,分割次数越多矩形越小经度越高,最终我们可以得到最够小的矩形来表示一个点。这个小矩形的编码可以代替这个点的坐标,矩形的边长就是GeoHash的误差。
这种分割方式让我们可以用Z型曲线涂满整个地图,这个Z型曲线叫做Peano空间填充曲线。在大多数情况下空间上相邻的点编码也非常相似。少数情况下编码会发生突变,导致编码相似的点空间距离却很大(比如上图中的0111与1000)。
如图所示除Peano空间填充曲线外还有很多空间填充曲线,其中效果公认较好是Hilbert空间填充曲线。相较于Peano曲线而言,Hilbert曲线没有较大的突变。但是由于Peano曲线实现更加简单,所以 Geohash 算法采用的是Peano空间填充曲线。
实现 GeoHash 编解码
看过上文的介绍,我们可以很快的写出GeoHash的编码过程:
// 返回二进制编码和对应矩形的经纬度范围
func encode0(latitude, longitude float64, bitSize uint) ([]byte, [2][2]float64) {
box := [2][2]float64{ // Geohash的矩形
{-180, 180}, // 经度
{-90, 90}, // 纬度
}
pos := [2]float64{longitude, latitude}
bits := make([]bit, 0)
var precision uint = 0
for precision < bitSize { // 循环直到精度足够
for direction, val := range pos { // 轮流处理经纬度,p.s. 看到这个循环了吗?你可以很方便的把GeoHash推广到N维空间
mid := (box[direction][0] + box[direction][1]) / 2 // 计算分割点
if val < mid {
// 经(纬)度小于中点,编码填0,把一下次二分的上界设为当前区间的中点
box[direction][1] = mid
bits = append(bits, 0)
} else {
// 经(纬)度大于中点,编码填1,把一下次二分的下界设为当前区间的中点
box[direction][0] = mid
bits = append(bits, 1)
}
bit++
precision++
if precision == bitSize {
break
}
}
}
return []byte(bits), box
}
代码非常简单,类似于二分查找。遗憾的是和大多数语言一样 golang 操作二进制数据的最小单位是 byte 而非 bit,所以我们需要额外做一些工作来实现按bit编码:
// 这才是真正的实现,请关注与上一节代码的不同
var bits = []uint8{128, 64, 32, 16, 8, 4, 2, 1}
func encode0(latitude, longitude float64, bitSize uint) ([]byte, [2][2]float64) {
box := [2][2]float64{
{-180, 180}, // lng
{-90, 90}, // lat
}
pos := [2]float64{longitude, latitude}
hash := &bytes.Buffer{}
bit := 0
var precision uint = 0
code := uint8(0)
for precision < bitSize {
for direction, val := range pos {
mid := (box[direction][0] + box[direction][1]) / 2
if val < mid {
box[direction][1] = mid
// 编码默认为0,不需要操作
} else {
box[direction][0] = mid
code |= bits[bit]
// 通过位或操作写入1,比如要在字节的第3位写入1应该 code |= 32
}
bit++
if bit == 8 { // 计算完一个字节的编码,将其写入buffer
hash.WriteByte(code)
bit = 0
code = 0
}
precision++
if precision == bitSize {
break
}
}
}
// precision 可能无法被 8 整除,此时剩下的二进制编码写到最后
if code > 0 {
hash.WriteByte(code)
}
return hash.Bytes(), box
}
为了方便传输GeoHash定义了一种文本格式的编码,它是将二进制编码进行Base32变换后得到的:
// GeoHash的映射表和标准Base32映射表有些不同
var enc = base32.NewEncoding("0123456789bcdefghjkmnpqrstuvwxyz").WithPadding(base32.NoPadding)
func ToString(buf []byte) string {
return enc.EncodeToString(buf)
}
写完代码后可以到www.geohash.cn上测试一下结果是否正确。
跟随二进制编码的指示进行二分既可完成解码的过程:
func decode0(hash []byte) [][]float64 {
box := [][]float64{
{-180, 180},
{-90, 90},
}
direction := 0
for i := 0; i < len(hash); i++ {
code := hash[i]
for j := 0; j < len(bits); j++ {
mid := (box[direction][0] + box[direction][1]) / 2
mask := bits[j] // 使用掩码取出指定位
if mask&code > 0 {
// 经(纬)度大于mid
box[direction][0] = mid
} else {
// 经(纬)度小于mid
box[direction][1] = mid
}
direction = (direction + 1) % 2
}
}
return box
}
解码过程不能得到精确的结果只能得到对应矩形的经纬度范围,我们通常使用矩形的中心点来作为编码对应的坐标。
搜索附近
因为位于同一个矩形中的点它们的GeoHash编码拥有相同的前缀,所以我们可以非常容易的找到某个矩形中的所有POI。
理论上我们在使用GeoHash来搜索附近POI时只需要找到一个合适的矩形然后找出其中所有POI即可,实际上我们面临两个问题:
- 即上文提到的GeoHash值突变会导致编码相近两个点空间距离很大。
- 若我们的位置在矩形的边缘, 那么隔壁矩形里的POI可能会更近。
若我们位于红点位置,北面的绿点虽然与我们不在同一个矩形内但显然更近。
为了解决这些问题,我们除了搜索定位点所在的矩形外,还搜索周围8个区域内的POI。
这样搜索附近需要分为两步来实现:
- 计算所有POI的GeoHash值,并使用跳表或B+树等便于进行范围查询的数据结构建立索引
- 计算“附近区域”对应的 GeoHash 编码,找出这些区域内的所有POI
建立空间索引
我们将GeoHash的精度设置为64位,这样我们就可以将GeoHash编码转换成uint64类型存入 SortedSet 数据结构中:
func ToInt(buf []byte) uint64 {
if len(buf) < 8 { // 用0填充不足的位数
buf2 := make([]byte, 8)
copy(buf2, buf)
return binary.BigEndian.Uint64(buf2)
}
return binary.BigEndian.Uint64(buf)
}
因为位于同一矩形的二进制编码拥有相同的前缀所以我们需要将二进制编码的低端作为uint64的高位(即使用大端序),这样位于同一矩形的uint64编码都会处于同一个数字区间内。比如0110矩形内所有点的uint64编码都会处于[0x6000000000000000, 0x7000000000000000)
区间内, 结合 SortedSet 的范围查询能力我们可以非常迅速地(时间复杂度O(logN))找到所有位于0110区域内的POI。
使用SortedSet 进行索引的代码可以在 Godis:db/geo.go 中找到。
找到附近的区域
我们知道地球半径约为 6371km 那么第一次分割后我们得到了四个东西宽 6371π km 南北高 3186π km 的矩形,以此递推在分割10次后我们可以得到宽约40km高约20km的矩形。也就是说 20bit 的 GeoHash 编码东西方向上的误差为 40km, 南北方向误差为 20 km。
我们在wikipedia 上查到 geohash 的误差范围:
表格中的geohash length 是 base32 编码后的字符串长度,1个字符可以表示5位(bit)。
我们也可以用代码估算指定的距离需要的geohash length:
func estimatePrecisionByRadius(radiusMeters float64, latitude float64) uint {
if radiusMeters == 0 {
return defaultBitSize - 1
}
var precision uint = 1
for radiusMeters < MercatorMax {
radiusMeters *= 2
precision++
}
precision -= 2
if latitude > 66 || latitude < -66 {
precision--
if latitude > 80 || latitude < -80 {
precision--
}
}
if precision < 1 {
precision = 1
}
if precision > 32 {
precision = 32
}
return precision*2 - 1
}
这段代码中需要注意两点:
- 因为地球是球型在绘制地图时采用墨卡托投影法(Mercator Projection)在靠近南北两极的地方投影面积比较大(在世界地图上靠近北极的格陵兰岛看上去非常大),所以在高纬度区域需要减少精度
- 经度的取值范围是
[-180,180]
纬度的取值范围是[-90,90]
, 所以在经纬度等分相同次数后我们得到的矩形总是东西长南北短。为了解决这个问题我们返回的精度总是奇数(precision*2 - 1), 这样经度比纬度多分割一次就可以得到长宽基本相等的矩形了。
接下来我们计算矩形区域对应的uint64编码的上下界:
func ToRange(scope []byte, precision uint) [2]uint64 {
lower := ToInt(scope)
radius := uint64(1 << (64 - precision))
upper := lower + radius
return [2]uint64{lower, upper}
}
比如位于0110矩形内的点它们的uint64编码会处于[0110..., 0111...)
范围内,在二进制编码中找到合适的位置+1就可以了。
下一步是计算九宫格的GeoHash编码,我们采用了计算各个矩形中心点经纬度然后重新编码的实现方式:
func GetNeighbours(latitude, longitude, radiusMeters float64) [][2]uint64 {
precision := estimatePrecisionByRadius(radiusMeters, latitude)
center, box := encode0(latitude, longitude, precision)
height := box[0][1] - box[0][0]
width := box[1][1] - box[1][0]
centerLng := (box[0][1] + box[0][0]) / 2
centerLat := (box[1][1] + box[1][0]) / 2
maxLat := ensureValidLat(centerLat + height)
minLat := ensureValidLat(centerLat - height)
maxLng := ensureValidLng(centerLng + width)
minLng := ensureValidLng(centerLng - width)
var result [10][2]uint64
leftUpper, _ := encode0(maxLat, minLng, precision)
result[1] = ToRange(leftUpper, precision)
upper, _ := encode0(maxLat, centerLng, precision)
result[2] = ToRange(upper, precision)
rightUpper, _ := encode0(maxLat, maxLng, precision)
result[3] = ToRange(rightUpper, precision)
left, _ := encode0(centerLat, minLng, precision)
result[4] = ToRange(left, precision)
result[5] = ToRange(center, precision)
right, _ := encode0(centerLat, maxLng, precision)
result[6] = ToRange(right, precision)
leftDown, _ := encode0(minLat, minLng, precision)
result[7] = ToRange(leftDown, precision)
down, _ := encode0(minLat, centerLng, precision)
result[8] = ToRange(down, precision)
rightDown, _ := encode0(minLat, maxLng, precision)
result[9] = ToRange(rightDown, precision)
return result[1:]
}
也可以通过某个矩形的编码快速推断出附近8个矩形的编码, 这种方式实现难度较高可以参考 Redis 中的实现:
- 入口: geohashNeighbors
- 东西向平移: geohash_move_x
- 南北向平移: geohash_move_y
最后在 SortedSet 中找到 POI:
func geoRadius0(sortedSet *sortedset.SortedSet, lat float64, lng float64, radius float64) redis.Reply {
areas := geohash.GetNeighbours(lat, lng, radius)
members := make([][]byte, 0)
for _, area := range areas {
lower := &sortedset.ScoreBorder{Value: float64(area[0])}
upper := &sortedset.ScoreBorder{Value: float64(area[1])}
elements := sortedSet.RangeByScore(lower, upper, 0, -1, true)
for _, elem := range elements {
members = append(members, []byte(elem.Member))
}
}
return reply.MakeMultiBulkReply(members)
}
OK, 大功告成。
Golang 实现 Redis(9): 使用GeoHash 搜索附近的人的更多相关文章
- Golang 实现 Redis(5): 用跳表实现SortedSet
本文是使用 golang 实现 redis 系列的第五篇, 将介绍如何使用跳表实现有序集合(SortedSet)的相关功能. 跳表(skiplist) 是 Redis 中 SortedSet 数据结构 ...
- Golang 实现 Redis(5): 使用跳表实现 SortedSet
本文是使用 golang 实现 redis 系列的第五篇, 将介绍如何使用跳表实现有序集合(SortedSet)的相关功能. 跳表(skiplist) 是 Redis 中 SortedSet 数据结构 ...
- Golang 实现 Redis(7): Redis 集群与一致性 Hash
本文是使用 golang 实现 redis 系列的第七篇, 将介绍如何将单点的缓存服务器扩展为分布式缓存.godis 集群的源码在Github:Godis/cluster 单台服务器的CPU和内存等资 ...
- go语言之行--golang操作redis、mysql大全
一.redis 简介 redis(REmote DIctionary Server)是一个由Salvatore Sanfilippo写key-value存储系统,它由C语言编写.遵守BSD协议.支持网 ...
- golang 操作 Redis & Mysql & RabbitMQ
golang 操作 Redis & Mysql & RabbitMQ Reids 安装导入 go get github.com/garyburd/redigo/redis import ...
- Golang 实现 Redis(4): AOF 持久化与AOF重写
本文是使用 golang 实现 redis 系列的第四篇文章,将介绍如何使用 golang 实现 Append Only File 持久化及 AOF 文件重写. 本文完整源代码在作者GithubHDT ...
- Golang 实现 Redis(6): 实现 pipeline 模式的 redis 客户端
本文是使用 golang 实现 redis 系列的第六篇, 将介绍如何实现一个 Pipeline 模式的 Redis 客户端. 本文的完整代码在Github:Godis/redis/client 通常 ...
- Golang 实现 Redis(8): TCC分布式事务
本文是使用 golang 实现 redis 系列的第八篇, 将介绍如何在分布式缓存中使用 Try-Commit-Catch 方式来解决分布式一致性问题. godis 集群的源码在Github:Godi ...
- Redis 到底是怎么实现“附近的人”这个功能的呢?
作者简介 万汨,饿了么资深开发工程师.iOS,Go,Java均有涉猎.目前主攻大数据开发.喜欢骑行.爬山. 前言:针对“附近的人”这一位置服务领域的应用场景,常见的可使用PG.MySQL和MongoD ...
随机推荐
- 飞塔5.4和5.6版本IPSec互备冗余测试
主电信.备联通:测试方法:修改诚盈的IPSec,将阶段一电信的对端地址改为错误的. 方法一: 通过静态路由的管理距离:电信设置为10:联通为15.经测试,可以实现自动切换,且电信恢复后 可以切换回电信 ...
- apache 创建多端口监听
httpd.conf 将 #LoadModule vhost_alias_module modules/mod_vhost_alias.so 改为 LoadModule vhost_alias_mod ...
- 天天写同步,5种SpringMvc异步请求了解下!
引言 说到异步大家肯定首先会先想到同步.我们先来看看什么是同步? 所谓同步,就是发出一个功能调用时,在没有得到结果之前,该调用就不返回或继续执行后续操作. 简单来说,同步就是必须一件一件事做,等前一件 ...
- Nacos服务心跳和健康检查源码介绍
服务心跳 Nacos Client会维护一个定时任务通过持续调用服务端的接口更新心跳时间,保证自己处于存活状态,防止服务端将服务剔除,Nacos默认5秒向服务端发送一次,通过请求服务端接口/insta ...
- Nginx 指定域名(或子域名)和网站绑定
问题起因 博主最近在 CentOS 上面部署另外一个网站,但并不想通过端口号来访问,因为端口号对于 SEO 优化不利,且用户访问较繁琐(使用域名不就是为了方便用户访问吗?再引入端口号岂不是和使用域名的 ...
- BZOJ1951 古代猪文 【数论全家桶】
BZOJ1951 古代猪文 题目链接: 题意: 计算\(g^{\sum_{k|n}(^n_k)}\%999911659\) \(n\le 10^9, g\le 10^9\) 题解: 首先,根据扩展欧拉 ...
- Codeforces Round #697 (Div. 3) D. Cleaning the Phone (思维,前缀和)
题意:你的手机有\(n\)个app,每个app的大小为\(a_i\),现在你的手机空间快满了,你需要删掉总共至少\(m\)体积的app,每个app在你心中的珍惜值是\(b_i\),\(b_i\)的取值 ...
- 同时拿到BATJMD的Offer是怎样的一种体验?
写在前面 又到了收割Offer的季节,你准备好了吗?曾经的我,横扫各个大厂的Offer.还是那句话:进大厂临时抱佛脚是肯定不行的,一定要注重平时的总结和积累,多思考,多积累,多总结,多复盘,将工作经历 ...
- kubernetes进阶(六)k8s平滑升级
当我们遇到K8S有漏洞的时候,或者为了满足需求,有时候可能会需要升级或者降级版本, 为了减少对业务的影响,尽量选择在业务低谷的时候来升级: 首先准备好文件:我这里选择的是内网文件服务器上下载的,请自行 ...
- dll的注册与反注册
regsvr32.exe是32位系统下使用的DLL注册和反注册工具,使用它必须通过命令行的方式使用,格式是:regsvr32 [/i[:cmdline]] DLL文件名命令可以在"开始→运行 ...