Skip to content
Go back

Redis GEO 地理位置:GeoHash 编码与 Sorted Set 的优雅结合

一句话结论(30s)

Redis GEO 的本质是「少即是多」——不新增数据类型,而是把经纬度用 GeoHash 编码成 52 位整数后塞进 Sorted Set:因为范围查询正是 ZRANGEBYSCORE 的强项;代价是 GeoHash 有边界问题(编码相近不等于地理相近),所以 Redis 查询中心点周围 9 个邻域再用 Haversine 精确过滤。

核心原理(2min)

存储分两步:二分区间法把经纬度各编码成 26 位二进制、交叉合并成 52 位整数作为 ZADD 的 score(相邻地理 → 编码相近,交叉合并而非拼接正是为了保留这一特性)。查询时 GEORADIUS 先算中心点 GeoHash 与半径范围,用 ZRANGEBYSCORE 取候选点,并对中心点周围 8 个邻域共 9 次查询覆盖边界情况,最后用 Haversine 公式精确过滤掉矩形框内、圆形外的点。52 位(26+26)恰好安全落在 double 的 53 位有效精度内,综合精度约 0.3-0.5 米。

底层深入(5-10min)

一、Redis GEO 不是什么

Redis GEO 并非额外新增一个数据类型——它 完全基于 Sorted Set 实现。这是 Redis “少即是多”哲学的又一次体现:

GEOADD cities 116.397 39.908 "Beijing"
# 等价于:
ZADD cities <GeoHash编> "Beijing"

所有 GEO 命令都是对 Sorted Set 的封装。理解这一点后,你会发现:GEO 的存储逻辑只有两步:(1)将经纬度编码为 GeoHash 整数值(2)用 Sorted Set 存储这个整数和名称。地理范围查询则利用 Sorted Set 的 ZRANGEBYSCORE 实现。

思考:Redis 为什么不给 GEO 单独建一个数据类型,而是硬塞进 Sorted Set?反着想——地理范围查询「点 X 周围 R 内有哪些点」本质是「一维区间内有哪些 score」,这正是 ZRANGEBYSCORE 的 O(log N + M) 强项;复用 Sorted Set 意味着 GEO 白拿它的持久化、复制、集群、淘汰全套能力,一行新存储结构都不用写。这就是「少即是多」——用编码把二维问题转成一维问题,再交给现成的 Sorted Set。

二、GeoHash 编码原理

2.1 二分区间法

GeoHash 的核心思想是 将二维坐标编码为一维整数值,且相邻位置编码相近

以经度 116.397 为例,编码过程:

经度区间 [-180, 180]
    ├── 116.397 > 0?是 → bit=1, 区间 → [0, 180]
    ├── 116.397 > 90?是 → bit=1, 区间 → [90, 180]
    ├── 116.397 < 135?是 → bit=0, 区间 → [90, 135]
    ├── 116.397 < 112.5?否 → bit=1, 区间 → [112.5, 135]
    ├── 116.397 < 123.75?是 → bit=0, 区间 → [112.5, 123.75]
    ├── ...(继续二分)
    └── 最终得到 26 位经度编码:11010 01010 11001 01000 10110 01

纬度同理(区间 [-90, 90]),得到 26 位纬度编码:

纬度 39.908 编码:10111 00011 01110 00110 01101 01

2.2 交叉合并(Interleave)

将 26 位经度和 26 位纬度 交叉合并,得到 52 位整数

经度bits:  j25 j24 j23 j22 j21 j20 ... j0
纬度bits:  w25 w24 w23 w22 w21 w20 ... w0
合并结果:  j25 w25 j24 w24 j23 w23 ... j0 w0

为什么交叉而不是拼接? 交叉合并确保:相邻的 GeoHash 值对应相邻的地理位置。如果简单拼接(高位=经度,低位=纬度),经度主导编码,南北相邻的两点可能编码相差很大。

交叉合并后的 52 位整数作为 Sorted Set 的 score 存储。GeoHash 的特性是:地理上靠近 → 编码值靠近(但反过来不一定——编码值靠近不一定地理靠近,这是 GeoHash 的边界问题)。

思考:为什么「编码值靠近」不一定「地理靠近」?原因是交叉合并把二维折叠进了一维——两个点可能在经度上相邻、纬度上却分处区间的正负两端(如经度 0.01 和 -0.01),二分第一步就分道扬镳,编码天差地别。理解了这个反例,就明白为什么后面必须查「9 个邻域」而不是只查中心点所在的那一格。

