|

Redis 实战篇 - 附近商铺模块学习笔记

📌 本模块基于 Redis GEO 数据结构实现”附近商铺搜索”功能,涵盖 GEO 基本命令、商铺数据导入、附近搜索与分页排序的完整实现。


一、GEO 数据结构基本用法(p088)

1.1 什么是 GEO?

GEO(Geolocation 的缩写)是 Redis 3.2 版本引入的地理空间数据类型,用于存储经纬度坐标,支持基于地理位置的检索。

底层实现: GEO 的底层是 Sorted Set(ZSet),经纬度坐标通过算法转换为一个数字作为 score,member 存储成员名称。

1.2 核心命令一览

命令 说明
GEOADD 向 Key 中添加地理空间点(经度、纬度、member)
GEODIST 计算两个 member 之间的距离
GEOHASH 返回 member 坐标对应的 GeoHash 字符串
GEOPOS 返回 member 的经纬度坐标
GEORADIUS 以指定圆心和半径画圆搜索(⚠️ Redis 6.2 后已废弃)
GEOSEARCH 搜索指定范围内的 member(支持圆形/矩形,推荐使用)
GEOSEARCHSTORE 搜索并将结果存储到新的 Sorted Set 中

1.3 命令详解

GEOADD — 添加地理坐标

# 语法:GEOADD key longitude latitude member [longitude latitude member ...]
GEOADD g1 116.37825 39.86468 "北京南" 116.42724 39.90378 "北京站" 116.32810 39.90056 "北京西"
# 返回值:成功添加的个数(如返回 3)

💡 要点: 一个 GEO 类型的 Key 可以存储多个点(point)的集合。member 可以是地名、数据库 ID 等任意字符串。

GEODIST — 计算两点距离

# 语法:GEODIST key member1 member2 [m|km|ft|mi]
GEODIST g1 "北京南" "北京西" km
# 返回:5.7299(千米)
GEODIST g1 "北京南" "北京西" m
# 返回:5729.9533(米,米是默认单位)

GEOPOS — 获取经纬度

GEOPOS g1 "北京站"
# 返回:116.42724 39.90378

GEOHASH — 获取 GeoHash 编码

GEOHASH g1 "北京站"
# 返回:wx4g05f66y0(相比经纬度,占用空间更小,可读性更好)

GeoHash 原理: 将经纬度转换为二进制数字,再通过特殊编码转换为 Base32 字符串,节省存储空间。

GEOSEARCH — 范围搜索(推荐)

# 语法:GEOSEARCH key FROMLONLAT longitude latitude BYRADIUS radius m|km [ASC|DESC] [WITHDIST] [COUNT count]
GEOSEARCH g1 FROMLONLAT 116.397428 39.90746 BYRADIUS 10 km ASC WITHDIST
# 搜索天安门周围 10 公里内的火车站,按升序排列并返回距离
# 结果示例:北京站 2.6km、北京南 5.14km、北京西 6.6km

GEOSEARCH 支持两种搜索方式: - BYRADIUS — 按圆形范围搜索(指定半径) - BYBOX — 按矩形范围搜索(指定长宽)

圆心指定方式: - FROMLONLAT longitude latitude — 直接指定经纬度 - FROMMEMBER member — 使用 Key 中已有 member 作为圆心

1.4 注意事项

  • GEORADIUS 在 Redis 6.2 后已废弃,统一使用 GEOSEARCH
  • GEOSEARCHSTOREGEOSEARCH 类似,但会将结果存入新的 Sorted Set,适合缓存搜索结果
  • GEO 底层为 Sorted Set,因此可以使用 ZRANGE 等 ZSet 命令查看

二、导入商铺数据到 GEO(p089)

2.1 需求分析

商铺数据存储在 MySQL 数据库中(tb_shop 表),包含经纬度字段 x(经度)和 y(纬度),以及 type_id(商铺类型)。

目标: 将商铺数据导入 Redis GEO,实现按距离排序的附近商铺搜索。

2.2 存储方案设计

❌ 错误方案:所有商铺存入一个 GEO

Key: shop:geo
Value: {shop1_id: (lng, lat), shop2_id: (lng, lat), ...}

问题: 无法根据商铺类型(type_id)进行过滤。

✅ 正确方案:按商铺类型分组存储

Key: shop:geo:1    → 美食类商铺(type_id=1)
Value: {shop_id1: (lng, lat), shop_id3: (lng, lat), ...}

Key: shop:geo:2    → KTV类商铺(type_id=2)  
Value: {shop_id2: (lng, lat), shop_id4: (lng, lat), ...}

💡 核心思路:type_id 作为 Key 的一部分,天然将不同类型的商铺分组,搜索时直接按类型取对应的 Key,无需额外过滤。

Member 存什么?

  • 不要存整条商铺信息(Redis 是内存存储,浪费空间)
  • 只存商铺 ID(member = shop_id)
  • 搜索得到 ID 后,再根据 ID 去数据库查询完整信息

