面对百万级 GPS 点的近邻查询,迟早会碰上同一堵墙:暴力距离扫描太慢,而原始经纬度坐标天然不适合建索引。空间网格系统的解法是把坐标对齐到预计算好的格网单元——查询快、聚合干净、单元 ID 可以直接分享。Geohash 从 2008 年就在做这件事。2018 年 Uber 开源 H3,把六边形网格带入主流。S2 则是 Google Maps 内部的底层支撑。三者并不通用,那些在原型阶段看似学术的差异,一旦数据跨越格网边界或者横跨多个大陆,就会在生产环境里变得非常棘手。
什么是空间网格索引系统?
空间网格索引系统把地球表面划分成离散的格网单元,并为每个单元分配唯一标识符。存储时不再保存原始坐标,而是存单元 ID——只需比较字符串前缀或整数大小,就能实现快速查找、范围查询和数据聚合。核心取舍在于:单元的均匀性、实现复杂度与查询性能之间的平衡。
三个系统来自不同的工程文化。Geohash 由 Gustavo Niemeyer 于 2008 年发明,作为公共领域的地理编码系统,设计上追求极简。S2 约在 2011 年由 Google 开发,是内部地图基础设施的几何库。H3 则是 Uber 在 2018 年开源,最初用于解决城市规模下司机与乘客的匹配问题。
单元形状折射出各自的出身:Geohash 生成矩形,S2 生成球面四边形(在球面上近似于正方形),H3 生成六边形(另有 12 个五边形用于封闭球面)。
各系统的工作原理
每个系统的编码方式,既决定了它的优势,也埋下了生产环境中的隐患。
H3 将地球投影到正二十面体(一个 20 面的多面体)上,然后对每个面进行六边形细分。最终得到 15 个分辨率层级,每个单元细分为大约 7 个子单元。单元 ID 是 64 位整数。六边形形状使得每个单元恰好有 6 个共边邻居——不存在共顶点的歧义。
Geohash 将纬度和经度的二进制表示交替交织,然后编码为 Base32 字符串。这就是 Geohash 字符串可读性好的原因:u4pruydqqvj 唯一标识巴黎市中心一块约 3 m × 3 m 的区域。精度由字符串长度(1–12 个字符)控制。前缀属性——u4pru 始终包含在 u4pr 内——使得范围查询几乎不需要额外工作。
S2 将地球表面展开映射到立方体的六个面上,在每个面上应用希尔伯特空间填充曲线,再将位置映射为 64 位整数。希尔伯特曲线保留了空间局部性:地球上相近的点会产生相近的整数。S2 有 31 个分辨率层级,并通过 S2RegionCoverer 支持任意多边形的覆盖——用尽可能少的单元来近似任意形状。
精度与覆盖范围对照表
三个系统使用互不兼容的精度刻度——H3 的 15 个分辨率、Geohash 的 12 个字符长度、S2 的 31 个层级彼此无法直接对应。下表按三个实用场景对齐:城市级聚合、街区路由、建筑级精度。
经度随纬度的收缩遵循余弦曲线,收缩速度往往比开发者预期的更快。在北纬 30°(洛杉矶、开罗、上海),一度经度对应 96.5 km,而一度纬度对应 110.6 km——东西方向缩短了 14.9%。到了北纬 60°(斯德哥尔摩、安克雷奇、圣彼得堡),比值达到 2:1:一度经度仅有 55.7 km 宽。一个 6 字符的 Geohash 单元在赤道附近覆盖约 0.74 km²,到了北纬 60° 只剩约 0.37 km²。如果你要构建全球服务并设定统一的近邻阈值(比如"500 米以内"),固定精度的 Geohash 会因纬度不同而产生不一致的结果——需要在低纬度用更长的 hash、在极点附近用更短的 hash 才能获得等效的实际覆盖范围,这会让查询逻辑变得复杂。H3 和 S2 都对球面变形做了补偿:H3 分辨率 9 级的单元面积在所有纬度范围内变化幅度不超过 3%。
| 场景 | H3 分辨率 | H3 单元面积 | Geohash 长度 | Geohash 单元面积 | S2 层级 | S2 单元面积 |
|---|---|---|---|---|---|---|
| 城市 | 5 | ~253 km² | 4 字符 | ~780 km² | 10 | ~325 km² |
| 街区 | 9 | ~0.11 km² | 6 字符 | ~0.74 km² | 15 | ~0.32 km² |
| 建筑 | 11 | ~0.0022 km² | 7 字符 | ~0.023 km² | 18 | ~0.005 km² |
邻居查询与边界效应
邻居查询是空间索引中最常见的操作:"给定当前单元,找出所有相邻单元。"三个系统在这方面的体验差异很大。
H3 的邻居模型最为简洁。每个六边形恰好有 6 个共边邻居——没有共顶点邻居,没有歧义。gridDisk(cell, k) 函数一次调用就能返回 k 环以内的所有单元。k=1 的圆盘返回 7 个单元(含中心),k=2 返回 19 个。由于六边形所有边长相等,用环数来做距离查询在几何上是自洽的。
Geohash 有 8 个邻居(4 条边 + 4 个角)。大多数单元的查询没有问题,但有一个臭名昭著的问题出现在格网边界:两个地理上相邻的单元,其 Geohash 字符串可能没有任何公共前缀。例如,跨越本初子午线(东经 0°)或赤道的单元,hash 可能完全不同。典型症状:查询返回了边界一侧的结果,却悄悄漏掉了另一侧就在附近的点。
一个有据可查的真实案例:法国西南部的 La Roche-Chalais 的 Geohash 前缀是 u000,而仅 30 km 之外的 Pomerol 的前缀是 ezzz。这两个临近地点在任意长度下都没有公共前缀,因为一条主格网边界就在它们中间穿过。基于前缀的近邻查询在"La Roche-Chalais 附近的点"这个请求下,会静默地返回零条来自 Pomerol 的结果。同样的问题出现在格林威治子午线(东经 0°):本初子午线两侧相距 10 米的两个 GPS 点,hash 分别以 e(西侧)和 s(东侧)开头——没有公共前缀,也没有公共索引范围。标准的解决方案是:查询时始终取目标单元加上它的 8 个邻居,再按实际 Haversine 距离过滤。Redis 的 GEOSEARCH 命令在内部处理了这一逻辑,这也是为什么用数据库原生支持比自己实现 Geohash 近邻逻辑更可靠。
S2 使用希尔伯特曲线,提供了较强的局部性保证——地球上相近的点会映射到索引中相近的整数。但"希尔伯特空间中相近"和"空间上相邻"并不总是等价的。立方体的六个面之间存在接缝,跨面边界的邻居计算需要仔细处理。S2 的 S2CellId::EdgeNeighbors() 能正确处理这一情况,但实现工作量比 H3 的 gridDisk 更大。
如果你的数据跨越地理边界——海岸线、国家边界、日期变更线——H3 均匀的六边形拓扑是更安全的默认选择。像 Geohash 这样的矩形格网系统,在特定经纬度上存在可预测的接缝,而在生产环境中调试一次边界漏查的体验并不愉快。
各系统适用场景
如果你已经在用 Redis 或 Elasticsearch,直接用 Geohash——近邻查询开箱即用,零额外代码。如果需要全球一致性或六边形可视化,选 H3。只有在需要索引任意多边形、或与现有 Google 基础设施协作时,才考虑 S2。下表覆盖了常见场景:
| 使用场景 | 推荐 | 原因 |
|---|---|---|
| Redis GEO 命令 / Elasticsearch geo_point | Geohash | 原生内置支持,零配置 |
| 出行/外卖需求热力图 | H3 | 单元大小一致 + 六边形可视化效果好 |
| 全球数据聚合(气候、遥测) | H3 | 消除极地纬度变形 |
| 任意多边形区域索引 | S2 | S2RegionCoverer 可近似任意形状 |
| 游戏格网系统(Pokémon GO 就用这个) | S2 | Niantic 的原始基础设施选型 |
| 快速原型与简单地理围栏 | Geohash | 学习曲线最低,ID 可读性好 |
| 多分辨率分析(缩放) | H3 | 父子层级结构清晰 |
| PostGIS / 空间数据库 | Geohash | GIS 生态标准支持 |
生态系统成熟度往往在几何因素之前就决定了选型。Geohash 自 2015 年起内置于 Redis(GEOADD、GEORADIUS),Elasticsearch 的 geo_point 字段类型也在内部使用 Geohash 做格网聚合。如果你的技术栈已经包含其中之一,Geohash 集成几乎不需要额外工作。
H3 有完善的 Python(h3)和 JavaScript(h3-js)库,不断壮大的可视化工具生态(Deck.gl 的 H3HexagonLayer),以及 DuckDB 空间扩展的一级支持。S2 的几何运算能力最强,但 JavaScript 支持最弱——主库是 C++,其他语言的移植由社区维护。
代码示例:H3、Geohash 和 S2 的实际用法
三个系统都有 JavaScript 和 Python 库。以下示例涵盖最常用的三个操作:编码坐标、查找邻居、获取单元边界多边形。关于 h3-js 和 ngeohash 两个 npm 包的详细对比(包体积、编码性能、生产踩坑),见 h3-js vs ngeohash 对比。
上线前有两个库的坑值得提前知道:
ngeohash 整数精度:encode_int / decode_int 函数在 JavaScript 中受限于 52 位(标准 IEEE 754 float64 安全整数范围)。大多数场景没问题,但如果你把 bitDepth 设置到 52 以上,结果会悄悄丢失精度。除非有特殊原因需要用整数,否则请使用字符串 API(encode / decode),并确保 encode 和 decode 调用时始终传入相同的 bitDepth 值。
h3-js 单元 ID 表示:H3 单元 ID 是 64 位整数,超出了 JavaScript 的安全整数范围(2^53 − 1)。h3-js 库默认以字符串形式返回(例如 "8928308280fffff")。一个常见的生产 bug:如果你的 API 把 H3 ID 序列化为 JSON 数字而非字符串,某些 JSON 解析器会悄悄对值进行取整,导致单元 ID 损坏。请在整个技术栈中始终把 H3 ID 当作字符串处理——数据库 schema、API 响应、前端代码都不例外。
JavaScript
import { latLngToCell, gridDisk, cellToBoundary } from "h3-js";
import ngeohash from "ngeohash";
// --- H3 ---
// Encode coordinate to H3 cell (resolution 9 ≈ 0.1 km²)
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
// → "8928308280fffff"
// Get all cells within 1 ring (7 cells including center)
const h3Neighbors = gridDisk(h3Cell, 1);
// Get cell boundary polygon (array of [lat, lng] pairs)
const h3Boundary = cellToBoundary(h3Cell);
// --- Geohash ---
// Encode coordinate to Geohash (precision 6 ≈ 0.74 km²)
const hash = ngeohash.encode(37.7749, -122.4194, 6);
// → "9q8yyk"
// Decode back to coordinate
const { latitude, longitude } = ngeohash.decode(hash);
// Get 8 neighbors (N, NE, E, SE, S, SW, W, NW)
const geohashNeighbors = ngeohash.neighbors(hash);
// Get cell bounding box [minLat, minLon, maxLat, maxLon]
const bbox = ngeohash.decode_bbox(hash);
Python
import h3
import geohash # pip install python-geohash
# --- H3 ---
cell = h3.latlng_to_cell(37.7749, -122.4194, 9)
neighbors = h3.grid_disk(cell, 1)
boundary = h3.cell_to_boundary(cell) # list of (lat, lng) tuples
# --- Geohash ---
hash_str = geohash.encode(37.7749, -122.4194, precision=6)
decoded_lat, decoded_lng = geohash.decode(hash_str)
neighbors = geohash.neighbors(hash_str) # dict with N/NE/E/SE/S/SW/W/NW keys
S2 在 JavaScript 中需要社区移植版(s2-geometry npm 包),与 C++ 主库存在滞后。在 JS 环境中做严肃的 S2 开发,最实用的方案是调用 Python/Java 服务,或者在 Cloudflare Workers 上使用 WASM。在 Python 中,s2sphere 库提供了简洁的接口:
import s2sphere
# Encode coordinate to S2 cell (level 15 ≈ 0.32 km²)
latlng = s2sphere.LatLng.from_degrees(37.7749, -122.4194)
cell_id = s2sphere.CellId.from_lat_lng(latlng).parent(15)
# Get 4 edge neighbors
neighbors = cell_id.edge_neighbors()
# Cover an arbitrary polygon with S2 cells
region_coverer = s2sphere.RegionCoverer()
region_coverer.min_level = 10
region_coverer.max_level = 15
covering = region_coverer.get_covering(some_s2_region)
你可以直接在浏览器中用坤舆的 GeoHash 编码工具 体验 Geohash 和 H3 编码——无需安装任何库。
常见问题
H3 比 Geohash 更好吗?
对于没有既有基础设施约束的新项目,H3 是更好的默认选择:单元大小均匀、邻居查询更简洁、没有边界 bug。Geohash 的优势在于:需要开箱即用的 Redis GEO 命令或 Elasticsearch 聚合时,或者 ID 可读性有要求时——Geohash 字符串在日志里一眼能看懂;而 H3 的 8928308280fffff 就没那么直观了。
Redis 支持 H3 吗?
Redis 没有原生 H3 支持。内置的 GEO 命令(GEOADD、GEORADIUS、GEOSEARCH)在内部使用 Geohash。如果要在 Redis 中使用 H3,需要在应用层把坐标编码为 H3 单元 ID,然后作为普通字符串键或有序集合存储。截至 Redis 7.x,官方没有计划向核心服务器添加 H3 原语。
S2 是什么的缩写?
S2 是 Sphere²(球面的平方)的缩写,指的是将球面映射到二维曲面的数学概念。该库由 Google 开发,在 Google Maps、Google Earth 及其他 Google 地理产品中广泛使用。
分辨率 5 级下,H3 单元覆盖地球需要多少个?
在分辨率 5 级,共有 2,016,842 个 H3 单元覆盖地球表面(含封闭正二十面体所需的 12 个五边形)。每个单元面积约为 252.9 km²,适合用于国家级或大都市区的分析。
可以在 H3 和 Geohash 之间直接转换吗?
没有直接的数学转换方法。两套格网是独立的——分辨率 9 的 H3 六边形和 6 字符的 Geohash 单元覆盖的区域不同,只能近似映射。实际做法是把任意一种格式解码回经纬度坐标,再重新编码为目标格式。需要注意,由于大小和形状不同,转换后的单元可能无法完整包含原始单元。
Geohash 的最大精度是多少?
Geohash 规范最多支持 12 个字符,对应的单元大小约为 3.7 cm × 1.9 cm。实际应用中大多使用 6–9 个字符(从约 1 km² 到约 7 m²)。超过 9 个字符后,精度已经超出大多数 GPS 硬件的定位能力,过长的字符串键也会带来不必要的存储和索引开销。