它是什么
“找出这个乘客附近的司机”和”2 公里内的餐厅”,都是对二维数据的邻近查询,而普通索引是一维的。地理空间索引把二维邻近性化简成一个一维 key,让普通索引(有序集合、B-tree、前缀扫描)也能服务。下游的每个设计决策(分片、更新成本、边界处理)都取决于你选了哪种方案:geohash、quad-tree,还是 S2 / H3 单元。
Important
核心要点: 把”附近”变成”同一个单元或相邻的单元”。按单元 id 索引点,查询这个单元及其邻居(3×3 的网格),然后按精确距离给候选排序。在许多地理空间系统里,真正的负载是位置更新速率,而不是查询,所以更新为 O(1) 的索引会胜出。
何时需要它
叫车匹配、“附近”搜索、配送、地理围栏。面试官会追问边界问题和索引的选择,常常还有真正的瓶颈。
Note
面试信号: 说”位置更新的洪流才是真正的负载,而不是乘客的查询”(每秒几十万次更新,对比少得多的查询),说明你在选择设计之前就找到了真正的负载。主动点出边界问题是第二个信号。
三种方案
| 维度 | Geohash | Quad-tree | S2 / 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 距离 | 单元只提供召回 |
相关
- 演练:设计地理空间匹配系统 — 这个索引所服务的完整叫车匹配设计,包括对司机的占用。
- 参考:两阶段排序与召回漏斗 — 同样的先召回再排序:单元提供召回,精确距离提供精度。
- 参考:分区策略 — 为什么按地理区域而不是按实体 id 分片,能让一次邻近查询只落在一个分片上。