摘要:查下去发现不是客户端耍脾气,也不是骑手集体罢工,问题卡在位置服务上。平台上有十万多个骑手,每个人大概每3秒钟上报一次位置,想象一下,这么多点位频繁刷上来,定位数据量瞬间被放大。原来的做法是把骑手位置塞在关系型数据库里,做“按半径查附近”的时候,靠全表扫描和计算
午饭高峰,国贸商圈外卖突然大量派单延迟——客户端显示附近找不到骑手,但骑手端却都在“待接单”。短短几分钟内,订单超时率飙到两位数,城市派配送几乎瘫痪。
查下去发现不是客户端耍脾气,也不是骑手集体罢工,问题卡在位置服务上。平台上有十万多个骑手,每个人大概每3秒钟上报一次位置,想象一下,这么多点位频繁刷上来,定位数据量瞬间被放大。原来的做法是把骑手位置塞在关系型数据库里,做“按半径查附近”的时候,靠全表扫描和计算经纬度来找3公里内的人。单次这样的查询平均就要800多毫秒,高峰期请求一来,数据库的锁竞争就起来了,查询超时率直接蹭蹭上到15%。于是排队、重试、锁超时一连串问题把派单系统拖垮,前端就看到“附近无可用骑手”的假象了。技术人把事情扒到最底层,结论很干脆:空间查询和高频位置更新在毫秒级别内办不下来。
碰到这种事,Redis 的 GEO 功能成了救火栓。简单点说,Redis用有序集合(ZSET)和GeoHash编码把经纬度变成一维的数值,放到ZSET的score里。这样就把空间问题转成了数轴上的范围查找,速度能从几百毫秒直接掉到亚毫秒级。实际生产上,GEO从Redis 3.2就有了,但建议上生产用Redis 6.2以上,这样能用更灵活的GEOSEARCH命令和一堆优化手段。
我把把这套东西落地的步骤拆成五步来讲,按倒过来的顺序讲,先说结果和关键要点,再回到实操细节。讲法就像工程记录,干脆利落。
第5步:上线后要盯的东西
系统跑起来后别以为就完事,监控要铺开。要看查询延迟分布、位置上报QPS、Redis命令耗时、慢查询和内存占用,还要盯命令阻塞(latency spikes)。现场用redis-cli的MONITOR和SLOWLOG可以抓热点命令,INFO能看到内存和连接数。生产环境建议部署Redis Cluster,最少做3主3从,避免主节点被压垮。常调的参数有maxmemory-policy、tcp-keepalive、hz、client-output-buffer-limit。持久化别只靠RDB或只靠AOF,RDB+AOF混合更稳,外加异地备份策略。任何亚秒级延迟抖动都要有报警,发现就扩容或排查热点键。
第4步:怎么分片和保证一致性
城市级别的数据量大,把整张表都往一个键里扔是不行的。把城市切成网格按区域分片,比较稳妥。常见做法是把城市划成100m×100m的格子,每个格子对应一个GEO键,查的时候只查当前格子再顺带看周边八格。格子ID可以用这个公式算:gridX = floor(lon / gridSizeLon),gridY = floor(lat / gridSizeLat),把两个数字拼成字符串当键名。这样单个ZSET的规模就被限制住了,热点也能散到不同的Redis分片。跨分片查询要并发去各分片拉数据,然后在服务端合并、排序,注意容错和合并逻辑别写成单点阻塞。
第3步:解决N+1问题,做服务端合并
拿到候选骑手之后,通常还要查每个骑手的实时状态(接单中、送餐中、在线否)。如果每个候选都去数据库或另一个键查状态,马上变成N+1查询。把这部分合并到Redis端执行比较合算:用Lua脚本把Geo查和状态查在服务端一次性做完,返回给应用层完整结果。Lua脚本还能把评分逻辑搬到Redis端,比如按距离、历史响应率、空闲时间做一个综合分,这样应用层只接收已排序的候选,避免大量网络往返。
第2步:建模和常用命令
在Redis里常用的命令有:GEOADD写位置、GEOPOS取坐标、GEODIST算两点距离、老命令GEORADIUS/GEORADIUSBYMEMBER做半径查,新的GEOSEARCH/GEOSEARCHSTORE更灵活。时间复杂度上,写入是O(logN),取坐标和距离接近O(1),范围查询接近O(N + logM)(N是检索范围内候选点数)。实战里建议优先用GEOSEARCH,它支持矩形和一些筛选条件,灵活性和性能都好。用Spring Boot + Spring Data Redis能把调用封装,pom里加spring-boot-starter-data-redis和lettuce或jedis,配置RedisTemplate,使用RedisGeoCommands接口去操作。初始化大量数据时用Pipeline批量写,能大幅减少网络往返。
第1步:先准备环境,弄清底层原理
先在本地起个Redis 6.2+容器做验证,Docker起单节点调通后再上Cluster。GeoHash原理别被名字唬住:它把经纬度递归划分成小格子,再用位交叉把两个维度编码成一个整数。相邻的点在编码上通常有相似前缀,Redis把这个整数当ZSET的score,用有序集合的范围能力把空间邻近问题变成一维查询问题。这套设计天然能借到ZSET的快照和排序优势。
一些实战场景的细节,不可省略,举几例说明:
- 派单流程:拿用户经纬度先用GEOSEARCH找候选骑手集合,按距离和空闲状态排序。把评分和状态查询放到Lua脚本里一次性返回。如果候选数量不足,就扩大半径或跨格子查询,必要时触发跨分片并发拉取。
- 距离估算:要粗估到达时间,用GEODIST算两点的直线距离就够了,别把它当精确行驶时间的替代。
- 骑手协同:某骑手需要支援周边单子时,可以用GEORADIUSBYMEMBER或GEOSEARCH查他周围的同行,按距离和空闲度选择。
运维和优化点要落到实处:
- 上报策略:高频上报和批量回填要分开。频繁上报的骑手可以把GEO记录设置短TTL,离线会自动过期;批量回填用Pipeline做批量写。
- 写入冲突:尽量把读密集型的空间筛选放到Redis做,减少对关系型数据库的全表扫描。写操作尽量非阻塞,不要在写路径上做长事务。
- 监控告警:给查询延迟和命令耗时设置阈值,达到就报警并触发自动扩容或人工排查。热点键要能快速定位。
- 备份恢复:Cluster下既做RDB快照也开AOF,异地备份到对象存储,恢复流程要定期演练,不能到真出事才摸索怎么恢复。
改造后的效果是能明显看出来:把位置服务从关系型扫描迁移到Redis GEO后,查询延迟从几百毫秒直接降到亚毫秒级,派单超时率也从15%级别掉到可控范围,系统吞吐量明显提升。与此同时,工程上也新增了运维项:网格分片策略、Lua脚本管理、Cluster运维和监控告警体系都得上线并长期维护。
如果你想把这套东西搬到你们环境里,建议按步骤来:先在小范围验证网格分片和GEO命令的表现,再把状态查询合并到Redis端做一次性返回,接着做压测和监控埋点,最后再按流量做扩容和分片策略调整。要是想看具体的代码片段、pom、Redis配置类、application.yml或示例Lua脚本,告诉我你想要哪一块,我把对应内容贴出来。
来源:松下惬意乘凉
