Go 企业级工程能力实战(10):用 Geohash 实现“附近的人“——地理围栏算法实战

一、开篇:为什么"附近的人"加载那么慢?

三年前我接手过一个"同城交友"项目,用户量不大(日活 5000),但"附近的人"接口平均响应时间 2.3 秒,P99 超过 8 秒。

当时的实现方式是这样的:

sql 复制代码
SELECT *, (
    6371 * acos(cos(radians(31.23)) * cos(radians(latitude)) 
    * cos(radians(longitude) - radians(121.47)) 
    + sin(radians(31.23)) * sin(radians(latitude)))
) AS distance
FROM user
HAVING distance < 5
ORDER BY distance
LIMIT 20

这就是经典的"Haversine 公式"查询。它的问题是:每一行都要计算三角函数 。acos、cos、sin、radians 这些函数没法用索引加速------数据库必须把 user 表的每一行都扫一遍,算一遍,排序一遍。

你可能会说:"那给 latitude 和 longitude 分别建索引啊!" 没用的,因为查询条件是 HAVING distance < 5,而 distance 是一个计算列,MySQL 根本不会用任何索引,直接全表扫描。

这就是为什么 5000 人也要 2.3 秒------不是因为数据多,而是因为没有一种数据结构能高效地回答"经纬度点附近有哪些点"

这时候该 Geohash 登场了。


二、概念铺垫:Geohash 是什么?

2.1 从"二维坐标"到"一维字符串"

地球是一个球面,每个地点用经度(longitude)和纬度(latitude)两个浮点数表示。比如上海外滩大约是 (31.23, 121.47)

数据库的 B+Tree 索引对一维数据很高效------一个范围查询(WHERE col BETWEEN a AND b)走索引很快。但对两个独立维度同时做范围查询,就麻烦了。

Geohash 的核心思想:把二维的经纬度编码成一维的字符串。

复制代码
(31.23, 121.47)  --编码--> "wtw3sj"

这个字符串的神奇之处是:前缀相同的字符串,地理位置上就相近。

比如:

  • "wtw3sj""wtw3sk" 前缀相同度很高 → 距离很近
  • "wtw3sj""wtw000" 前缀几乎不同 → 距离很远

2.2 用生活场景类比

想象一个巨大的图书馆。书架按照"类别→子类→作者首字母→书名首字母"逐级摆放。你要找一本"计算机→Go语言→张老师→高并发实战"的书。

你不需要挨个书架翻找。你直接走到"计算机"区→"Go语言"书架→"张"行→"高"本。每一步都排除了 90% 的书。

Geohash 同理:

  • 第 1 位字母,把地球分成 32 个格子(每个约 5000km 见方)
  • 第 2 位字母,每个格子再分成 32 个子格(每个约 1250km 见方)
  • 以此类推,每多一位,精度翻倍

所以 WHERE loc_geohash LIKE 'wtw3s%' 等价于"在 wtw3s 这个约 5km 见方的格子里找用户"。

这就是为什么 Geohash 能走索引------B+Tree 对 LIKE 前缀查询天然友好。

2.3 精度选择对照表

Geohash 的长度决定了格子的"精度"(大约边长):

长度 格子大致边长 适用场景
1 5000 km 大洲级别
2 1250 km 国家级别
3 156 km 省份级别
4 39 km 城市级别
5 4.9 km 城区级别
6 1.2 km "附近的人"默认精度
7 153 m 街道级别
8 38 m 精确地点
9 4.8 m 门牌号级别
10 1.2 m 室内定位
11 15 cm 过高精度,一般不用
12 3.7 cm 实际上是一个点

user-service 项目中,Geohash 的编码和查询用了两个不同的精度:

  • 编码时用精度 8user.LocGeohash 字段长度 8,约 38m 精度)
  • 查询时默认精度 6(约 1.2km 半径范围)

为什么要这样设计?因为编码精度决定了"能匹配到的最近精度",查询精度决定了"搜索半径"。编码比查询精度高,意味着数据本身很精确,只是搜索时放宽了范围。


三、循序渐进:从编码到查询的完整链路

3.1 编码:用户注册时生成 Geohash

service/UserService.go:116-119(用户创建):

go 复制代码
if req.Latitude >= 0 && req.Longitude >= 0 {
    hash_base32 := geohash.EncodeWithPrecision(req.Latitude, req.Longitude, 8)
    user.LocGeohash = hash_base32
}

