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.90378GEOHASH — 获取 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.6kmGEOSEARCH 支持两种搜索方式: - BYRADIUS — 按圆形范围搜索(指定半径) - BYBOX — 按矩形范围搜索(指定长宽)
圆心指定方式: - FROMLONLAT longitude latitude — 直接指定经纬度 - FROMMEMBER member — 使用 Key 中已有 member 作为圆心
1.4 注意事项
GEORADIUS在 Redis 6.2 后已废弃,统一使用GEOSEARCHGEOSEARCHSTORE与GEOSEARCH类似,但会将结果存入新的 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¤t=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 GEOSEARCH 的 LIMIT 只支持 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¤t=1&x=116.397&y=39.907
响应:
- 菜马红桃烤肉(距离 170.4m)
- 杨老三羊蝎子(距离 1km)
- 钱曹乌(距离 1km)
- ...
第 2 页请求:GET /shop/of/type?typeId=1¤t=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"