外观
50 万点实测:Redis GEOSEARCH 附近司机检索避坑实录
问题背景:附近司机查询为什么不能只靠数据库
在网约车、即时配送、同城跑腿等 LBS 场景里,最常见的请求是:用户下单后,根据经纬度查找 3 公里范围内在线司机,并按距离返回前 50 名。这个需求看起来只是一个“圆形范围查询”,但在高峰期会遇到两个压力:一是司机位置持续上报,二是用户侧查询 QPS 高且要求低延迟。
如果直接使用 MySQL 的经纬度字段做全表过滤,即使加上普通索引,也很难高效表达二维空间距离;使用 PostgreSQL PostGIS、Elasticsearch Geo 或专业时空库当然可以,但会引入额外组件。对于已经大量使用 Redis 的团队,Redis GEO 是一个轻量、低延迟的附近点位检索方案。
Redis GEO 底层基于 Sorted Set:它使用 GeoHash 将二维经纬度编码成 52 位整数,再以 score 存储。写入时使用 GEOADD,查询时推荐使用 Redis 6.2 引入的 GEOSEARCH。它可以同时完成中心点半径检索、距离排序、距离回传和数量限制。
本文用 50 万个模拟司机点进行压测,重点说明 GEOSEARCH 怎么用、性能边界在哪里,以及生产环境中最容易踩的坑。
方案对比:GEOSEARCH、GEORADIUS 与外部空间库
早期 Redis 附近查询常使用 GEORADIUS,但该命令在 Redis 6.2 后已被废弃,新代码应优先使用 GEOSEARCH。二者底层都依赖 GeoHash,但 GEOSEARCH 的语义更清晰,支持 FROMMEMBER、FROMLONLAT、BYRADIUS、BYBOX、ASC、WITHDIST、WITHCOORD、COUNT 等参数,也更适合统一封装。
从方案选择看:
- Redis GEOSEARCH:适合在线司机、门店、骑手等热点位置查询,优点是延迟低、接入简单;缺点是复杂地理条件、多边形检索和大规模多维筛选能力较弱。
- GEORADIUS:旧版本可用,但已不推荐;只支持以某个经纬度为中心,参数风格也不如 GEOSEARCH 统一。
- PostGIS / Elasticsearch Geo:适合复杂空间分析、历史轨迹、多边形围栏和组合过滤,但运维成本更高。
- 应用层计算:只适合点位极少的场景。50 万点每次全量计算距离,会浪费大量 CPU 和网络资源。
因此,推荐定位是:Redis GEO 承担“在线、热点、半径型”的 LBS 查询;复杂画像过滤可以在拿到候选司机后,再结合司机状态缓存或筛选服务完成。
数据结构设计:一个 GEO Key 不要装下全世界
Redis GEO 的核心数据类型是 zset,member 建议存司机 ID,score 由 Redis 根据经纬度自动编码为 GeoHash。命令如下:
bash
# 写入司机位置:key 经度 纬度 member
GEOADD drivers:online:city:10 116.397128 39.916527 driver:10001
GEOADD drivers:online:city:10 116.405285 39.904989 driver:10002
GEOADD drivers:online:city:10 116.412023 39.908960 driver:10003
# 查看某个司机的 GeoHash 字符串
GEOHASH drivers:online:city:10 driver:10001
# 查询北京天安门附近 3 公里,按距离升序返回 50 个,携带距离和坐标
GEOSEARCH drivers:online:city:10
FROMLONLAT 116.397128 39.916527
BYRADIUS 3 km
ASC
COUNT 50
WITHDIST
WITHCOORD
WITHHASH返回结果中,每个元素是数组:第一项为 member,第二项为距离中心点的距离,第三项是 GeoHash 整数,第四项是经纬度。实际业务通常不需要 WITHHASH,可减少返回体积。
如果要以某个已存在司机为中心查询,可使用 FROMMEMBER:
bash
# 查询 driver:10001 周围 5 公里内的其他在线司机
GEOSEARCH drivers:online:city:10
FROMMEMBER driver:10001
BYRADIUS 5 km
ASC
COUNT 20
WITHDISTKey 设计建议至少按城市或业务区域拆分,例如 drivers:online:{cityId}、drivers:online:{cityId}:{vehicleType}。不要把全国所有司机放入一个巨大的 GEO Key。虽然 GeoHash 会利用相邻区域缩小扫描范围,但单次查询仍可能检查候选网格中的大量点;半径越大、点越密、COUNT 越宽松,CPU 消耗越高。
50 万点压测:构造数据与观测命令
下面用 bash + redis-cli 构造 50 万个点。为了模拟真实城市密度,将点位集中在经度 116.20 到 116.60、纬度 39.70 到 40.10 的范围内。
bash
#!/usr/bin/env bash
# 生成 50 万个模拟司机点,并批量写入 Redis
# 运行前请确认 redis-cli 可连接,且这是测试环境
KEY="drivers:online:压测"
TOTAL=500000
BATCH=2000
rm -f /tmp/geo_data.txt
for i in $(seq 1 $TOTAL); do
# awk 生成指定范围内的随机经纬度,保留 6 位小数
lon=$(awk -v s="$RANDOM" 'BEGIN{srand(s+NR); printf "%.6f", 116.20 + rand() * 0.40}')
lat=$(awk -v s="$RANDOM" 'BEGIN{srand(s+NR*7); printf "%.6f", 39.70 + rand() * 0.40}')
echo "GEOADD $KEY $lon $lat driver:$i" >> /tmp/geo_data.txt
if [ $((i % BATCH)) -eq 0 ]; then
redis-cli --pipe < /tmp/geo_data.txt >/dev/null
: > /tmp/geo_data.txt
echo "written: $i"
fi
done
# 写入剩余数据
if [ -s /tmp/geo_data.txt ]; then
redis-cli --pipe < /tmp/geo_data.txt >/dev/null
fi
# 确认点数
ZCARD "$KEY"压测查询示例:
bash
# 1 公里半径,最多返回 50 个司机,只返回 member,性能最好
redis-cli --latency
redis-cli GEOSEARCH drivers:online:压测 \
FROMLONLAT 116.397128 39.916527 \
BYRADIUS 1 km ASC COUNT 50
# 3 公里半径,携带距离和坐标,返回数据更多
redis-cli GEOSEARCH drivers:online:压测 \
FROMLONLAT 116.397128 39.916527 \
BYRADIUS 3 km ASC COUNT 50 WITHDIST WITHCOORD
# 使用 benchmark 观察吞吐,注意不要在生产直接压满
redis-benchmark -c 50 -n 200000 \
GEOSEARCH drivers:online:压测 \
FROMLONLAT 116.397128 39.916527 \
BYRADIUS 1 km ASC COUNT 50在一台 4C8G 测试机、单 Redis 7.x 实例、点集中分布的条件下,典型观测结果是:1 公里半径加 COUNT 50 的 P99 通常可控制在数毫秒内;3 公里半径且 WITHCOORD、WITHDIST 时,平均延迟会随返回字段和候选点数量上升;如果去掉 COUNT 或半径扩大到 10 公里,热点区域可能扫描数万候选点,P99 抖动明显。
需要强调,GEOSEARCH 的耗时不只由 50 万这个总量决定,更由查询范围内的点密度决定。同样是 50 万点,均匀分布在全国和集中在一个城市,结果完全不同。压测时应尽量还原真实分布,而不是只看 ZCARD。
性能优化:COUNT、ANY 与业务分层
GEOSEARCH 的 COUNT 默认会在候选范围内尽量保证结果顺序与数量语义。数据量较大时,可以使用 COUNT n ANY 提示 Redis 在找到足够结果后更早退出,降低延迟:
bash
# 高性能候选召回:找到 50 个即可,适合后面还有业务过滤的场景
GEOSEARCH drivers:online:city:10
FROMLONLAT 116.397128 39.916527
BYRADIUS 3 km
ASC
COUNT 50 ANY
WITHDIST不过,ANY 并不承诺在所有极端分布下都得到严格意义上“最近的 50 个”。如果业务强依赖精确排序,例如调度系统必须取距离最近的车辆,应谨慎使用;如果只是召回一批候选司机,再由调度服务结合车型、接单状态、方向和服务分过滤,ANY 往往更划算。
位置更新可直接使用 GEOADD 覆盖同一个 member 的 score:
bash
# 司机每 5 秒上报一次,同一 member 会更新为新位置
GEOADD drivers:online:city:10 116.406000 39.905000 driver:10001
# 司机下线时移除,避免召回不可服务车辆
ZREM drivers:online:city:10 driver:10001如果在线状态还需要 TTL,单个 GEO 集合中的 member 无法单独设置过期时间。常见做法是司机心跳写一个带 TTL 的状态 key,定时任务或下线逻辑负责 ZREM;也可以按分钟或小时分片 GEO Key,让旧 Key 整体过期,但查询时需要合并多个 Key。
避坑总结:生产环境至少注意这 5 点
不要忽略经纬度顺序:Redis 使用“经度 longitude、纬度 latitude”,即
(lon, lat)。很多端上 SDK 习惯返回(lat, lng),直接写入会出现查询点跑到异常区域、结果为空或距离明显错误。修正方法是在接入层统一做参数校验:经度范围 -180 到 180,纬度范围 -90 到 90,并固定字段命名。不要在超大 Key 上做大半径无 COUNT 查询:错误现象是高峰期 Redis CPU 突增、慢日志出现 GEOSEARCH、P99 延迟抖动。修正方法是按城市、区域或车型拆分 Key,半径限制在业务可接受范围,并始终加上 COUNT。热点城市还要监控 ZCARD、半径分布和慢查询。
不要把 WITHDIST、WITHCOORD 当成免费字段:返回坐标和距离会增加序列化与网络开销。高 QPS 接口如果只需要司机 ID,就不要携带坐标;需要距离时可只加 WITHDIST。若返回后还要批量查司机资料,建议先用 Pipeline 或本地缓存补充信息,避免一个请求引发多次网络往返。
不要用 GEO Key 单独表达在线状态 TTL:Redis 只能对整个 Key 设置过期时间,不能对 zset 中某个 member 单独 TTL。司机关机、断网后如果没有 ZREM,就会被查询到“幽灵司机”。修正方法是心跳 key 与 GEO 索引配合:心跳过期后由在线管理服务删除位置,或通过短时分片 Key 降低脏数据存活时间。
不要把 GeoHash 邻近误认为绝对距离正确:GeoHash 本质是空间编码,靠近本初子午线、180 度经线或极点时会有边界问题,矩形网格也不能直接代表圆形距离。Redis 会对候选点做距离校验,但业务设计仍应限制城市范围,不要用一个全国 Key 配合跨城大半径查询。跨国家或全球业务应按区域拆分,并验证边界城市数据。
综上,Redis GEOSEARCH 非常适合 50 万级甚至更高量级的热点在线点位检索,但前提是 Key 分片合理、半径受控、COUNT 明确、返回字段精简,并处理好司机上下线一致性。把它当作高并发候选召回层,而不是全能空间数据库,通常能在成本和延迟之间取得最佳平衡。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。