service/UserService.go:228-231(用户修改位置):

go 复制代码
if ok1 && ok2 && tmpLatitude >= 0 && tmpLongitude >= 0 {
    hash_base32 := geohash.EncodeWithPrecision(tmpLatitude, tmpLongitude, 8)
    modifyArr["loc_geohash"] = hash_base32
}

使用的是第三方库 github.com/mmcloughlin/geohashEncodeWithPrecision,精度固定为 8。

还有两个前置校验:

  • req.Latitude >= 0 && req.Longitude >= 0:只处理合法坐标。经度范围 -180, 180,纬度范围 -90, 90。小于 0 意味着南半球/西半球,但本系统可能只服务中国地区,不处理负值也合理。
  • 修改时同时传了 latitudelongitude 才重新编码(ok1 && ok2)。只传一个坐标不会触发重算。

3.2 存储:loc_geohash 字段和索引

sql/init.sql:16-21

sql 复制代码
`loc_geohash` VARCHAR(16)   NOT NULL DEFAULT '' COMMENT 'geohash 位置编码',
...
KEY `idx_geohash` (`loc_geohash`)

idx_geohash 索引是 Geohash 查询能走索引的根本保障。VARCHAR(16) 支持最大 12 位的 Geohash(实际上最多用 8 位)。

3.3 查询:附近好友 vs 附近陌生人

service/FriendsServer.go:44-161 提供了两个接口。

查询"附近的好友"(GetNearbyFriend)

业务场景:你已经和某些人是好友,你想知道哪些好友在你附近(比如 1.2km 以内)。

service/FriendsServer.go:62-98

go 复制代码
func (s *Service) GetNearbyFriend(c *gin.Context) {
    info, err := s.UserDao.FindUser(c.Request.Context(), uid)
    geohashStr := info.LocGeohash

    precision := 6  // 默认精度 6
    if p := c.Query("precision"); p != "" {
        n, err := strconv.Atoi(p)
        if err != nil || n < 1 || n > 12 {
            s.returnError(c, constant.ERROR_PARAM_ERR, "param precision must be 1-12")
            return
        }
        precision = n
    }
    if len(geohashStr) < precision {
        precision = len(geohashStr)  // 如果 geohash 不够长,用最大可用长度
    }
    likeSubStr := geohashStr[:precision]  // 截取前缀

    list, err := s.FriendsDao.GetNearbyFriend(ctx, uid, likeSubStr, pageSize, offset)
}

对应的 SQL 在 dao/FriendsDao.go:48-56

sql 复制代码
SELECT user.id as fri_uid, user.name as fri_name, 
       user.latitude, user.longitude, user.loc_geohash, 
       friends.create_time
FROM friends, user
WHERE friends.uid = ?
  AND friends.fri = user.id
  AND user.loc_geohash LIKE ?
LIMIT ? OFFSET ?

LIKE ? 的实际值是 'wtw3sj%'(前缀 + % 通配符)。

这里的 SQL 用的是隐式内连接 (FROM friends, user),等价于 INNER JOIN。先通过 friends.uid = ? 找到当前用户的所有好友 ID,再 JOIN 到 user 表,用 loc_geohash LIKE 'wtw3sj%' 过滤出"和当前用户在同一个 1.2km 格子里的好友"。

这就是 "附近的好友" = "好友 ∩ 同格子"

查询"附近的陌生人"(GetNearbyStranger)

这是更复杂的场景:陌生人 = 和当前用户在同一个格子里,但不是好友、没有待处理的请求、没有被拉黑。

dao/FriendsDao.go:71-96

sql 复制代码
SELECT u.id AS fri_uid, u.name AS fri_name, 
       u.latitude, u.longitude, u.loc_geohash
FROM user u
WHERE u.loc_geohash LIKE ?
  AND u.id != ?
  AND u.id NOT IN (SELECT fri FROM friends WHERE uid = ?)
  AND u.id NOT IN (SELECT from_uid FROM friend_requests WHERE to_uid = ? AND status = 'pending')
  AND u.id NOT IN (SELECT to_uid FROM friend_requests WHERE from_uid = ? AND status = 'pending')
  AND u.id NOT IN (SELECT blocked_uid FROM blacklist WHERE uid = ?)
