实战篇 13. 附近商铺 - 实现附近商户分页查询学习文档

5 阅读5分钟

太棒了!数据已经全部导入到 Redis 的 GEO 结构中了。万事俱备,只欠东风。

现在我们要打通这最后一公里:接收前端传来的用户经纬度,从 Redis 中查出附近的商铺,并把具体的商铺信息返回给前端展示。

在这个功能中,你将遇到一个关于 GEO 分页的特殊痛点


📚 实战篇 13. 附近商铺 - 实现附近商户分页查询学习文档

一、 业务流程与参数分析

当用户在 App 中点击“美食”分类,并同意授权地理位置时,前端会向后端发起请求。

前端传递的核心参数:

  1. typeId:商铺分类 ID(比如 1 代表美食,对应我们要查的 Redis Key shop:geo:1)。
  2. x, y:用户当前的经度和纬度。
  3. current:当前页码(前端下拉刷新时会递增)。

后端的处理主线:

拿到坐标 -> 去 Redis 查附近 5 公里内的商铺 ID 和距离 -> 截取当前页需要的数据 -> 拿着 ID 去 MySQL 查详细信息 -> 把距离拼接到商铺对象里 -> 返回给前端。


二、 核心痛点:Redis GEO 的“伪分页” (面试大坑)

如果让你按页码查 MySQL,你会很自然地写出 LIMIT (current-1)*size, size

但在 Redis 的 GEO 查询中,它原生不支持像 LIMIT offset, count 这样的按偏移量截取功能!

GEOSEARCH 命令只有一个 COUNT n 参数,意思是: “从近到远,给我前 n 条数据”

💥 痛点场景:

假设每页 10 条数据。

  • 查第 1 页:我们需要第 1 ~ 10 条。可以告诉 Redis COUNT 10
  • 查第 2 页:我们需要第 11 ~ 20 条。但是 Redis 无法直接跳过前 10 条,我们只能告诉 Redis COUNT 20(把前 20 条全查出来),然后在 Java 代码的内存中,手动扔掉前 10 条,只保留后 10 条!

这就是典型的**“逻辑分页(内存分页)”**。虽然看起来有点笨,但在 Redis 极高的内存读取速度下,只要单次查询的范围(比如 COUNT 1000 以内)不是特别夸张,性能依然是完全可以接受的。


三、 核心代码落地 (Service 层)

这段代码是 Spring Data Redis 操作 GEO 的集大成之作,细节非常多(尤其是结果解析和拼装):

Java

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // 1. 判断是否需要根据坐标查询
    if (x == null || y == null) {
        // 如果用户没开定位,就退化成普通的 MySQL 数据库分页查询
        Page<Shop> page = query()
                .eq("type_id", typeId)
                .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        return Result.ok(page.getRecords());
    }

    // 2. 计算分页参数
    int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; // 起始条数
    int end = current * SystemConstants.DEFAULT_PAGE_SIZE;        // 截止条数

    // 3. 拼接 Redis 的 Key
    String key = "shop:geo:" + typeId;

    // 4. 调用 Redis GEO 查询:查出以 (x,y) 为圆心,5公里内,由近到远排序的前 end 条数据
    // 对应命令:GEOSEARCH key FROMLONLAT x y BYRADIUS 5000 m WITHDIST ASC COUNT end
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
            .search(
                    key,
                    GeoReference.fromCoordinate(x, y),
                    new Distance(5000), // 默认单位是米
                    RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
            );

    // 5. 判空处理 (可能这方圆5公里内一家店都没有)
    if (results == null || results.getContent().isEmpty()) {
        return Result.ok(Collections.emptyList());
    }

    // 6. 解析 Redis 拿到的一坨结果 (截取我们需要的那一页数据)
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
    
    // 防御性拦截:如果查出来的数据总数,连我们要跳过的条数 (from) 都不到,说明已经没数据了
    if (list.size() <= from) {
        return Result.ok(Collections.emptyList());
    }

    // 收集商铺 ID 和 距离的映射表
    List<Long> ids = new ArrayList<>(list.size());
    Map<String, Distance> distanceMap = new HashMap<>(list.size());

    // 7. 【核心】:利用 stream() 的 skip 方法进行逻辑分页!只取 from 到 end 的数据
    list.stream().skip(from).forEach(result -> {
        // 7.1 获取商铺 ID
        String shopIdStr = result.getContent().getName();
        ids.add(Long.valueOf(shopIdStr));
        // 7.2 获取距离
        Distance distance = result.getDistance();
        distanceMap.put(shopIdStr, distance);
    });

    // 8. 根据 ID 批量查询 MySQL 里的商铺详情
    // ⚠️ 经典防坑:再次使用 ORDER BY FIELD,保证 MySQL 查出来的数据不被打乱,依然是由近到远!
    String idStr = StrUtil.join(",", ids);
    List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

    // 9. 将从 Redis 拿到的距离 (distance) 塞进从 MySQL 拿到的 Shop 对象中
    for (Shop shop : shops) {
        // 从 Map 中取出距离,保留一位小数,塞入实体类中 (实体类中需要添加 @TableField(exist=false) private Double distance; )
        shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
    }

    // 10. 大功告成,返回带有距离排好序的商铺列表
    return Result.ok(shops);
}

四、 学习总结与面试话术

在这个功能里,你综合运用了三大绝技:

  1. Redis GEO 检索: O(logN)O(\log N) 级别的时间复杂度,碾压 MySQL 空间函数的计算性能。
  2. Stream 的 Skip 截取: 巧妙化解了 Redis GEO 原生不支持标准 offset 分页的痛点。
  3. ORDER BY FIELD 继点赞排行榜之后,这是你第二次精准踩中并避开了 MySQL IN 查询默认打乱顺序的隐患!

💡 面试模拟套路:

面试官:“你们的附近商铺是怎么做分页的?”

你: “由于 Redis 6.2 之前的 GEO 指令不支持 offset 偏移量,我们采用了逻辑分页。每次查询传入 limit = 当前页 * 页大小 查出总范围,然后在 Java 内存中通过 list.stream().skip(from) 丢弃掉前面的多余数据。虽然会多查一些冗余数据,但考虑到 LBS 场景下用户很少会一直往下翻几十页,单次网络传输量极小,这种处理方式在性能和实现成本上是最优解。另外,在拿着这些 ID 去查 MySQL 时,我特意使用了 ORDER BY FIELD 函数,确保了商铺详情依然严格按照由近到远的顺序渲染呈现!”