2026-06-19
Geohash 原理与实践:从 B 端地理聚合看空间编码
为什么聊 Geohash?
最近做了一个 B 端企业入驻订单聚合系统的 Demo,核心需求之一是:根据企业提交的商户表单的地理坐标,按最近距离自动批量聚合。
10w+ 条数据,全表算距离再做聚类是 O(n²) 级别的运算,显然不可行。项目里用了 Geohash 粗分桶 + 距离细聚类 的两阶段策略,把性能降到了 O(n log n) 的水平。
这篇文章从原理开始讲,再结合项目里的实际代码,帮你彻底搞懂 Geohash。
一、Geohash 是什么?
Geohash 是一种将二维经纬度编码为一维字符串的空间编码方案。由 Gustavo Niemeyer 在 2008 年提出。
一句话概括它的核心思想:地球表面是一张可以不断二分的大纸,每次二分的结果用 0 或 1 标记,最后把这些 0/1 转成 base32 字符串。
编码过程(以南京新街口为例)
新街口的坐标大约是 lat=32.04, lng=118.78。
Step 1:经纬度交替二分
核心规则:偶数位(从 0 开始)放经度,奇数位放纬度。
第一次划分(5 bit 对应 1 个 base32 字符)时,前 3 位是经度(分 8 段),后 2 位是纬度(分 4 段),组合成 32 个矩形块。
经度范围 [-180, 180],纬度范围 [-90, 90]
│
├─ 第0位(偶数 → 经度):118.78 在右半边 → 1
├─ 第1位(奇数 → 纬度):32.04 在右半边 → 1
├─ 第2位(偶数 → 经度):细化到 [0, 180],118.78 在右半边 → 1
├─ 第3位(奇数 → 纬度):细化到 [0, 90], 32.04 在右半边 → 1
├─ 第4位(偶数 → 经度):细化到 [90, 180],118.78 在右半边 → 1
│ ...
└─ 不断二分下去,得到一个二进制序列
不断二分下去,得到一个二进制序列:11100 11010 10110...
Step 2:5 位一组转 base32
将二进制每 5 位一组,映射到 base32 字符表:
base32 = "0123456789bcdefghjkmnpqrstuvwxyz"
11100 = 28 → 'w'
11010 = 26 → 's'
10110 = 22 → 'q'
...
最终得到类似 wtsq 的字符串——这就是新街口的 Geohash 编码。
精度对照
| Geohash 长度 | 格子宽 | 格子高 | 说明 |
|---|---|---|---|
| 1 | ~5,000km | ~5,000km | 大洲级 |
| 2 | ~1,250km | ~625km | 国家/大区域 |
| 3 | ~156km | ~156km | 省份/州 |
| 4 | ~39km | ~19km | 城市级 |
| 5 | ~4.9km | ~4.9km | 区县级 |
| 6 | ~1.2km | ~0.6km | 街道级(项目中使用) |
| 7 | ~153m | ~153m | 小区级 |
| 8 | ~38m | ~19m | 路口级 |
| 9 | ~4.8m | ~4.8m | 建筑级 |
二、Geohash 的核心特性
特性 1:前缀匹配 = 空间邻近
这是 Geohash 最有用也最容易误解的性质:
wtsq ≈ 南京新街口
wtsr ≈ 南京鼓楼
wtss ≈ 南京玄武
wtsqj ≈ 新街口更精确的位置
前缀越相似,两个点距离越近。 wtsqj 和 wtsqk 的前 5 位一样,它们在同一条街上;wtsq 和 wxyz 只有前 2 位一样,它们在完全不同的城市。
但这里有个重要陷阱——边界跳跃:
wtsq 和 wtsr 虽然前缀相似,但如果两个点恰好在 wtsq 格子的最东边和 wtsr 格子的最西边,
它们的实际距离可能只有几米,但 geohash 前缀已经不同了。
这就是为什么项目里只用 Geohash 做粗分桶,而不是直接用前缀相等做聚类——靠不够。
特性 2:一维索引效率高
Geohash 把二维坐标变成了一维字符串,这意味着:
- 可以用普通的 B-tree 索引(PostgreSQL 默认就是 B-tree)
- 范围查询转成了前缀查询:
WHERE geohash LIKE 'wtsq%' - 不需要 PostGIS 的空间索引也能做基本的地理过滤
这在数据量不大或者精度要求不高的时候特别方便。但我们的项目里最终还是上了 PostGIS GIST 索引——后面会讲为什么。
三、项目里的实际用法:两阶段聚合
在 B 端项目里,Geohash 没有被独立使用,而是作为 两阶段聚合的第一阶段。
代码实现
# backend/app/routes/aggregation.py(简化版)
@router.post("/run")
async def run_aggregation(distance_m: float = 500, precision: int = 6):
# 获取所有已提交且有地理坐标的表单
forms = await db.execute(
select(MerchantForm).where(
MerchantForm.status == FormStatus.SUBMITTED,
MerchantForm.geo.isnot(None),
)
)
# 阶段 1:Geohash 粗分桶
buckets: dict[str, list] = {}
for f in forms:
prefix = f.geohash[:precision] # 取前 6 位
buckets.setdefault(prefix, []).append(f)
# 阶段 2:桶内距离聚类(简化版——这里其实只是分配 batch_id)
batch_count = 0
for prefix, group in buckets.items():
batch_id = f"batch_{int(time.time())}_{prefix}"
for f in group:
f.batch_id = batch_id
f.status = FormStatus.PROCESSING
batch_count += 1
return {"total_forms": len(forms), "clusters": batch_count}
为什么这个设计有效?
先看一个数学问题:如果要对 N 个点做全量距离聚类,复杂度是 O(N² / M),其中 M 是聚类半径内的平均点数。
对于 10w+ 的表单:
- 全量计算:需要算 100,000 × 99,999 / 2 ≈ 50 亿次距离
- Geohash 分桶后:假设均匀分布在 1,024 个 6 位桶中 → 每个桶约 98 个点
- 每个桶内:98 × 97 / 2 ≈ 4,753 次距离
- 总共:4,753 × 1,024 ≈ 487 万次
从 50 亿降到 487 万,减少了 99.99% 的计算量。
这就是 Geohash 粗分桶的价值——它不是聚类本身,而是用一个足够便宜的操作(字符串前缀分组)大幅缩减后续精确计算的规模。
实测数据
| 表单数 | 桶数(6 位精度) | 耗时 |
|---|---|---|
| 1,000 | 636 | 0.24s |
| 10,000 | ~1,024 | ~2s(预估) |
| 100,000 | ~1,024 | ~20s(预估) |
四、Geohash 的局限性
1. 边界跳跃问题
前面已经提到了:两个实际距离很近的点可能因为落在不同格子里而有完全不同的 Geohash 前缀。
格子 A(wtsq) 格子 B(wtsr)
┌──────────────┐┌──────────────┐
│ ││ │
│ A点 ││ B点 │
│ (距离 C 点 ││ (距离 C 点 │
│ 仅 10 米) ││ 也仅 10 米) │
│ ││ │
│ C点 ││ │
└──────────────┘└──────────────┘
C 点和 A 点只隔 10 米,但 C 在 wtsq,A 在 wtsr——按照 Geohash 分组它们会被分到不同的桶。
标准解法:8 邻域扩展
在查询附近点时,不只看当前格子,而是取当前格子 + 周围 8 个相邻格子的所有点,再做距离过滤:
def get_neighbors(geohash: str) -> list[str]:
"""获取 geohash 的 8 个相邻格子"""
lat, lng = geohash_decode(geohash)
precision = len(geohash)
neighbors = []
for dl in [-1, 0, 1]:
for dg in [-1, 0, 1]:
if dl == 0 and dg == 0:
continue
neighbor = geohash_encode(lat + dl * step_lat,
lng + dg * step_lng,
precision)
neighbors.append(neighbor)
return neighbors
计算出这 9 个 Geohash 字符串后,用 geohash IN (...) 一次性查询,然后对返回的结果做精确距离排序。这样既覆盖了边界附近的点,又比全表扫描快了几个数量级。
这个 8 邻域解法是参考了知乎专栏的思路,也是 Uber H3 六边形网格解决边界问题的灵感来源。
2. 精度不均匀
Geohash 格子的大小随纬度变化——越靠近极地,格子越窄:
- 赤道附近:6 位 ≈ 1.2km × 0.6km
- 北京(40°N):6 位 ≈ 1.2km × 0.46km
- 莫斯科(55°N):6 位 ≈ 1.2km × 0.34km
经纬度交替二分的代价就是纬度方向的不均匀。如果需要均匀格子,应该考虑 Google S2 或 Uber H3。
3. 为什么不直接用 PostGIS?
既然项目已经装了 PostGIS,为什么还要手动算 Geohash?PostGIS 有 ST_ClusterDBSCAN 可以直接做基于距离的聚类。
原因很简单:
ST_ClusterDBSCAN是窗口函数,会在一次扫描里做完计算,对 10w+ 数据的内存消耗大- Geohash 是廉价的预过滤,可以在应用层做,也可以在 SQL 层做
GROUP BY geohash_prefix - 预过滤后再调用 PostGIS 聚类,两者的结合才是最经济的
所以最终方案是:Geohash 做 SQL 层 GROUP BY + 应用层逐桶处理 + PostGIS ST_DWithin 做桶内精确距离判断。
4. 其他选择:Redis Geo
Redis 从 3.2 版本开始内置了 Geo 相关操作(GEOADD、GEORADIUS、GEOHASH 等),底层就是基于 Geohash 编码 + sorted set 实现的。如果数据量不太大(百万级以内),Redis Geo 是一个零额外依赖的轻量方案。但 Redis 不支持空间 join 和复杂的地理聚合(如 ST_ClusterDBSCAN),适合做"附近的人"这类查询,不适合做批量聚类。
五、其他空间编码对比
| 特性 | Geohash | Google S2 | Uber H3 |
|---|---|---|---|
| 形状 | 矩形 | 矩形/六边形 | 六边形 |
| 编码 | 一维字符串 | 64位整数 | 64位整数 |
| 层级 | 任意精度 | 30级 | 16级 |
| 均匀性 | ❌ 纬度不均 | ✅ 相对均匀 | ✅ 最均匀 |
| 边界问题 | ❌ 有 | ❌ 有 | ✅ 无(共享边) |
| 实现成本 | ✅ 极低 | 🟡 需要库 | 🟡 需要库 |
| PostGIS 支持 | ✅ 原生 | ❌ 需扩展 | ❌ 需扩展 |
| 适用场景 | 快速原型/小规模 | 大规模/高精度 | 蜂窝网格/可视化 |
为什么项目选 Geohash?因为项目里已经有 PostGIS 了,Geohash 的编码和索引可以直接用 PostgreSQL 的普通 B-tree,不需要额外的库依赖。对于 Demo 和中等规模的数据,足够了。
六、写在最后
Geohash 是一个「简单但够用」的编码方案。它没有 S2 或 H3 那样精密的数学设计,但胜在:
- 实现极其简单(核心代码不到 50 行)
- 数据库原生支持(PostgreSQL B-tree 索引)
- 和经纬度的映射关系直观
在 B 端场景里,它作为 第一道过滤网 非常称职——先粗筛,再精算,把昂贵的距离计算留给小规模数据。
最后补充一句:对于生产级的系统,建议不要只依赖 Geohash。理想的方案是 Geohash(或 H3)做第一层过滤 + PostGIS 或 Elasticsearch 做第二层精确计算。两层各取所长,才能同时保证效率和精度。
项目完整代码:github.com/freezetheflame/B-demo
相关阅读:B端企业入驻订单聚合系统:从设计到优化的完整复盘