做附近充电桩查询的时候,我第一反应是用 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_WithinMBRContains 可以走索引。但配置比较麻烦,而且性能仍然不如 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,这些细节在排查问题的时候都有用。

另外坐标系那个坑,国内项目一定要提前想好,否则数据都存进去了才发现距离对不上,改起来很麻烦。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