【黑马点评日记】RedisGEO实战:黑马点评附近商铺功能

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介: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 复制代码
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 复制代码
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 复制代码
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。因此采用策略:

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

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

相关推荐
LuDvei2 小时前
ubuntu环境下qt打包
linux·数据库·qt·ubuntu
逸Y 仙X2 小时前
文章二十六:ElasticSearch 异步查询执行重度任务
java·大数据·linux·运维·elasticsearch·搜索引擎·全文检索
洛阳泰山2 小时前
Maxkb4j集成sqlbot MCP实现企业智能问数智能体
java·ai·springboot·agent·智能问数
iuvtsrt2 小时前
C#怎么获取当前所在的函数名_C#如何使用MethodBase读取【代码】
jvm·数据库·python
阿Y加油吧3 小时前
RAG 必学:ANN 检索、HNSW 算法与 Milvus 核心概念详解
数据库·mysql·json
SamDeepThinking3 小时前
RocketMQ消息可靠性的三道关卡
java·后端·程序员
Hesionberger3 小时前
LeetCode79:单词搜索DFS回溯详解
java·开发语言·c++·python·算法·leetcode·c#
skywalk81633 小时前
下载安装 Temurin® JDK JDK 21 - LTS 速度很慢,有办法加速吗?
java·开发语言
Mr数据杨3 小时前
【Codex】用PPT文案额外描述优化课件生成细节
java·javascript·django·powerpoint·codex·项目开发