主题
第二十四章:地理位置检索(Geo)
Q74:Redis 的 Geo 是怎么实现的?底层用的是什么数据结构?
答:
Redis 从 3.2 版起提供了 Geo(地理位置)相关命令,用于存储经纬度坐标并按距离检索(如「附近的人」「附近的店」)。它的底层实现其实就是 ZSet(有序集合),Geo 只是在 ZSet 之上套了一层「经纬度 ↔ 分数」的编码转换。
一、底层是 ZSet
Geo 类型在 Redis 内部就是一个 ZSet:
- member = 位置点名称(如用户 id、店铺 id);
- score = 经纬度经过 Geohash 编码后得到的 52 位整数。
text
普通 ZSet:member → score(自定义分数)
Geo(本质是 ZSet):member → score(Geohash 编码的经纬度)这也是为什么 Geo 支持 ZSet 的部分命令(如 ZREM 删除位置点),因为它就是 ZSet。
二、Geohash 是什么
Geohash 是一种把「二维的经纬度」编码成「一维字符串/整数」的算法。核心思想是把地图递归地二分划分,越分越细,用一串二进制位表示位置落在哪个小格子里。
text
经纬度 (116.397, 39.908) ──Geohash──► "wx4g0b9xr" 或 一串二进制两个关键特性:
- 前缀相近 = 位置相近:Geohash 前缀相同的点,地理位置也接近(可用于范围检索);
- 一维表示二维:把经纬度变成单一数值,正好可以当作 ZSet 的 score。
更准确地说,Redis Geo 用的是 Geohash 的一个变体(52 位整数),直接作为 ZSet 的 score 存储。这样就把「地理距离」问题,转化成了「ZSet 上一维数值的排序和范围查询」问题。
三、注意:Geohash 的「边界问题」
Geohash 有一个经典缺陷:两个在地理上很近的点,如果恰好落在相邻的不同格子里,它们的 Geohash 字符串可能完全不同(前缀不相近)。
text
格子 A │ 格子 B
甲 ● │ 乙 ● ← 甲、乙实际很近,但被分到了不同格子
│所以 Geo 在「搜附近」时,通常要同时搜相邻的 8 个格子(九宫格),才能避免漏掉边界附近的点。Redis 的 GEORADIUS/GEOSEARCH 内部已经处理了这个问题,使用者一般无需关心。
四、为什么选 ZSet 而不是别的
| 需求 | ZSet 提供的能力 |
|---|---|
| 按距离排序 | score(编码后的位置)天然有序,ZRANGEBYSCORE 范围查询 |
| 快速范围查询 | 跳表实现 O(log N + M) 范围查询 |
| 存储海量位置点 | ZSet 内存效率高 |
| 删除/更新位置 | ZREM / ZADD 直接复用 |
五、小结
- Geo 底层就是 ZSet,Geo 命令是 ZSet 的一层「经纬度编码」封装;
- 经纬度经 Geohash 52 位整数编码后作为 score 存储;
- Geohash 有「格子边界」问题,搜附近要覆盖相邻格子(Redis 已处理)。
Q75:怎么用 Redis 实现「附近的人」?GEOSEARCH 的用法和参数是什么?
答:
「附近的人」「附近的店」本质是:给定一个坐标 + 半径,找出这个半径内的所有点,并按距离排序。Redis 的 GEOSEARCH 一条命令就能完成。
一、添加位置:GEOADD
bash
# GEOADD key 经度 纬度 成员
redis-cli GEOADD users:location 116.397 39.908 user:1001
redis-cli GEOADD users:location 116.410 39.910 user:1002
redis-cli GEOADD users:location 116.420 39.920 user:1003python
redis_client.geoadd("users:location", (116.397, 39.908, "user:1001"))
redis_client.geoadd("users:location", (116.410, 39.910, "user:1002"))注意参数顺序是 经度(longitude)在前、纬度(latitude)在后,写反了位置就错了。
二、搜索附近:GEOSEARCH(Redis 6.2+)
GEOSEARCH 支持以「中心点坐标」或「已存的某个成员」为中心,配合半径搜索。
bash
# 以 (116.397, 39.908) 为中心,搜索 5 公里内的点,带距离和坐标
redis-cli GEOSEARCH users:location FROMLONLAT 116.397 39.908 BYRADIUS 5 km WITHDIST ASCpython
results = redis_client.geosearch(
"users:location",
longitude=116.397, latitude=39.908,
radius=5, unit="km",
withdist=True,
sort="ASC",
)三、核心参数详解
| 参数 | 含义 | 说明 |
|---|---|---|
FROMLONLAT | 以「经纬度坐标」为中心 | 后跟 经度 纬度 |
FROMMEMBER | 以「已存在的某个成员」为中心 | 后跟成员名,自动用该成员坐标为中心 |
BYRADIUS | 按「半径」搜索 | 后跟 半径值 + 单位(m/km/ft/mi) |
BYBOX | 按「矩形」搜索 | 后跟 宽 高 + 单位 |
WITHDIST | 返回结果带「距离」 | 距离单位与 radius 一致 |
WITHCOORD | 返回结果带「经纬度」 | 便于前端展示 |
WITHHASH | 返回结果带「Geohash 值」 | 一般用于调试 |
ASC / DESC | 按距离 升序 / 降序 排序 | 默认未指定则不保证顺序 |
COUNT n | 只返回前 n 个 | 需要 ANY 配合 |
COUNT n ANY | 返回任意 n 个(不一定最近的) | 性能更好,不必全量排序 |
四、完整示例
bash
# 以 user:1001 的位置为中心,搜 3 公里内的人,按距离升序,带距离
redis-cli GEOSEARCH users:location FROMMEMBER user:1001 BYRADIUS 3 km WITHDIST ASC
# 返回示例(member、距离、坐标)
1) 1) "user:1001"
2) "0.0000"
3) 1) "116.3970"
2) "39.9080"
2) 1) "user:1002"
2) "1.1234"
3) 1) "116.4100"
2) "39.9100"五、计算两点距离:GEODIST
bash
redis-cli GEODIST users:location user:1001 user:1002 km
# 返回 1.1234(公里)六、获取坐标 / Geohash
bash
redis-cli GEOPOS users:location user:1001 # 经纬度
redis-cli GEOHASH users:location user:1001 # Geohash 字符串七、实际「附近的人」业务要点
- 位置更新频繁:用户移动要更新
GEOADD,注意是覆盖写入; - 隐私 / 分层:一般展示「距离」而非精确坐标,或只返回附近的人 id,再由业务层模糊处理;
- 过滤条件:Geo 只能按距离搜,「性别/年龄」等其他条件要在业务层过滤;
- 大量用户:附近的人配合
COUNT n限制返回数量,避免一次返回太多; - 坐标精度:经纬度保留到小数点后 5~6 位即可(约 1 米级精度),太精确反而浪费。
八、小结
- 用
GEOADD存位置(经度在前、纬度在后); GEOSEARCH ... BYRADIUS搜附近,WITHDIST/ASC带距离排序;GEODIST算距离、GEOPOS查坐标、GEOHASH查编码;- 业务层要处理位置更新、隐私、额外过滤条件。
Q76:Geo 和「经纬度距离计算」相比,什么时候用 Geo,什么时候直接算距离更合适?
答:
「附近的人」有两种实现思路:用 Redis Geo 做范围检索,或者自己算出所有点的距离再排序。两者适用场景不同,核心区别在于数据量和检索方式。
一、两种方式对比
| 维度 | Redis Geo | 自己计算距离 |
|---|---|---|
| 实现方式 | GEOSEARCH 直接范围查询 | 取所有点,应用层算 Haversine 距离再排序 |
| 数据量 | 适合海量位置点(百万级) | 适合少量点(几十、几百个) |
| 效率 | O(log N + M),靠索引快速过滤 | O(N) 全量计算,N 大时很慢 |
| 是否需存 Redis | 是,位置点存 ZSet | 否,点数据可以在应用侧(内存/DB) |
| 精度 | 距离是近似(Geohash 网格) | 可精确计算(Haversine 公式) |
| 扩展检索 | 支持半径、矩形、排序、Count | 需自己写 |
二、什么时候用 Geo
- 位置点数量大(几千、几万、百万级),需要快速过滤出「附近」的点;
- 需要频繁的范围查询(半径、矩形);
- 位置点需要持久化在 Redis,且愿意为此占内存;
- 需要按距离排序取 Top N。
典型:附近的人、附近的商家、外卖骑手就近派单、打车就近匹配。
三、什么时候自己算距离更合适
- 位置点数量很少(如只有几十个门店),直接全量算距离排序更快更简单,不值得引入 Geo;
- 位置点不在 Redis,是「静态」或「低频变化」的数据,用不着为它建一个 ZSet;
- 需要高精度距离(Geo 的 Geohash 距离是近似值,虽误差很小,但极致精确要求下可自己算);
- 只是偶尔一次的「算两个点距离」,用
GEODIST或直接 Haversine 公式即可。
四、Haversine 公式(自己算距离时用)
两个经纬度点之间的距离,用 Haversine 公式计算球面距离:
python
import math
def haversine(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
R = 6371 # 地球半径(公里)
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2
return 2 * R * math.asin(math.sqrt(a))五、选择决策
text
海量点、要范围检索 ──► Redis Geo
少量点、一次性计算 ──► 自己 Haversine 算
只需两点距离 ──► GEODIST 或 Haversine
要持久化 + 频繁附近查询 ──► Redis Geo
点数据在别处、不愿入 Redis ──► 应用层算距离六、小结
- 数据量大、频繁范围查询 → Geo;
- 数据量小、一次性/少量计算 → 自己算 Haversine;
- Geo 的距离是近似值,但误差极小(一般米级以下),业务通常可忽略;
- 选型核心是「点的数量级」和「是否需要索引式的高效范围检索」。