全栈开发那些事

全栈开发那些事

ES中的桶聚合

2024-06-25
ES中的桶聚合

桶聚合

在前面几篇博客中介绍的聚合指标是指符合条件的文档字段的聚合,有时还需要根据某些维度进行聚合。例如在搜索酒店时,按照城市、是否满房、标签和创建时间等维度统计酒店的平均价格。这些字段统称为“桶”,在同一维度内有一个或者多个桶。例如城市桶,有“北京”、“天津”等,是否满房桶,有“满房”和“非满房”。

1.1 单维度同聚合

最简单的桶聚合是单维度桶聚合,指的是按照一个维度对文档进行分组聚合。在桶聚合时,聚合的桶也需要匹配,匹配的方式有termsfilterranges等。这里只介绍比较有代表性的terms查询和ranges查询。

1.1.1 terms聚合

terms聚合是按照字段的实际完整值进行匹配和分组的,它使用的维度字段必须是keywordboolkeyword数组等适合精确匹配的数据类型,因此不能对text字段直接使用terms聚合,如果对text字段有terms聚合的需求,则需要在创建索引时为该字段增加多字段功能。

以下的DSL描述的是按照城市进行聚合的查询:

# 按照城市进行聚合
GET /hotel_poly/_search
{
  "size": 0,
  "aggs": {
    "my_agg": {
      "terms": {				//按照城市进行聚合
        "field": "city"
      }
    }
  }
}

因为ES支持多聚合,所以每个桶聚合需要定义一个名字,此处定义了一个桶聚合,名字为my_agg。在这个桶聚合中使用了一个terms聚合,聚合字段选择了城市,目的是统计各个城市的酒店的文档个数。在聚合外面,因为不希望返回任何文档,所以指定查询返回的文档为0。执行该DSL后,ES返回的结果如下:

image-20240422154731832

在默认情况下,进行桶聚合时如果不指定指标,则ES默认聚合的是文档计数,该值以doc_count为key存储在每一个bucket子句中。在聚合结果的buckets的两个bucket中,key字段的值分别为“北京”“天津”,表示两个bucket的唯一标识;doc_count字段的值分别为3和2,表示两个bucket的文档计数。返回的doc_count是近似值,并不是一个准确数,因此在聚合外围,ES给出了连个参考值doc_count_error_upper_boundsum_other_doc_count

  • doc_count_error_upper_bound表示被遗漏的文档数量可能存在的最大值
  • sum_other_doc_count表示除了返回给用户的文档外剩下的文档总数

以下DSL是按照满房状态进行聚合的查询,注意该字段是bool型:

# 按照满房状态进行查询
GET /hotel_poly/_search
{
  "size": 0,
  "aggs": {
    "my_agg": {
      "terms": {
        "field": "full_room"
      }
    }
  }
}

ES返回的结果如下:

image-20240422155312865

在上述结果中可以看到,在满房和非满房的bucket结果中多出了一个字段,名称为key_as_string,其值分别为truefalse。另外,这两个bucket的key值分别为1和0.这是因为,如果桶字段类型不是keyword类型,ES在聚合时会将桶字段转换为Lucene存储的实际值进行识别。true在Lucene中存储为1,false在Lucene中存储为0,这就是为什么满房和非满房的key字段分别为1和0的原因。

这种情况给用户的使用带来了一些困惑,因为和原始值的差别比较大。针对这个问题,我们可以使用ES提供的key_as_string桶识别字段,它是原始值的字符串形式,和原始值的差别比较小。

在Java中国使用terms聚合进行单维度桶聚合的逻辑如下:

