
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 mmi英里1 mi ≈ 1609.34 mft英尺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(hostlocalhost, port6379, decode_responsesTrue) 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_km3, limit20): results r.geosearch( geo:spots:cityA, longitudelng, latitudelat, radiusradius_km, unitkm, sortASC, countlimit, withdistTrue, withcoordTrue ) 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这段代码有几个地方值得说sortASC保证按距离排序产品要求“最近的在前面”时必须加。countlimit限制返回数量避免一次拉太多数据。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集群报 CROSSSLOTkey 不在同一 slot检查 key 命名用 hash tag 或应用层路由返回数量不对COUNT 与 ANY 配合问题去掉 ANY 对比根据业务选择是否用 ANY提示生产环境建议给 GEO 查询加监控记录查询耗时和返回数量。如果发现某次查询耗时突增优先检查是不是半径设得过大或者数据量激增。5. 性能优化与进阶玩法5.1 内存占用与数据清理GEO 数据不是永久有效的。服务点会下线用户位置会过期。如果不清理内存会一直涨。清理策略我一般用两种主动删除业务侧知道某个点失效了直接ZREMZREM 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 快速搭一个原型跑通业务逻辑等数据量和精度要求真的上来了再考虑换更专业的方案。大多数项目根本走不到那一步。