2.3 Base32 的误区

很多文章将 GeoHash 与 Base32 编码(如 “wx4g0”)混淆。Base32 是 GeoHash 的 字符表示形式(用于 URL 等可读场景),Redis 内部使用的是 原始 52 位整数值,不做 Base32 转换。

// Redis 源码:geohash.c
// 直接返回 uint64_t 的 52 位编码值
int geohashEncode(const GeoHashRange *long_range, const GeoHashRange *lat_range,
                   double longitude, double latitude, uint8_t step,
                   GeoHashBits *hash) {
    // ...
    hash->bits = interleave64(lo, la);  // 交叉合并
    hash->step = step;
    return 1;
}

三、Sorted Set 存储架构

3.1 为什么选 Sorted Set?

GEO 需要的操作是”某个点周围半径 R 范围内的所有点”——这本质上是 范围查询。Sorted Set 的 ZRANGEBYSCORE key min max 提供 O(log N + M) 的范围查询(N为总元素数,M为结果数),完美匹配。

3.2 GEORADIUS 的执行流程

GEORADIUS cities 116.397 39.908 10 km WITHDIST

内部执行:

1. 计算中心点 (116.397, 39.908) 的 52 位 GeoHash 值  →  center_hash
2. 计算半径 10km 对应的 GeoHash 范围 [min_hash, max_hash]
   (通过 geohashGetAreasByRadiusWGS84 函数)
3. 使用 ZRANGEBYSCORE cities min_hash max_hash 获取候选点
4. 对候选点计算精确 Haversine 距离,过滤掉超出半径的边界点
5. 按距离排序,返回结果

第 4 步是关键:GeoHash 的方形范围是近似,ZRANGEBYSCORE 会返回 矩形框内所有点,但其中有些点可能不在圆形范围(半径 R 的区域内)。Redis 对候选点用 Haversine 公式做精确过滤:

// Redis 源码:geohash_helper.c
double geohashGetDistance(double lon1d, double lat1d,
                           double lon2d, double lat2d) {
    // Haversine 公式计算球面距离
    double lat1r, lon1r, lat2r, lon2r, u, v, a;
    lat1r = deg_rad(lat1d);
    lon1r = deg_rad(lon1d);
    lat2r = deg_rad(lat2d);
    lon2r = deg_rad(lon2d);
    u = sin((lat2r - lat1r) / 2);
    v = sin((lon2r - lon1r) / 2);
    a = u * u + cos(lat1r) * cos(lat2r) * v * v;
    return 2.0 * EARTH_RADIUS_IN_METERS * asin(sqrt(a));
}

3.3 GEOADD 存储了什么?

GEOADD cities 116.397 39.908 "Beijing"

内部实际存储的是:

Sorted Set: cities
member: "Beijing"
score: 4069880541808923    ← 52 位 GeoHash 整数值

四、相邻区域编码相近的含义与局限

GeoHash 的编码相近 ≠ 地理相近——这是 GeoHash 最著名的陷阱。

4.1 边界问题(Edge Case)

考虑赤道附近(纬度 = 0)的两个点:

点 A:经度 = 0.01
点 B:经度 = -0.01(即 359.99)

两点物理距离约 2.2 km(赤道上),但 GeoHash 编码差别极大(经度区间的正负边缘)。

在经度 0 处:区间 [-180, 180] → A 在正半轴,B 在负半轴。二分编码的第一步就分道扬镳。

4.2 Redis 的解决方案

Redis 不是简单地用单个 GeoHash 值查询,而是 查询中心点周围 8 个 GeoHash 邻域 加上中心点本身,共 9 个区域。这覆盖了所有可能的边界情况:

查询区域 = { 中心点所在GeoHash区域 } ∪ { 该区域周围8个邻域 }
// Redis 源码:geo.c
membersOfAllNeighbors(ga, radius_meters, &ga->hash, &ga->neighbors);
// ga->neighbors 包含 8 个邻域的 GeoHash 范围

这 9 个区域中的 8 个邻域对应的 Sorted Set score 范围可能相差很大(在 2^52 量级上),但通过 9 次 ZRANGEBYSCORE 查询 + 集合合并 + 精确距离过滤,覆盖率可达 100%。

