Redis GEO 实现附近商户:从坐标导入到按距离分页查询
Redis GEO 实现附近商户:从坐标导入到按距离分页查询
本文整理自黑马点评 Redis 实战篇第 10 章「附近商户」。这一章的核心问题是:用户打开某个商户分类后,如何根据用户当前位置,查询附近 5km 内的店铺,并按照距离从近到远排序?
1. 这一篇解决什么问题
在点评类应用里,「附近商户」是非常典型的功能。
用户点击「美食」分类后,前端会传:
typeId:商户类型
x:用户当前经度
y:用户当前纬度
current:当前页码
后端要返回:
附近的美食店铺
按距离从近到远排序
支持分页
每个店铺带上距离
如果只用 MySQL,需要对大量店铺计算距离、排序、分页,压力比较大。Redis GEO 正好可以帮我们做地理位置检索。
2. Redis GEO 是什么
GEO 是 Geolocation 的缩写,表示地理位置。
Redis GEO 可以存储:
member + 经度 + 纬度
比如:
GEOADD shop:geo:1 120.149993 30.334229 101
含义是:
把店铺 101 的坐标加入 shop:geo:1。
其中:
120.149993 是经度 longitude
30.334229 是纬度 latitude
101 是 member,也就是店铺 id
Redis GEO 能做:
添加地理位置 GEOADD
计算距离 GEODIST
查询坐标 GEOPOS
附近搜索 GEOSEARCH
本章最核心的是 GEOSEARCH。
3. 为什么 Redis GEO 里只存店铺 id
店铺完整信息很多:
店名
图片
地址
评分
人均价格
营业时间
这些都不适合塞进 Redis GEO。
Redis GEO 的职责只是:
根据坐标快速找附近店铺 id。
完整店铺详情仍然放在 MySQL。
所以 Redis 中存:
shopId + 经纬度
MySQL 中存:
完整 Shop 对象
这和 Feed 流很像:
Feed ZSet 只存 blogId + 时间戳
GEO 只存 shopId + 经纬度
完整对象都回 MySQL 查
4. 为什么要按 typeId 分组
如果所有店铺都放到一个 GEO key:
shop:geo
那用户点击「美食」时,Redis 会查出附近所有类型的店铺:
美食、酒店、KTV、理发店……
但 Redis GEO 的 member 只存 shopId,不存 typeId,没法直接在 GEO 查询时按类型过滤。
所以项目采用按类型分组:
shop:geo:1 typeId=1 的店铺坐标
shop:geo:2 typeId=2 的店铺坐标
shop:geo:3 typeId=3 的店铺坐标
用户查美食时,只查:
shop:geo:1
这样天然只返回美食类店铺。
5. 导入店铺坐标到 Redis GEO
测试类中有导入方法:
@Test
void loadShopData(){
List<Shop> list = shopService.list();
Map<Long, List<Shop>> map = list.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
for (Shop shop : value) {
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
流程:
查询所有店铺
-> 按 typeId 分组
-> 每个 typeId 对应一个 GEO key
-> 批量写入 shopId + 坐标
SHOP_GEO_KEY 是:
public static final String SHOP_GEO_KEY = "shop:geo:";
如果 typeId=1,key 就是:
shop:geo:1
6. 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);
}
这个接口同时支持两种情况。
无坐标:
GET /shop/of/type?typeId=1¤t=1
走普通 MySQL 分页。
有坐标:
GET /shop/of/type?typeId=1¤t=1&x=120.1&y=30.3
走 Redis GEO 附近查询。
7. 无坐标时走 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());
}
如果前端没有传用户位置,就退化成普通分类查询。
8. 有坐标时计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
项目中默认每页 5 条。
第 1 页:from=0, end=5
第 2 页:from=5, end=10
第 3 页:from=10, end=15
为什么要这样算?
因为 Redis GEO 查询这里使用的是:
.limit(end)
它只能表示查前 end 条,不能直接表示从 from 开始查 5 条。
所以做法是:
Redis 先查前 end 条
Java 再 skip(from)
9. GEOSEARCH 查询附近店铺
核心代码:
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs
.newGeoSearchArgs()
.includeDistance()
.limit(end)
);
对应 Redis 思路:
GEOSEARCH shop:geo:1
BYLONLAT x y
BYRADIUS 5000 m
WITHDIST
COUNT end
含义是:
在 shop:geo:1 中,
以用户坐标 x/y 为中心,
查 5000 米范围内的店铺,
返回店铺 id 和距离,
最多返回 end 条。
10. results 和 list 到底是什么
search() 返回的是:
GeoResults<RedisGeoCommands.GeoLocation<String>> results
它是一个包装对象。
真正的结果列表要通过:
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
注意:results.getContent() 取出来的是一组 GeoResult,不是单纯的店铺 id。
每个 GeoResult 里面有:
content:GeoLocation,也就是 shopId + 坐标
distance:距离
所以后面可以:
String shopIdStr = result.getContent().getName();
Distance distance = result.getDistance();
getContent().getName() 拿到的是 member,也就是店铺 id。
getDistance() 拿到的是 Redis 根据用户坐标计算出来的距离。
11. Java 层截取当前页
if (list.size() <= from) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
假设查第 2 页:
from = 5
end = 10
Redis 返回最近的前 10 条。
Java 执行:
skip(5)
跳过前 5 条,剩下第 6 到第 10 条,就是第 2 页。
这里收集两个东西:
ids:当前页店铺 id,用于回 MySQL 查详情
distanceMap:店铺 id 到距离的映射,用于把距离补回 Shop
12. 为什么总是 ids + idStr
回表查询:
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")")
.list();
ids 用于:
WHERE id IN (...)
idStr 用于:
ORDER BY FIELD(id, ...)
为什么要 ORDER BY FIELD?
因为 Redis GEO 返回的顺序是按距离排好的:
[5, 2, 9]
但 MySQL 的 IN 查询不保证顺序,可能返回:
[2, 5, 9]
这样距离排序就乱了。
所以要强制 MySQL 按 Redis 返回的 id 顺序排序。
13. 补充距离字段
Shop 实体里有:
@TableField(exist = false)
private Double distance;
distance 不是数据库字段,而是运行时计算出来的字段。
因为距离取决于用户当前位置:
用户位置变了,距离就变了。
所以不能存在数据库里,只能查询时动态补充:
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
14. 完整流程
文字版:
请求 /shop/of/type
-> 如果无坐标,走 MySQL 普通分页
-> 如果有坐标,查 shop:geo:{typeId}
-> Redis 返回按距离排序的 shopId + distance
-> Java 截取当前页
-> MySQL 回表查完整 Shop
-> ORDER BY FIELD 保持 Redis 顺序
-> 设置 distance
-> 返回
15. 易错点
1. x 是经度,y 是纬度
写入 Point 时:
new Point(shop.getX(), shop.getY())
顺序不要反。
2. shop:geo:{typeId} 里的 typeId 不是店铺 id
它表示某个类型下的所有店铺坐标集合。
3. Redis GEO 只返回 shopId 和 distance
完整 Shop 仍然要回 MySQL 查。
4. 回表后必须保持 Redis 顺序
否则距离排序会乱。
5. distance 不是数据库字段
它是动态计算字段,所以要 @TableField(exist = false)。
16. 面试怎么说
如果面试官问:附近商户怎么实现?
可以回答:
我们先把店铺按照 typeId 分组,将每个店铺的 shopId 和经纬度写入 Redis GEO,key 设计为
shop:geo:{typeId}。查询附近商户时,前端传 typeId 和用户坐标,后端通过GEOSEARCH在对应类型的 GEO 集合中查 5km 内的店铺,Redis 返回按距离排序的 shopId 和 distance。然后根据 shopId 回 MySQL 查询完整店铺信息,用ORDER BY FIELD保持 Redis 返回顺序,最后把 distance 设置到 Shop 的临时字段中返回。
17. 总结
第 10 章的核心是:
Redis GEO 负责地理位置索引,MySQL 负责完整店铺信息。按 typeId 分组建立 GEO key,查询时用用户坐标做半径搜索,再回表补详情和距离。
更多推荐



所有评论(0)