public void getBucketDocCountAggSearch() throws IOException {
    //创建搜索请求
    SearchRequest searchRequest = new SearchRequest("hotel_poly");
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    String termsAggName = "my_terms";    //指定聚合的名称
    //定义terms聚合,指定字段为城市
    TermsAggregationBuilder termsAggregationBuilder = AggregationBuilders.terms(termsAggName).field("full_room");
    //添加聚合
    searchSourceBuilder.aggregation(termsAggregationBuilder);
    //设置查询请求
    searchRequest.source(searchSourceBuilder);
    //执行查询
    SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    //获取聚合结果
    Aggregations aggregations = searchResponse.getAggregations();
    //获取聚合返回的对象
    Terms terms = aggregations.get(termsAggName);
    for (Terms.Bucket bucket : terms.getBuckets()) {
        String bucketKey = bucket.getKeyAsString();//获取桶名称
        long docCount = bucket.getDocCount();   //获取文档个数
        log.info("termsKey={},docCount={}", bucketKey, docCount);
    }
}

image-20240422155853339

1.1.2 ranges聚合

ragnes聚合也是经常使用的一种聚合。它匹配的是数值字段,表示按照数值范围进行分组。用户可以在ranges中添加分组,每个分组使用fromto表示分组的起止数值。注意该分组包含起始数值,不包含终止数值。

示例如下:

# ranges聚合
GET /hotel_poly/_search
{
  "size": 0,
  "aggs": {
    "my_agg":{
      "range": {
        "field": "price",
        "ranges": [			//多个范围桶
          {         
            "to": 200		//不指定from,默认from为0
          },
          {
            "from": 200,
            "to": 500
          },
          {
            "from": 500		//不指定to,默认to为该字段最大值
          }
        ]
      }
    }
  }
}

执行上述DSL后,ES返回的结果如下:

image-20240422160213569

在上面的分组划分中,第一个分组规则为price<200,没有文档与其匹配,因此其doc_count为0;第二个分组规则为200 \le price<500,文档003与其匹配,因此其doc_count为1;第三个分组规则为price \ge 500,文档001、004和005与其匹配,因此其doc_count值为3。

以下代码演示了在Java中使用ranges聚合的逻辑:

public void getRangeDocCountAggSearch() throws IOException {
    //创建搜索请求
    SearchRequest searchRequest = new SearchRequest("hotel_poly");
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    String rangeAggName = "my_range"; //聚合的名称
    //定义ranges聚合,指定字段为price
    RangeAggregationBuilder rangeAgg = AggregationBuilders.range(rangeAggName).field("price");
    rangeAgg.addRange(new RangeAggregator.Range(null, null, 200d));
    rangeAgg.addRange(new RangeAggregator.Range(null, 200d, 500d));
    rangeAgg.addRange(new RangeAggregator.Range(null, 500d, null));
    //添加ranges聚合
    searchSourceBuilder.aggregation(rangeAgg);
    //设置查询请求
    searchRequest.source(searchSourceBuilder);
    //执行查询
    SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    //获取聚合结果
    Aggregations aggregations = searchResponse.getAggregations();
    Range range = aggregations.get(rangeAggName);
    for (Range.Bucket bucket : range.getBuckets()) {
        String bucketKey = bucket.getKeyAsString(); //获取桶名称
        long docCount = bucket.getDocCount();       //获取聚合文档个数
        log.info("termsKey={},docCount={}", bucketKey, docCount);
    }
}

image-20240422160607820

有时还需要对单维度桶指定聚合指标,聚合指标单独使用子aggs进行封装。

以下请求表示按照城市维度进行聚合,统计各个城市的平均酒店价格:

# 按照城市维度进行聚合,统计各个城市的平均酒店价格
GET /hotel_poly/_search
{
  "size": 0,
  "aggs": {
    "my_agg": {					//单维度聚合名称
      "terms": {				//定义单维度桶
        "field": "city"	
      },
      "aggs": {					//用于封装单维度桶下的聚合指标
        "my_sum": {			//聚合指标名称
          "sum": {			//最price字段进行加和
            "field": "price",
            "missing": 200
          }
        }
      }
    }
  }
}

image-20240422161000865

在上面的结果中,聚合桶的维度是城市,当前索引中城市为“北京”的文档个数为3,城市为“天津”的文档个数为2.将这两组文档的聚合结果在buckets子句中进行了封装,可以根据key字段进行聚合桶的识别,每个聚合的组中既有文档个数又有价格的加和值。

在Java中使用桶聚合和指标聚合的逻辑如下:

