45.5 地理空间检索(GeoHash / 空间索引)
一句话
“附近的人”“附近的车”本质是二维范围查询:先用矩形把候选圈小,再精算距离排序。数据库能算但撑不住高并发,通用解法是把二维压成一维、按前缀批量取——这就是 GeoHash(Redis 的 GEO 就是它),代价是边界必须查相邻 9 格与单 key 不能太大。
三种方案,先看代价
| 方案 | 做法 | 能撑的量级 | 代价 |
|---|---|---|---|
| 数据库矩形查询 | x0-r < x < x0+r and y0-r < y < y0+r + 复合索引 (x, y),再逐条算距离排序 | 十万级数据、低频查询 | 两维范围扫描;半径一大,候选集暴涨;高并发下 DB 先挂 |
| GeoHash + zset(Redis GEO) | 二维坐标 → 52 位整数 → base32 前缀;zset 的 score 存整数,member 存元素 | 百万~千万 | 边界问题、单 key 过大、坐标有损 |
空间索引树(R 树 / PostGIS / ES geo_point) | 树形划分空间,按包围盒剪枝 | 亿级、复杂几何 | 组件重、运维成本高、查询延迟高于内存 |
🔑 选型的判据不是 “哪个更先进”,而是 数据量 × 查询频率:十万 POI + 低频查询,加个复合索引就够了;百万级 + 每秒几万次“附近”,才需要把二维压成一维放进内存。
数据库方案:能跑,但只能跑到哪
-- 第一步:矩形圈选(必须有 (x, y) 复合索引,单列索引只能用上一个维度)
SELECT id, x, y FROM positions
WHERE x BETWEEN ?-r AND ?+r AND y BETWEEN ?-r AND ?+r;
-- 第二步:应用层算球面距离、按距离排序、截断前 N
两个坑:
| 坑 | 后果 |
|---|---|
只有 x 或 y 单列索引 | 另一维退化成过滤,扫描量随数据量线性涨 |
| 忘了复合索引的“最左前缀”规则 | WHERE y BETWEEN … AND x BETWEEN … 的顺序会让复合索引失效(细节见 44.8 索引原理) |
半径 r 直接当“距离” | 经纬度密度不同(经度 360°、纬度 180°),必须按 cos(纬度) 加权 |
半径要“先小后大”:结果不够就把 r 翻倍重查,而不是一次给一个巨大半径——后者会让候选集和排序成本同时爆炸。
GeoHash:把二维压成一维
三步,每一步都对应一个工程后果:
| 步 | 做法 | 后果 |
|---|---|---|
| ① 二分交错 | 经度、纬度各自二分 26 次,交替取位 → 52 位整数 | 相邻格子编码相近,但不是“前缀相同就等于同格” |
| ② base32 | 每 5 位一个字符,字母表 0-9 b-z 去掉 a、i、l、o | 编码可直接做字符串前缀查询;去字母是为了避免手抄混淆 |
| ③ 存 zset | score = 52 位整数(double 53 位尾数可无损),member = 元素 | 前缀查询 = ZRANGEBYLEX 或分数区间扫描;底层是 skiplist |
坐标往返是有损的:写进去的 116.481050 读出来可能是 116.48104995…,误差在格子尺寸以内。
实测:精度表 + 与 Redis 对齐
同一套编码自己实现一遍(Python 3.14 / macOS arm64),先把“精度与位数的关系”量出来:
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
def encode(lng, lat, bits=52):
box = [-180.0, 180.0, -90.0, 90.0]
val = 0
for i in range(bits):
k = 0 if i % 2 == 0 else 1 # 0=经度 1=纬度
mid = (box[2*k] + box[2*k+1]) / 2
v = lng if k == 0 else lat
if v >= mid: val = val << 1 | 1; box[2*k] = mid
else: val = val << 1; box[2*k+1] = mid
return val, box
def b32(val, bits=52):
return "".join(BASE32[(val >> s) & 31] for s in range(bits - 5, -1, -5))
真实输出(北京纬度附近,经度方向已按 cos(40°) ≈ 0.766 折算):
bits base32位数 cell 宽×高
20 4 29,892 m × 19,512 m
25 5 3,736 m × 4,878 m
30 6 934 m × 610 m
35 7 117 m × 152 m
40 8 29 m × 19 m
52 10 46 cm × 30 cm
juejin 52位=4068808852105427 base32='wx4gd94yjn'
ireader 52位=4068807884281270 base32='wx4g52e1ce'
最后两行可以和 Redis 对账:Redis GEOHASH 对同一个点返回 wx4gd94yjn0 与 wx4g52e1ce0——前 10 位完全一致,第 11 位是补齐字符。这说明算法实现是对的,也说明“位数 = 精度”的换算可以直接拿上表用。
边界问题:2 米的距离,两个前缀
GeoHash 最反直觉的一条:两个点再近,也可能落在不同格子。在 30 位(格子约 900m × 600m)下,贴着格子右边界取两个相距 2 米的点:
格子经度范围 [116.477051, 116.488037]
点A 116.488027 编码 wx4gd9
点B 116.488047 编码 wx4gdc 两点相距 2 m
→ 前缀查询命中 A 的格子拿不到 B,必须同时查相邻 8 个格子(共 9 格)
所以任何“按前缀查附近”的实现都要做两件事:
| 必须做 | 原因 |
|---|---|
| 查中心格 + 8 个邻居(共 9 格) | 目标点可能贴边,邻居格里的点可能更近 |
| 取回后重新算球面距离并排序 | zset 的分数顺序 ≈ 编码顺序,不等于真实距离顺序 |
半径与格子的匹配关系也要算清楚:要让 “9 格” 覆盖半径 r,格子边长必须 ≥ 2r/√2(即 r 不超过格子内切半径)。格子太大则一次扫太多点,太小则要扩到 25 格——实践中按目标半径反选位数。
Redis GEO:六个命令与三个坑
| 命令 | 用途 | 注意 |
|---|---|---|
GEOADD key lng lat member ... | 写入(可一次多个三元组) | 没有 GEODEL,删除用 ZREM |
GEODIST key m1 m2 km | 两点距离,单位 m/km/mi/ft | 同一 member 返回 0.0000 |
GEOPOS key member | 取回坐标 | 有损,和写入值有微小差异 |
GEOHASH key member | base32 字符串 | 11 位,末位补齐 |
GEORADIUS key lng lat r unit [COUNT n] [ASC/DESC] [WITHDIST] [WITHCOORD] [WITHHASH] | 按坐标查附近 | 不排除查询点自身(查成员版本会包含自己) |
GEORADIUSBYMEMBER key member r unit ... | 按成员查附近 | 同上 |
三个坑,都在生产上见过:
⚠️ 单 key 太大:把所有城市的 POI 塞进一个 zset,超过 1MB 后集群做槽迁移会卡顿。Geo 数据建议单独实例、不进集群;数据过亿按国家/省/市/区拆 key。
⚠️
GEORADIUS不排自身:用GEORADIUSBYMEMBER时返回结果里包含被查元素本身(距离 0),要自己过滤。
⚠️ 别拿
GEOPOS当精确坐标:编码是有损的,往返后坐标会漂几十厘米到几米(取决于位数)。要精确坐标就另存一份原始经纬度。
容量与拆分:按行政区切 key
| 数据量 | 做法 |
|---|---|
| 十万级(单城市) | 一个 zset + GEORADIUS,够了 |
| 百万~千万(全国 POI) | 按城市拆 key(geo:city:110000),查询先定位城市 |
| 过亿 | 按省/市/区多级拆;热门城市再按网格分片 |
| 判据 | 单 key < 1MB(原文口径,超过会拖慢集群迁移) |
拆 key 后要处理“跨城市边界”的查询:先按查询点半径判断涉及哪几个城市 key,再分别查、合并排序——和 9 格查询是同一个思路。
生产落地:指名 → 可抄 → 可验 → 可退
指名:内存方案用 Redis GEO(3.2+,底层 zset);需要复杂几何/多边形用 PostGIS 或 Elasticsearch geo_point / geo_shape;超大规模网格聚合可用 Uber H3(六边形网格,邻接一致性比矩形好)。
可抄(Redis 侧,单机验证):
# 写入(可一次多个三元组)
redis-cli GEOADD company 116.48105 39.996794 juejin 116.514203 39.905409 ireader
# 距离
redis-cli GEODIST company juejin ireader km # "10.5501"
# 附近 20km 内最多 3 个,按距离升序,带距离
redis-cli GEORADIUSBYMEMBER company ireader 20 km COUNT 3 ASC WITHDIST
# 按坐标查(用户定位场景)
redis-cli GEORADIUS company 116.514202 39.905409 20 km COUNT 3 ASC WITHDIST
⚠️ 以上命令未在本机验证(本机没有 Redis)。
GEORADIUS在 Redis 6.2 起标记为弃用,集群下应改用GEOSEARCH;老版本继续用GEORADIUS。
可验:三个检查点。
| 检查 | 怎么看 | 合格线 |
|---|---|---|
| 坐标有损是否可接受 | GEOPOS 对比写入值 | 偏差 < 格子尺寸,业务无感 |
| 边界有没有漏 | 构造贴边两点,查其中一个看能否召回另一个 | 必须能召回(否则就是只查了 1 格) |
| key 是否过大 | MEMORY USAGE geo:city:xxx | 单 key < 1MB;接近就拆 |
可退:Geo 服务不可用时降级为 “按城市/行政区返回缓存列表 + 客户端自算距离”——附近的人这类功能对精度不敏感,退化成“同城列表”通常可接受;数据库矩形查询方案也应保留为兜底(低频、小半径时够用)。
反模式
| 反模式 | 后果 |
|---|---|
只做矩形查询却不建 (x, y) 复合索引 | 另一维退化成全表过滤 |
半径 r 直接用经纬度差,不按 cos(纬度) 折算 | 高纬度地区距离算错(越往北误差越大) |
| 只查前缀相同的一个格子 | 贴边点的邻居永远查不到,表现为“偶尔漏人” |
| 把所有城市塞进一个 zset | 单 key 过大,集群迁移卡顿 |
用 GEOPOS 当精确坐标 | 有损编码导致坐标漂移 |
| 拿 GeoHash 前缀相等判断“同一栋楼” | 边界上的两点前缀不同,判断失真 |
依赖 GEORADIUS 返回顺序当最终排序 | 编码顺序 ≠ 球面距离顺序,要重算 |
| 没过滤查询点自身 | 结果里出现距离 0 的自己 |
三条铁律
🔑 先用矩形把候选圈小,再精算距离:无论哪种方案,最后一步都是应用层算球面距离并排序。
🔑 前缀查询必须查邻居:9 格是下限,半径大时要 25 格;只查 1 格必然漏。
🔑 单 key 有上限:1MB 是红线,按行政区/网格拆 key 是唯一解。
自检
Q1 数据库方案为什么“能跑但撑不住”?瓶颈到底在哪一步?
▸ 答案:瓶颈在两维范围扫描 + 结果集膨胀。单列索引只能用上一个维度,必须 (x, y) 复合索引;而半径 r 一大,候选集随面积增长(πr²),随后又要逐条算距离排序,成本与数据量、半径同时相关。十万级 POI、低频查询够用;百万级 + 每秒几万次“附近”,DB 会先被打满。
Q2 GeoHash 把二维压成一维,为什么“相邻格子编码相近”不等于“前缀相同就是同格”?
▸ 答案:编码相近是连续性的性质(相邻格子的整数差小),但前缀相同是包含关系的性质(同一格子内的所有点前缀必然相同)。反过来不成立:同一个格子里的点前缀一定相同,但相距 2 米跨边界的两个点,前缀会不同(本文实测 wx4gd9 vs wx4gdc)。所以“前缀查询”只能保证找到同格的点,跨格的点必须查邻居。
Q3 为什么至少要查 9 个格子(中心 + 8 邻居)?
▸ 答案:因为查询点可能靠近格子边界,半径内的点可能落在相邻格里。若只查中心格,贴边时最多漏掉接近一半的候选。要保证 9 格覆盖半径 r,格子边长必须 ≥ 2r/√2;半径更大时 9 格不够,要扩到 25 格或换更长的前缀(更小的格子)。
Q4 Redis GEO 底层是什么结构?为什么 GEOPOS 取回的坐标和写入的有微小差异?
▸ 答案:底层是一个 zset(skiplist):score 是 52 位 GeoHash 整数(double 尾数 53 位,可无损存储),member 是元素。坐标被量化到 52 位对应的格子(北京纬度附近约 46cm × 30cm),取回时只能还原到格子中心或边界,所以会有微小偏差——这是编码有损,不是 bug。
Q5 单 key 超过 1MB 会有什么问题?怎么拆?
▸ 答案:Redis Cluster 做槽迁移时以 key 为单位,key 太大会让迁移卡顿、影响线上请求;主从全量同步与 AOF 重写也会被放大。拆法按业务维度:全国 POI 按城市拆(geo:city:110000),过亿再按省/市/区或网格拆;热门城市可再按查询半径内的网格分片。判据是 MEMORY USAGE 单 key < 1MB。
Q6 GEORADIUS 返回的结果为什么不能直接当“按距离排序”的最终结果?
▸ 答案:因为 zset 内部的顺序是编码序(近似空间邻近),不是严格的球面距离序。Redis 会按距离做一次排序(ASC/DESC),但那是基于它自己的距离计算与截断(COUNT)逻辑;应用层若还需要与其他来源合并、或做业务加权(如“营业中优先”),必须重新算距离并重排。
Q7 如果 Geo 服务整体不可用,怎么降级?
▸ 答案:三类退路,按代价从低到高:① 返回同城/同区缓存列表,客户端自己算距离排序(精度换可用性);② 降级到数据库矩形查询(低频、小半径可接受);③ 只返回“默认推荐列表”并提示“附近功能暂时不可用”。附近的这类功能对精度不敏感,通常方案 ① 就够。
关联
- 前置:44.8 索引原理(复合索引与范围扫描)、44.15 NoSQL 四类 (KV / 文档 / 列族 / 图)
- 并列:45.1 倒排索引与分词(另一种“把查询变成键查找”的索引)、41.6 热点key与分片(单 key 过大与拆分)
- 下游:45.3 搜索引擎架构(ES 的 geo 查询)、45.4 产品 (Elasticsearch / Solr / OpenSearch)