LIMIT ? OFFSET ?

5 个过滤条件对应的传入参数(subStr, uid, uid, uid, uid, uid),我们在第 ⑦ 篇文章中已经详细解读过。

这里我们换个角度------从 Geohash 的视角来看这条 SQL 的执行计划

  1. 首先通过 idx_geohash 索引定位到 loc_geohash LIKE 'wtw3sj%' 的所有行。这步走了索引,扫描的行数可能是全表的 1/1000。
  2. 然后对扫描到的行逐一检查 5 个 NOT-IN 条件(子查询也会走各自索引)。
  3. 返回最终结果。

如果没有 Geohash 索引,第一步就是全表扫描。这就是性能从 O(n) 到 O(log n) 的跨越。

3.4 分页与精度调整

service/FriendsServer.go:126-142(GetNearbyStranger 部分):

go 复制代码
if geohashStr == "" {
    s.returnSuccess(c, []interface{}{})
    return
}

precision := 6
if p := c.Query("precision"); p != "" {
    // ...
}
if len(geohashStr) < precision {
    precision = len(geohashStr)
}
likeSubStr := geohashStr[:precision]

如果用户的 geohashStr 为空(没设置位置),直接返回空列表。

精度可在 1-12 之间由客户端传入。前端可以这样设计交互:

  • 初始展示"1.2km 范围内的陌生人"(精度 6)
  • 用户点"扩大范围"时,前端用精度 5(约 5km 范围)重新请求
  • 用户点"缩小范围"时,用精度 7(约 153m 范围)

precision 不能超过 len(geohashStr)------如果用户坐标只编码了 4 位(虽然本项目是 8 位),请求精度 8 也没意义。


四、代码实战:Geohash 的边界问题与工程取舍

4.1 边界穿越问题

Geohash 有一个著名的缺陷:边界不连续

假设两个用户:

  • 用户 A 在 "wtw3sj"(格子左下角)
  • 用户 B 在 "wtw3sm"(相邻格子,物理距离只有 50 米)

你的 SQL WHERE loc_geohash LIKE 'wtw3sj%' 查不到 B 。因为 B 的 Geohash 前缀是 "wtw3sm",不匹配 "wtw3sj%"

但实际上 A 和 B 只隔了 50 米! 这就是 Geohash 的"边界断裂"问题。

解决方案:同时查询相邻的 8 个格子

真正生产级的"附近的人"需要在查询时同时查 9 个格子(当前格子 + 周围 8 个格子):

复制代码
东南西北 + 东北+ 西北+ 东南+ 西南

在 SQL 里就是:

sql 复制代码
WHERE loc_geohash LIKE 'wtw3sj%'
   OR loc_geohash LIKE 'wtw3sk%'  -- 东
   OR loc_geohash LIKE 'wtw3sh%'  -- 西
   OR loc_geohash LIKE 'wtw3sv%'  -- 北
   ...(共 9 个 LIKE)

user-service 没有做这个优化 。为什么?因为精度 6 的格子边长是 1.2km,在这个尺度上边界断裂的影响很小

要不要做相邻格子查询,取决于你的业务容忍度:

  • 精度 6(1.2km):不做也行,最多漏掉格子边缘的用户
  • 精度 8(38m):必须做,否则距离 60m 的朋友可能搜不到
  • 精度 5(4.9km):完全不需要做

这是工程中的取舍------不为低概率场景过度优化

4.2 为什么编码精度(8)和查询精度(6)不一样?

编码精度 8(38m)意味着用户的 loc_geohash 可以精确到 38m。查询精度 6(1.2km)意味着搜索时只看前 6 位前缀。

高编码精度 + 低查询精度 = 数据储备灵活。未来如果产品要求"把附近的人精确到 150m",只需要把查询精度改成 7,不需要重新编码所有用户的坐标。

4.3 为什么不直接在 SQL 里算距离?

因为 Geohash 的 LIKE 查询可以走索引,而 Haversine 公式不行。所以工程上的正确姿势是:

复制代码
第一步:用 Geohash LIKE 快速粗筛(走索引,过滤掉 99% 的不相关数据)
第二步:在应用层对第一步的结果用 Haversine 算精确距离(数据量已经很小)
第三步:按距离排序,返回最终结果

user-service 只做了第一步,没有做第二步和第三步。如果你需要"按距离排序",只需要 20 行 Go 代码:

