1. 从一个“附近的人”需求说起:为什么我最终选了Redis GEO
前阵子接了个同城服务类的小项目,核心功能之一就是“查找我附近3公里内的服务点”。需求听起来简单,但真动手做的时候,摆在面前的路有好几条:用MySQL存经纬度然后写个Haversine公式硬算、上PostGIS这种专业地理数据库、或者用Redis自带的GEO能力。我一开始图省事,直接在MySQL里用经纬度字段加索引,写了个球面距离公式做筛选。数据量小的时候跑得挺欢,等到测试环境灌了十万条点位数据,每次查询都要全表扫描算距离,响应时间直接飙到几百毫秒,页面转圈转到用户想砸手机。
后来我把方案换成了Redis GEO,同样的查询场景,响应时间稳定在个位数毫秒。这篇文章就把我踩过的坑、选型的思考过程、以及Redis GEO在实际项目里怎么用、怎么避坑,完整地聊一遍。如果你也在做“附近搜索”“距离计算”“地理位置排序”这类功能,不管你是刚接触Redis的新手,还是已经用过但总觉得哪里不对劲的老手,这篇内容应该都能给你一些直接能抄的作业。
Redis GEO本质上不是一种全新的数据结构,它是在Sorted Set(有序集合)基础上封装出来的一套地理位置操作命令。每个位置点被编码成一个52位的整数,作为Sorted Set的score,成员名就是你的业务ID。这个编码方式叫Geohash,它把二维的经纬度映射成一维的整数,从而让“距离排序”变成了“score排序”,这就是它快的根本原因。理解这一点,后面很多行为你就能想通了。
提示:Redis GEO的底层是Sorted Set,这意味着你可以用ZREM、ZRANGE等通用命令来操作GEO数据,但反过来,如果你用ZADD往一个GEO key里塞数据,GEO命令读出来的位置可能是错的。这个坑后面会细说。
2. GEOADD命令的编码逻辑与参数陷阱
2.1 经纬度顺序和精度:新手最容易翻车的地方
GEOADD的语法是GEOADD key longitude latitude member,注意经度在前、纬度在后。我见过不止一个同事把顺序写反,结果点位跑到南极洲去了。经度的范围是-180到180,纬度是-90到90,Redis会做校验,超出范围会直接报错,这点还算友好。但有个隐蔽的坑:如果你传的是字符串形式的数字,Redis会尝试解析,解析失败才报错,所以从外部接口拿到的数据一定要先做类型转换和范围检查。
精度方面,Redis GEO内部用52位表示位置,理论精度大约在0.6米左右。但实际使用中,你传入的经纬度小数位数决定了存储精度。我一般建议保留6位小数,大约0.1米的精度,足够覆盖绝大多数业务场景。保留太多位没必要,反而增加序列化开销;保留太少,比如只留2位小数,误差可能到公里级,那就闹笑话了。
# 正确的写法:经度在前,纬度在后 GEOADD service_points 116.404269 39.915119 "point:1001" GEOADD service_points 116.410123 39.918456 "point:1002" # 批量添加,一次网络往返搞定 GEOADD service_points 116.404269 39.915119 "point:1001" 116.410123 39.918456 "point:1002"2.2 批量写入的吞吐量优化
单个GEOADD命令可以一次添加多个位置点,格式是GEOADD key lon1 lat1 member1 lon2 lat2 member2 ...。我在压测环境里试过,单条命令带100个点位,吞吐量比循环单条写入高出将近8倍。原因很简单,网络往返次数从100次降到1次。但要注意,单条命令的参数数量不宜过大,我一般控制在500个点位以内,再大就拆批。因为Redis是单线程处理命令的,一条超大命令会阻塞其他请求,反而影响整体响应。
另外,如果你是从数据库批量导入历史数据,可以用管道(pipeline)把多条GEOADD打包发送,效果和批量参数类似,但更灵活,因为可以控制每批的大小。我通常用管道每批200条,实测下来对线上服务的影响最小。
2.3 更新位置时到底发生了什么
当你对同一个member再次执行GEOADD时,Redis会更新它的位置,而不是新增一条。底层就是Sorted Set的ZADD操作,score被重新计算。这里有个细节值得注意:更新位置并不会改变member在集合中的存在性,所以你可以放心地用GEOADD来做位置更新,不需要先删后加。
但如果你用ZADD直接改score,GEO命令读出来的经纬度就会错乱,因为ZADD写入的score不符合GEO的52位编码规则。我曾经为了图快,用ZADD批量导入数据,结果GEOPOS读出来的坐标完全不对,排查了半天才反应过来。所以记住一条铁律:GEO key只能用GEO命令写入,不要混用ZADD。
3. GEODIST与GEOPOS:距离计算和坐标读取的实战细节
3.1 GEODIST的单位选择和返回值处理
GEODIST用来计算两个member之间的直线距离,语法是GEODIST key member1 member2 [unit]。unit可以是m(米)、km(千米)、mi(英里)、ft(英尺),默认是米。我一般统一用米,在应用层做单位转换,这样避免不同接口传不同单位导致的混乱。
返回值是一个字符串形式的浮点数,如果某个member不存在,返回nil。这里有个容易忽略的点:GEODIST计算的是球面距离,用的是Haversine公式,假设地球是完美球体。实际上地球是椭球体,所以这个距离和真实地表距离有大约0.5%以内的误差。对于“附近3公里”这种场景完全够用,但如果你做的是测绘级应用,那就得用更专业的工具了。
# 计算两个点之间的距离,单位米 GEODIST service_points "point:1001" "point:1002" m # 返回示例:"623.45"3.2 GEOPOS读取坐标的精度损失
GEOPOS返回member的经纬度,格式是数组,每个元素是[经度, 纬度]。但要注意,返回的坐标是经过Geohash编码再解码后的结果,和原始输入会有微小偏差。我实测过,输入116.404269,读出来可能是116.4042689,偏差在厘米级。这个偏差来自52位编码的量化误差,属于正常现象。
如果你需要精确的原始坐标,建议在业务数据库里也存一份,Redis GEO只用来做距离计算和范围查询。这样既享受了Redis的速度,又保留了数据的精确性。我现在的做法就是MySQL存精确坐标,Redis存一份用于GEO查询,两边通过member ID关联。
3.3 距离计算的边界情况
GEODIST在计算跨180度经线的两个点时,结果可能不符合直觉。比如经度179和-179,实际只差2度,但Haversine公式会算出很远的距离。这是因为公式没有处理经度环绕。如果你的业务涉及跨经线区域,需要自己处理这种情况。不过大多数同城服务不会遇到这个问题,知道有这么回事就行。
另外,如果两个member是同一个点,GEODIST返回0。如果其中一个member不存在,返回nil。在代码里一定要判空,我见过因为没判空导致距离计算出NaN,最后排序乱掉的案例。
4. GEOSEARCH与GEORADIUS:范围查询的选型与性能对比
4.1 GEORADIUS的经典用法和它的历史包袱
GEORADIUS是Redis 3.2引入的老牌范围查询命令,语法是GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT n] [ASC|DESC]。它以一个给定坐标为中心,找出半径内的所有member。我早期项目用的就是这个命令,功能确实全,能返回坐标、距离、Geohash,还能排序和限制数量。
但它有个设计上的问题:它是“以坐标为中心”查询,而不是“以某个member为中心”。如果你想查“某个服务点周围3公里的其他服务点”,得先用GEOPOS拿到坐标,再传给GEORADIUS,多了一次网络往返。而且GEORADIUS在Redis 6.2之后被标记为废弃,虽然还能用,但新项目不建议再用了。
# 老写法:以坐标为中心查半径内的点 GEORADIUS service_points 116.404269 39.915119 3 km WITHDIST ASC COUNT 104.2 GEOSEARCH:新版本的正确打开方式
Redis 6.2引入了GEOSEARCH,语法更清晰,支持两种查询模式:FROMMEMBER(以某个member为中心)和FROMLONLAT(以坐标为中心),以及两种范围模式:BYRADIUS(半径)和BYBOX(矩形框)。这个设计比GEORADIUS优雅得多,而且一次命令就能完成“以member为中心查周围”的需求。
# 新写法:以某个member为中心,查3公里内的点,带距离,按距离升序 GEOSEARCH service_points FROMMEMBER "point:1001" BYRADIUS 3 km WITHDIST ASC COUNT 10 # 矩形框查询:查一个矩形区域内的点 GEOSEARCH service_points FROMLONLAT 116.404269 39.915119 BYBOX 5 5 km WITHDIST ASCBYBOX模式在实际项目中很有用。比如你要查“屏幕可视范围内的服务点”,矩形框比圆形更贴合地图的视口。我做过一个地图应用,用BYBOX查询视口内的点位,配合前端的地图边界坐标,效果很自然。
4.3 性能对比:GEOSEARCH到底快多少
我在测试环境做了一组对比,数据量10万点位,查询半径3公里,返回前10条。GEORADIUS平均耗时1.2毫秒,GEOSEARCH平均耗时0.9毫秒。差距不算大,但GEOSEARCH的优势在于命令语义更清晰,代码可读性更好。而且GEOSEARCH支持FROMMEMBER模式,省去了一次GEOPOS调用,整体链路少一次网络往返,这在微服务架构下意义更大。
另一个关键点是COUNT参数。加上COUNT n之后,Redis会用一种叫“边界框扫描”的优化策略,先估算一个能覆盖n个点的边界框,只扫描框内的数据,而不是全量扫描。这个优化在数据量大、半径大的场景下效果非常明显。我试过查50公里半径,不加COUNT要扫描全部10万条,加了COUNT 10之后只扫描了几千条,耗时从十几毫秒降到1毫秒以内。
注意:COUNT参数虽然能提速,但它返回的是“距离最近的n个点”,如果你需要精确的“半径内所有点”,就不能加COUNT,否则会漏掉一些点。这个取舍要根据业务需求来定。
5. 实际项目中的性能调优与内存控制
5.1 数据量增长后的内存占用估算
Redis GEO的每个member在Sorted Set里占用的内存,包括member字符串本身、score的8字节、以及Sorted Set的跳表结构开销。我实测下来,一个典型的“point:1001”这样的member,加上经纬度编码,大约占用100到150字节。10万个点位大约占10到15MB,100万个点位大约100到150MB。这个量级对Redis来说完全可控,但如果你有上千万点位,就要考虑分片了。
分片的思路很简单:按地理区域分key。比如按城市分,北京一个key,上海一个key。查询时先确定用户在哪个城市,再查对应的key。这样每个key的数据量可控,而且可以利用Redis Cluster做水平扩展。我做过一个全国范围的点位系统,按省份分了34个key,每个key平均几万条,查询性能很稳定。
5.2 热点key问题的规避
如果你的业务有明显的热点区域,比如市中心的一个key被频繁查询,可能会造成单节点压力过大。解决办法有两个:一是把热点区域再细分,比如按商圈分key;二是加本地缓存,把查询结果缓存几秒钟,减少对Redis的冲击。我用过第二种方案,在应用层用Caffeine缓存GEOSEARCH的结果,TTL设5秒,QPS直接降了一个数量级,而用户几乎感知不到延迟。
5.3 持久化和主从复制的注意事项
Redis GEO的数据和其他数据一样,受RDB和AOF持久化机制保护。但要注意,GEO数据在RDB里是以Sorted Set的形式存储的,恢复后GEO命令能正常读取,因为编码规则是一致的。主从复制也没问题,GEO命令会正常同步到从节点。
但有一个坑:如果你在从节点上执行GEO写入命令,会报错,因为从节点默认只读。这个大家都知道,但如果你用了Redis Cluster,GEO key的路由是按key的哈希槽来的,同一个key的所有GEO操作都会路由到同一个节点,不会出现跨节点问题。不过如果你用GEOSEARCH的FROMMEMBER模式,member必须在同一个key里,跨key查询是不支持的。
6. 那些年我踩过的GEO坑与排查实录
6.1 坐标写反导致查询结果为空
有一次测试环境反馈“附近搜索什么都搜不到”,我查了半天代码逻辑没问题,最后用GEOPOS读了一下数据,发现所有点的纬度都是负的。原来数据导入脚本里把经纬度参数顺序写反了,经度传成了纬度,纬度传成了经度。因为中国的纬度是正的,经度也是正的,写反之后纬度变成了一百多,超出了-90到90的范围,Redis居然没报错?我试了一下,GEOADD对超出范围的坐标确实会报错,但那个脚本用的是ZADD,绕过了校验,所以数据就这么脏进去了。
排查过程:先用TYPE key确认是zset类型,再用ZRANGE key 0 -1 WITHSCORES看score,发现score值异常大,然后用GEOPOS key member读坐标,发现纬度是116,经度是39,明显反了。修复方案是删掉key重新用GEOADD导入。这个教训让我养成了一个习惯:任何GEO数据导入后,先用GEOPOS抽查几条,确认坐标在合理范围内。
6.2 GEOSEARCH返回结果数量不对
另一个坑是COUNT参数的理解偏差。我以为GEOSEARCH ... COUNT 10返回的是“半径内距离最近的10个点”,但实际上它返回的是“扫描到的前10个点”,不保证是全局最近的。Redis的文档里写得很清楚,COUNT是一种优化提示,不是精确的Top-N。如果你需要精确的最近10个,得用GEOSEARCH ... ASC然后自己在应用层截取前10条,或者用GEOSEARCHSTORE把结果存下来再排序。
我当时的业务场景是“展示最近的10个服务点”,用了COUNT 10之后发现偶尔会漏掉一些明明很近的点。后来改成不加COUNT,全量返回再在应用层排序截取,问题就解决了。虽然多返回了一些数据,但半径3公里内的点位通常也就几十个,网络传输开销可以接受。
6.3 距离单位混用导致的排序错误
还有一次,前端传过来的半径单位是公里,我后端代码里默认按米处理,结果查出来的范围大了1000倍。用户看到的是“附近3公里”,实际查的是“附近3000公里”,把全国的点都返回了。这个bug很隐蔽,因为功能“能用”,只是结果不对。后来我在接口层加了单位校验,只接受m和km两种,并且在日志里打印实际使用的单位和数值,方便排查。
6.4 大半径查询拖慢整个实例
有一次运营提了个需求,要查“附近500公里”的点位。我直接放开了半径限制,结果这个查询扫描了大量数据,Redis单线程被阻塞了好几秒,导致其他接口超时。后来我加了半径上限,超过50公里的查询走离线任务或者专门的搜索引擎,不再走Redis GEO。这个经验告诉我,GEO查询的半径一定要有业务上限,不能任由用户输入。
7. 和其他方案的对比:什么场景该用Redis GEO,什么场景不该用
7.1 Redis GEO vs MySQL空间索引
MySQL 5.7之后支持了空间索引和ST_Distance函数,也能做距离计算。但MySQL的空间索引在数据量超过百万后,性能下降明显,而且距离计算是在数据库层做的,CPU开销大。Redis GEO把计算放在内存里,速度优势明显。但MySQL的优势是数据持久化和事务支持,适合做最终存储。我的做法是MySQL存全量数据,Redis存一份用于GEO查询,两边通过binlog或者消息队列同步。
7.2 Redis GEO vs Elasticsearch Geo
Elasticsearch的Geo查询功能更强大,支持多边形、地理围栏、聚合分析等高级功能。但ES的运维成本高,查询延迟通常在几十毫秒级别,不如Redis GEO的毫秒级响应。如果你的业务需要复杂的地理分析,比如“统计每个区域的点位密度”,那ES更合适。如果只是简单的“附近搜索”,Redis GEO足够了。
7.3 Redis GEO vs 专业地理数据库
PostGIS这类专业地理数据库支持椭球体计算、投影变换、拓扑分析等高级功能,精度和功能都是最强的。但部署和维护复杂,学习曲线陡峭。对于大多数互联网应用来说,Redis GEO的精度和功能已经够用,没必要上重型武器。
| 方案 | 查询延迟 | 功能丰富度 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| Redis GEO | 毫秒级 | 基础 | 低 | 附近搜索、距离排序 |
| MySQL空间索引 | 十毫秒级 | 中等 | 低 | 小数据量、事务要求高 |
| Elasticsearch Geo | 几十毫秒 | 丰富 | 高 | 复杂地理分析 |
| PostGIS | 十毫秒级 | 极丰富 | 高 | 专业测绘、GIS系统 |
8. 一套可直接复用的GEO查询封装思路
8.1 接口设计:统一入参和出参
我在项目里封装了一个GEO查询服务,入参包括:中心点(member ID或经纬度)、半径、单位、返回数量、是否带距离。出参统一为:member ID、距离(米)、坐标(可选)。这样上层业务不用关心底层用的是GEOSEARCH还是GEORADIUS,也不用关心单位转换。
# 伪代码示例:GEO查询封装 def search_nearby(center, radius_km, limit=20): # 统一转换为米 radius_m = radius_km * 1000 # 调用GEOSEARCH results = redis.geosearch( key, member=center, radius=radius_m, unit='m', withdist=True, sort='ASC', count=limit ) # 解析结果,统一距离单位为米 return [{'id': r[0], 'distance_m': float(r[1])} for r in results]8.2 缓存策略:减少重复查询
对于同一个中心点的查询,如果短时间内重复请求,可以在应用层加缓存。我用的是本地缓存加Redis缓存的两级策略:本地缓存TTL 1秒,Redis缓存TTL 5秒。这样既能应对突发流量,又能保证数据的时效性。缓存key的设计要包含中心点、半径、数量等参数,避免不同查询互相覆盖。
8.3 降级方案:Redis不可用时的兜底
Redis虽然稳定,但也要考虑极端情况。我的做法是:如果Redis GEO查询超时或报错,降级到MySQL空间索引查询,虽然慢一些,但至少能用。降级逻辑用熔断器控制,连续失败几次后自动切换,过一段时间再尝试恢复。这样既保证了可用性,又不会让MySQL被压垮。
8.4 监控指标:关注查询延迟和命中率
上线后一定要加监控,我关注这几个指标:GEO查询的P99延迟、每秒查询次数、缓存命中率、Redis内存使用量。如果P99延迟超过10毫秒,就要排查是不是有超大半径查询或者热点key。如果缓存命中率低于80%,说明缓存策略需要调整。这些指标用Prometheus加Grafana就能搞定,配置简单,效果直观。
9. 写在最后:一些个人体会
Redis GEO这个功能,用起来简单,但要用好,需要对它的底层原理有清晰的认识。我最大的体会是:不要把它当成黑盒,知道它是Sorted Set,知道Geohash编码的原理,很多问题就能自己推理出来。比如为什么GEO key不能用ZADD写,为什么GEOPOS读出来的坐标有微小偏差,为什么COUNT参数不保证精确,这些都能从底层找到答案。
另外,任何技术方案都有边界。Redis GEO适合“附近搜索”这类简单场景,不要试图用它做复杂的地理分析。该用ES的时候用ES,该用PostGIS的时候用PostGIS,选型要根据业务需求来,不要因为Redis快就什么都往上套。
最后分享一个小技巧:如果你不确定GEO查询的结果对不对,可以用GEOPOS把几个点的坐标读出来,手动用Haversine公式算一下距离,和GEODIST的结果对比。我排查问题时经常这么干,虽然笨,但很有效。