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 新功能

案例

需求:

  1. 添加下面几条数据:
    • 北京南站(116.378248 39.865275)
    • 北京站(116.42803 39.903738)
    • 北京西站(116.322287 39.893729)
  2. 计算北京西站到北京站的距离
  3. 搜索天安门(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 ASC
  • FROMLONLAT 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数据

按照商户类型分组

image-8

  • 核心方案:
    • 以”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);  
    }  
}

image-9

实现功能

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);
    }

根据商铺类型和可选的位置信息分页查询商铺信息,如果提供了位置信息则按距离排序。

  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());
    }
    • 如果x或y为null(即没有提供位置信息),则直接从数据库查询该类型的商铺,使用分页查询,每页大小为SystemConstants.DEFAULT_PAGE_SIZE(值为5)。
  2. 计算分页参数

    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。
  3. 基于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(即当前页的结束位置)
  4. 处理查询结果

    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());
    }
    • 如果没有查询到结果或当前页没有数据,则返回空列表
  5. 提取商铺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中
  6. 根据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查询结果相同的顺序
    • 为每个商铺设置距离信息
  7. 返回结果

    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);  
}