外观
09 - GEO 附近商户
知识图谱
客户端请求: GET /shop/of/type?typeId=1&x=116.4&y=39.9¤t=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 字段
│
▼
返回 ResultRedis 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。所以采用了 查多跳前 策略:
- 查询前
end条结果(limit(end))- 在 Java 中
skip(from)跳过前页数据- 取
from ~ end之间的数据作为当前页
追问:这种分页方式有什么问题?
深分页性能差:第 100 页需要查出前 100 × pageSize 条数据,大部分被丢弃。
优化方案:
- 缩小搜索半径:随着页数增加,前端可以引导用户缩小范围
- 游标分页:记录上一页最远距离,下一页以此为起点
- 限制最大页数:大多数 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)
关联文档
- 00-项目总览与架构 — Redis GEO 数据结构概览
- 01-Redis缓存设计 — 商铺缓存与 GEO 的配合
- 11-已知问题与优化方向 — GEO 数据一致性问题