public void getBucketAggSearch() throws IOException{
    //创建搜索请求
    SearchRequest searchRequest = new SearchRequest("hotel_poly");
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    String termsAggName="my_terms"; //聚合的名称
    //定义terms聚合,指定字段为城市
    TermsAggregationBuilder termsAggregationBuilder=AggregationBuilders.terms(termsAggName).field("city");
    //sum聚合的名称
    String sumAggName="my_sum";
    //定义sum聚合,指定字段为价格
    SumAggregationBuilder sumAggregationBuilder=AggregationBuilders.sum(sumAggName).field("price");
    sumAggregationBuilder.missing(200);
    //定义聚合的父子关系
    termsAggregationBuilder.subAggregation(sumAggregationBuilder);
    //添加聚合
    searchSourceBuilder.aggregation(termsAggregationBuilder);
    //设置查询请求
    searchRequest.source(searchSourceBuilder);
    //执行查询
    SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    //获取聚合结果
    Aggregations aggregations = searchResponse.getAggregations();
    Terms terms = aggregations.get(termsAggName);   //获取聚合返回的对象
    for (Terms.Bucket bucket : terms.getBuckets()) {
        String termsKey = bucket.getKey().toString();
        log.info("termsKey={}",termsKey);
        Sum sum=bucket.getAggregations().get(sumAggName);
        String key=sum.getName();                   //获取聚合名称
        double sumVal = sum.getValue();             //获取聚合值
        log.info("key={},count={}",key,sumVal);
    }
}

image-20240422161348970

1.2 多维度桶嵌套聚合

在某些业务需求中,不仅需要一个维度的桶聚合,而且还可能有多维度桶嵌套聚合的需求。例如在搜索酒店时,可能需要统计各个城市的满房和非满房状态下的酒店平均价格。ES支持嵌套桶聚合,进行嵌套时,可以使用aggs子句进行子桶的继续嵌套,指标放在最里面的子桶内

以下DSL演示多维度桶的使用方法:

# 多维度桶嵌套聚合
GET /hotel_poly/_search
{
  "size": 0, 
  "aggs": {
    "group_city": {								//多维度桶名称
      "terms": {
        "field": "city"
      },
      "aggs": {										//单维度桶
        "group_full_room": {			
          "terms": {
            "field": "full_room"
          },
          "aggs": {							//聚合指标
            "my_sum":{
              "sum": {
                "field": "price",
                 "missing": 200
              }
            }
          }
        }
      }
    }
  }
}

ES返回结果如下:

image-20240422161912966

从结果中可以看到,第一层的分桶先按照城市分组分为“北京”“天津”;第二层在“北京”“天津”桶下面继续分桶,分为“满房”和“非满房”桶,对应的聚合指标即价格的加和值存储在内部的my_sum字段中。

在Java中使用多维度桶进行聚合的逻辑如下:

public void getExternalBucketAggSearch() throws IOException{
    //创建搜索请求
    SearchRequest searchRequest = new SearchRequest("hotel_poly");
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    //按城市聚合的名称
    String aggNameCity="my_terms_city";
    //定义terms聚合,指定字段为城市
    TermsAggregationBuilder termsAggCity=AggregationBuilders.terms(aggNameCity).field("city");

    //按满房状态聚合的名称
    String aggNameFullRoom="my_terms_full_room";
    //定义terms聚合,指定字段为满房状态
    TermsAggregationBuilder termsAggFullRoom=AggregationBuilders.terms(aggNameFullRoom).field("full_room");

    //sum聚合的名称
    String sumAggName="my_sum";
    //定义sum聚合,指定字段为价格
    SumAggregationBuilder sumAgg=AggregationBuilders.sum(sumAggName).field("price").missing(200);

    //定义聚合的父子关系
    termsAggFullRoom.subAggregation(sumAgg);
    termsAggCity.subAggregation(termsAggFullRoom);
    //添加聚合
    searchSourceBuilder.aggregation(termsAggCity);
    //设置查询请求
    searchRequest.source(searchSourceBuilder);
    //执行查询
    SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    //获取聚合结果
    Aggregations aggregations = searchResponse.getAggregations();
    //获取聚合返回的对象
    Terms terms=aggregations.get(aggNameCity);
    for (Terms.Bucket bucket : terms.getBuckets()) {    //遍历第一层bucket
        //获取第一层bucket名称
        String termsKeyCity = bucket.getKey().toString();
        log.info("-------termsKeyCity={}--------",termsKeyCity);
        Terms termsFullRoom = bucket.getAggregations().get(aggNameFullRoom);
        //遍历第二层bucket
        for (Terms.Bucket bucketFullRoom : termsFullRoom.getBuckets()) {
            //获取第二层bucket名称
            String termsKeyFullRoom = bucketFullRoom.getKeyAsString();
            log.info("termsKeyFullRoom={}",termsKeyFullRoom);
            //获取聚合指标
            Sum sum=bucketFullRoom.getAggregations().get(sumAggName);
            //获取聚合指标名称
            String key=sum.getName();
            //获取聚合指标值
            double sumVal=sum.getValue();
            log.info("key={},count={}",key,sumVal);
        }
    }
}

