GEO 地理空间命令:从 Geohash 编码到附近的人

Redis GEO 地理空间命令实战:GEOADD 写入与 52 位 Geohash 编码原理、GEOSEARCH 的 FROMMEMBER/FROMLONLAT 与 BYRADIUS/BYBOX 组合、COUNT ANY 与 WITHCOORD 参数、O(N) 全量扫描的性能本质与分片策略、附近的人与门店搜索实战、RediSearch 与 PostGIS 方案对比

「查一下我附近 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,就是经纬度编码后的结果。编码过程分三步:

  1. 归一化:经度范围 [-180, 180]、纬度范围 [-85.05112878, 85.05112878](这是墨卡托投影的纬度上限,不是 ±90)。
  2. 交错编码:分别对经纬度做二分区间逼近,各得到 26 位二进制串,然后按位交错(interleave)拼成 52 位整数。经度放在偶数位、纬度放在奇数位,这样相邻地理位置在数值上也相邻。
  3. 作为 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 位编码的固有精度,属于正常范围。

命令复杂度说明
GEOADDO(log N) 每点底层是 ZADD
GEOPOSO(1) 每点直接解码 score
GEODISTO(1)取两个 score 后解码计算
GEOHASHO(1)52 位转 Base32
GEOSEARCHO(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 unitGEOSEARCH key FROMLONLAT lon lat BYRADIUS radius unit
GEORADIUSBYMEMBER key member radius unitGEOSEARCH key FROMMEMBER member BYRADIUS radius unit
GEORADIUS ... STORE dstGEOSEARCHSTORE 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 让查询变快的三种手段

  1. 加 COUNT n ANY:凑够即停,避免全量排序。地图撒点场景收益最大。
  2. 按地理维度分片:把一个大集合拆成多个小集合(见下节)。
  3. 减少 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与元素数线性相关
写 QPSINFO 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。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. 多租户隔离与资源配额:共享 Redis 的边界设计
  2. Key 设计与命名规范:Redis 里唯一的结构约束
  3. 代理与路由方案:客户端直连之外的另一种选择