2.3 代码实现(单元测试)

@SpringBootTest
class ShopGeoTest {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Autowired
    private IShopService shopService;

    @Test
    void loadShopData() {
        // 1. 查询所有商铺
        List<Shop> list = shopService.list();
        
        // 2. 按 type_id 分组(Stream API)
        Map<Long, List<Shop>> map = list.stream()
                .collect(Collectors.groupingBy(Shop::getTypeId));
        
        // 3. 分批写入 Redis
        for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
            Long typeId = entry.getKey();
            List<Shop> shops = entry.getValue();
            
            // 构造 Key:shop:geo:typeId
            String key = RedisConstants.SHOP_GEO_KEY + typeId;
            
            // 将 Shop 转换为 GeoLocation 集合
            List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shops.size());
            for (Shop shop : shops) {
                locations.add(new RedisGeoCommands.GeoLocation<>(
                        shop.getId().toString(),
                        new Point(shop.getX(), shop.getY())
                ));
            }
            
            // 批量写入(一次性请求,效率更高)
            stringRedisTemplate.opsForGeo().add(key, locations);
        }
    }
}

2.4 批量写入 vs 逐条写入

方式 代码 效率
逐条写入 template.opsForGeo().add(key, new Point(x,y), id) 在循环中 1000 条 = 1000 次请求 ❌
批量写入 template.opsForGeo().add(key, locations) 一次性提交 1000 条 = 1 次请求 ✅

2.5 数据验证

导入后在 Redis 中查看:

Key: shop:geo:1(美食)
Value: ZSet 类型
  - member: shop_id
  - score: 经纬度转换后的数字

三、实现附近商铺搜索功能(p090)

3.1 接口分析

请求: GET /shop/of/type?typeId=1&current=1&x=116.397&y=39.907

参数 说明 必填
typeId 商铺类型 ID
current 当前页码
x 用户经度(前端从手机定位获取) ❌ 可选
y 用户纬度 ❌ 可选

💡 x/y 可能为空: 用户不传坐标时,按数据库原方式查询;传了坐标则按 GEO 距离排序。

3.2 Spring Data Redis 版本兼容问题

问题: 项目使用的 Spring Boot 版本对应 Spring Data Redis 2.3.9(Lettuce 5.3.7),不支持 Redis 6.2 的 GEOSEARCH 命令。

解决方案:pom.xml 中排除旧版本依赖,手动引入新版本。

<!-- 排除 Spring Boot 自带的旧版本 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
        </exclusion>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- 引入新版 -->
<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</version>
</dependency>

3.3 Controller 层改造

@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);
}

3.4 Service 层核心实现

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // 1. 判断是否需要根据坐标查询
    if (x == null || x == 0 || y == null || y == 0) {
        // 不需要:按数据库查询(原有逻辑)
        Page<Shop> page = query()
                .eq("type_id", typeId)
                .page(new Page<>(current, DEFAULT_PAGE_SIZE));
        return Result.ok(page.getRecords());
    }

    // 2. 计算分页参数
    int from = (current - 1) * DEFAULT_PAGE_SIZE;
    int end = current * DEFAULT_PAGE_SIZE;

    // 3. Redis GEO 搜索
    String key = SHOP_GEO_KEY + typeId;
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate
            .opsForGeo()
            .search(
                    key,
                    GeoReference.fromCoordinate(x, y),     // 圆心:用户坐标
                    new Distance(5000),                      // 半径:5 公里
                    RedisGeoCommands.GeoSearchCommandArgs.newArgs()
                            .includeDistance()               // 带上距离
                            .sortAscending()                 // 升序(近的排前面)
                            .limit(end)                      // 查 0~end 条
            );

    // 4. 解析结果(逻辑分页)
    if (results == null) {
        return Result.ok(Collections.emptyList());
    }

    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = results.getContent();
    
    // ⚠️ 关键判断:skip 后可能为空
    if (content.size() <= from) {
        return Result.ok(Collections.emptyList());
    }

    // 5. 手动截取 from~end 部分(Stream skip 实现逻辑分页)
    List<Long> ids = new ArrayList<>(content.size());
    Map<Long, Distance> distanceMap = new HashMap<>(content.size());

    content.stream()
            .skip(from)
            .forEach(result -> {
                String shopId = result.getContent().getName();
                ids.add(Long.valueOf(shopId));
                distanceMap.put(Long.valueOf(shopId), result.getDistance());
            });

    // 6. 根据 ID 批量查询商铺(保证有序)
    String idStr = StrUtil.join(",", ids);
    List<Shop> shops = query()
            .in("id", ids)
            .last("ORDER BY FIELD(id," + idStr + ")")
            .list();

    // 7. 将距离信息填充到商铺对象中
    for (Shop shop : shops) {
        shop.setDistance(distanceMap.get(shop.getId()).getValue());
    }

    return Result.ok(shops);
}

