

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
继续前面的学习,完成黑马点评项目的业务。
摘要:
本文介绍了基于Redis GEO实现黑马点评项目附近商铺功能的技术方案。针对传统MySQL地理查询性能瓶颈,采用Redis GEO数据结构存储商铺ID和坐标,通过GeoHash算法实现高效距离计算与排序。详细解析了数据初始化、分页查询、距离计算等核心实现逻辑,并给出版本兼容、性能优化等解决方案。该方案显著提升了海量数据下的地理查询效率,为类似LBS应用提供了参考实现。
一、功能概述
在黑马点评项目中,附近商铺功能允许用户根据当前地理位置,查询指定类型商铺的距离和基本信息。类似于大众点评的"附近商家"推荐,用户可以按照距离排序,快速找到身边的美食、购物等场所。
核心需求:点击美食分类后,展示附近的美食店铺,按距离由近到远排序,支持分页加载。
技术选型 :由于需要高效的地理空间查询和排序,传统MySQL的
sqrt()或ST_Distance()函数在海量数据下性能堪忧。项目选用Redis的GEO(地理空间)数据结构来实现该功能。
二、核心思路与方案设计
2.1 为什么使用Redis GEO
MySQL实现距离查询需要计算全表每条记录的经纬度距离,无法使用索引,数据量大时会导致接口响应缓慢。Redis GEO基于有序集合(ZSet) 实现,内部使用GeoHash算法将二维经纬度转换为一维字符串作为score进行存储,查询效率为O(logN)。
2.2 数据存储策略
由于Redis是内存数据库,不能存储所有店铺字段。采取以下策略:
存储内容:仅存储店铺ID,内存占用小
分组策略 :按商铺类型(typeId)分组,
key = "shop:geo:" + typeId查询流程:先从Redis GEO中查出符合条件的店铺ID列表,再根据ID批量查询MySQL获取详细信息
三、环境准备:解决版本兼容问题
3.1 版本要求
Redis GEO的核心搜索命令从2.8版本开始支持,但推荐使用Redis 6.2+ ,因为6.2版本引入了更标准的
GEOSEARCH命令,废弃了旧的GEORADIUS。3.2 Spring Data Redis版本升级
Spring Boot默认的spring-data-redis版本(如2.3.x)不支持
GEOSEARCH,需要手动升级:
XMLxml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <artifactId>spring-data-redis</artifactId> <groupId>org.springframework.data</groupId> </exclusion> <exclusion> <artifactId>lettuce-core</artifactId> <groupId>io.lettuce</groupId> </exclusion> </exclusions> </dependency> <!-- 升级到支持GEOSEARCH的版本 --> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-redis</artifactId> <version>2.6.2</version> </dependency> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.1.6.RELEASE</version> </dependency>
四、数据初始化:将商铺数据导入Redis
项目启动后,需要将MySQL中的商铺数据按类型分组导入Redis。
4.1 导入代码实现
java
java
@Test
void loadShopData() {
// 1. 查询所有店铺信息
List<Shop> shopList = shopService.list();
// 2. 按typeId分组
Map<Long, List<Shop>> typeGroupMap = shopList.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
// 3. 逐组写入Redis GEO
for (Map.Entry<Long, List<Shop>> entry : typeGroupMap.entrySet()) {
Long typeId = entry.getKey();
String key = "shop:geo:" + typeId;
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for (Shop shop : entry.getValue()) {
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
// 批量写入,提升性能
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
4.2 关键点说明
-
GeoLocation的member:存储店铺ID(字符串形式),用于后续回查MySQL
-
Point:封装经度(x)和纬度(y)
-
groupingBy:Java 8 Stream API,按typeId自动分组
五、核心功能实现:附近商铺查询
5.1 Controller层
前端请求URL:/shop/of/type?typeId=1¤t=1&x=116.397128&y=39.916527
java
java
@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);
}
5.2 Service层完整逻辑
java
java
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1. 降级处理:如果前端没有传递经纬度(如PC端访问),回退到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());
}
// 2. 计算分页参数(Redis GEO需要手动分页)
int pageSize = SystemConstants.DEFAULT_PAGE_SIZE; // 默认5条
int from = (current - 1) * pageSize; // 起始索引
int end = current * pageSize; // 结束索引
// 3. 调用Redis GEO搜索
String key = "shop:geo:" + typeId;
// GEOSEARCH key FROMLONLAT x y BYRADIUS 5000 km WITHDISTANCE
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
stringRedisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y), // 参考点
new Distance(5000), // 搜索半径5km
RedisGeoCommands.GeoSearchCommandArgs
.newGeoSearchArgs()
.includeDistance() // 包含距离
.limit(end) // 限制返回数量
);
// 4. 解析结果
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = results.getContent();
if (content.size() <= from) {
return Result.ok(Collections.emptyList()); // 没有下一页
}
// 5. 提取店铺ID列表和距离Map,并进行内存分页
List<Long> shopIds = new ArrayList<>();
Map<String, Distance> distanceMap = new HashMap<>();
content.stream().skip(from).forEach(result -> {
String shopId = result.getContent().getName();
shopIds.add(Long.valueOf(shopId));
distanceMap.put(shopId, result.getDistance());
});
// 6. 批量查询店铺详情(保持与GEO返回的顺序一致)
String idStr = StrUtil.join(",", shopIds);
List<Shop> shops = query()
.in("id", shopIds)
.last("ORDER BY FIELD(id, " + idStr + ")")
.list();
// 7. 设置距离值
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}
六、代码深度解析
6.1 为什么需要手动处理分页
Redis GEO的
search方法只支持limit参数(限制总返回数量),不支持offset。因此采用策略:
先查询
limit = current * pageSize条数据在应用层通过
skip(from)跳过前面的数据这种方式的弊端:用户翻页越深,Redis返回的数据量越大。优化方案可使用
GEOSEARCHSTORE将结果存储后再分页,但复杂度较高。6.2 保持SQL查询结果的顺序
sql
ORDER BY FIELD(id, 1, 2, 3)GEO返回的店铺ID是按距离排序的,但MySQL的
IN查询默认按主键顺序返回。必须使用FIELD()函数强制按指定顺序排序,否则前端展示的距离和店铺名称会错位。6.3 距离单位
Distance(5000)默认单位为米。获取到的result.getDistance().getValue()返回的距离值单位为千米(km) ,直接赋予shop.setDistance()即可。
七、常见问题与解决方案
7.1 GEOSEARCH命令不可用
现象 :调用
search方法时抛出异常或查询不到数据原因:
Redis版本低于6.2(需要检查
redis-server --version)spring-data-redis版本过低
解决:
Windows用户下载带
-with-Service的Redis 6.2+安装包按第三节升级Maven依赖
7.2 前端滚动分页加载失败
现象:鼠标滚动到底部时不触发下一页请求
原因:前端无限滚动组件检测条件:列表底部距离屏幕底部 < 阈值。如果第一页数据量少(如2条),未占满屏幕,组件永远不会触发。
解决 :修改前端
shop-list.html,强制在第一页时也触发加载检测,或增加页面占位元素。7.3 切换分类后出现重复店铺
原因:切换商铺类型时,前端没有清空旧的店铺数组,导致新旧数据拼接。
前端修复代码:
javascript
sortAndQuery(sortBy) { this.params.sortBy = sortBy; this.params.current = 1; // 重置分页 this.shops = []; // 清空旧数据关键行! this.queryShops(); }
八、性能优化建议
8.1 批量查询优化
当前实现中对MySQL的查询是一次性完成的(使用
in+FIELD),不存在N+1问题,性能良好。8.2 GEO Key的设计
建议统一常量管理:
java
public class RedisConstants { public static final String SHOP_GEO_KEY = "shop:geo:"; }8.3 缓存预热
商铺数据相对稳定,可以在项目启动时自动执行数据导入:
java
@PostConstruct public void init() { loadShopData(); // 复用test中的导入逻辑 }
九、总结
| 环节 | 技术要点 |
|---|---|
| 数据存储 | Redis GEO,按typeId分组,value存店铺ID |
| 核心命令 | GEOSEARCH + BYRADIUS + WITHDISTANCE |
| 分页策略 | 应用层skip + limit(因GEO不支持offset) |
| 排序保持 | MySQL ORDER BY FIELD() |
| 版本要求 | Redis 6.2+,spring-data-redis 2.6.2+ |
| 常见坑点 | 版本兼容、前端分页触发、数组残留 |
结语:如果对你有帮助,请**点赞,关注,收藏,**你的支持就是我最大的鼓励!