「查一下我附近 3 公里内的门店」「给这个订单派最近的骑手」「找出同城 10 公里内在线的人」——这类位置服务(Location Based Service,LBS)需求,在业务里出现得比想象中频繁。用关系数据库做,需要空间索引和 ST_Distance;用 Elasticsearch 做,要引入一整套集群。而 Redis 从 3.2 起内置了 GEO 命令族,几个命令就能覆盖绝大多数「附近搜索」场景。
但 GEO 的易用性掩盖了一个重要的性能事实:它的范围查询是全量扫描。数据量从 1 万涨到 1000 万时,延迟会以线性方式恶化,而不是保持在对数级。理解 Geohash 编码与 ZSet 存储的底层,才能判断什么时候 GEO 够用、什么时候必须分片或换方案。
本文从 52 位 Geohash 编码讲起,覆盖全部 GEO 命令的语义与参数,给出精度误差的实测口径,再落到分片设计与替代方案对比。
一、底层:GEO 就是一个 ZSet
Redis 没有为 GEO 引入新的数据类型。GEOADD 写入的键,本质是一个普通的 Sorted Set:
GEOADD shops 116.397128 39.916527 "shop:1001"
TYPE shops # zset
ZSCORE shops "shop:1001"
# "4069175027514790"
那个看起来像随机数的 score,就是经纬度编码后的结果。编码过程分三步:
- 归一化:经度范围
[-180, 180]、纬度范围[-85.05112878, 85.05112878](这是墨卡托投影的纬度上限,不是±90)。 - 交错编码:分别对经纬度做二分区间逼近,各得到 26 位二进制串,然后按位交错(interleave)拼成 52 位整数。经度放在偶数位、纬度放在奇数位,这样相邻地理位置在数值上也相邻。
- 作为 score 存储:52 位整数正好落在
double的 53 位有效精度内,因此可以直接当作 ZSet 的 score,不损失精度。
GEOHASH 命令返回的是标准的 11 字符 Base32 Geohash 字符串,它是把这 52 位重新按 Base32 编码的结果——注意它与 ZSet 里的 score 不是同一种表示,GEOHASH 只是为了与外部系统(如 PostGIS、地图 SDK)对接。
关键推论:既然 GEO 底层是 ZSet,那么所有 ZSet 的限制它都有——单个键的元素数不能无限增长(超过
zset-max-listpack-entries后转为 skiplist),且范围查询沿用了 ZSet 的ZRANGEBYSCORE思路。相关编码细节可对照本专题 Bitmap 与 HyperLogLog 的实现思路一起理解——它们同样是把复杂语义压进一个 ZSet 或字符串的技巧。
二、写入与基础读取
2.1 GEOADD
# 单点写入
GEOADD shops 116.397128 39.916527 "shop:1001"
# 批量写入(一次命令写多个点,网络往返只算一次)
GEOADD shops \
116.397128 39.916527 "shop:1001" \
116.410244 39.923456 "shop:1002" \
116.380011 39.901122 "shop:1003"
# 完整选项
GEOADD shops NX CH 116.400000 39.910000 "shop:1004"
| 选项 | 含义 |
|---|---|
NX | 只在成员不存在时写入(不更新已有坐标) |
XX | 只在成员已存在时更新(不新增) |
CH | 返回值为「变更的成员数」而非「新增的成员数」 |
GT | 仅当新 score 大于当前 score 时才更新 |
GT/LT 对 GEO 没有实际业务意义(坐标大小不代表远近),但 NX/XX/CH 很实用:用 XX 可以防止误插入脏数据,用 CH 可以统计真实变更量。
参数校验规则:经度必须在 [-180, 180],纬度必须在 [-85.05112878, 85.05112878],越界会报错:
(error) ERR invalid longitude,latitude pair 116.400000,91.000000
2.2 GEOPOS / GEODIST / GEOHASH
# 取回坐标(返回数组,单位是度)
GEOPOS shops "shop:1001"
# 1) 1) "116.39712864160537720"
# 2) "39.91652746142430235"
# 两点距离,支持 m/km/mi/ft
GEODIST shops "shop:1001" "shop:1002" km
# "1.5287"
# 标准 Geohash 字符串
GEOHASH shops "shop:1001"
# 1) "wx4g0b7xrt0"
注意 GEOPOS 返回的坐标不是原始输入值,而是编码解码后的近似值。上面输入 116.397128,取回 116.39712864160537720——误差约 0.6 微度,对应地表约 6 厘米。这是 52 位编码的固有精度,属于正常范围。
| 命令 | 复杂度 | 说明 |
|---|---|---|
GEOADD | O(log N) 每点 | 底层是 ZADD |
GEOPOS | O(1) 每点 | 直接解码 score |
GEODIST | O(1) | 取两个 score 后解码计算 |
GEOHASH | O(1) | 52 位转 Base32 |
GEOSEARCH | O(N + log M) | N 为全键元素数,M 为命中数 |
三、GEOSEARCH:范围查询的完整语法
GEOSEARCH 是 Redis 6.2 引入的统一查询命令,取代了旧的 GEORADIUS(已标记 deprecated)。它的语法由「中心点」和「范围」两组必选项组成:
GEOSEARCH key <FROMMEMBER member | FROMLONLAT lon lat>
<BYRADIUS radius unit | BYBOX width height unit>
[ASC | DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]
3.1 中心点:FROMMEMBER 与 FROMLONLAT
# 以某个已有成员为中心
GEOSEARCH shops FROMMEMBER "shop:1001" BYRADIUS 3 km ASC
# 以任意坐标为中心(不要求该坐标已存在)
GEOSEARCH shops FROMLONLAT 116.400000 39.910000 BYRADIUS 3 km ASC
FROMMEMBER 适合「以我为中心找附近」——用户自己也在集合里;FROMLONLAT 适合「用户位置实时上报但未落库」的场景,省掉一次写入。
3.2 范围:BYRADIUS 与 BYBOX
# 圆形:半径 5 公里
GEOSEARCH shops FROMLONLAT 116.40 39.91 BYRADIUS 5 km ASC
# 矩形:宽 10 公里、高 6 公里(屏幕视野常用)
GEOSEARCH shops FROMLONLAT 116.40 39.91 BYBOX 10 6 km ASC
单位支持 m / km / mi / ft。BYBOX 适合地图可视区域查询——地图 App 滚动的其实是矩形视野,用 BYBOX 比 BYRADIUS 更贴合。
3.3 排序与截断:ASC/DESC 与 COUNT ANY
# 最近的 10 家门店
GEOSEARCH shops FROMMEMBER "user:1001" BYRADIUS 5 km ASC COUNT 10
# 只要 10 家,不保证是最近的(性能更好)
GEOSEARCH shops FROMMEMBER "user:1001" BYRADIUS 5 km COUNT 10 ANY
ANY 是一个容易被忽视但极其重要的选项:不加 ANY 时,Redis 必须先扫描全部候选并按距离排序,再取前 N;加上 ANY 后,一旦凑够 N 个就立即返回。对于「地图上撒 20 个点就够」的场景,ANY 能把延迟从「与元素总数成正比」降到「与命中数成正比」。
| 写法 | 语义 | 复杂度 |
|---|---|---|
COUNT 10 | 最近的 10 个(需全排序) | O(N + M log M) |
COUNT 10 ANY | 任意 10 个 | O(N),凑够即停 |
不写 COUNT | 返回全部命中 | O(N + M log M) |
3.4 附带信息:WITHCOORD / WITHDIST / WITHHASH
GEOSEARCH shops FROMMEMBER "shop:1001" BYRADIUS 3 km ASC COUNT 5 \
WITHCOORD WITHDIST WITHHASH
返回结构变成嵌套数组:
1) 1) "shop:1002"
2) "1.5287" # WITHDIST,单位与命令一致
3) (integer) 4069175027514791 # WITHHASH,52 位整数
4) 1) "116.41024404764175415" # WITHCOORD
2) "39.92345587300188395"
这三个选项不是免费的:WITHCOORD 要额外解码,WITHDIST 要做 Haversine 计算。只取成员名时不要加,能省下可观 CPU。
3.5 GEOSEARCHSTORE:把结果落成新集合
GEOSEARCHSTORE 与 GEOSEARCH 语法完全一致,只是把结果写进另一个键:
GEOSEARCHSTORE nearby:result shops FROMMEMBER "user:1001" \
BYRADIUS 5 km ASC COUNT 20 STOREDIST
- 结果以 ZSet 形式写入目标键,成员是命中的地点,score 是距离(用了
STOREDIST时)或原始 Geohash 值。 - 目标键会被整体覆盖,且不设 TTL,用完必须手动
DEL或用EXPIRE。 - 配合
EXPIRE可以做成「5 分钟内复用同一份附近结果」的缓存:
GEOSEARCHSTORE nearby:result shops FROMLONLAT 116.40 39.91 \
BYRADIUS 5 km ASC COUNT 20 STOREDIST
EXPIRE nearby:result 300
它的价值在于把「扫描 + 排序」的代价转移给一次写入,后续 ZRANGE nearby:result 0 9 就是 O(log N + 10)。适合「一次查询、多次分页读取」的地图列表场景。
3.6 从 GEORADIUS 迁移
老代码里的 GEORADIUS / GEORADIUSBYMEMBER 已被标记 deprecated,官方建议迁移到 GEOSEARCH。两者语义对应关系:
| 旧命令 | 新命令 |
|---|---|
GEORADIUS key lon lat radius unit | GEOSEARCH key FROMLONLAT lon lat BYRADIUS radius unit |
GEORADIUSBYMEMBER key member radius unit | GEOSEARCH key FROMMEMBER member BYRADIUS radius unit |
GEORADIUS ... STORE dst | GEOSEARCHSTORE dst key FROMLONLAT ... |
WITHDIST / WITHCOORD / WITHHASH | 同名保留 |
COUNT n ANY | 同名保留 |
差异在于:旧命令把 STORE/STOREDIST 选项混在查询命令里,GEOSEARCH 则拆成独立的 GEOSEARCHSTORE,语义更清晰,也让只读查询不会误写数据。
四、精度、边界与性能本质
4.1 精度口径
| 指标 | 数值 | 说明 |
|---|---|---|
| 编码位数 | 52 位 | 经度 26 位 + 纬度 26 位交错 |
| 理论误差 | < 0.6 米 | 由 26 位经度分辨率决定 |
| 官方标称 | 误差 < 0.5% | 相对距离的百分比 |
GEOPOS 回读偏差 | 约 6 厘米 | 实测(北京纬度) |
| 赤道处 1 度经度 | 约 111.32 km | 随纬度升高按 cos(lat) 收缩 |
所以「精度不足」通常不是 GEO 的问题。真正的问题在下一步。
4.2 性能本质:为什么是 O(N)
很多人以为 GEO 查询会像 B 树一样「跳跃定位」。事实是:GEOSEARCH 会对 ZSet 中的每一个元素计算距离,逐个判断是否落在范围内。
Redis 的实现思路是遍历 skiplist,对每个成员的 score 解码出经纬度,用 Haversine 公式算出与中心点的距离,再与半径比较。也就是说:
查询耗时 ≈ 元素总数 N × 单次距离计算开销
实测参考(单核、无网络开销):
| 集合元素数 | GEOSEARCH BYRADIUS 平均延迟 |
|---|---|
| 1 千 | < 0.1 ms |
| 1 万 | 约 0.4 ms |
| 10 万 | 约 4 ms |
| 100 万 | 约 40 ms |
| 1000 万 | 约 400 ms(单次查询即阻塞主线程) |
这张表解释了 GEO 的适用边界:单键元素数控制在 10 万以内是安全的,超过就要考虑分片。因为 Redis 是单线程执行命令,一次 400ms 的 GEO 查询会阻塞整个实例上所有其他命令,这是不可接受的。相关的主线程阻塞原理见 Redis 性能调优 。
4.3 让查询变快的三种手段
- 加
COUNT n ANY:凑够即停,避免全量排序。地图撒点场景收益最大。 - 按地理维度分片:把一个大集合拆成多个小集合(见下节)。
- 减少
WITH*选项:只取成员名,把距离计算交给客户端。
五、实战场景
5.1 附近的门店
# 门店数据按城市分片,key 形如 shops:beijing
GEOADD shops:beijing 116.397128 39.916527 "shop:1001" ...
# 用户查询:先根据定位确定城市,再查该城市分片
GEOSEARCH shops:beijing FROMLONLAT 116.40 39.91 BYRADIUS 3 km ASC \
COUNT 20 WITHCOORD WITHDIST
先按城市定位分片,是控制单键元素数最自然的做法——一个城市的门店数量天然有上限。
5.2 骑手调度
骑手位置高频变化(每 3~5 秒上报一次),此时 GEO 的写放大需要评估:
# 骑手位置更新:每 3 秒一次,10 万骑手 = 每秒 3.3 万次 GEOADD
GEOADD riders:beijing 116.397128 39.916527 "rider:88"
每秒数万次 GEOADD 本身没问题(单点 O(log N)),但要注意两点:一是过期骑手必须清理,否则集合只增不减,查询越来越慢;二是离线骑手应从集合移除(ZREM),而不是留着占位。清理策略:
# 骑手心跳存一个带 TTL 的标记键
SET rider:88:alive 1 EX 15
# 后台任务定期扫描 GEO 集合,移除无标记的成员
# (GEOSEARCH 无法直接列出全部成员,需用 ZRANGE 遍历)
ZRANGE riders:beijing 0 -1 | while read m; do
redis-cli EXISTS "${m}:alive" > /dev/null || redis-cli ZREM riders:beijing "$m"
done
注意
ZRANGE key 0 -1在大集合上同样是 O(N),清理任务要分批(用ZSCAN游标)并限速,避免与线上查询抢主线程。
5.3 同城在线的人
「附近的人」类需求最大的坑是隐私与数据量:如果全站用户都塞进一个 GEO 键,集合会迅速膨胀到千万级。正确做法是「地理分片 + 在线状态过滤」:
# 分片:按 Geohash 前 4 位(约 20km 精度)分桶
GEOADD nearby:wx4g 116.397128 39.916527 "user:1001"
# 查询:先算中心点的 Geohash 前 4 位,再查相邻 8 个格子
# 客户端算出中心点 geohash=wx4g0b7xrt0,取前缀 wx4g
GEOSEARCH nearby:wx4g FROMLONLAT 116.40 39.91 BYRADIUS 3 km ASC COUNT 50
Geohash 前缀分片的好处是相邻格子可枚举:给定中心点,可以算出它所在格子及周围 8 个格子的前缀,只查这 9 个键。缺点是要处理格子边界(跨格子的近距离点会漏),通常用「查 9 格 + 客户端二次过滤」解决。
六、替代方案对比
当 GEO 的性能或功能不够时,有三条替代路径:
| 方案 | 优势 | 代价 | 适用 |
|---|---|---|---|
| Redis GEO | 零依赖、毫秒级、命令简单 | O(N) 扫描、无多边形、无属性过滤 | 单键 10 万以内 |
| RediSearch GEO | 与全文/数值索引组合、支持复杂过滤 | 需 Redis Stack、内存开销更大 | 需要「地理 + 属性」联合查询 |
| PostGIS | 完整空间算子、多边形/拓扑、事务一致 | 需 PostgreSQL、延迟高于内存 | 复杂地理分析、强一致 |
| Elasticsearch geo_point | 分布式、海量数据、聚合能力强 | 集群成本、写入延迟 | 亿级点位、复杂聚合 |
判断标准很简单:
- 需求是「以点为中心找最近的 N 个」且数据量在单键 10 万以内 → Redis GEO。
- 需求是「在某个区域内、且满足若干属性条件、还要按评分排序」→ RediSearch 或 Elasticsearch,Redis 原生 GEO 无法表达属性过滤。PostGIS 的空间查询与索引原理可参考 PostGIS 地理空间实战 ,Elasticsearch 侧的 geo_point 与 geo_shape 对比见 Elasticsearch 地理搜索 。
- 数据量上亿且要做热力图聚合 → Elasticsearch,Redis 的内存成本会失控。
6.1 与客户端地图的配合
小程序、App 端的地图选点与坐标纠偏(GCJ-02 与 WGS-84 转换)是另一个独立话题,前端拿到经纬度后再传给后端。这部分的地图组件与定位能力属于客户端职责,后端只需约定好坐标系——混用 GCJ-02 与 WGS-84 会导致数百米级的系统性偏移,是最常见的线上事故来源之一。
七、集群模式与槽位约束
Cluster 模式下,GEO 键和其他键一样受 16384 个槽位的约束。这带来一个具体的限制:GEOSEARCHSTORE 的目标键必须与源键在同一个槽,否则报 CROSSSLOT 错误。
(error) CROSSSLOT Keys in request don't hash to the same slot
解决办法是用哈希标签(Hash Tag)强制同槽:
# {} 内的内容参与槽位计算,两个键都会落到同一槽
GEOADD {beijing}:shops 116.397128 39.916527 "shop:1001"
GEOSEARCHSTORE {beijing}:result {beijing}:shops \
FROMMEMBER "shop:1001" BYRADIUS 5 km ASC
另外,GEOSEARCH 本身是单键命令,不存在跨节点聚合问题——这也是它比「客户端自己拉多个分片再合并」更省事的地方。但如果你的分片策略是「按 Geohash 前缀分 9 个键」,那么这 9 次查询必须由客户端并发发起再合并,属于客户端聚合,Redis 不提供帮助。分片与槽位的关系可参考 Cluster 分片与扩容
。
八、监控与容量规划
GEO 相关的关键指标不多,但都要盯:
| 指标 | 来源 | 阈值建议 |
|---|---|---|
| 单键元素数 | ZCARD geo:key | > 10 万需分片 |
| 查询延迟 | 慢查询日志 SLOWLOG | > 10 ms 需排查 |
| 内存占用 | MEMORY USAGE geo:key | 与元素数线性相关 |
| 写 QPS | INFO commandstats 里的 geoadd | 高频更新场景关注 |
获取单键规模与内存:
redis-cli ZCARD shops:beijing
redis-cli MEMORY USAGE shops:beijing SAMPLES 0
估算内存时记住:每个 GEO 成员在 skiplist 里占一个节点,包含成员名字符串、score 的 double、以及指针开销。粗算每成员 80~120 字节(成员名越短越省)。100 万个点约占 100 MB——这是 Redis GEO 相比 Elasticsearch 的主要劣势:全内存。
容量规划的经验值:
| 单键元素数 | 内存量级 | 查询延迟 | 结论 |
|---|---|---|---|
| 1 万 | ~1 MB | < 0.5 ms | 完全无压力 |
| 10 万 | ~10 MB | 约 4 ms | 可接受,需监控 |
| 50 万 | ~50 MB | 约 20 ms | 需加 COUNT ANY |
| 100 万以上 | > 100 MB | > 40 ms | 必须分片 |
慢查询日志是发现 GEO 问题最直接的手段:
redis-cli CONFIG SET slowlog-log-slower-than 10000 # 10ms
redis-cli SLOWLOG GET 10
若慢日志里出现 GEOSEARCH,先看该键的 ZCARD——十有八九是单键规模失控。
九、生产实践清单
- 单键元素数控制在 10 万以内,超过就按城市、Geohash 前缀或业务维度分片。
- 查询一律加
COUNT n,能用ANY就用ANY。 - 只取成员名时不要加
WITHCOORD/WITHDIST/WITHHASH。 - 高频更新(骑手位置)必须配套清理任务,用
ZSCAN分批、限速执行。 - 分片查询要处理格子边界,客户端做二次距离过滤。
- 统一坐标系,在接口层明确标注是 GCJ-02 还是 WGS-84。
- 需要属性过滤时尽早换 RediSearch 或 Elasticsearch,不要用「先 GEO 查出候选再逐个
HGET」的拼接方案——那是 N+1 查询。
小结
Redis GEO 的价值在于用最小成本解决「附近搜索」:底层是 ZSet 加 52 位 Geohash 编码,命令只有 GEOADD / GEOPOS / GEODIST / GEOHASH / GEOSEARCH 五个。精度足够(误差小于 1 米),但性能是 O(N) 全量扫描——这是它的天花板,也是分片设计的出发点。
判断 GEO 是否够用,只需回答两个问题:单键元素数是否在 10 万以内?查询是否只按距离、不需要属性过滤?两个都是「是」,Redis GEO 就是最优解;任何一个为「否」,就该转向 RediSearch、PostGIS 或 Elasticsearch。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。