Redis 的 GEO 功能是我在位置服务类项目里用得最多、也最容易被低估的一块能力。很多人一提“附近的人”“同城配送”“打车距离计算”,第一反应就是上 PostGIS、MongoDB 地理索引,或者干脆自己写一套经纬度加索引的方案。但真到落地的时候你会发现,如果业务对精度要求不是测绘级别,Redis GEO 往往是性价比最高的那个选择——它把经纬度存进一个有序集合,用 Geohash 编码做空间索引,距离计算、范围查询、排序全都能在内存里毫秒级完成。这篇文章我就围绕“Redis geo 计算距离”这个主题,把我在实际项目里踩过的坑、调过的参数、写过的命令,从头到尾捋一遍。不管你是刚接触 Redis 的新手,还是已经在用 GEO 但总觉得哪里不对劲的老手,应该都能从里面找到能直接抄作业的东西。
1. 先搞清楚 Redis GEO 到底在算什么
1.1 它不是数据库,是一层空间索引封装
很多人对 Redis GEO 有个误解,以为它是个独立的数据类型。其实不是。Redis 从 3.2 版本开始提供的 GEO 系列命令,底层用的就是Sorted Set(有序集合)。你往里面加一个经纬度,Redis 会先把这个坐标通过Geohash算法编码成一个 52 位的整数,然后把这个整数当作 Sorted Set 的 score 存进去,member 就是你自定义的地点标识。
这个设计带来的直接好处是:所有 Sorted Set 的能力它都继承下来了,比如按 score 排序、范围扫描、删除成员。而 GEO 命令只是在这层结构上做了一层“翻译”,把经纬度、距离、半径这些人类能看懂的概念,翻译成 Geohash 整数和 score 区间。
我经常用一个类比来解释:Geohash 就像把地球切成无数个方格,每个方格有一个编号,编号越接近的方格在地理上也越接近。Redis 存的就是这个编号,查询的时候先找到目标方格,再把周围一圈方格里的点捞出来算真实距离。理解了这一点,后面很多“为什么查不准”“为什么边界会漏”的问题就都能自己想明白了。
1.2 距离计算用的是 Haversine 公式
Redis 计算两个坐标之间的直线距离,用的是Haversine 公式。这个公式把地球近似成一个半径为 6372797.560856 米的球体,然后根据两点的经纬度算出球面大圆距离。
这里有个关键点必须说清楚:它算的是球面直线距离,不是实际道路距离。也就是说,你从 A 点到 B 点,Redis 告诉你 3 公里,那是空中直线 3 公里,实际开车可能要绕 5 公里。这个差异在打车、外卖这类场景里非常致命,必须提前和产品对齐预期。
那为什么不用更精确的椭球模型(比如 Vincenty 公式)?因为 Redis 追求的是速度和内存的平衡。Haversine 的误差在大多数城市场景下可以接受,而且计算量小,单次距离计算在微秒级别。如果你真的需要测绘级精度,那 Redis GEO 就不该是你的第一选择。
1.3 支持的距离单位与命令全景
Redis GEO 支持四种距离单位,这个在命令里通过参数指定:
| 单位参数 | 含义 | 换算关系 |
|---|---|---|
| m | 米 | 基准单位 |
| km | 千米 | 1 km = 1000 m |
| mi | 英里 | 1 mi ≈ 1609.34 m |
| ft | 英尺 | 1 ft ≈ 0.3048 m |
核心命令其实就六个,我列一下,后面会逐个展开:
GEOADD:添加地点坐标GEOPOS:查询某个地点的经纬度GEODIST:计算两个地点之间的距离GEOHASH:返回地点的 Geohash 字符串GEOSEARCH:范围搜索(Redis 6.2 之后推荐用这个)GEORADIUS:老版范围搜索(6.2 之后标记为废弃,但很多老项目还在用)
提示:如果你现在还在用
GEORADIUS,建议尽快迁移到GEOSEARCH。前者在集群模式下有兼容性问题,而且官方已经明确不再推荐。
2. 距离计算的核心命令与参数拆解
2.1 GEOADD:坐标入库的正确姿势
GEOADD是入口,语法是这样的:
GEOADD key longitude latitude member [longitude latitude member ...]注意顺序是经度在前,纬度在后。这个顺序坑过无数人,包括我自己。因为平时我们说坐标都是“纬度、经度”,但 Redis 和大多数 GIS 系统一样,遵循的是“经度、纬度”的顺序。写反了不会报错,但点会跑到地球另一边去。
举个实际例子,往一个“城市服务点”集合里加三个点:
GEOADD city:spots 116.397128 39.916527 "spot:001" GEOADD city:spots 116.407526 39.904030 "spot:002" GEOADD city:spots 116.388392 39.928216 "spot:003"一次加多个也可以,批量操作能减少网络往返:
GEOADD city:spots 116.397128 39.916527 "spot:001" 116.407526 39.904030 "spot:002"这里有几个实操要点:
- 经度范围是 -180 到 180,纬度范围是 -85.05112878 到 85.05112878。超出范围会报错。为什么纬度不是 -90 到 90?因为 Geohash 用的是 Web Mercator 投影,两极区域被截掉了。
- member 是字符串,重复添加同一个 member 会更新它的坐标,而不是新增。这个特性可以用来做位置更新。
- 如果 key 不存在,
GEOADD会自动创建。
2.2 GEODIST:两点距离怎么算才准
GEODIST是最直接的距离计算命令:
GEODIST key member1 member2 [unit]比如算上面 spot:001 和 spot:002 的距离:
GEODIST city:spots "spot:001" "spot:002" km返回结果是一个字符串,比如"1.5678"。如果不指定单位,默认是米。
这里有个细节很多人不知道:如果两个 member 有一个不存在,返回 nil。所以在业务代码里一定要做空值判断,不能直接拿返回值做数值运算。
还有一个性能上的点:GEODIST的时间复杂度是 O(log N),因为它要先在 Sorted Set 里找到两个 member 的 score,然后做 Haversine 计算。N 是集合里的元素数量。这个复杂度在百万级数据下依然很快,因为 log N 增长极慢。
2.3 GEOPOS 与 GEOHASH:坐标反查与调试利器
GEOPOS用来反查某个 member 的经纬度:
GEOPOS city:spots "spot:001"返回的是一个嵌套数组,包含经度和纬度。注意返回值是字符串格式,精度可能和你存入时略有差异,这是因为 Geohash 编码是有损的。一般来说误差在厘米级别,业务上可以忽略。
GEOHASH返回的是标准的 Geohash 字符串:
GEOHASH city:spots "spot:001"这个命令在调试的时候特别有用。你可以拿返回的 Geohash 去对比不同点的前缀,前缀相同的位数越多,说明两个点越接近。比如wx4g0和wx4g1就是相邻的两个方格。
注意:Redis 返回的 Geohash 是 11 位字符,但实际内部用的是 52 位整数。11 位 Geohash 的精度大约在厘米级,足够绝大多数业务使用。
2.4 GEOSEARCH:范围查询的主力命令
GEOSEARCH是 Redis 6.2 引入的,用来替代GEORADIUS和GEORADIUSBYMEMBER。它的语法更清晰,功能也更全:
GEOSEARCH key <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius unit | BYBOX width height unit> [ASC | DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]拆开看几个关键部分:
- 来源:可以是从某个 member 出发(
FROMMEMBER),也可以是从指定经纬度出发(FROMLONLAT)。 - 范围形状:圆形用
BYRADIUS,矩形用BYBOX。矩形范围在“屏幕可视区域查询”这类场景里很有用。 - 排序:
ASC由近到远,DESC由远到近。不指定的话顺序不保证。 - 数量限制:
COUNT限制返回数量,配合ANY可以在找到足够数量后就返回,不用扫描全部,性能更好。 - 附加信息:
WITHCOORD返回坐标,WITHDIST返回距离,WITHHASH返回 Geohash 整数。
一个典型的“附近 5 公里内的服务点,按距离排序,返回最近 10 个”:
GEOSEARCH city:spots FROMLONLAT 116.40 39.91 BYRADIUS 5 km ASC COUNT 10 WITHDIST WITHCOORD返回结果里每个元素会包含 member 名称、距离、坐标等信息,具体格式取决于你带了哪些 WITH 参数。
3. 从零搭建一个距离计算服务的完整实操
3.1 场景设定与数据结构设计
假设我们要做一个“同城服务点推荐”功能:用户打开 App,定位到自己的位置,系统返回附近 3 公里内的服务点,按距离从近到远排序,最多显示 20 个。
数据结构设计上,我建议按城市或区域分 key,而不是所有点都塞一个 key。原因有两个:
- 单个 Sorted Set 太大时,虽然查询是 O(log N),但内存占用和持久化开销会上升。
- 分 key 之后可以做更细粒度的过期和清理。
比如:
geo:spots:cityA geo:spots:cityB如果服务点数量不多(几万以内),一个 key 也完全扛得住。我实测过一个 key 存 50 万个点,GEOSEARCH查询依然在 10 毫秒以内。
3.2 批量导入坐标数据
实际项目里,坐标数据通常来自数据库。我一般写一个脚本批量导入,用 pipeline 减少网络往返。下面是 Python 示例:
import redis r = redis.Redis(host='localhost', port=6379, decode_responses=True) spots = [ (116.397128, 39.916527, "spot:001"), (116.407526, 39.904030, "spot:002"), (116.388392, 39.928216, "spot:003"), # ... 更多数据 ] pipe = r.pipeline() for lng, lat, member in spots: pipe.geoadd("geo:spots:cityA", (lng, lat, member)) pipe.execute()这里有个坑:pipeline 里的geoadd参数格式。redis-py 支持(lng, lat, member)这种元组形式,但不同版本 API 略有差异。如果你用的是老版本,可能需要写成pipe.geoadd(key, lng, lat, member)。建议先小批量测试。
导入完成之后,用ZCARD确认数量:
ZCARD geo:spots:cityA因为 GEO 底层是 Sorted Set,所以ZCARD可以直接用。
3.3 距离查询的完整代码实现
用户请求进来,拿到经纬度,执行查询:
def find_nearby_spots(r, lng, lat, radius_km=3, limit=20): results = r.geosearch( "geo:spots:cityA", longitude=lng, latitude=lat, radius=radius_km, unit="km", sort="ASC", count=limit, withdist=True, withcoord=True ) spots = [] for item in results: # redis-py 返回格式取决于版本,通常是 [member, dist, [lng, lat]] member = item[0] dist = float(item[1]) coord = item[2] spots.append({ "id": member, "distance_km": round(dist, 2), "longitude": float(coord[0]), "latitude": float(coord[1]) }) return spots这段代码有几个地方值得说:
sort="ASC"保证按距离排序,产品要求“最近的在前面”时必须加。count=limit限制返回数量,避免一次拉太多数据。withdist和withcoord按需开启,不需要的话别开,能省一点序列化开销。
3.4 参数选择背后的计算逻辑
半径选多大、count 选多少,这些不是拍脑袋定的。我一般按下面的逻辑来:
半径的确定:先看业务场景。外卖一般 3 到 5 公里,打车 5 到 10 公里,社交“附近的人”可能 1 到 10 公里可调。半径越大,扫描的 Geohash 方格越多,性能越差。如果半径超过 50 公里,我会考虑换方案,因为这时候 Redis 需要扫描大量方格,优势就不明显了。
count 的确定:取决于前端一屏能显示多少。列表页一般 10 到 20 个,加上翻页可以适当放大。但要注意,COUNT不带ANY时,Redis 仍然会扫描完整个范围再取前 N 个;带上ANY则找到 N 个就返回,性能更好但可能不是最近的 N 个。这个取舍要看业务能不能接受“近似最近”。
精度与内存的权衡:Geohash 52 位整数,每个点大约占几十字节(加上 member 字符串)。100 万个点大概占几十 MB 内存。这个量级对 Redis 来说很轻松。
4. 那些年我踩过的坑与排查实录
4.1 距离算出来偏差很大怎么办
这是最常见的问题。有人反馈“明明两个点很近,算出来好几公里”。排查思路按顺序来:
第一,检查经纬度顺序。这是头号嫌疑。经度纬度写反,点会跑到几千公里外。用GEOPOS反查一下,看看返回的坐标是不是你预期的。
第二,确认单位。GEODIST不指定单位默认是米,如果你以为是公里,就会觉得结果大了 1000 倍。
第三,理解球面距离和实际距离的差异。前面说过,Redis 算的是直线距离。如果两个点之间隔着一条河、一座山,实际通行距离可能是直线的两三倍。这不是 bug,是预期管理问题。
第四,检查坐标精度。有些数据源给的经纬度只有小数点后两三位,精度本身就不够。纬度差 0.001 度大约是 111 米,精度不够会导致距离计算失真。
4.2 边界点漏查的经典问题
Geohash 有个天然缺陷:相邻方格边界上的点可能被漏掉。比如两个点实际距离只有 100 米,但正好被分在两个不相邻的 Geohash 方格边界两侧,简单的前缀匹配就会漏。
Redis 的GEOSEARCH和GEORADIUS已经帮你处理了这个问题——它会扫描目标方格周围的 8 个相邻方格,所以正常情况下不会漏。但如果你自己用GEOHASH命令做前缀匹配来实现范围查询,那就一定会踩这个坑。
我的建议很直接:范围查询就用GEOSEARCH,别自己造轮子。自己实现 Geohash 前缀匹配,边界处理、精度控制、性能优化全都是坑,得不偿失。
4.3 集群模式下的注意事项
Redis Cluster 下,GEO 命令有个限制:所有涉及的 key 必须在同一个 slot。因为底层是 Sorted Set,跨 slot 操作会报错。
如果你按城市分 key,而查询又可能跨城市,就会有问题。解决方案有两个:
- 用 hash tag 强制把相关 key 放到同一个 slot,比如
geo:spots:{cityA}和geo:spots:{cityB}里的{cityA}和{cityB}不同,还是不同 slot。真正要做的是让需要一起查询的 key 用相同的 hash tag。 - 在应用层做路由,根据用户位置先确定城市,再查对应 key。
我一般选第二种,逻辑更清晰,也不会因为 hash tag 设计不当导致数据倾斜。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 距离结果异常大 | 经纬度顺序写反 | GEOPOS反查坐标 | 调整为经度在前 |
| 距离结果差 1000 倍 | 单位理解错误 | 检查命令是否带 unit | 明确指定 km 或 m |
| 查询结果为空 | 半径太小或 key 错误 | ZCARD确认数据存在 | 扩大半径或检查 key |
| 边界点查不到 | 自己实现前缀匹配 | 对比GEOSEARCH结果 | 改用GEOSEARCH |
| 集群报 CROSSSLOT | key 不在同一 slot | 检查 key 命名 | 用 hash tag 或应用层路由 |
| 返回数量不对 | COUNT 与 ANY 配合问题 | 去掉 ANY 对比 | 根据业务选择是否用 ANY |
提示:生产环境建议给 GEO 查询加监控,记录查询耗时和返回数量。如果发现某次查询耗时突增,优先检查是不是半径设得过大或者数据量激增。
5. 性能优化与进阶玩法
5.1 内存占用与数据清理
GEO 数据不是永久有效的。服务点会下线,用户位置会过期。如果不清理,内存会一直涨。
清理策略我一般用两种:
主动删除:业务侧知道某个点失效了,直接ZREM:
ZREM geo:spots:cityA "spot:001"定期重建:对于位置频繁变化的场景(比如司机位置),我会用两个 key 轮换。当前小时写 key A,查询也查 key A;下一个小时写 key B,查询同时查 A 和 B,然后清理掉过期的 A。这样避免了大 key 的删除阻塞。
5.2 结合其他数据结构做复合查询
GEO 只能按位置查,但业务往往还需要按类别、状态过滤。比如“附近 3 公里内营业中的餐厅”。
我的做法是:先用GEOSEARCH拿到附近的所有点 ID,然后在应用层用其他 Redis 结构(比如 Hash 存状态、Set 存类别)做二次过滤。如果数据量不大,这一步在应用层做完全没问题;如果数据量大,可以考虑用 Lua 脚本把过滤逻辑下推到 Redis。
但要注意,Lua 脚本里做复杂过滤会增加 Redis 的阻塞时间,不建议在脚本里做太重的逻辑。
5.3 距离排序与分页的正确做法
GEOSEARCH的COUNT不支持 offset,也就是说没法直接做“第 2 页”。这是个常见的痛点。
解决方案有几种:
- 游标式分页:记录上一页最后一个点的距离,下一页查询时用
BYRADIUS加上一个距离下限。但GEOSEARCH不支持距离下限,所以这个方案要配合应用层过滤。 - 一次查足够多:如果总数据量可控,一次查 100 个,应用层分页。这是最简单的做法。
- 缩小半径:第一页查 3 公里,第二页查 3 到 5 公里。适合按距离分层的场景。
我一般根据业务量选。数据量小就方案 2,数据量大就方案 3。
5.4 精度与性能的平衡参数
最后说几个我调过的参数:
- Geohash 精度:Redis 固定用 52 位,没法调。但你可以通过控制 member 的粒度来间接影响,比如把太近的点合并。
- COUNT 大小:不是越大越好。COUNT 越大,Redis 需要扫描和排序的数据越多。我一般控制在 50 以内。
- 半径:超过 100 公里的查询,我会考虑用其他方案,比如预先按区域分桶。
注意:Redis 是单线程处理命令的,一个慢查询会阻塞后续所有请求。GEO 查询虽然通常很快,但在数据量极大、半径极大的极端情况下也可能变慢。生产环境一定要做压测,别等上线了才发现问题。
6. 写在最后的一点个人体会
Redis GEO 这个功能,我用了大概有五六年,从最早的GEORADIUS到现在的GEOSEARCH,踩过的坑基本都在这篇文章里了。它最大的价值不是“功能多强”,而是“够用且快”。在绝大多数 LBS 场景里,你不需要 PostGIS 那种重型方案,一个 Redis 实例就能扛住每秒几万次的附近查询。
但我也见过不少团队把它用歪了:有人拿它做精确的路径规划,有人拿它存全球几千万个点还不分 key,有人自己实现 Geohash 前缀匹配结果边界漏点。这些问题归根结底都是没搞清楚它的定位——它是一个基于内存的、近似的、球面直线距离的空间索引。认清这一点,用起来就顺了。
如果你正在做位置相关的功能,我的建议是先用 Redis GEO 快速搭一个原型,跑通业务逻辑,等数据量和精度要求真的上来了,再考虑换更专业的方案。大多数项目,根本走不到那一步。