image-20240422162152715

1.3 地理距离聚合

按照地理距离聚合是一个非常实用的功能,例如在搜索酒店时,可能需要对附近的酒店个数先预览一下:查看距离当前位置2km范围内、2~3km范围内、5km范围内的酒店个数。

用户可以使用geo_distance聚合进行地理距离聚合,通过field参数来设置距离计算的字段,可以在origin子句中设定距离的原点,通过unit参数来设置距离的单位,可以选择mikm,分别表示米和千米。ranges子句用来对距离进行阶段性的分组,该子句的使用方式和前面介绍的range聚合类似。

示例DSL如下:

# 地理距离聚合
GET /hotel_poly/_search
{
  "size": 0,
  "aggs": {
    "my_agg":{
      "geo_distance": {
        "field": "location",
        "origin": {							//指定聚合的中心点经纬度
          "lat": 39.915143,
          "lon": 116.4039
        },
        "unit": "km", 					//指定聚合时的距离计量单位
        "ranges": [							//指定每一个聚合桶的距离范围
          {
            "to": 3
          },
          {
            "from": 3,
            "to": 10
          },
          {
            "from":10
          }
        ]
      }
    }
  }
}

在上述DSL中,给定了一个地理位置,此处使用ranges聚合对距离该位置的就带你划分了3个分组的桶:第1个桶为3km范围内;第2个桶为3~10km;第3个桶为大于等于10km。执行上述DSL后,ES返回结果如下:

image-20240422162819130

其中,在aggregations结果子句中对应查询的分组有3个bucket桶,表示按照距离划分的3个组,每个bucket桶内分别给出了key和文档数量等信息。

也可以指定聚合指标进行地理距离聚合,下面的DSL将按照bucket分桶聚合酒店的最低价格:

# 指定聚合指标进行地理距离聚合
GET /hotel_poly/_search
{
  "size": 0,
  "aggs": {
    "my_agg": {
      "geo_distance": {
        "field": "location",
        "origin": {
          "lat": 39.915143,
          "lon": 116.4039
        },
        "unit": "km",
        "ranges": [
          {
            "to": 3
          },
          {
            "from": 3,
            "to": 10
          },
          {
            "from": 10
          }
        ]
      },
      "aggs": {
        "my_min": {
          "min": {
            "field": "price",
            "missing": 100
          }
        }
      }
    }
  }
}

ES返回结果如下:

image-20240422163113234

在上面的结果中,ES给出了各个地理距离分组的名称及最低价格。

在Java中使用地理距离分组并计算最低价格的逻辑如下:

