聚合(aggregation) 可以让我们极其方便的实现对数据的统计、分析、运算。例如:
- 什么品牌的手机最受欢迎?
- 这些手机的平均价格、最高价格、最低价格?
- 这些手机每月的销售情况如何?
实现这些统计功能的比数据库的 SQL 要方便的多,而且查询速度非常快,可以实现近实时搜索效果。
聚合的种类
聚合常见的有三类:
- 桶(Bucket)聚合:用来对文档做分组
- TermAggregation:按照文档字段值分组,例如按照品牌值分组、按照国家分组
- Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
- 度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
- Avg:求平均值
- Max:求最大值
- Min:求最小值
- Stats:同时求 max、min、avg、sum 等
- 管道(pipeline)聚合:其它聚合的结果为基础做聚合
注意:参加聚合的字段必须是 keyword、日期、数值、布尔类型
DSL 实现聚合
现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。
此时可以根据酒店品牌的名称做聚合,也就是 Bucket 聚合。
Bucket 聚合语法
语法如下:
GET /hotel/_search{"size": 0, // 设置 size 为 0,结果中不包含文档,只包含聚合结果"aggs": { // 定义聚合"brandAgg": { //给聚合起个名字"terms": { // 聚合的类型,按照品牌值聚合,所以选择term"field": "brand", // 参与聚合的字段"size": 5 // 希望获取的聚合结果数量}}}}
结果如下:
{"took" : 36,"timed_out" : false,"_shards" : {"total" : 1,"successful" : 1,"skipped" : 0,"failed" : 0},"hits" : {"total" : {"value" : 201,"relation" : "eq"},"max_score" : null,"hits" : [ ]},"aggregations" : {"brandAgg" : {"doc_count_error_upper_bound" : 0,"sum_other_doc_count" : 96,"buckets" : [{"key" : "7天酒店","doc_count" : 30},{"key" : "如家","doc_count" : 30},{"key" : "皇冠假日","doc_count" : 17},{"key" : "速8","doc_count" : 15},{"key" : "万怡","doc_count" : 13}]}}}
聚合结果排序
默认情况下,Bucket 聚合会统计 Bucket 内的文档数量,记为 _count,并且按照 _count 降序排序。
我们可以指定 order 属性,自定义聚合的排序方式:
GET /hotel/_search{"size": 0,"aggs": {"brandAgg": {"terms": {"field": "brand","order": {"_count": "asc" // 按照 _count 升序排列},"size": 5}}}}
限定聚合范围
默认情况下,Bucket 聚合是对索引库的所有文档做聚合,但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。
我们可以限定要聚合的文档范围,只要添加 query 条件即可:
GET /hotel/_search{"query": {"range": {"price": {"lte": 200 // 只对200元以下的文档聚合}}},"size": 0,"aggs": {"brandAgg": {"terms": {"field": "brand","size": 5}}}}
Metric 聚合语法
现在我们需要对桶内的酒店做运算,获取每个品牌的用户评分的 min、max、avg 等值。
这就要用到 Metric 聚合了,例如 stats 聚合:就可以获取 min、max、avg 等结果。
语法如下:
GET /hotel/_search{"size": 0,"aggs": {"brandAgg": {"terms": {"field": "brand","size": 5},"aggs": { // 是brands聚合的子聚合,也就是分组后对每组分别计算"score_stats": { // 聚合名称"stats": { // 聚合类型,这里stats可以计算min、max、avg等"field": "score" // 聚合字段,这里是score}}}}}}
这次的 score_stats 聚合是在 brandAgg 的聚合内部嵌套的子聚合。因为我们需要在每个桶分别计算。
另外,我们还可以给聚合结果做个排序:
GET /hotel/_search{"size": 0,"aggs": {"brandAgg": {"terms": {"field": "brand","size": 5,"order": {"scoreAgg.avg": "desc"}},"aggs": {"scoreAgg": {"stats": {"field": "score"}}}}}}
DSL 实现聚合小结
aggs 代表聚合,与 query 同级,此时 query 的作用是?
- 限定聚合的的文档范围
聚合必须的三要素:
- 聚合名称
- 聚合类型
- 聚合字段
聚合可配置属性有:
- size:指定聚合结果数量
- order:指定聚合结果排序方式
- field:指定聚合字段
Rest Client 实现聚合
API 语法
聚合条件与 query 条件同级别,因此需要使用 request.source() 来指定聚合条件。
聚合条件的语法:
request.source().size(0);request.source().aggregation(AggregationBuilders.terms("brand_agg").field("brand").size(20));
聚合的结果也与查询结果不同,API 也比较特殊。不过同样是 JSON 逐层解析:
// 4. 解析结果// 4.1 获取 aggregationsAggregations aggregations = response.getAggregations();// 4.2 根据名称获取聚合结果Terms brandTerms = aggregations.get("brandAgg");// 4.3 获取 buckets 并遍历for (Terms.Bucket bucket : brandTerms.getBuckets()) {// 获取 keyString key = bucket.getKeyAsString();System.out.println(key);}
业务需求
需求:搜索页面的品牌、城市等信息不应该是在页面写死,而是通过聚合索引库中的酒店数据得来的
分析:目前,页面的城市列表、星级列表、品牌列表都是写死的,并不会随着搜索结果的变化而变化。但是用户搜索条件改变时,搜索结果会跟着变化。
例如:用户搜索“东方明珠”,那搜索的酒店肯定是在上海东方明珠附近,因此,城市只能是上海,此时城市列表中就不应该显示北京、深圳、杭州这些信息了。也就是说,搜索结果中包含哪些城市,页面就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌。
如何得知搜索结果中包含哪些品牌?如何得知搜索结果中包含哪些城市?
使用聚合功能,利用 Bucket 聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了。
因为是对搜索结果聚合,因此聚合是限定范围的聚合,也就是说聚合的限定条件跟搜索文档的条件一致。
返回结果是一个 Map 结构:
- key 是字符串,城市、星级、品牌、价格
- value 是集合,例如多个城市的名称
业务实现
在 cn.itcast.hotel.web 包的 HotelController 中添加一个方法,遵循下面的要求:
- 请求方式:
POST - 请求路径:
/hotel/filters - 请求参数:
RequestParams,与搜索文档的参数一致 - 返回值类型:
Map<String, List<String>>
代码:
@PostMapping("filters")
public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
return hotelService.getFilters(params);
}
这里调用了 IHotelService 中的 getFilters 方法,尚未实现。
在 cn.itcast.hotel.service.IHotelService 中定义新方法:
Map<String, List<String>> filters(RequestParams params);
在 cn.itcast.hotel.service.impl.HotelService 中实现该方法:
@Override
public Map<String, List<String>> getFilters(RequestParams params) {
try {
// 1. 准备 request
SearchRequest request = new SearchRequest("hotel");
// 2. 准备 DSL
// query
FunctionScoreQueryBuilder query = getQueryBuilder(params);
request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
request.source().query(query);
// 2.1 设置 size = 0
request.source().size(0);
// 2.2 聚合
HashMap<String, String> items = new HashMap<>();
items.put("brand", "品牌");
items.put("city", "城市");
items.put("starName", "星级");
for (String item : items.keySet()) {
request.source().aggregation(AggregationBuilders
.terms(item + "Agg")
.field(item)
.size(100));
}
// 3. 发出请求
SearchResponse response = null;
response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析结果
// 4.1 获取 aggregations
Aggregations aggregations = response.getAggregations();
HashMap<String, List<String>> itemListHashMap = new HashMap<>();
for (String item : items.keySet()) {
// 4.2 根据名称获取聚合结果
Terms brandTerms = aggregations.get(item + "Agg");
// 4.3 获取 buckets 并遍历
ArrayList<String> itemList = new ArrayList<>();
for (Terms.Bucket bucket : brandTerms.getBuckets()) {
// 获取 key
itemList.add(bucket.getKeyAsString());
}
itemListHashMap.put(item, itemList);
}
return itemListHashMap;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