3.5 关键技术点

🔑 逻辑分页

Redis GEOSEARCHLIMIT 只支持 COUNT(从第 0 条到第 N 条),不支持指定起始位置。因此采用逻辑分页

第 1 页:查询 0~5 条 → 返回 0~5
第 2 页:查询 0~10 条 → skip(5) → 返回 5~10
第 3 页:查询 0~15 条 → skip(10) → 返回 10~15

使用 Stream.skip(from) 跳过前 N 条,比 List.subList() 更省内存(不需要拷贝集合)。

🔑 保证查询结果有序

使用 MySQL 的 FIELD() 函数按 ID 列表顺序排序:

SELECT * FROM tb_shop WHERE id IN (3,1,5) ORDER BY FIELD(id, 3,1,5)

🔑 距离信息存储

Shop 实体类中添加 distance 字段:

@TableField(exist = false)  // 非数据库字段
private Double distance;

3.6 ⚠️ Bug 修复:分页越界异常

现象: 翻到最后一页时,SQL 报错 WHERE id IN () 为空。

原因: skip(from) 跳过所有元素后,ids 为空集合,导致 SQL 语法错误。

// 错误:只判断了 results == null,没判断 skip 后是否为空
if (results == null) { return Result.ok(Collections.emptyList()); }

// 正确:补充 skip 后的空集合判断
if (content.size() <= from) {
    return Result.ok(Collections.emptyList());
}

💡 教训: 逻辑分页时,skip 操作可能导致结果集为空,必须在 skip 前做边界检查。

3.7 最终效果

第 1 页请求:GET /shop/of/type?typeId=1&current=1&x=116.397&y=39.907
响应:
  - 菜马红桃烤肉(距离 170.4m)
  - 杨老三羊蝎子(距离 1km)
  - 钱曹乌(距离 1km)
  - ...

第 2 页请求:GET /shop/of/type?typeId=1&current=2&x=116.397&y=39.907
响应:下一页商铺(无重复)

最后一页:返回空数组(不再报错)

四、整体架构总结

┌─────────────┐     ┌─────────────────────────────────────┐
│   前端请求    │     │            后端处理流程               │
│             │     │                                     │
│ typeId ──────────→ │ 1. 判断 x/y 是否有值                  │
│ current ─────────→ │                                     │
│ x (可选) ────────→ │  ┌─ 有坐标 → Redis GEOSEARCH         │
│ y (可选) ────────→ │  │   ├─ 按类型取 Key (shop:geo:typeId) │
│             │     │  │   ├─ 以用户坐标为圆心,5km 半径搜索   │
│             │     │  │   ├─ 按距离升序 + 逻辑分页            │
│             │     │  │   ├─ 解析出 shopId + distance        │
│             │     │  │   ├─ 根据 ID 批量查数据库             │
│             │     │  │   └─ 填充距离,返回结果               │
│             │     │  │                                     │
│             │     │  └─ 无坐标 → 直接查数据库(原有逻辑)    │
└─────────────┘     └─────────────────────────────────────┘

Redis 存储结构:
  shop:geo:1  →  SortedSet {shop_id: score, ...}  (美食类)
  shop:geo:2  →  SortedSet {shop_id: score, ...}  (KTV类)
  ...

五、常见问题与注意事项

Q1: 为什么不用 GEORADIUS?

GEORADIUS 在 Redis 6.2 后已废弃,GEOSEARCH 功能更强大(支持圆形 + 矩形搜索),统一使用新命令。

Q2: 为什么要按 type_id 分组存储?

如果所有商铺存入一个 GEO,搜索时无法按类型过滤。按类型分组存储后,根据 type_id 直接取对应的 Key,天然完成过滤。

Q3: 为什么 member 只存 ID 不存完整信息?

Redis 是内存数据库,存储整条商铺信息太浪费。只存 ID,搜索后根据 ID 去数据库查,是性能与空间的平衡方案。

Q4: 逻辑分页有什么缺点?

  • 随着页码增大,需要查的数据越来越多(第 10 页要查 0~50 条再 skip 45 条)
  • 不适合深度分页场景
  • 适合”附近商铺”这类浅分页场景(用户一般不会翻太深)

Q5: Spring Data Redis 版本冲突怎么处理?

排除 Spring Boot 自带的旧版本依赖,手动引入新版 spring-data-redis 和 lettuce-core。


六、相关命令速查表

# 添加坐标
GEOADD shop:geo:1 116.397 39.907 "1001"

# 搜索附近(5km 内,升序,带距离,限 10 条)
GEOSEARCH shop:geo:1 FROMLONLAT 116.397 39.907 BYRADIUS 5 km ASC WITHDIST COUNT 10

# 计算距离
GEODIST shop:geo:1 "1001" "1002" km

# 获取坐标
GEOPOS shop:geo:1 "1001"

# 获取 GeoHash
GEOHASH shop:geo:1 "1001"
评论交流

文章目录