Redis GEO 实现附近商户:从坐标导入到按距离分页查询

本文整理自黑马点评 Redis 实战篇第 10 章「附近商户」。这一章的核心问题是:用户打开某个商户分类后,如何根据用户当前位置,查询附近 5km 内的店铺,并按照距离从近到远排序?

1. 这一篇解决什么问题

在点评类应用里,「附近商户」是非常典型的功能。

用户点击「美食」分类后,前端会传:

typeId:商户类型
x:用户当前经度
y:用户当前纬度
current:当前页码

后端要返回:

附近的美食店铺
按距离从近到远排序
支持分页
每个店铺带上距离

如果只用 MySQL,需要对大量店铺计算距离、排序、分页,压力比较大。Redis GEO 正好可以帮我们做地理位置检索。


2. Redis GEO 是什么

GEO 是 Geolocation 的缩写,表示地理位置。

Redis GEO 可以存储:

member + 经度 + 纬度

比如:

GEOADD shop:geo:1 120.149993 30.334229 101

含义是:

把店铺 101 的坐标加入 shop:geo:1。

其中:

120.149993 是经度 longitude
30.334229 是纬度 latitude
101 是 member,也就是店铺 id

Redis GEO 能做:

添加地理位置 GEOADD
计算距离 GEODIST
查询坐标 GEOPOS
附近搜索 GEOSEARCH

本章最核心的是 GEOSEARCH


3. 为什么 Redis GEO 里只存店铺 id

店铺完整信息很多:

店名
图片
地址
评分
人均价格
营业时间

这些都不适合塞进 Redis GEO。

Redis GEO 的职责只是:

根据坐标快速找附近店铺 id。

完整店铺详情仍然放在 MySQL。

所以 Redis 中存:

shopId + 经纬度

MySQL 中存:

完整 Shop 对象

这和 Feed 流很像:

Feed ZSet 只存 blogId + 时间戳
GEO 只存 shopId + 经纬度
完整对象都回 MySQL 查

4. 为什么要按 typeId 分组

如果所有店铺都放到一个 GEO key:

shop:geo

那用户点击「美食」时,Redis 会查出附近所有类型的店铺:

美食、酒店、KTV、理发店……

但 Redis GEO 的 member 只存 shopId,不存 typeId,没法直接在 GEO 查询时按类型过滤。

所以项目采用按类型分组:

shop:geo:1  typeId=1 的店铺坐标
shop:geo:2  typeId=2 的店铺坐标
shop:geo:3  typeId=3 的店铺坐标

用户查美食时,只查:

shop:geo:1

这样天然只返回美食类店铺。


5. 导入店铺坐标到 Redis GEO

测试类中有导入方法:

@Test
void loadShopData(){
    List<Shop> list = shopService.list();
    Map<Long, List<Shop>> map = list.stream()
            .collect(Collectors.groupingBy(Shop::getTypeId));

    for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
        Long typeId = entry.getKey();
        String key = SHOP_GEO_KEY + typeId;
        List<Shop> value = entry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
        for (Shop shop : value) {
            locations.add(new RedisGeoCommands.GeoLocation<>(
                    shop.getId().toString(),
                    new Point(shop.getX(), shop.getY())
            ));
        }
        stringRedisTemplate.opsForGeo().add(key, locations);
    }
}

流程:

查询所有店铺
  -> 按 typeId 分组
  -> 每个 typeId 对应一个 GEO key
  -> 批量写入 shopId + 坐标

SHOP_GEO_KEY 是:

public static final String SHOP_GEO_KEY = "shop:geo:";

如果 typeId=1,key 就是:

shop:geo:1

6. Controller 接口

@GetMapping("/of/type")
public Result queryShopByType(
        @RequestParam("typeId") Integer typeId,
        @RequestParam(value = "current", defaultValue = "1") Integer current,
        @RequestParam(value = "x", required = false) Double x,
        @RequestParam(value = "y", required = false) Double y
) {
    return shopService.queryShopByType(typeId, current, x, y);
}

这个接口同时支持两种情况。

无坐标:

GET /shop/of/type?typeId=1&current=1

走普通 MySQL 分页。

有坐标:

GET /shop/of/type?typeId=1&current=1&x=120.1&y=30.3

走 Redis GEO 附近查询。


7. 无坐标时走 MySQL 普通分页

if (x == null || y == null) {
    Page<Shop> page = query()
            .eq("type_id", typeId)
            .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
    return Result.ok(page.getRecords());
}

如果前端没有传用户位置,就退化成普通分类查询。


8. 有坐标时计算分页参数

int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;

项目中默认每页 5 条。

第 1 页:from=0,  end=5
第 2 页:from=5,  end=10
第 3 页:from=10, end=15

为什么要这样算?

因为 Redis GEO 查询这里使用的是:

.limit(end)

它只能表示查前 end 条,不能直接表示从 from 开始查 5 条。

所以做法是:

Redis 先查前 end 条
Java 再 skip(from)

9. GEOSEARCH 查询附近店铺

核心代码:

String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
        .search(
                key,
                GeoReference.fromCoordinate(x, y),
                new Distance(5000),
                RedisGeoCommands.GeoSearchCommandArgs
                        .newGeoSearchArgs()
                        .includeDistance()
                        .limit(end)
        );

对应 Redis 思路:

GEOSEARCH shop:geo:1
BYLONLAT x y
BYRADIUS 5000 m
WITHDIST
COUNT end

含义是:

在 shop:geo:1 中,
以用户坐标 x/y 为中心,
查 5000 米范围内的店铺,
返回店铺 id 和距离,
最多返回 end 条。

10. results 和 list 到底是什么

search() 返回的是:

GeoResults<RedisGeoCommands.GeoLocation<String>> results

它是一个包装对象。

真正的结果列表要通过:

List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();

注意:results.getContent() 取出来的是一组 GeoResult,不是单纯的店铺 id。

每个 GeoResult 里面有:

content:GeoLocation,也就是 shopId + 坐标
distance:距离

所以后面可以:

String shopIdStr = result.getContent().getName();
Distance distance = result.getDistance();

getContent().getName() 拿到的是 member,也就是店铺 id。

getDistance() 拿到的是 Redis 根据用户坐标计算出来的距离。


11. Java 层截取当前页

if (list.size() <= from) {
    return Result.ok(Collections.emptyList());
}

List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
    String shopIdStr = result.getContent().getName();
    ids.add(Long.valueOf(shopIdStr));
    Distance distance = result.getDistance();
    distanceMap.put(shopIdStr, distance);
});

假设查第 2 页:

from = 5
end = 10

Redis 返回最近的前 10 条。

Java 执行:

skip(5)

跳过前 5 条,剩下第 6 到第 10 条,就是第 2 页。

这里收集两个东西:

ids:当前页店铺 id,用于回 MySQL 查详情
distanceMap:店铺 id 到距离的映射,用于把距离补回 Shop

12. 为什么总是 ids + idStr

回表查询:

String idStr = StrUtil.join(",", ids);
List<Shop> shops = query()
        .in("id", ids)
        .last("ORDER BY FIELD(id," + idStr + ")")
        .list();

ids 用于:

WHERE id IN (...)

idStr 用于:

ORDER BY FIELD(id, ...)

为什么要 ORDER BY FIELD

因为 Redis GEO 返回的顺序是按距离排好的:

[5, 2, 9]

但 MySQL 的 IN 查询不保证顺序,可能返回:

[2, 5, 9]

这样距离排序就乱了。

所以要强制 MySQL 按 Redis 返回的 id 顺序排序。


13. 补充距离字段

Shop 实体里有:

@TableField(exist = false)
private Double distance;

distance 不是数据库字段,而是运行时计算出来的字段。

因为距离取决于用户当前位置:

用户位置变了,距离就变了。

所以不能存在数据库里,只能查询时动态补充:

for (Shop shop : shops) {
    shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}

14. 完整流程

前端传 typeId 和用户坐标

Controller 接收参数

x 或 y 是否为空?

MySQL 按类型分页查询

拼接 GEO key: shop:geo:typeId

Redis GEOSEARCH 查询附近店铺

得到 shopId 和 distance

Java skip 处理分页

根据 shopId 回 MySQL 查详情

ORDER BY FIELD 保持距离顺序

补充 distance 字段

返回店铺列表

文字版:

请求 /shop/of/type
  -> 如果无坐标,走 MySQL 普通分页
  -> 如果有坐标,查 shop:geo:{typeId}
  -> Redis 返回按距离排序的 shopId + distance
  -> Java 截取当前页
  -> MySQL 回表查完整 Shop
  -> ORDER BY FIELD 保持 Redis 顺序
  -> 设置 distance
  -> 返回

15. 易错点

1. x 是经度,y 是纬度

写入 Point 时:

new Point(shop.getX(), shop.getY())

顺序不要反。

2. shop:geo:{typeId} 里的 typeId 不是店铺 id

它表示某个类型下的所有店铺坐标集合。

3. Redis GEO 只返回 shopId 和 distance

完整 Shop 仍然要回 MySQL 查。

4. 回表后必须保持 Redis 顺序

否则距离排序会乱。

5. distance 不是数据库字段

它是动态计算字段,所以要 @TableField(exist = false)


16. 面试怎么说

如果面试官问:附近商户怎么实现?

可以回答:

我们先把店铺按照 typeId 分组,将每个店铺的 shopId 和经纬度写入 Redis GEO,key 设计为 shop:geo:{typeId}。查询附近商户时,前端传 typeId 和用户坐标,后端通过 GEOSEARCH 在对应类型的 GEO 集合中查 5km 内的店铺,Redis 返回按距离排序的 shopId 和 distance。然后根据 shopId 回 MySQL 查询完整店铺信息,用 ORDER BY FIELD 保持 Redis 返回顺序,最后把 distance 设置到 Shop 的临时字段中返回。


17. 总结

第 10 章的核心是:

Redis GEO 负责地理位置索引,MySQL 负责完整店铺信息。按 typeId 分组建立 GEO key,查询时用用户坐标做半径搜索,再回表补详情和距离。

Logo

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

更多推荐