public void getGeoDistanceAggSearch() throws IOException{
    //创建搜索请求
    SearchRequest searchRequest = new SearchRequest("hotel_poly");
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    //聚合的名称
    String geoDistanceAggName="location";
    //定义GeoDistance聚合
    GeoDistanceAggregationBuilder geoDistanceAgg = AggregationBuilders.geoDistance(geoDistanceAggName, new GeoPoint(39.915143, 116.4039));
    geoDistanceAgg.unit(DistanceUnit.KILOMETERS);
    geoDistanceAgg.field("location");
    //指定分桶范围规则
    geoDistanceAgg.addRange(new GeoDistanceAggregationBuilder.Range(null,0d,3d));
    geoDistanceAgg.addRange(new GeoDistanceAggregationBuilder.Range(null,3d,10d));
    geoDistanceAgg.addRange(new GeoDistanceAggregationBuilder.Range(null,10d,null));
    //min聚合的名称
    String minAggName="my_min";
    //定义sum聚合,指定字段为价格
    MinAggregationBuilder minAgg=AggregationBuilders.min(minAggName).field("price");
    minAgg.missing(100);    //指定默认值
    //定义聚合的父子关系
    geoDistanceAgg.subAggregation(minAgg);
    searchSourceBuilder.aggregation(geoDistanceAgg);    //添加聚合
    searchRequest.source(searchSourceBuilder);          //设置查询请求
    //执行查询
    SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    //获取聚合结果
    Aggregations aggregations = searchResponse.getAggregations();
    //获取GeoDistance聚合返回的对象
    ParsedGeoDistance range=aggregations.get(geoDistanceAggName);
    for (Range.Bucket bucket : range.getBuckets()) {
        //获取bucket名称的字符串形式
        String termsKey = bucket.getKeyAsString();
        log.info("termsKey={}",termsKey);
        ParsedMin min = bucket.getAggregations().get(minAggName);
        String key = min.getName();             //获取聚合名称
        double minVal = min.getValue();         //获取聚合值
        log.info("key={},min={}",key,minVal);   //打印结果
    }
}

image-20240422163249162

数据源

索引结构

PUT /hotel_poly
{
  "settings": {
    "number_of_shards": 1
  },
  "mappings": {
    "properties": {
      "title":{
        "type": "text"
      },
      "city":{
        "type": "keyword"
      },
      "price":{
        "type": "double"
      },
      "create_time":{
        "type": "date"
      },
      "full_room":{
        "type": "boolean"
      },
      "location":{
        "type": "geo_point"
      },
      "tags":{
        "type": "keyword"
      },
      "comment_info":{
        "properties": {
          "favourable_comment":{
            "type":"integer"
          },
          "negative_comment":{
            "type":"integer"
          }
        }
      }
    }
  }
}

酒店数据

POST /_bulk
{"index":{"_index":"hotel_poly","_id":"001"}}
{"title":"文雅假日酒店","city":"北京","price":556.00,"create_time":"20200418120000","full_room":true,"location":{"lat":39.938838,"lon":106.449112},"tags":["wifi","小型电影院"],"comment_info":{"favourable_comment":20,"negative_comment":10}}
{"index":{"_index":"hotel_poly","_id":"002"}}
{"title":"金都嘉怡假日酒店","city":"北京","create_time":"20210315200000","full_room":false,"location":{"lat":39.915153,"lon":116.4030},"tags":["wifi","免费早餐"],"comment_info":{"favourable_comment":20,"negative_comment":10}}
{"index":{"_index":"hotel_poly","_id":"003"}}
{"title":"金都假日酒店","city":"北京","price":200.00,"create_time":"20210509160000","full_room":true,"location":{"lat":40.002096,"lon":116.386673},"comment_info":{"favourable_comment":20,"negative_comment":10}}
{"index":{"_index":"hotel_poly","_id":"004"}}
{"title":"金都假日酒店","city":"天津","price":500.00,"create_time":"20210218080000","full_room":false,"location":{"lat":39.155004,"lon":117.203976},"tags":["wifi","免费车位"]}
{"index":{"_index":"hotel_poly","_id":"005"}}
{"title":"文雅精选酒店","city":"天津","price":800.00,"create_time":"20210101080000","full_room":true,"location":{"lat":39.178447,"lon":117.219999},"tags":["wifi","充电车位"],"comment_info":{"favourable_comment":20,"negative_comment":10}}