Skip to content

09 - GEO 附近商户

知识图谱

客户端请求: GET /shop/of/type?typeId=1&x=116.4&y=39.9&current=1


                   ShopServiceImpl.queryShopByType()

              ┌─────────────┴──────────────┐
              │ 有坐标(x,y)?               │
              │                            │
         YES  ▼                       NO   ▼
    Redis GEO 查询                   MySQL 分页查询
         │                           (普通 type_id 过滤)

    GEOSEARCH shop:geo:{typeId}
      BYLONLAT x y
      BYRADIUS 5000m
      WITHDISTANCE
      LIMIT {end}


    ┌────────────────────┐
    │ 结果: shopId + 距离 │
    │  skip(from) 跳过前页│
    │  → ids + distanceMap│
    └────────┬───────────┘


    MySQL: WHERE id IN (ids)
           ORDER BY FIELD(id, ...)


    设置每个 Shop 的 distance 字段


         返回 Result

Redis GEO 原理

Redis GEO 底层使用 ZSet 实现:

  • Member: shopId
  • Score: GeoHash 编码值(将经纬度映射为一维整数)
GeoHash 原理:
  经度(-180~180) → 二进制编码 → 交叉合并 → 52位整数
  纬度(-90~90)   → 二进制编码 ↗

  相邻位置的 GeoHash 值接近 → ZSet 中相邻 → 范围查询高效

核心代码走读

文件: src/main/java/com/hmdp/service/impl/ShopServiceImpl.java

java
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // 1.无坐标 → 走数据库
    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());
    }

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

    // 3.Redis GEO 查询: 5km 半径内的商铺
    String key = SHOP_GEO_KEY + typeId;  // shop:geo:{typeId}
    GeoResults<RedisGeoCommands.GeoLocation<String>> results =
        stringRedisTemplate.opsForGeo().search(
            key,
            GeoReference.fromCoordinate(x, y),
            new Distance(5000),  // 5000米 = 5km
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
                .includeDistance()  // 返回距离
                .limit(end)        // 只取前 end 条
        );

    // 4.手动跳页: GEO 不支持 offset, 用 skip 模拟
    List<GeoResult<...>> list = results.getContent();
    if (list.size() <= from) {
        return Result.ok(Collections.emptyList()); // 无下一页
    }

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

    // 5.MySQL 查询完整信息 + 保持顺序
    String idStr = StrUtil.join(",", ids);
    List<Shop> shops = query().in("id", ids)
            .last("ORDER BY FIELD(id," + idStr + ")")
            .list();

    // 6.设置距离
    for (Shop shop : shops) {
        shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
    }
    return Result.ok(shops);
}

数据预热

商铺 GEO 数据需要提前导入 Redis:

java
// 按 typeId 分组,批量 GEOADD
// GEOADD shop:geo:1 116.397128 39.916527 "shopId1"
// GEOADD shop:geo:1 116.405285 39.904989 "shopId2"
stringRedisTemplate.opsForGeo().add(key, new Point(x, y), shopId);

面试 Q&A

Q1: 为什么用 Redis GEO 而不是 MySQL 的空间函数?

性能差异巨大:

  • MySQL ST_Distance:需要全表扫描或空间索引,QPS 有限(~千级)
  • Redis GEOSEARCH:基于 GeoHash + ZSet,O(N+logN) 复杂度,QPS 可达万级

对于高频的「附近商户」查询,Redis GEO 是更好的选择。

追问:Redis GEO 的底层数据结构是什么?

底层是 ZSet(跳表 + 压缩列表)。经纬度通过 GeoHash 算法编码为 52 位整数作为 score。相邻地理位置的 GeoHash 值相近,因此 ZSet 的范围查询天然支持空间邻近查询。

再追问:GeoHash 有什么缺陷?

边界问题:两个物理上相邻的位置,如果恰好跨越 GeoHash 网格边界,它们的 GeoHash 值可能差距很大。Redis 内部通过搜索目标网格及其 8 个相邻网格来解决这个问题,但使用者需要了解这个特性。

Q2: 分页是怎么实现的?

Redis GEOSEARCH 的 COUNT 参数只支持 LIMIT(返回前 N 条),不支持 OFFSET

所以采用了 查多跳前 策略:

  1. 查询前 end 条结果(limit(end)
  2. 在 Java 中 skip(from) 跳过前页数据
  3. from ~ end 之间的数据作为当前页

追问:这种分页方式有什么问题?

深分页性能差:第 100 页需要查出前 100 × pageSize 条数据,大部分被丢弃。

优化方案:

  1. 缩小搜索半径:随着页数增加,前端可以引导用户缩小范围
  2. 游标分页:记录上一页最远距离,下一页以此为起点
  3. 限制最大页数:大多数 LBS 应用只展示前几页

Q3: ORDER BY FIELD 是什么?为什么需要它?

MySQL 的 IN 查询不保证返回顺序与 IN 列表一致。例如 WHERE id IN (3, 1, 2) 可能返回顺序为 1, 2, 3

ORDER BY FIELD(id, 3, 1, 2) 强制按指定顺序排列结果,保持 Redis GEO 返回的距离排序。

这个技巧在项目中多处使用:Feed 流查询、点赞 Top5 查询都用了相同模式。

追问:ORDER BY FIELD 有 SQL 注入风险吗?

当前代码直接拼接 idStr 到 SQL 中(last("ORDER BY FIELD(id," + idStr + ")")),理论上有注入风险。但因为 ids 来源于 Redis GEO 结果(shopId),不是用户直接输入,风险较低。

更安全的做法是使用参数化查询或在 Java 层面做结果排序。


踩坑点

踩坑点说明面试官考察意图
GEO 无 OFFSET只能查多跳前,深分页性能差LBS 场景的分页设计
distanceMap 手动维护GEO 返回的 ID 和 DB 查询需要手动关联距离跨数据源数据组装
数据预热GEO 数据需要提前导入,新增商铺需同步更新数据一致性
5km 硬编码搜索半径固定 5000m,不可配置可配置性设计
ORDER BY FIELD 拼接存在 SQL 注入风险(虽然输入来自内部)安全意识

加分回答

  • 对比其他 LBS 方案:Elasticsearch Geo Query(支持更丰富的空间查询)、PostGIS、MongoDB 2d索引
  • 提到 GeoHash 精度与搜索效率的关系(Redis 默认使用 52 位精度,约 0.6m 误差)
  • 分析 GEOSEARCH 的时间复杂度:O(N+logM),N 是范围内结果数,M 是 ZSet 总元素数
  • 提到商铺数据更新时需要同步更新 GEO 索引(当前代码只更新了缓存,没更新 GEO)

关联文档