一、开篇:为什么"附近的人"加载那么慢?
三年前我接手过一个"同城交友"项目,用户量不大(日活 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 的编码和查询用了两个不同的精度:
- 编码时用精度 8 (
user.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/geohash 的 EncodeWithPrecision,精度固定为 8。
还有两个前置校验:
req.Latitude >= 0 && req.Longitude >= 0:只处理合法坐标。经度范围 -180, 180,纬度范围 -90, 90。小于 0 意味着南半球/西半球,但本系统可能只服务中国地区,不处理负值也合理。- 修改时同时传了
latitude和longitude才重新编码(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 的执行计划:
- 首先通过
idx_geohash索引定位到loc_geohash LIKE 'wtw3sj%'的所有行。这步走了索引,扫描的行数可能是全表的 1/1000。 - 然后对扫描到的行逐一检查 5 个 NOT-IN 条件(子查询也会走各自索引)。
- 返回最终结果。
如果没有 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 条件顺序是:
friends.uid = ?(走idx_uid索引)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