Redis GEO,一个被我低估的功能
做附近充电桩查询的时候,我第一反应是用 MySQL 的地理位置函数。写了一版,本地跑着没问题,数据量一上去就开始变慢。
后来查资料,发现 Redis 有个 GEO 模块,专门处理这类问题。用了之后感觉之前绕了很大一段弯路,所以把 GEO 这块从头到尾整理了一遍。
先说一个前提:经纬度坐标
用 Redis GEO 之前,要先理解经纬度是怎么表示一个位置的。
地球是个(近似的)球体,用两个角度就能唯一确定球面上的一点:
- 纬度(latitude):当前点和地心连线,与赤道平面的夹角。赤道是 0°,北极是 +90°,南极是 -90°。
- 经度(longitude):当前点和地心连线,与本初子午线平面的夹角。本初子午线(穿过英国格林威治)是 0°,向东最大 +180°,向西最大 -180°。
两个角度组合,就能在地球上唯一定位一个点。这是 GEO 所有功能的基础。
GEO 能做什么
Redis GEO 提供了一套命令,核心能力就三个:
1. 存储位置:把一批地点的经纬度存进去
2. 查询距离:算出两个地点之间的直线距离
3. 范围查询:找出距离某个点 X 公里以内的所有地点
这三个能力组合起来,基本上能覆盖大部分"附近 XX"的业务场景。
基本用法
存入位置
GEOADD locations 116.397128 39.916527 "天安门"
GEOADD locations 121.473701 31.230416 "外滩"
GEOADD locations 113.264385 23.128794 "广州塔"
GEOADD 的参数顺序是:key 经度 纬度 名称。
注意是先经度后纬度,和我们说话的习惯(先纬度后经度)反过来,第一次用很容易搞混。
也可以一次性批量写入:
GEOADD locations
116.397128 39.916527 "天安门"
121.473701 31.230416 "外滩"
113.264385 23.128794 "广州塔"
查询两点距离
GEODIST locations 天安门 外滩 km
返回:1067.5980,单位是公里。
距离单位支持 m(米)、km(公里)、mi(英里)、ft(英尺)。
附近搜索场景
GEORADIUS key lng lat radius
当你想要找到某个位置(lng, lat)周围一定半径内的所有地点时,例如:
- 查找用户当前位置5公里范围内的所有充电站
- 查找某经纬度点附近10公里范围内的所有门店
- 查找某个坐标点周围的所有兴趣点
// 查找经度116.404, 纬度39.915 周围5公里内的所有站点
List<Object> nearbyStations = geoOperations.radius("stations",
new Circle(116.404, 39.915, DistanceUnit.KILOMETERS.convert(5)));
查询某点的坐标
GEOPOS locations 天安门
返回:116.39712899923324585 39.91652720872259769
这里有个细节:返回的坐标和存入的坐标有极细微的误差(最后几位小数不同)。这是 GEO 底层编码机制导致的,误差极小(约 0.6 米以内),不影响使用。
范围查询(核心用法)
查询以某个坐标为中心,5 公里以内的所有地点:
GEOSEARCH locations
FROMMEMBER 天安门
BYRADIUS 5 km
ASC
COUNT 10
WITHCOORD
WITHDIST
FROMMEMBER 天安门:以"天安门"这个已存储的点为圆心BYRADIUS 5 km:半径 5 公里ASC:按距离从近到远排序COUNT 10:最多返回 10 条WITHCOORD:结果里带上坐标WITHDIST:结果里带上距离
也可以用经纬度直接指定圆心(不需要是已存储的点):
GEOSEARCH locations
FROMLONLAT 116.397128 39.916527
BYRADIUS 5 km
ASC
这个更实用,用户当前位置往往不在存储的地点列表里。
底层原理:GeoHash
GEO 命令用起来很简单,但底层原理相当有意思,值得多花点时间搞清楚。
问题的本质是:如何用 Redis 的数据结构存储二维坐标,并支持高效的范围查询?
Redis 本身没有专门的二维数据结构,GEO 的解法是:把二维坐标编码成一个一维的整数,然后用有序集合(ZSet)来存。
这个编码算法叫 GeoHash。
GeoHash 的思路
地球是一个球面,GeoHash 的做法是把球面坐标投影到一个二维平面,然后对这个平面做递归二分。
以经度为例,经度范围是 -180 到 +180。第一次二分:
- 如果经度在 -180 到 0 之间,记 0
- 如果经度在 0 到 +180 之间,记 1
然后对落入的那个区间再次二分,继续记 0 或 1。做足够多次之后,就得到一串二进制,这串二进制就是经度的编码。纬度同理,范围是 -90 到 +90,同样做递归二分。
最后,把经度的二进制和纬度的二进制交替合并成一串,就是这个坐标点的 GeoHash 编码。Redis 里用 52 位的整数来存储这个编码,精度误差在 0.6 米以内。
经度编码:1 0 1 1 0 ...
纬度编码:0 1 1 0 1 ...
交替合并:1 0 0 1 1 1 1 0 0 1 ...
↑ 经度位 ↑ 纬度位(交替排列)
为什么交替合并
这是整个设计最精妙的地方。
如果把经度和纬度直接拼接(先放经度再放纬度),那这个数值在地理上没有连续性——数值相邻的两个编码,对应的地理位置可能相差很远。
交替合并之后,数值相近的编码,对应的地理位置也在附近。这样就把二维的空间邻近关系,映射到了一维数值的大小关系上。
范围查询就变成了:在 ZSet 里找某个值附近的所有成员——这是有序集合的拿手好戏,时间复杂度 O(log N + M),非常快。
有一个边界问题
GeoHash 编码有个边界情况:两个地理上相邻的点,可能因为落在区间边界的两侧,编码差异很大。
举个极端例子:经度 -0.0001 和 +0.0001,地理上只差 20 米,但经度编码的第一位就不一样了,最终的 GeoHash 差距很大。
Redis 的做法是在查询时搜索目标区域周围的多个格子,把边界两侧的格子都覆盖到,确保不会漏掉附近的点。这也是为什么 GEOSEARCH 返回的结果需要再按距离做一次精确过滤——初步筛选的格子范围略大于查询半径,还需要过滤掉边角的点。
GEO 底层用的是 ZSet
这一点很多人不知道:GEO 在 Redis 里不是一个独立的数据类型,它的数据实际上存在一个 ZSet(有序集合)里。
GeoHash 编码后的 52 位整数就是 ZSet 的 score,地点名称是 member。
所以所有 ZSet 的命令对 GEO 的 key 都有效,比如:
# 查看存了多少个地点
ZCARD locations
# 删除某个地点
ZREM locations 天安门
# 用 ZSCAN 遍历所有地点
ZSCAN locations 0
但反过来,不建议用 ZSet 命令去直接操作 score,因为那个 score 是 GeoHash 编码后的整数,直接改会破坏地理位置数据。
在项目里怎么用
做"附近门店"功能的时候,大概是这样的流程:
数据初始化
服务启动时,把所有门店的经纬度批量写入 Redis:
// Spring Data Redis 的写法
Map<String, Point> storeLocations = new HashMap<>();
storeLocations.put("朝阳店", new Point(116.443205, 39.921985));
storeLocations.put("海淀店", new Point(116.298262, 39.959912));
storeLocations.put("丰台店", new Point(116.286968, 39.858470));
redisTemplate.opsForGeo().add("stores", storeLocations);
用户查询附近门店
// 用户当前位置:经度 116.397, 纬度 39.916
// 查询 5km 内,按距离排序,最多返回 10 家
GeoSearchCommandArgs args = GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.includeCoordinates()
.sortAscending()
.limit(10);
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redisTemplate.opsForGeo().search(
"stores",
new GeoReference.GeoCoordinateReference<>(116.397, 39.916),
new Distance(5, Metrics.KILOMETERS),
args
);
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
String storeName = result.getContent().getName();
double distance = result.getDistance().getValue();
System.out.println(storeName + " 距离:" + distance + " km");
}
数据更新
门店位置变了,直接用 GEOADD 重新写入同名 member,会自动覆盖旧坐标:
GEOADD stores 116.450000 39.925000 朝阳店
用 MySQL 做同样的事,为什么慢
绕回最开始的问题,为什么 MySQL 做范围查询会变慢。
MySQL 也支持地理位置查询,用 ST_Distance_Sphere 函数:
SELECT name,
ST_Distance_Sphere(
point(lng, lat),
point(116.397128, 39.916527)
) AS distance
FROM stores
WHERE ST_Distance_Sphere(
point(lng, lat),
point(116.397128, 39.916527)
) < 5000
ORDER BY distance
LIMIT 10;
这条 SQL 的问题是:ST_Distance_Sphere 是一个函数,作用在每一行上,没有办法走索引。数据量小的时候感受不到,数据量一大就是全表扫描,每一行都要算一次球面距离,当然慢。
MySQL 其实有专门的空间索引(SPATIAL INDEX),配合 ST_Within 或 MBRContains 可以走索引。但配置比较麻烦,而且性能仍然不如 Redis GEO。
Redis GEO 基于 ZSet 的有序结构,范围查询是 O(log N + M),数据全在内存里,速度不是一个量级。
几个使用细节
坐标系问题
Redis GEO 使用的坐标系是 WGS84,这是 GPS 和大多数国际地图的坐标系。但在国内,高德地图、百度地图等使用的是各自的偏移坐标系(GCJ-02、BD-09)。
如果数据来源是高德地图,坐标是 GCJ-02,直接存进 Redis 里做距离计算,结果会有偏差(大约几十到几百米)。在中国大陆的项目里,要注意统一坐标系,或者在存入和读取时做转换。
数据不要堆在一个 key 里
如果全国几十万个门店都存在 stores 这一个 key 里,单个 ZSet 会非常大,查询时扫描范围也很大。
更合理的做法是按城市或区域分 key:
stores:beijing
stores:shanghai
stores:guangzhou
用户在北京查,只搜 stores:beijing,效率高很多,也方便维护。
距离计算是球面距离,不是路线距离
GEO 算出来的距离是两点之间的直线(球面)距离,不是实际开车或步行的路线距离。
如果业务需要路线距离,要接高德地图或百度地图的路线规划 API,Redis GEO 只能做"直线距离初筛"。
最后
用 Redis GEO 之前,我觉得地理位置查询是个复杂的事,各种三角函数、坐标系转换,很难搞。
真正用起来之后发现,几条命令就能解决大部分场景,难的部分 GeoHash 都替你处理掉了。
但了解 GeoHash 的底层原理还是有必要的——知道它是用 ZSet 存的、知道边界问题的存在、知道为什么不能直接操作 score,这些细节在排查问题的时候都有用。
另外坐标系那个坑,国内项目一定要提前想好,否则数据都存进去了才发现距离对不上,改起来很麻烦。
更多推荐




所有评论(0)