go 复制代码
import "math"

func haversine(lat1, lng1, lat2, lng2 float64) float64 {
    const R = 6371.0
    dLat := (lat2 - lat1) * math.Pi / 180
    dLng := (lng2 - lng1) * math.Pi / 180
    a := math.Sin(dLat/2)*math.Sin(dLat/2) +
        math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
            math.Sin(dLng/2)*math.Sin(dLng/2)
    return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}

然后在获取到 Geohash 粗筛结果后,用 sort.Slice 按距离排序。

4.4 索引的关键性

如果没有 idx_geohash 索引(sql/init.sql:20),LIKE 查询会退化为全表扫描。10 万用户的全表扫描大约需要 200ms,加上 5 个 NOT-IN 子查询,轻松超过 1 秒。

但有索引后:10 万用户,精度 6 的格子平均包含约 100 人(假设用户分布均匀),索引扫描约 100 行,加上子查询约 5ms 完成。

差距是 40 倍。 这就是为什么要在数据库设计阶段就考虑查询模式------索引不是后来"优化"加上去的,而是在设计期就确定的。


4.5 Geohash vs Quadtree vs Google S2:三种空间索引方案对比

Geohash 不是唯一的选择。在 Geo 领域有两种经典替代方案,各有优劣。

(1)Quadtree(四叉树)

四叉树的思想:把地图递归地分成四个象限,直到每个叶子节点只包含少量点。

复制代码
地球
├─ 西北象限
│  ├─ 西北-西北
│  ├─ 西北-东北
│  ├─ 西北-西南
│  └─ 西北-东南
├─ 东北象限
├─ 西南象限
└─ 东南象限

优势在于精度自适应------人口密集的区域树更深,荒野区域树很浅。但四叉树一般需要在应用层维护(内存或文件系统),不像 Geohash 可以用数据库索引直接检索。适合 MongoDB 这类内置空间索引的数据库,不适合 MySQL。

(2)Google S2

S2 是 Google 地图使用的空间索引算法,用 Hilbert 曲线把球面映射到 64 位整数。它比 Geohash 解决得更好的是:

  • 边界连续:Hilbert 曲线是空间填充曲线,始终连续,相邻格子编号接近
  • 球面直接映射:Geohash 是先映射到平面(Mercator 投影)再分割,S2 直接在球面上分割
  • 多级精度:一个 64 位整数可以无损表示任意级别的 Cell

S2 的问题在于:算法复杂,不易理解;且 MySQL 中没有原生支持,需要应用层做位运算。适合 Google 级的大规模场景,对大多数中小项目来说属于过度设计。

(3)为什么 user-service 选择 Geohash

很简单:在一维字符串 + MySQL B+Tree 索引的约束下,Geohash 是"刚刚好"的方案

需求 Geohash Quadtree S2
MySQL 原生支持 ✅ LIKE查询 ❌ 需额外存储 ❌ 需位运算
实现复杂度 低(一个库函数) 中(树结构) 高(球面希尔伯特)
边界连续 ❌ 有缺陷 ✅ 较好 ✅ 最优
适用的最大用户量 百万级 千万级 亿级

user-service 是轻量微服务,日活预测在万级别。在这个量级下,Geohash 已经足够。工程选择不是选"最好的"技术,而是选"在当前约束下最合适的"。

4.6 附近好友查询的 SQL 优化潜力

再回看 dao/FriendsDao.go:48-56 中的附近好友查询:

sql 复制代码
SELECT user.id, user.name, user.latitude, user.longitude, user.loc_geohash, friends.create_time
FROM friends, user
WHERE friends.uid = ? AND friends.fri = user.id AND user.loc_geohash LIKE ?

这条 SQL 有什么优化空间?

优化方向 1:用 INNER JOIN 替代隐式连接

FROM friends, user WHERE friends.fri = user.id 等价于 FROM friends INNER JOIN user ON friends.fri = user.id。显式 JOIN 更清晰,但对 MySQL 来说执行计划完全一样------优化器会把隐式连接转换为等价的 JOIN。所以这不是性能优化,是代码风格问题。

优化方向 2:调整索引使用顺序

当前查询的 WHERE 条件顺序是:

  1. friends.uid = ?(走 idx_uid 索引)
  2. user.loc_geohash LIKE ?(走 idx_geohash 索引)

