GEO数据结构
- 基本概念:GEO是Geolocation的缩写,代表地理坐标。Redis 3.2版本开始支持GEO数据结构,用于存储地理坐标信息(经纬度)。
- 底层实现:GEO底层使用sorted set实现,将经纬度转换为二进制数字后作为score存储。
- 典型应用:支持附近的人、附近商户、附近车辆等基于地理位置的功能开发。
GEO 相关知识
GEO 是 Geolocation 的简写形式,代表地理坐标。Redis 在 3.2 版本中加入了对 GEO 的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定 member 的坐标转为 hash 字符串形式并返回
- GEOPOS:返回指定 member 的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有 member,并按照与圆心之间的距离排序后返回。6.2 以后已废弃
- GEOSEARCH:在指定范围内搜索 member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2 新功能
- GEOSEARCHSTORE:与 GEOSEARCH 功能一致,不过可以把结果存储到一个指定的 key。6.2 新功能
案例
需求:
- 添加下面几条数据:
- 北京南站(116.378248 39.865275)
- 北京站(116.42803 39.903738)
- 北京西站(116.322287 39.893729)
- 计算北京西站到北京站的距离
- 搜索天安门(116.397904 39.909005)附近10km内的所有火车站,并按照距离升序排序
解决方案
以下是使用 Redis 的 GEO 相关命令来解决上述需求的步骤:
1. 添加数据
使用 GEOADD 命令添加地理空间信息,命令如下:
GEOADD train_stations 116.378248 39.865275 "北京南站"
GEOADD train_stations 116.42803 39.903738 "北京站"
GEOADD train_stations 116.322287 39.893729 "北京西站"这里将三个火车站的地理坐标添加到名为 train_stations 的 GEO 集合中。
2. 计算北京西站到北京站的距离
使用 GEODIST 命令计算两个地点之间的距离,命令如下:
GEODIST train_stations "北京西站" "北京站" km该命令会返回北京西站到北京站的距离,单位为千米(km)。
3. 搜索天安门附近10km内的火车站并排序
使用 GEOSEARCH 命令(Redis 6.2+ 版本支持),命令如下:
GEOSEARCH train_stations FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km ASCFROMLONLAT 116.397904 39.909005指定了天安门的经纬度作为圆心。BYRADIUS 10 km指定了搜索半径为 10 千米。ASC表示按照与天安门的距离升序排序返回结果。
导入店铺数据到GEO
数据结构
- 存储策略:
- member: 仅存储商户ID(如”1”对应103茶餐厅)
- score: 存储经纬度组合(经度x+纬度y)
- 设计原因:
- 避免Redis内存浪费(不存储完整商户信息)
- 查询流程:先通过GEO获取ID → 再用ID查数据库
- 注意事项:
- 需处理坐标精度(如120.149192保留6位小数)
- 商户更新时需要同步Redis数据
按照商户类型分组

- 核心方案:
- 以”shop:geo:{typeId}“为key建立多个GEO集合
- 示例:
- 美食类key: shop:geo:1
- KTV类key: shop:geo:2
- 实现优势:
- 天然实现类型过滤(查询时直接选择对应key)
- 避免全量扫描(只需处理目标类型的GEO集合)
测试代码
HmDianPingApplicationTests
@Test
void loadShopData() {
// 1.查询店铺信息
List<Shop> list = shopService.list();
// 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3.分批完成写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 3.1.获取类型id
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
// 3.2.获取同类型的店铺的集合
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
// 3.3.写入redis GEOADD key 经度 纬度 member for (Shop shop : value) {
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
实现功能
SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM
第一步:导入pom
<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> /**
* 根据商铺类型分页查询商铺信息
* @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);
}根据商铺类型和可选的位置信息分页查询商铺信息,如果提供了位置信息则按距离排序。
-
判断是否需要基于位置查询
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()); }- 如果x或y为null(即没有提供位置信息),则直接从数据库查询该类型的商铺,使用分页查询,每页大小为SystemConstants.DEFAULT_PAGE_SIZE(值为5)。
-
计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; int end = current * SystemConstants.DEFAULT_PAGE_SIZE;- 计算当前页的起始和结束位置索引,例如第1页:from=0, end=5;第2页:from=5, end=10。
-
基于Redis地理位置查询商铺
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的GEO功能,以用户位置(x,y)为中心,查找5000米范围内的商铺
- 结果包含商铺ID和距离信息
- 限制结果数量为end(即当前页的结束位置)
-
处理查询结果
if (results == null) { return Result.ok(Collections.emptyList()); } List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent(); if (list.size() <= from) { // 没有下一页了,结束 return Result.ok(Collections.emptyList()); }- 如果没有查询到结果或当前页没有数据,则返回空列表
-
提取商铺ID和距离信息
List<Long> ids = new ArrayList<>(list.size()); Map<String, Distance> distanceMap = new HashMap<>(list.size()); list.stream().skip(from).forEach(result -> { // 获取店铺id String shopIdStr = result.getContent().getName(); ids.add(Long.valueOf(shopIdStr)); // 获取距离 Distance distance = result.getDistance(); distanceMap.put(shopIdStr, distance); });- 跳过前面页的数据(skip(from)),只处理当前页的数据
- 提取商铺ID并转换为Long类型
- 将商铺ID和对应距离存入Map中
-
根据ID查询商铺详细信息并设置距离
String idStr = StrUtil.join(",", ids); List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for (Shop shop : shops) { shop.setDistance(distanceMap.get(shop.getId().toString()).getValue()); }- 使用SQL的IN语句查询所有商铺的详细信息
- 使用
ORDER BY FIELD保持与Redis查询结果相同的顺序 - 为每个商铺设置距离信息
-
返回结果
return Result.ok(shops);
总结
这个方法巧妙地结合了Redis的GEO功能和数据库查询,实现了基于地理位置的商铺查询和分页功能。它首先使用Redis快速获取按距离排序的商铺ID列表,然后通过数据库查询获取详细信息,同时保持了Redis中的排序顺序。这种方式既利用了Redis在地理位置计算方面的优势,又避免了将所有商铺详细信息存储在Redis中的复杂性。
完整代码如下
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.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2.计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3.查询redis、按照距离排序、分页。结果:shopId、distance
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
// 4.解析出id
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 4.1.截取 from ~ end的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
// 4.2.获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 4.3.获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 5.根据id查询Shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6.返回
return Result.ok(shops);
}