思考:为什么是「9 次 ZRANGEBYSCORE + Haversine 精确过滤」两步走,而不是一步到位?因为 ZRANGEBYSCORE 只能按 score 区间取一个矩形框,框内必然混进「在框角、不在圆内」的误命中点;所以先用 9 个邻域框保证「不漏」,再用 Haversine 球面距离把「框内圆外」的点精确剔除,保证「不错」。一粗一精配合,才是既完整又准确的范围查询。

五、完整操作命令速查

命令Sorted Set 等价复杂度
GEOADDZADDO(log N) per element
GEODISTZSCORE A + ZSCORE B + HaversineO(log N)
GEOHASHZSCORE → Base32 编码O(log N)
GEOPOSZSCORE → 解码经纬度O(log N)
GEORADIUSZRANGEBYSCORE × 9 + HaversineO(N+log N)
GEORADIUSBYMEMBERZSCORE + GEORADIUSO(N+log N)
GEOSEARCH (6.2+)同上,支持 FROMMEMBER/FROMLONLAT + BYRADIUS/BYBOXO(N+log N)

Redis 6.2 的 GEOSEARCH

GEOSEARCH cities FROMLONLAT 116.4 39.9 BYRADIUS 10 km
GEOSEARCH cities FROMMEMBER Beijing BYBOX 20 20 km

GEOSEARCH 统一了 GEORADIUSGEORADIUSBYMEMBER,并支持矩形框(BYBOX)查询。内部仍是 9 次 ZRANGEBYSCORE + Haversine 过滤。

六、GeoHash 精度与误差

52 位 GeoHash 的精度:

经度 26 位:180 / 2^26 ≈ 2.68 × 10^(-6) 度
  ≈ 0.3 米(赤道处)

纬度 26 位:90 / 2^26 ≈ 1.34 × 10^(-6) 度
  ≈ 0.15 米(赤道处)

综合精度约 0.3-0.5 米,对于绝大多数 LBS 应用足够。52 位(26+26)是 Redis 的固定选择,没有提供可配置的精度选项——因为 Sorted Set 的 score 就是 double(53 位有效精度),52 位恰好安全落在范围内。

思考:为什么偏偏是 52 位、还不可配置?顺着约束推——score 是 IEEE 754 double,有效精度 53 位,GeoHash 要想无损转成整数存进 score,最多只能用 52 位;52 位再对半分成 26+26,综合精度约 0.3-0.5 米,对 LBS 已经绰绰有余。既然 double 的精度天花板钉死了 52 位,Redis 干脆不做可配置项,把复杂度留给自己、把简单留给用户。

章末提问

Q1:Redis GEO 为什么不用单独的数据类型,而是基于 Sorted Set 实现?

回答思路:结论——因为 GEO 的范围查询本质是一维区间查询,Sorted Set 的 ZRANGEBYSCORE 天然适配,复用它是「少即是多」。因为:经纬度经 GeoHash 编码成一维整数当 score,点周围 R 范围内的点就变成 score 区间内的成员;复用 Sorted Set 还能白拿持久化、复制、集群等全套能力,不需要新增存储结构。

Q2:GeoHash 编码相近一定地理相近吗?Redis 怎么解决边界问题?

回答思路:结论——不一定,这是 GeoHash 的著名陷阱,Redis 用「查询 9 个邻域 + Haversine 精确过滤」解决。因为:交叉合并把二维折进一维,经度 0.01 和 -0.01 这种跨区间的点编码天差地别,只查中心点一格会漏掉边界邻居;所以 Redis 查中心点周围 8 个邻域共 9 次 ZRANGEBYSCORE 保证不漏,再用 Haversine 球面距离剔除框内圆外的误命中点保证不错。

Q3:GeoHash 编码为什么用经纬度交叉合并(interleave),而不是先经度后纬度简单拼接?

回答思路:结论——交叉合并能让「地理相邻」尽量映射成「编码相邻」,这是范围查询能用一个 score 区间覆盖的前提。因为:若简单拼接,高位全是经度、纬度被挤到低位,南北相邻的两点编码可能相差巨大,一个 score 区间就包不住「周围一圈」;交叉合并让经度、纬度位交替影响编码高位,地理上靠近的点编码才靠近,ZRANGEBYSCORE 才能一次框住邻近区域。


Share this post on:

Previous Post
Redis RDB和AOF持久化——bgrewriteaof子进程如何不丢数据
Next Post
Redis Cluster无感扩容——MOVED和ASK重定向