Skip to main content

参考:地理空间索引——Geohash、Quad-Tree、S2 与 H3

一份关于地理空间索引的参考——geohash、quad-tree、S2 与 H3——涵盖 9 格扫描解决边界问题、受位置更新约束的负载,以及按地理区域分片。

它是什么

“找出这个乘客附近的司机”和”2 公里内的餐厅”,都是对二维数据的邻近查询,而普通索引是一维的。地理空间索引把二维邻近性化简成一个一维 key,让普通索引(有序集合、B-tree、前缀扫描)也能服务。下游的每个设计决策(分片、更新成本、边界处理)都取决于你选了哪种方案:geohash、quad-tree,还是 S2 / H3 单元。

Important

核心要点: 把”附近”变成”同一个单元或相邻的单元”。按单元 id 索引点,查询这个单元及其邻居(3×3 的网格),然后按精确距离给候选排序。在许多地理空间系统里,真正的负载是位置更新速率,而不是查询,所以更新为 O(1) 的索引会胜出。

何时需要它

叫车匹配、“附近”搜索、配送、地理围栏。面试官会追问边界问题和索引的选择,常常还有真正的瓶颈。

Note

面试信号: 说”位置更新的洪流才是真正的负载,而不是乘客的查询”(每秒几十万次更新,对比少得多的查询),说明你在选择设计之前就找到了真正的负载。主动点出边界问题是第二个信号。

三种方案

维度GeohashQuad-treeS2 / H3
Key一维的字符串前缀,轻松建索引树遍历一维单元 id(Hilbert 曲线或六边形)
对密度的适应每个前缀长度对应固定的单元大小:对倾斜较差极好:在密集处拆分好:多分辨率的单元
更新成本O(1):重新计算哈希并换到新的单元O(log n):可能触发拆分或合并O(1):重新计算单元 id
边界问题严重:邻居可能没有共同前缀由树结构处理由单元的邻接关系处理
复杂度低中高:使用库
易解释性高中低(投影部分一笔带过)

Geohash。 把纬度和经度的比特交错在一起,并用 base 32 编码。邻近的点共享一个前缀,所以邻近查询就是在普通字符串索引上的前缀扫描。单元大小由前缀长度决定:

Geohash 长度单元的大致大小
5约 4.9 km × 4.9 km
6约 1.2 km × 0.6 km
7约 150 m × 150 m
8约 38 m × 19 m

Quad-tree。 只在一个单元包含超过 K 个点的地方,才递归地把空间拆成四个象限,所以密集的市中心会得到深而细的单元,而乡村地区则保持粗糙。查询下降到相关的叶子。它能适应密度,但增加了重新平衡的逻辑。

S2 和 H3。 S2 把球面投影到立方体上,并沿 Hilbert 曲线给单元排序;H3 使用六边形单元。六边形有一些对调度和定价很重要的特性:每个邻居都共享一条边(而不只是一个角),所以 6 个邻居构成的环,比 8 个方形邻居更紧、更均匀,而且所有邻居到中心的距离都相等。

Tip

为什么面试里用 geohash,同时点出 H3: Geohash 把难的部分(二维邻近)变成了容易的部分(任何有序索引都支持的一维前缀扫描),而 O(1) 的更新适合受更新约束的系统。当密度倾斜或邻居的均匀性很重要时,把 H3 作为生产环境的升级路线点出来。在小规模下,Redis 内置的 geo 命令(GEOADD、GEOSEARCH)就是完整的答案;要往前走的原因是一个 key 不能按区域分片。

边界问题

Caution

常见错误: 只查询乘客自己所在的那个单元。分处单元边缘两侧、相距一米的两个点,有完全不同的 geohash,没有共同前缀,所以街对面的司机会被漏掉。这是最常见的地理空间 bug。

修法:查询乘客的单元加上它的 8 个邻居(3×3 的网格;geohash 库有 neighbors() 函数),收集候选,再计算精确的 haversine 距离来过滤和排序。单元提供召回,精确距离提供精度。要选择单元大小,使 3×3 的单元能覆盖搜索半径。

算例。 一个乘客搜索 500 米以内。在 geohash 长度 6(单元约 1.2 km × 0.6 km)下,3×3 的块在中心单元的各个方向上至少延伸 0.6 km,这就包含了中心单元内任意一点周围 500 m 的半径。扫描这 9 个单元,收集可能几百个司机,计算到每个的精确距离,返回最近的。单元太细(长度 8)要覆盖 500 m 就需要多得多的单元;太粗(长度 5,约 4.9 km)收集的候选又会多到无法排序。

真正的负载在哪里

叫车场景就是例子:司机每隔几秒上报一次位置,所以一个大城市每秒会看到大约 75 万次位置更新,而乘客的查询要少得多(大约每秒 10 万次)。实时索引很小(所有活跃司机加起来只有几百 MB),但被不停地重写,所以它必须放在内存里,设计由更新吞吐主导:O(1) 的更新(重新计算单元,把司机在两个单元集合之间移动)才是重要的属性。

  • 索引结构: 单元 id -> 司机 id 的集合,再加 司机 id -> (lat, lng, 单元, updated_at),这样一次移动就是从旧单元 O(1) 地删除并插入新单元。
  • 分片: 按地理区域(城市或一个粗粒度的单元前缀),因为邻近查询天然是局部的,这样乘客的一次查询就由一个分片回答。按司机 id 分片会迫使每个查询 scatter 到所有分片。
  • 匹配保持: 当两个请求想要同一个司机时,用一次原子的 set-if-absent 加上存活时间来占住司机,这样就不可能双重匹配,而一个崩溃的匹配器所持有的 hold 会自己释放。

如何选择

情形选择原因
小规模,用托管存储内置的 geo 命令(Redis GEO)一天就能做完
受更新约束,密度大致均匀Geohash 网格,9 单元扫描O(1) 更新;简单
密度差异很大(市中心 vs 郊区)Quad-tree,或多分辨率的 H3让单元大小适应负载
调度、动态定价、邻居的均匀性H3六边形的邻居等距
分片按地理区域,而不是按实体 id一次查询只命中一个分片
精确排序对候选做 haversine 距离单元只提供召回

相关