【Redis | 黑马点评】附近商铺功能源码解读

目录

  • [0. 业务背景:附近商户要解决什么](#0. 业务背景:附近商户要解决什么)
  • [1. GEO 数据结构的基本用法](#1. GEO 数据结构的基本用法)
  • [2. 导入店铺数据到 GEO](#2. 导入店铺数据到 GEO)
  • [3. 实现附近商户功能](#3. 实现附近商户功能)
  • [4. 关键技术原理总结](#4. 关键技术原理总结)
  • [5. 练习题](#5. 练习题)
  • [6. 练习题参考答案与逐题反馈](#6. 练习题参考答案与逐题反馈)
  • 附录:源码索引

0. 业务背景:附近商户要解决什么

0.1 用户场景

用户打开"附近商户"页面时,页面需要:

  1. 按商户类型(美食、KTV、美发......)筛选;
  2. 以用户当前位置为中心,找出一定范围内(本项目 5km)的商户;
  3. 按距离从近到远排序;
  4. 显示每家店距离用户多少米/公里;
  5. 滚动到底部时继续加载下一页。

0.2 前端已经约定好的接口

shop-list.html:90-96:

js 复制代码
params: {
  typeId: 0,
  current: 1,
  sortBy: "",
  x: 120.149993, // 经度
  y: 30.334229   // 纬度
}

shop-list.html:118-133:

js 复制代码
queryShops() {
  axios.get("/shop/of/type", { params: this.params })
    .then(({data}) => {
      if (!data) return;
      data.forEach(s => s.images = s.images.split(',')[0]);
      this.shops = this.shops.concat(data);
    })
}

距离展示(shop-list.html:64):

html 复制代码
<span v-if="s.distance">
  {{s.distance < 1000 ? s.distance.toFixed(1) + 'm' : (s.distance/1000).toFixed(1) + 'km'}}
</span>

由此可以确认后端的契约:

  • 请求参数:typeId、current、x、y(sortBy 目前未使用);
  • 返回:商铺列表,每个商铺可以带 distance,单位必须是米;
  • 分页:每页 5 条(SystemConstants.DEFAULT_PAGE_SIZE)。

0.3 为什么不能用普通 SQL 做"附近"

假设店铺表有 x(经度)、y(纬度)两列。最直觉的 SQL 是:

sql 复制代码
SELECT * FROM tb_shop
WHERE type_id = ?
  AND x BETWEEN ? AND ?
  AND y BETWEEN ? AND ?

问题:

  1. 经纬度是两个独立维度,B+ 树索引只能高效处理一维范围;二维范围查询会退化成大量扫描;
  2. 即便筛出矩形区域,还要计算每条记录到用户的球面距离并排序,数据库压力大;
  3. 高并发下,附近查询会变成热点。

Redis GEO 把二维坐标编码成一维的 geohash 分数,利用 ZSet 的有序性做范围检索,正好适合这种场景。

0.4 本项目的整体流程

ShopServiceImpl ShopController shop-list.html Redis GEO 导入测试 MySQL tb_shop ShopServiceImpl ShopController shop-list.html Redis GEO 导入测试 MySQL tb_shop #mermaid-svg-3Yb5KCeqJIeWNaCX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Yb5KCeqJIeWNaCX .error-icon{fill:#552222;}#mermaid-svg-3Yb5KCeqJIeWNaCX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Yb5KCeqJIeWNaCX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Yb5KCeqJIeWNaCX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Yb5KCeqJIeWNaCX .marker.cross{stroke:#333333;}#mermaid-svg-3Yb5KCeqJIeWNaCX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Yb5KCeqJIeWNaCX p{margin:0;}#mermaid-svg-3Yb5KCeqJIeWNaCX .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Yb5KCeqJIeWNaCX text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Yb5KCeqJIeWNaCX .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3Yb5KCeqJIeWNaCX .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3Yb5KCeqJIeWNaCX #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3Yb5KCeqJIeWNaCX .sequenceNumber{fill:white;}#mermaid-svg-3Yb5KCeqJIeWNaCX #sequencenumber{fill:#333;}#mermaid-svg-3Yb5KCeqJIeWNaCX #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3Yb5KCeqJIeWNaCX .messageText{fill:#333;stroke:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Yb5KCeqJIeWNaCX .labelText,#mermaid-svg-3Yb5KCeqJIeWNaCX .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .loopText,#mermaid-svg-3Yb5KCeqJIeWNaCX .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Yb5KCeqJIeWNaCX .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3Yb5KCeqJIeWNaCX .noteText,#mermaid-svg-3Yb5KCeqJIeWNaCX .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3Yb5KCeqJIeWNaCX .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Yb5KCeqJIeWNaCX .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Yb5KCeqJIeWNaCX .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Yb5KCeqJIeWNaCX .actorPopupMenu{position:absolute;}#mermaid-svg-3Yb5KCeqJIeWNaCX .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3Yb5KCeqJIeWNaCX .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Yb5KCeqJIeWNaCX .actor-man circle,#mermaid-svg-3Yb5KCeqJIeWNaCX line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3Yb5KCeqJIeWNaCX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} list() 查询全部店铺 loadShopGeoData() GEOADD shop:geo:{typeId} x y shopId GET /shop/of/type?typeId=&current=&x=&y= queryShopByType(typeId, current, x, y) GEORADIUS shop:geo:{typeId} x y 5000 m ASC WITHDIST shopId + 距离(按距离升序) listByIds(ids) 查店铺详情 List<Shop>,填充 distance Result.ok(list)


1. GEO 数据结构的基本用法

1.1 Redis GEO 是什么

Redis 从 3.2 开始提供 GEO(地理位置)相关命令。它并不是一个全新的数据类型,而是基于 ZSet 实现的:

  • 每个地理位置是一个 ZSet 的 member;
  • 经纬度经过 geohash 编码后,变成一个 52 位的整数,作为 ZSet 的 score;
  • 因此 GEO 天然继承了 ZSet 的有序性、去重和范围查询能力。

这也解释了一个常见现象:对 GEO key 执行 TYPE 会得到 zset。

1.2 坐标顺序:经度在前,纬度在后

Redis GEO 的坐标顺序固定为:

复制代码
GEOADD key 经度(longitude) 纬度(latitude) member
  • 经度范围:-180 ~ 180;
  • 纬度范围:-85.05112878 ~ 85.05112878(Web Mercator 投影的有效范围);
  • 顺序写反会得到错误的位置,甚至直接报坐标非法。

项目里 Shop.x 是经度、Shop.y 是纬度(Shop.java:64,69),所以构造坐标时必须:

java 复制代码
new Point(shop.getX(), shop.getY())

1.3 常用命令

命令 作用 示例
GEOADD key lon lat member 添加/更新地理位置 GEOADD shop:geo:1 120.149192 30.316078 1
GEOPOS key member 查看某个 member 的经纬度 GEOPOS shop:geo:1 1
GEODIST key m1 m2 [unit] 计算两个 member 的距离 GEODIST shop:geo:1 1 2 km
GEOSEARCH key FROMMEMBER m BYRADIUS 5 km ASC WITHDIST 以某个 member 为中心搜索(Redis 6.2+) ---
GEOSEARCH key FROMLONLAT lon lat BYRADIUS 5 km ASC WITHDIST 以坐标为中心搜索(Redis 6.2+) ---
GEORADIUS key lon lat 5 km WITHDIST ASC COUNT 10 以坐标为中心半径查询(Redis 6.2 起标记废弃,但可用) 本项目 Spring Data Redis 2.3.9 使用这个
ZREM key member 删除地理位置(GEO 本质是 ZSet) ZREM shop:geo:1 1
ZCARD key 地理位置数量 ZCARD shop:geo:1

实际项目数据示例:

bash 复制代码
# 店铺 1 位于 (120.149192, 30.316078)
GEOPOS shop:geo:1 1
# 120.14919072389602661
# 30.31607744382542791

# 以用户位置为中心,查 5km 内最近的 10 家店
GEORADIUS shop:geo:1 120.149993 30.334229 5000 m WITHDIST ASC COUNT 10
# 2  170.4498
# 9  1000.3119
# 8  1002.1991
# ...

1.4 geohash 与精度

geohash 把地球表面划分成网格,用一串二进制位表示一个格子:

  • 位数越多,格子越小,精度越高;
  • Redis 使用 52 位 geohash,误差大约在 0.6 米量级;
  • 同一个格子里的点,score 相同,因此 GEO 的"精确距离"仍然要在查询时计算。

对附近商户场景来说,这个精度远远够用(业务只关心几十米到几公里的差异)。

1.5 距离单位

Redis 支持的距离单位:

单位 含义
m 米
km 千米
mi 英里
ft 英尺

本项目前端要显示米/公里,所以查询时用米:

java 复制代码
new Distance(5000, RedisGeoCommands.DistanceUnit.METERS)

返回的 distance.getValue() 也就是米。

1.6 Spring Data Redis 2.3.9 的对应 API

Redis 命令 Spring API
GEOADD opsForGeo().geoAdd(key, Iterable<GeoLocation<M>>)
GEORADIUS opsForGeo().geoRadius(key, Circle, GeoRadiusCommandArgs)
距离单位 RedisGeoCommands.DistanceUnit.METERS
查询结果 GeoResults<GeoLocation<String>> → getContent()
单条结果 GeoResult.getContent().getName()、getDistance().getValue()

注意两个 2.3.9 的细节:

  1. Metrics 枚举只有 KILOMETERS、MILES、NEUTRAL,没有 METERS ,要用 RedisGeoCommands.DistanceUnit.METERS;
  2. GeoRadiusCommandArgs.limit(long) 只有 COUNT,没有 offset,这一点会影响分页实现(见第 3 节)。

2. 导入店铺数据到 GEO

2.1 为什么要"导入"

MySQL 是店铺数据的权威来源,但 MySQL 不擅长"按距离筛选和排序"。所以采用"双存储":

  • MySQL:保存完整店铺信息,负责详情、增删改;
  • Redis GEO:只保存"店铺 id + 坐标",负责附近检索和距离排序。

导入就是一次性把 MySQL 里的店铺坐标同步到 Redis。

2.2 key 的设计

java 复制代码
// RedisConstants.java:24
public static final String SHOP_GEO_KEY = "shop:geo:";

实际 key 为 shop:geo:{typeId},例如:

复制代码
shop:geo:1  -> 美食类的所有店铺
shop:geo:2  -> KTV 类的所有店铺

为什么按类型分 key:

  • 前端一次只查一个类型;
  • 查询时不需要在结果里再按 type 过滤;
  • 每个 ZSet 更小,范围查询更快。

2.3 导入代码

ShopServiceImpl.java:253-278:

java 复制代码
@Override
public void loadShopGeoData() {
    // 1.按 typeId 分组,构造 GeoLocation(member=shopId,坐标=经度、纬度)
    Map<Long, List<RedisGeoCommands.GeoLocation<String>>> typeMap = new HashMap<>();
    for (Shop shop : list()) {
        if (shop.getTypeId() == null || shop.getX() == null || shop.getY() == null) {
            continue;
        }
        typeMap.computeIfAbsent(shop.getTypeId(), k -> new ArrayList<>())
                .add(new RedisGeoCommands.GeoLocation<>(
                        shop.getId().toString(),
                        new Point(shop.getX(), shop.getY())));
    }

    // 2.按类型写入 Redis GEO,key = shop:geo:{typeId}
    GeoOperations<String, String> geoOps = stringRedisTemplate.opsForGeo();
    typeMap.forEach((typeId, locations) -> {
        String key = SHOP_GEO_KEY + typeId;
        // 全量重建:先删旧 key,避免已删除/改类型的店铺残留
        stringRedisTemplate.delete(key);
        for (int i = 0; i < locations.size(); i += 500) {
            int end = Math.min(i + 500, locations.size());
            geoOps.geoAdd(key, locations.subList(i, end));
        }
    });
}

逐段解释:

(1)查询全部店铺

list() 是 MyBatis-Plus IService 的方法,等价于 SELECT * FROM tb_shop。

(2)按 typeId 分组

java 复制代码
typeMap.computeIfAbsent(shop.getTypeId(), k -> new ArrayList<>())
        .add(new RedisGeoCommands.GeoLocation<>(
                shop.getId().toString(),
                new Point(shop.getX(), shop.getY())));
  • GeoLocation<String> 的 name 是 member,这里用 shopId 字符串;
  • Point(x, y) 的顺序是经度、纬度;
  • computeIfAbsent 避免手动判空,代码更简洁。

(3)跳过无坐标的店铺

java 复制代码
if (shop.getTypeId() == null || shop.getX() == null || shop.getY() == null) {
    continue;
}

数据库里 x、y 是 NOT NULL,但 Java 对象仍可能因为数据异常或手动构造而为空;跳过可以避免 GEOADD 报错。

(4)先删旧 key,再批量写入

java 复制代码
stringRedisTemplate.delete(key);
for (int i = 0; i < locations.size(); i += 500) {
    geoOps.geoAdd(key, locations.subList(i, end));
}
  • 只做 GEOADD 是 upsert,能更新已有 member,但不会删除已经从数据库删掉或改了类型的店铺;
  • 先 DEL 再全量写入,保证 Redis 与 MySQL 一致;
  • 每批 500 条,减少单次命令过大带来的网络/内存压力,当前 14 条数据其实一批就够。

2.4 如何触发导入

HmDianPingApplicationTests.java:31-34:

java 复制代码
@Test
void testLoadShopGeoData(){
    shopService.loadShopGeoData();
}

执行:

bash 复制代码
mvn -Dtest=HmDianPingApplicationTests#testLoadShopGeoData test

为什么不在应用启动时自动导入:

  • 启动阶段做全量 SELECT * + Redis 写入会拖慢启动;
  • 店铺数据不是每次启动都会变化;
  • 课程/开发阶段用测试方法手动触发更可控。生产环境可以用定时任务或数据变更事件同步。

2.5 导入结果验证

本机实际执行后:

bash 复制代码
redis-cli -a 123456 ZCARD shop:geo:1
# 9

redis-cli -a 123456 ZCARD shop:geo:2
# 5

redis-cli -a 123456 GEOPOS shop:geo:1 1
# 120.14919072389602661
# 30.31607744382542791

tb_shop 共 14 条:type_id=1 有 9 家,type_id=2 有 5 家,与 ZCARD 一致。

2.6 导入模块的边界与风险

问题 触发条件 影响 当前处理
店铺坐标为空 数据异常 GEOADD 报错 跳过
店铺被删除 导入后 DB 删除 Redis 残留,查询时被跳过 重新导入即可清理
店铺改类型 type_id 变化 旧 key 残留新 key 缺失 重新导入
某个类型被清空 该 type 已无店铺 旧 key 不会被清理 未处理;可额外扫描 shop:geo:* 清理
导入中途失败 Redis/网络异常 部分 key 已删/部分写入 未处理;可加重试或先写临时 key 再 rename
数据量大 店铺很多 单次 GEOADD 过大 已按 500 条分批
重复执行 多次运行测试 DEL + 全量写入,幂等 已处理

3. 实现附近商户功能

3.1 Controller:接收坐标

ShopController.java:70-78:

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);
}
  • x、y 不是必填:老客户端或没有定位时,可以回退到数据库分页;
  • sortBy 前端会传,但后端没有声明,Spring MVC 会忽略未知参数;
  • /shop/** 在 MvcConfig 的排除列表里,所以这个接口不需要登录。

3.2 Service:有坐标时走 GEO

ShopServiceImpl.java:280-336:

java 复制代码
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    int pageSize = SystemConstants.DEFAULT_PAGE_SIZE;
    int page = (current == null || current < 1) ? 1 : current;

    // 没有坐标:保留原来的数据库分页
    if (x == null || y == null) {
        Page<Shop> dbPage = query()
                .eq("type_id", typeId)
                .page(new Page<>(page, pageSize));
        return Result.ok(dbPage.getRecords());
    }

    long offset = (long) (page - 1) * pageSize;
    // GeoRadiusCommandArgs.limit 只有 COUNT,没有 offset,先多取再截取
    long count = offset + pageSize;

    GeoResults<RedisGeoCommands.GeoLocation<String>> results =
            stringRedisTemplate.opsForGeo().geoRadius(
                    SHOP_GEO_KEY + typeId,
                    new Circle(new Point(x, y),
                            new Distance(5000, RedisGeoCommands.DistanceUnit.METERS)),
                    RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                            .includeDistance()
                            .sortAscending()
                            .limit(count));

    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content =
            results == null ? Collections.emptyList() : results.getContent();
    if (content.size() <= offset) {
        return Result.ok(Collections.emptyList());
    }

    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> pageContent =
            content.subList((int) offset, (int) Math.min(content.size(), count));

    List<Long> ids = new ArrayList<>(pageContent.size());
    for (GeoResult<RedisGeoCommands.GeoLocation<String>> item : pageContent) {
        ids.add(Long.valueOf(item.getContent().getName()));
    }

    // listByIds 不保证顺序,按 GEO 的距离顺序重建
    Map<Long, Shop> shopMap = new HashMap<>();
    for (Shop shop : listByIds(ids)) {
        shopMap.put(shop.getId(), shop);
    }

    List<Shop> shops = new ArrayList<>(pageContent.size());
    for (GeoResult<RedisGeoCommands.GeoLocation<String>> item : pageContent) {
        Shop shop = shopMap.get(Long.valueOf(item.getContent().getName()));
        if (shop != null) {
            shop.setDistance(item.getDistance().getValue()); // 单位:米
            shops.add(shop);
        }
    }
    return Result.ok(shops);
}

3.3 逐段解释

(1)参数兜底

java 复制代码
int page = (current == null || current < 1) ? 1 : current;

防止 current=0 或负数导致 offset 为负。

(2)无坐标回退

java 复制代码
if (x == null || y == null) {
    ... 数据库分页 ...
}

保持接口向后兼容:没有定位信息时,仍然可以按类型分页浏览,只是没有距离和距离排序。

(3)构造 GEO 查询

java 复制代码
new Circle(new Point(x, y),
        new Distance(5000, RedisGeoCommands.DistanceUnit.METERS))
  • 圆心是用户坐标 (x, y);
  • 半径 5000 米(5km);
  • 单位用 DistanceUnit.METERS,返回的距离就是米。

参数:

java 复制代码
GeoRadiusCommandArgs.newGeoRadiusArgs()
        .includeDistance()
        .sortAscending()
        .limit(count)
  • includeDistance():结果里带距离,否则拿不到 getDistance();
  • sortAscending():由近到远;
  • limit(count):只取前 count 条。

(4)分页的坑:limit 没有 offset

Redis 的 GEORADIUS ... COUNT n 只有"取多少条",没有"跳过多少条"。Spring Data Redis 2.3.9 的 GeoRadiusCommandArgs.limit(long) 也只映射到 COUNT。

所以当前实现是:

java 复制代码
long offset = (long) (page - 1) * pageSize;
long count = offset + pageSize;
  • 第 1 页:取前 5 条,截取 [0,5);
  • 第 2 页:取前 10 条,截取 [5,10);
  • 第 3 页:取前 15 条,截取 [10,15);
  • 以此类推。

在 Java 里:

java 复制代码
content.subList((int) offset, (int) Math.min(content.size(), count));

这种方式的代价是每次多取前面已经看过的数据,数据量大时效率会下降;课程规模完全够用。生产环境可考虑:

  • 用最后一条的距离作为游标(类似滚动分页);
  • 或者限制最大页数/半径。

(5)从 GEO 结果到店铺详情

GEO 里只有 shopId 和距离,店铺名称、图片、评分等仍在 MySQL:

java 复制代码
listByIds(ids)

一条 WHERE id IN (...) 查回详情,避免 N+1。

listByIds 不保证返回顺序,所以:

java 复制代码
Map<Long, Shop> shopMap = ...;
for (GeoResult<...> item : pageContent) { ... }

按 GEO 的距离顺序重新组装,保证前端看到的顺序是"由近到远"。

(6)填充距离

java 复制代码
shop.setDistance(item.getDistance().getValue());

Shop.distance 是 @TableField(exist = false)(Shop.java:108),不属于数据库列,只在返回前端时使用。单位是米,前端据此显示 m/km。

3.4 实际查询结果

以用户坐标 (120.149993, 30.334229) 查询美食类(typeId=1):

页码 返回数量 距离(米)
第 1 页 5 170.4 / 1000.3 / 1002.2 / 1047.0 / 1741.6
第 2 页 4 2020.4 / 2411.3 / 2445.9 / 2728.9
第 3 页 0 ---

总共 9 家,全部在 5km 内;第 3 页为空,前端滚动自然停止。

KTV(typeId=2)第 1 页返回 5 家,距离 1067m ~ 2817m。

不传 x/y 时返回数据库分页的 5 条,且 JSON 中没有 distance 字段。

3.5 边界与风险

问题 说明 当前处理
坐标顺序写反 Point(y, x) 会定位到错误位置或报错 代码明确 Point(x, y)
空结果/越界页 半径内没有店,或页码超过总数 返回 []
店铺已删除 GEO 有、MySQL 没有 跳过,列表可能少一条
GEO key 不存在 没有执行导入 geoRadius 返回空,建议先导入
距离单位 前端按米判断 m/km 统一用 DistanceUnit.METERS
sortBy 前端有人气/评分按钮 当前忽略,始终按距离排序
半径固定 5km 用户想更远/更近 可把半径做成参数
分页效率 每页多取前面所有数据 课程规模可接受;大数据考虑游标
Redis 与 DB 不一致 导入后店铺变化 重新执行导入
GEO 内存 每家店一个 member 14 条数据很小;大规模需评估

4. 关键技术原理总结

4.1 GEO = ZSet + geohash

  • GEOADD 把经纬度编码成 52 位 geohash 整数,作为 ZSet 的 score;
  • member 是业务 id(本项目是 shopId);
  • 因此 TYPE shop:geo:1 是 zset,ZREM、ZCARD 也能用;
  • 附近查询本质是"按 score 范围 + 距离过滤 + 排序"。

4.2 坐标顺序与单位

  • Point(经度, 纬度) / GEOADD key 经度 纬度 member;
  • 纬度有效范围约 ±85.05;
  • 距离单位用 m/km/mi/ft,本项目统一用米。

4.3 双存储

存储 内容 职责
MySQL tb_shop 完整店铺信息 权威数据、详情、增删改
Redis shop:geo:{typeId} shopId + 坐标 附近筛选、距离排序

4.4 分页

  • Redis GEO 的 COUNT 只限制数量,不支持 offset;
  • 本项目用"多取 + Java 截取"实现分页;
  • 数据量大时可改为距离游标或限制查询范围。

4.5 Spring Data Redis 版本差异

  • opsForGeo().add/radius 在 2.3.9 已标记废弃,但可用;新版本推荐 geoAdd/geoRadius;
  • Metrics 没有 METERS,要用 RedisGeoCommands.DistanceUnit.METERS;
  • GeoRadiusCommandArgs.limit(long) 只有 count,没有 offset。

5. 练习题

  1. GEO 与坐标 :Redis GEO 底层用的是什么数据结构?GEOADD 的坐标顺序是什么?如果把经度和纬度写反,会发生什么?

  2. 导入设计 :为什么按 typeId 分成多个 key,而不是所有店铺共用一个 shop:geo?导入前为什么要先 delete(key)?如果某家店铺的 x 或 y 为空,当前代码会怎么处理?

  3. 附近查询 :Circle、Distance(5000, DistanceUnit.METERS)、includeDistance()、sortAscending() 分别起什么作用?为什么返回的 distance 是米?

  4. 分页实现 :Redis GEORADIUS 的 COUNT 和 SQL 的 LIMIT offset, count 有什么区别?本项目是如何实现第 2 页的?这种方式有什么性能问题?

  5. 一致性与边界 :导入 GEO 后,如果数据库里新增/删除/修改了一家店铺,Redis 会怎样?查询时如果 GEO 里有 shopId 但 MySQL 查不到,代码会怎么处理?前端传了 sortBy=score 会生效吗?


6. 练习题参考答案与逐题反馈

第 1 题:GEO 与坐标

标准答案

底层数据结构: Redis GEO 基于 ZSet 实现。GEOADD 会把经纬度编码成 52 位 geohash 整数,作为 ZSet 的 score,member 是业务 id。对 GEO key 执行 TYPE 会返回 zset。

坐标顺序: GEOADD key 经度 纬度 member,即 longitude 在前,latitude 在后 。Java 里是 new Point(x, y),其中 x 是经度、y 是纬度。

写反的后果: Redis 会按"纬度=120、经度=30"去定位,纬度超出 ±85.05 的有效范围会直接报坐标非法;如果数值恰好都在范围内,会定位到地球另一处,距离计算结果完全错误,而且不会报错,属于隐蔽 bug。

常见误区
  • ❌ "GEO 是 Redis 的第五种基本数据类型。"------它是基于 ZSet 的封装。
  • ❌ "坐标顺序先纬度后经度。"------Redis 是经度在前。
  • ❌ "写反了 Redis 一定会报错。"------只有超出范围才报错,范围内会静默算错。

第 2 题:导入设计

标准答案

按 typeId 分 key 的原因:

  • 前端一次只查一个类型,按类型分 key 可以直接查对应的 ZSet;
  • 每个 ZSet 更小,范围查询更快;
  • 不需要在查询结果里再按 type 过滤。

先 delete 的原因:

  • GEOADD 是 upsert:能更新已有 member,但不会删除已经从数据库删除或改了类型的店铺;
  • 先删 key 再全量写入,保证 Redis 与 MySQL 一致,重复执行也幂等;
  • 代价是导入期间该类型的 GEO 数据短暂为空。

坐标为空的处理:

java 复制代码
if (shop.getTypeId() == null || shop.getX() == null || shop.getY() == null) {
    continue;
}

直接跳过,不写入 GEO,避免 GEOADD 报错。

常见误区
  • ❌ "GEOADD 会自动删除数据库里不存在的店铺。"------不会,Redis 不知道数据库的事。
  • ❌ "所有店铺放一个 key 更省内存。"------省不了多少,查询还要额外过滤类型。
  • ❌ "坐标为空的店铺会被写成 (0,0)。"------当前代码是跳过,不是写 0。

第 3 题:附近查询

标准答案
  • Circle:定义圆心和半径,圆心是 Point(x, y),半径是 Distance(5000, METERS);
  • Distance(5000, DistanceUnit.METERS):半径 5000 米,同时决定返回距离的单位;
  • includeDistance():让 Redis 在结果里返回每条记录到圆心的距离,不调用就取不到 getDistance();
  • sortAscending():按距离从近到远排序,保证第一页是最近的店;
  • 因为半径单位是米,Spring Data Redis 返回的 Distance.getValue() 也是米,所以前端可以直接用米判断 m/km。
常见误区
  • ❌ "includeDistance 只是格式化输出。"------不加这个参数,结果里根本没有距离数据。
  • ❌ "排序是数据库做的。"------距离排序由 Redis GEO 完成,数据库只负责按 id 查详情。
  • ❌ "距离单位可以随便写,前端会换算。"------单位不一致会导致前端显示错误。

第 4 题:分页实现

标准答案

区别:

  • SQL 的 LIMIT offset, count 支持"跳过 offset 条,再取 count 条";
  • Redis GEORADIUS 的 COUNT 只有"取多少条",没有 offset。

本项目的实现:

java 复制代码
long offset = (long) (page - 1) * pageSize;
long count = offset + pageSize;

每次都从最近的一条开始取 count = page * pageSize 条,再在 Java 里截取:

java 复制代码
content.subList((int) offset, (int) Math.min(content.size(), count));

例如每页 5 条:

  • 第 1 页:取 5 条,截取 [0,5);
  • 第 2 页:取 10 条,截取 [5,10);
  • 第 3 页:取 15 条,截取 [10,15)。

性能问题:

  • 页码越大,Redis 返回的数据越多,Java 也要处理前面已经看过的数据;
  • 时间复杂度和网络传输会随页码增长;
  • 生产环境可改用"最后一条的距离"作为游标(距离滚动分页),或限制最大页数/半径。
常见误区
  • ❌ "limit(offset, count) 在 Spring Data Redis 里有重载。"------2.3.9 只有 limit(long)。
  • ❌ "COUNT 10 就是跳过 10 条。"------COUNT 是数量,不是偏移。
  • ❌ "多取再截取没有性能问题。"------页码大时明显浪费。

第 5 题:一致性与边界

标准答案

数据库变化后 Redis 的表现:

  • 新增店铺:Redis 里没有,附近查询看不到,直到重新导入;
  • 删除店铺:GEO 里残留,查询时 listByIds 查不到,会被跳过;
  • 修改坐标/类型:GEO 里还是旧坐标/旧 key,查询结果错误,直到重新导入;
  • 解决方式:数据变更后重新执行 loadShopGeoData(),或用定时任务/事件同步。

GEO 有、MySQL 查不到:

java 复制代码
Shop shop = shopMap.get(...);
if (shop != null) { ... }

直接跳过,不会抛异常,但该页可能少一条。

sortBy=score:

不会生效。后端没有声明 sortBy 参数,Spring MVC 会忽略它;有坐标时始终按距离升序返回。如果需要按评分/人气排序,可以走无坐标的数据库分支,或在 GEO 结果基础上再排序(会失去距离排序)。

常见误区
  • ❌ "Redis 和 MySQL 会自动同步。"------没有同步机制,必须显式导入。
  • ❌ "删除店铺后 GEO 会自动清理。"------不会,要重新导入或手动 ZREM。
  • ❌ "sortBy 已经实现了。"------当前只是前端传了参数,后端没有使用。

附录:源码索引

内容 位置
附近商户接口 controller/ShopController.java:70-78
服务接口 service/IShopService.java:21,23
GEO 导入实现 service/impl/ShopServiceImpl.java:253-278
附近查询实现 service/impl/ShopServiceImpl.java:280-336
距离填充 service/impl/ShopServiceImpl.java:331
GEO key 常量 utils/RedisConstants.java:24
Shop 坐标字段 entity/Shop.java:64,69
Shop.distance entity/Shop.java:108
tb_shop 建表 resources/db/hmdp.sql:106-124
导入测试 test/java/com/hmdp/HmDianPingApplicationTests.java:31-34
前端请求参数 shop-list.html:90-96
前端查询 shop-list.html:118-133
前端距离显示 shop-list.html:64

Redis 命令速查

用途 命令
添加坐标 GEOADD shop:geo:{typeId} 经度 纬度 shopId
查看坐标 GEOPOS shop:geo:{typeId} shopId
两点距离 GEODIST shop:geo:{typeId} shopId1 shopId2 km
半径查询 GEORADIUS shop:geo:{typeId} 经度 纬度 5 km WITHDIST ASC COUNT 10
新式半径查询 GEOSEARCH shop:geo:{typeId} FROMLONLAT 经度 纬度 BYRADIUS 5 km ASC WITHDIST
数量 ZCARD shop:geo:{typeId}
删除 ZREM shop:geo:{typeId} shopId
相关推荐
念何架构之路2 小时前
Go reflect 包:结构体反射 —Field、Tag 与 VisibleFields
java·数据库·golang
舰长1152 小时前
将百万数据插入到 Redis,有哪些实现方案?
数据结构·redis·开源
EnCi Zheng2 小时前
PostgreSQL 实战指南:安装配置、数据类型、索引选型、MVCC 事务与高可用备份全解析
数据库·sql·postgresql·索引·1024程序员节
泡海椒3 小时前
JQuick-SQL REST API 数据接入:应用层调用接口后注册分析表
数据库·sql·1024程序员节
Random Glow3 小时前
聚簇索引 vs 二级索引:回表、覆盖索引、索引下推
数据库·mysql·性能优化·innodb·1024程序员节·索引优化
bksczm3 小时前
Redis数据结构之hash
数据结构·redis·哈希算法·1024程序员节
要开心吖ZSH4 小时前
一次线上锁风暴复盘:号源表大范围 DELETE 的隐患与分批删除改造
数据库·mysql·行锁·1024程序员节·间隙锁·lockwaittimeout
小南家的青蛙5 小时前
Redis的资料
redis·缓存·1024程序员节