太棒了!数据已经全部导入到 Redis 的 GEO 结构中了。万事俱备,只欠东风。
现在我们要打通这最后一公里:接收前端传来的用户经纬度,从 Redis 中查出附近的商铺,并把具体的商铺信息返回给前端展示。
在这个功能中,你将遇到一个关于 GEO 分页的特殊痛点。
📚 实战篇 13. 附近商铺 - 实现附近商户分页查询学习文档
一、 业务流程与参数分析
当用户在 App 中点击“美食”分类,并同意授权地理位置时,前端会向后端发起请求。
前端传递的核心参数:
typeId:商铺分类 ID(比如 1 代表美食,对应我们要查的 Redis Keyshop:geo:1)。x,y:用户当前的经度和纬度。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);
}
四、 学习总结与面试话术
在这个功能里,你综合运用了三大绝技:
- Redis GEO 检索: 级别的时间复杂度,碾压 MySQL 空间函数的计算性能。
- Stream 的 Skip 截取: 巧妙化解了 Redis GEO 原生不支持标准
offset分页的痛点。 ORDER BY FIELD: 继点赞排行榜之后,这是你第二次精准踩中并避开了 MySQLIN查询默认打乱顺序的隐患!
💡 面试模拟套路:
面试官:“你们的附近商铺是怎么做分页的?”
你: “由于 Redis 6.2 之前的 GEO 指令不支持 offset 偏移量,我们采用了逻辑分页。每次查询传入
limit = 当前页 * 页大小查出总范围,然后在 Java 内存中通过list.stream().skip(from)丢弃掉前面的多余数据。虽然会多查一些冗余数据,但考虑到 LBS 场景下用户很少会一直往下翻几十页,单次网络传输量极小,这种处理方式在性能和实现成本上是最优解。另外,在拿着这些 ID 去查 MySQL 时,我特意使用了ORDER BY FIELD函数,确保了商铺详情依然严格按照由近到远的顺序渲染呈现!”