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 ≈ 新街口更精确的位置

前缀越相似,两个点距离越近。 wtsqjwtsqk 的前 5 位一样,它们在同一条街上;wtsqwxyz 只有前 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 S2Uber H3

3. 为什么不直接用 PostGIS?

既然项目已经装了 PostGIS,为什么还要手动算 Geohash?PostGIS 有 ST_ClusterDBSCAN 可以直接做基于距离的聚类。

原因很简单:

  1. ST_ClusterDBSCAN 是窗口函数,会在一次扫描里做完计算,对 10w+ 数据的内存消耗大
  2. Geohash 是廉价的预过滤,可以在应用层做,也可以在 SQL 层做 GROUP BY geohash_prefix
  3. 预过滤后再调用 PostGIS 聚类,两者的结合才是最经济的

所以最终方案是:Geohash 做 SQL 层 GROUP BY + 应用层逐桶处理 + PostGIS ST_DWithin 做桶内精确距离判断

4. 其他选择:Redis Geo

Redis 从 3.2 版本开始内置了 Geo 相关操作(GEOADDGEORADIUSGEOHASH 等),底层就是基于 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端企业入驻订单聚合系统:从设计到优化的完整复盘

参考文献

  1. GeoHash算法学习讲解、解析及原理分析 - 知乎/薯片要配干燥剂
  2. Geohash - Wikipedia
  3. PostGIS ST_ClusterDBSCAN 文档
  4. Redis Geo 官方文档
  5. Uber H3 六边形网格系统