目录
- [0. 业务背景:附近商户要解决什么](#0. 业务背景:附近商户要解决什么)
- [1. GEO 数据结构的基本用法](#1. GEO 数据结构的基本用法)
- [2. 导入店铺数据到 GEO](#2. 导入店铺数据到 GEO)
- [3. 实现附近商户功能](#3. 实现附近商户功能)
- [4. 关键技术原理总结](#4. 关键技术原理总结)
- [5. 练习题](#5. 练习题)
- [6. 练习题参考答案与逐题反馈](#6. 练习题参考答案与逐题反馈)
- 附录:源码索引
0. 业务背景:附近商户要解决什么
0.1 用户场景
用户打开"附近商户"页面时,页面需要:
- 按商户类型(美食、KTV、美发......)筛选;
- 以用户当前位置为中心,找出一定范围内(本项目 5km)的商户;
- 按距离从近到远排序;
- 显示每家店距离用户多少米/公里;
- 滚动到底部时继续加载下一页。
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 ?
问题:
- 经纬度是两个独立维度,B+ 树索引只能高效处理一维范围;二维范围查询会退化成大量扫描;
- 即便筛出矩形区域,还要计算每条记录到用户的球面距离并排序,数据库压力大;
- 高并发下,附近查询会变成热点。
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=¤t=&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 的细节:
Metrics枚举只有KILOMETERS、MILES、NEUTRAL,没有METERS,要用RedisGeoCommands.DistanceUnit.METERS;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. 练习题
-
GEO 与坐标 :Redis GEO 底层用的是什么数据结构?
GEOADD的坐标顺序是什么?如果把经度和纬度写反,会发生什么? -
导入设计 :为什么按
typeId分成多个 key,而不是所有店铺共用一个shop:geo?导入前为什么要先delete(key)?如果某家店铺的x或y为空,当前代码会怎么处理? -
附近查询 :
Circle、Distance(5000, DistanceUnit.METERS)、includeDistance()、sortAscending()分别起什么作用?为什么返回的distance是米? -
分页实现 :Redis
GEORADIUS的COUNT和 SQL 的LIMIT offset, count有什么区别?本项目是如何实现第 2 页的?这种方式有什么性能问题? -
一致性与边界 :导入 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 |