【黑马点评日记】RedisGEO实战:黑马点评附近商铺功能
2026/5/8 4:43:06 网站建设 项目流程

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
❄️个人专栏:苍穹外卖日记,SSM框架深入,JavaWeb
命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

继续前面的学习,完成黑马点评项目的业务。

摘要:

本文介绍了基于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,需要手动升级:

xml <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 @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&current=1&x=116.397128&y=39.916527

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 @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。因此采用策略:

  1. 先查询limit = current * pageSize条数据

  2. 在应用层通过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)
排序保持MySQLORDER BY FIELD()
版本要求Redis 6.2+,spring-data-redis 2.6.2+
常见坑点版本兼容、前端分页触发、数组残留

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询