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 服务整体不可用,怎么降级?

▸ 答案:三类退路,按代价从低到高:① 返回同城/同区缓存列表,客户端自己算距离排序(精度换可用性);② 降级到数据库矩形查询(低频、小半径可接受);③ 只返回“默认推荐列表”并提示“附近功能暂时不可用”。附近的这类功能对精度不敏感,通常方案 ① 就够。

关联