附近商户
约 1131 字大约 4 分钟
2025-07-10
10.1 GEO 简介
GEO 是 Geolocation 的缩写,代表地理坐标。Redis 在 3.2 版本开始支持 GEO,允许存储地理坐标信息,并根据经纬度检索数据。
常用的 GEO 命令有:
命令 | 功能说明 | 版本备注 |
---|---|---|
GEOADD | 添加地理空间信息,包含经度(longitude)、纬度(latitude)和值(member)。 | - |
GEODIST | 计算两个指定点之间的距离并返回。 | - |
GEOHASH | 将指定 member 的坐标转换为 hash 字符串形式并返回。 | - |
GEOPOS | 返回指定 member 的坐标。 | - |
GEORADIUS | 指定圆心和半径,找到该圆内包含的所有 member,并按照与圆心之间的距离排序后返回。 | Redis 6.0 以后已废弃 |
GEOSEARCH | 在指定范围内搜索 member,并按照与指定点的距离排序后返回。范围可以是圆形或矩形。 | Redis 6.2 新功能 |
GEOSEARCHSTORE | 与 GEOSEARCH 功能一致,但可以将结果存储到一个指定的 key。 | Redis 6.2 新功能 |
10.2 导入店铺数据到 GEO
在 App 上,用户点击美食时会展示一系列商家,并且商家可以按照多种方式排序,其中包括距离排序。为了实现这一功能,需要使用 GEO 数据结构。该功能以用户当前地址为圆心,返回按照距离排名的指定类型的商家分页信息。
由于 Redis 是内存数据库,不适合存储海量数据,并且 GEO 数据类型无法直接存储商家地理位置的同时存储商家类型。因此,采取以下策略:
- GEO 的值中存储商户的 ID。
- 按照商户类型进行分组,相同类型的商户分到同一组,以类型为 key 存入同一个 GEO 集合。
可以使用 SpringTest
方法将店铺数据从数据库导入到 Redis 的 GEO 结构中。下面的代码展示了如何实现这一过程:
/**
* 将店铺数据从数据库导入到 Redis 的 GEO 结构中。
*/
@Test
void loadShopData() {
// 1. 查询店铺信息
List<Shop> shops = shopService.list();
// 2. 按照 typeId 将店铺分组
Map<Long, List<Shop>> map = shops.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3. 按照 typeId 写入 Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 3.1 获取 typeId
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
// 3.2 获取同类型店铺的集合
List<Shop> shopList = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shopList.size());
for (Shop shop : shopList) {
locations.add(
new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
10.3 实现附近商户功能
注
Spring Data Redis 对 GEOSEARCH
命令的支持是从 Spring Data Redis 2.6 开始引入的,对于早期版本需要升级相关依赖的版本。
Spring Boot 2.7.x 自带 Spring Data Redis 2.6.x + Lettuce 6.1.x,因此有两种实现方式:
- 直接升级父依赖。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.6</version> <relativePath/> <!-- lookup parent from repository --> </parent>
- 局部排除并引入新版本。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <artifactId>spring-data-redis</artifactId> <groupId>org.springframework.data</groupId> </exclusion> <exclusion> <artifactId>lettuce-core</artifactId> <groupId>io.lettuce</groupId> </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.RELEASE</version> </dependency>
如果用户提供了经纬度坐标,则使用 GEO 数据结构查询附近店铺,否则直接从数据库中查询。
控制器
/**
* 根据商铺类型分页查询商铺信息
* @param typeId 商铺类型
* @param current 页码
* @return 商铺列表
*/
@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);
}
实现类
/**
* 根据商铺类型分页查询商铺信息
* @param typeId
* @param current
* @param x
* @param y
* @return
*/
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1. 判断是否需要根据坐标查询
if (x == null || y == null) {
Page<Shop> page = query().eq("type_id", typeId).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
return Result.ok(page.getRecords());
}
// 2. 计算分页参数
int begin = (current - 1) * SystemConstants.MAX_PAGE_SIZE;
int end = current * SystemConstants.MAX_PAGE_SIZE;
// 3. 查询 end 个商家
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)
);
if (results == null) {
return Result.ok(Collections.emptyList());
}
// 4. 解析 shopId 和距离
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = results.getContent();
if (content.size() <= begin) {
// 没有下一页数据
return Result.ok(Collections.emptyList());
}
// 4.1 截取 begin-end 部分商家
List<Long> ids = new ArrayList<>(content.size() - begin);
Map<Long, Distance> distanceMap = new HashMap<>(content.size() - begin);
content.stream().skip(begin).forEach(result -> {
// 4.2 获取 shopId
Long shopId = Long.valueOf(result.getContent().getName());
ids.add(shopId);
// 4.3 获取距离
distanceMap.put(shopId, result.getDistance());
});
// 5. 根据 shopId 查询店铺
String idsStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id, " + idsStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId()).getValue());
}
return Result.ok(shops);
}