MySQL 只能选一个索引驱动查询。如果用户的 friends 表有 1000 个好友,MySQL 可能选择先用 idx_uid 查出所有好友,再逐一 JOIN user 表并用 idx_geohash 过滤。反之,如果 geohash 格子里的用户很少(比如 10 个),MySQL 可能先用 idx_geohash 查出格子里的所有用户,再 JOIN friends 表过滤哪些是好友。

MySQL 的优化器会根据统计信息自动选择最优策略。但可以通过创建覆盖索引进一步优化------让索引包含 SELECT 需要的所有字段,避免回表查询。

优化方向 3:覆盖索引

创建 (uid, fri, create_time) 的覆盖索引,配合 user 表上的 (loc_geohash, id, name, latitude, longitude) 覆盖索引,让整条查询变成纯索引扫描,零回表。但增加索引数量会拖慢写入性能。在这个项目量级下,现阶段索引已经足够,不需要过早优化。

4.7 从"附近的人"到"附近的好友"------一条 SQL 的两种面孔

你会发现 GetNearbyFriend(附近好友)和 GetNearbyStranger(附近陌生人)用了完全不同的 SQL 策略。前者是 JOIN friends 表然后 LIKE geohash,后者是直接查 user 表然后用 5 个 NOT-IN 过滤。

这两种策略对应不同的查询基数:

  • 附近好友:用户一般只有几十到几百个好友。先找到好友,再做 geohash 过滤,基数小→效率高。
  • 附近陌生人:全表用户可能几十万,geohash 过滤先缩小到几百人,再用 NOT-IN 过滤。基数大→必须先粗筛再精滤。

如果反过来写:

  • 附近好友用"先查 geohash 再过滤好友":格子里的用户可能有几百个,而用户好友可能只有 50 个。先查格子(几百行)再 JOIN 过滤(50 行),多做了无用功。
  • 附近陌生人用"先查 NOT-IN 再 geohash":NOT-IN 无法走索引缩小范围,全表扫描后再 LIKE,性能灾难。

因此,同一种功能需求,不同的查询基数决定了不同的 SQL 策略。这就是"SQL 没有银弹"的最好例证。

4.8 地理位置的精度退化策略

service/FriendsServer.go:78-80 有一个关键逻辑:

go 复制代码
if len(geohashStr) < precision {
    precision = len(geohashStr)
}

这行代码的意思是:如果用户的 Geohash 字符串只有 3 位(可能是旧数据或某些特殊坐标),但请求的精度是 6,系统自动退化到 3 位精度查询。

这是向前兼容的设计。想象一下:

  • 版本 1.0 编码精度只有 4(约 39km 范围)
  • 版本 1.2 升级到编码精度 8,同时支持 1-12 的查询精度
  • 老用户的 loc_geohash 还只有 4 位
  • 如果查询时不做退化,geohashStr[:6] 会 panic(index out of range)或产生垃圾数据

但有了这行代码,老用户的查询自动限定在 4 位精度,不会出错。这就是"优雅的向前兼容"------系统升级不伤害存量数据。


五、总结

Geohash 的设计哲学是"降维"------把一个二维的空间搜索问题,降维成一维的字符串前缀匹配问题。

层级 做法 工程价值
编码 注册/更新时用EncodeWithPrecision(lat, lng, 8) 生成 8 位 geohash 一次计算,永久存储
索引 KEY idx_geohash (loc_geohash) LIKE 前缀查询走 B+Tree 索引
查询 WHERE loc_geohash LIKE 'wtw3sj%' 精确到 1.2km 的空间过滤
过滤 5 个 NOT-IN 子查询 在数据库层面完成社交关系过滤
精度可调 查询精度 1-12 由客户端传入 一个接口支持多种搜索半径

user-service 项目中,Geohash 不是单独使用的,而是和好友关系、好友请求、黑名单耦合在一起,形成了一个完整的"地理位置 + 社交关系"推荐引擎。5 个 NOT-IN 条件同时过滤掉已经是好友的人、正在申请的人、已经拉黑的人,确保推荐列表的"纯粹性"。

下次有人问你"附近的人怎么实现?",你的回答应该包含:Geohash 编码、索引优化、粗筛+精算两步法、边界断裂的处理方案------而不只是"用 MySQL 的坐标函数算距离"。


完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:

① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性

⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash

⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile