「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战」
Elasticsearch 除了最基本的搜索,数据分析功能也是十分强大,今天我们就来认识一下 ES 的聚合搜索功能,也可以叫分组搜索,类似于SQL语句中的 group by
。
1. 什么是聚合搜索
聚合可以将你的数据进行汇总,让其称为各种维度的指标,统计数据。聚合可以很好地回答你下面的几个问题:
- 我的网站的平均加载时间是多少?
- 根据交易量,谁是我最有价值的客户?
- 什么会被视为网络上的大文件?
- 每个产品类别中有多少产品?
Elasticsearch 将聚合分为三类:
- 从字段值计算指标(例如总和或平均值)的指标聚合。
- 基于字段值、范围或其他标准将文档分组到桶中的桶聚合,也称为桶。
- 从其他聚合而不是文档或字段获取输入的管道聚合。
下面我们就看一下如何来实现这样聚合。
2. 基本DSL语法
我们首先往 book
索引中插入几条数据,然后就可以来看一下具体效果。
2.1 指标聚合
比如我们想要得到索引里价格的平均值,那么就可以如下这么写:
GET /book/_search
{
"aggs": {
"avg_bucket": {
"avg": {
"field": "price"
}
}
}
}
复制代码
其中 aggs
是 aggregrations
的缩写,代表是聚合搜索的意思,跟我们之前使用的搜索使用的 query
代表的是常规搜索一样。然后 avg_bucket
是我们自己定义的聚合搜索的名称,其下一层的属性 avg
表示的是要进行的计算,这里的关键字只有 avg
、sum
、max
、min
几种运算,下一层的 field
属性就是要计算的字段,然后我们就可以得到所有图书的平均价格,返回如下结果:
{
"took": 62,
"timed_out": false,
"_shards": {
"total": 3,
"successful": 3,
"skipped": 0,
"failed": 0
},
"hits": {
...
},
"aggregations": {
"avg_bucket": {
"value": 65.64846185537485
}
}
}
复制代码
其中 aggregations
就是我们计算得到的结果了,还有如果我们只想返回计算结果,不想返回搜索得到的 hits
值怎么办呢?那么就需要将搜索的 size
设置为0:
GET /book/_search
{
"size": 0,
"aggs": {
"avg_bucket": {
"avg": {
"field": "price"
}
}
}
}
复制代码
就可以得到下面的结果:
{
"took": 43,
"timed_out": false,
"_shards": {
"total": 3,
"successful": 3,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 13,
"relation": "eq"
},
"max_score": null,
"hits": []
},
"aggregations": {
"avg_bucket": {
"value": 65.64846185537485
}
}
}
复制代码
2.2 桶聚合
下面呢,我们可以搞一个比较复杂的桶聚合,比如说,我们要看每个图书类目下,价格最贵的一本书是哪本。那么基本逻辑就是,先要按照图书类目进行分组,然后每个类目分组中要有一个价格 top1 的图书。这种就需要使用桶聚合进行搜索了:
GET /book/_search
{
"size": 0,
"aggs": {
"group": {
"terms": {
"field": "categoryId"
},
"aggs": {
"price_top_1": {
"top_hits": {
"size": 1,
"_source": {
"includes": [ // 注:为了节省返回数据,这里我们定义只返回这些字段
"id",
"bookName",
"categoryId",
"categoryName",
"price"
]
},
"sort": [
{
"price": {
"order": "desc"
}
}
]
}
}
}
}
}
}
复制代码
我们一层一层地看,最外层的 aggs
即为最初的聚合搜索条件,里面的 terms
表示我们需要根据某个字段进行分组,这里设置分组字段为类目ID categoryId
;下面还有一个 aggs
这个聚合搜索为要查询类目下面的内容,属于最外层的 sub_aggregration ,然后我们并不是取的类目下的所有数据,而是只取一条,所以这里是用的 top_hits
的方法,price_top_1
为我们自定义的聚合搜索名称,top_hits
为聚合搜索的关键字,表示这里取部分数据;然后 top_hits
中的属性,就是和我们正常搜索的属性差不多,定义 size: 1
,按照价格 price
倒序排列,表明我们按照价格倒序只取第一条。那么最后得到结果:
{
"took":23,
"timed_out":false,
"_shards":{
"total":3,
"successful":3,
"skipped":0,
"failed":0
},
"hits":{
"total":{
"value":13, // 文档总数
"relation":"eq"
},
"max_score":null,
"hits":[]
},
"aggregations":{
"group":{
"buckets":[ // 桶数组,下面即为有多少分组
{
"key":1, // 这里就是分组的值,即为 categoryId
"doc_count":6, // 这是表示这里命中了6个文档
"price_top_1":{ // 自定义的top1聚合结果
"hits":{
"total":{
"value":6, // 文档数
"relation":"eq"
},
"hits":[ // 这里就是拿到的 类目1 下的价格最高的图书
{
"_index":"book",
"_type":"_doc",
"_id":"3",
"_score":null,
"_source":{
"price":88,
"id":3,
"bookName":"Java编程思想",
"categoryName":"教科书",
"categoryId":1
}
}
]
}
}
},
{
"key":3,
"doc_count":4,
"price_top_1":{
"hits":{
"total":{
"value":4,
"relation":"eq"
},
"max_score":null,
"hits":[
{
"_index":"book",
"_type":"_doc",
"_id":"12",
"_source":{
"price":99.68,
"id":12,
"bookName":"周恩来传",
"categoryName":"人物传记",
"categoryId":3
}
}
]
}
}
},
{
"key":2,
"doc_count":3,
"price_top_1":{
"hits":{
"total":{
"value":3,
"relation":"eq"
},
"max_score":null,
"hits":[
{
"_index":"book",
"_type":"_doc",
"_id":"8",
"_source":{
"price":40,
"id":8,
"bookName":"神雕侠侣",
"categoryName":"小说",
"categoryId":2
}
}
]
}
}
}
]
}
}
}
复制代码
最后我们得到了分组结果,以及想要的数据。
注:想必聪明的你已经从搜索得到的分组结果里看出来了,分组的排序并不是按照类目ID进行排序的。这里分组的默认排序条件有两个:一个是 doc_count
,即分组中的文档数量,默认是降序排列,文档数越多越靠前;另一个是 _key
,即分组的key值,默认是生序排序,基本就是数字排序或者字符排序了。那么该怎么定义聚合搜索排序呢?这个大家可以自行探索一下。
3. Java代码
那么问题来了,我们怎么通过 Java 代码来实现 2.2 中的 DSL 语句呢?
套路还是固定的,首先需要构建一个搜索请求,然后组装搜索条件,只不过由原先的查询条件,变成了现在的聚合条件,代码如下:
private SearchResponse searchGroupData() {
// 构建搜索请求
SearchRequest searchRequest = new SearchRequest(EsConstant.BOOK_INDEX_NAME);
AggregationBuilder groupAggregation = AggregationBuilders
// 这里是定义的最外层聚合的名字
.terms("group")
// 这是返回的分组的数量,默认是10
// 大家可以根据自己的需要改返回的总数,最大不能超过设置的 max_result_window
.size(10)
// 要分组的字段
.field("categoryId");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 不需要返回hits中的数据,设置 size 为 0
searchSourceBuilder.size(0);
// TOP hits 取图书的 top1
// 定义返回的字段
String[] includes = {"id", "bookName", "categoryId", "categoryName", "price"};
String[] excludes = {};
AggregationBuilder topHits = AggregationBuilders
// 自定义的topHits名称
.topHits("price_top_1")
// 按照价格倒序
.sort(SortBuilders.fieldSort("price").order(SortOrder.DESC))
.fetchSource(includes, excludes)
// 只取1条
.size(1);
// 将两个聚合结合起来,topHits 作为分组的子聚合
groupAggregation.subAggregation(topHits);
// 求分组之后的总数
CardinalityAggregationBuilder totalAggregation = AggregationBuilders.cardinality("total")
.field("categoryId");
// 把聚合放在搜索构造器里
searchSourceBuilder.aggregation(groupAggregation);
searchSourceBuilder.aggregation(totalAggregation);
searchRequest.source(searchSourceBuilder);
try {
return client.search(searchRequest, COMMON_OPTIONS);
} catch (IOException e) {
log.error("Failed to aggregations search data", e);
}
return null;
}
复制代码
上面就是我们的查询代码,然后就算得到了结果,怎么解析得到我们想要的结果呢?我简单写了一个示例,大家可以自行参考:
@Override
public List<CategoryGroup> categoryGroup() {
SearchResponse groupSearchResponse = searchGroupData();
if (Objects.isNull(groupSearchResponse)) {
return Collections.emptyList();
}
Aggregations aggregations = groupSearchResponse.getAggregations();
List<CategoryGroup> groupList = new ArrayList<>();
if (Objects.nonNull(aggregations)) {
// 获取最外层的分组
// 这里我们分组的条件是categoryId,ES类型为long,所以需要使用 ParsedLongTerms 来接收结果
ParsedLongTerms group = aggregations.get("group");
// 获取总数的 aggregation
ParsedCardinality totalCount = aggregations.get("total");
// 这里的总数我们暂时不返回,只是打印
log.info("得到分组的总数:{}", totalCount);
// 遍历得到的桶
for (Terms.Bucket bucket : group.getBuckets()) {
// 获取每个桶中的top1数据
ParsedTopHits topHits = bucket.getAggregations().get("price_top_1");
SearchHits groupHits = topHits.getHits();
SearchHit[] hits = groupHits.getHits();
List<Book> books = new ArrayList<>();
Arrays.stream(hits).forEach(hit -> {
Map<String, Object> sourceAsMap = hit.getSourceAsMap();
Book book = BeanUtil.mapToBean(sourceAsMap, Book.class, false, new CopyOptions());
if (Objects.nonNull(book.getId())) {
books.add(book);
}
});
CategoryGroup categoryGroup = new CategoryGroup();
// 键值即为类目ID
categoryGroup.setCategoryId(Integer.valueOf(bucket.getKeyAsString()));
categoryGroup.setBooks(books);
groupList.add(categoryGroup);
}
}
return groupList;
}
复制代码
总结
本文我们简单展示了一下聚合搜索可以做的事情,但是不只是这些,还有一些未展示的内容,需要大家自己去尝试,比如聚合后结果如何进行分页,如何统计分组总数,排序是怎么自定义的等等。如果你遇到了问题,或者有了自己的方法,我们也可以一起交流一下,共同探讨。