一句话结论(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 等价 | 复杂度 |
|---|---|---|
GEOADD | ZADD | O(log N) per element |
GEODIST | ZSCORE A + ZSCORE B + Haversine | O(log N) |
GEOHASH | ZSCORE → Base32 编码 | O(log N) |
GEOPOS | ZSCORE → 解码经纬度 | O(log N) |
GEORADIUS | ZRANGEBYSCORE × 9 + Haversine | O(N+log N) |
GEORADIUSBYMEMBER | ZSCORE + GEORADIUS | O(N+log N) |
GEOSEARCH (6.2+) | 同上,支持 FROMMEMBER/FROMLONLAT + BYRADIUS/BYBOX | O(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 统一了 GEORADIUS 和 GEORADIUSBYMEMBER,并支持矩形框(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 才能一次框住邻近区域。