前言

elasticsearch中如何实现多字段聚合统计?类似于mysql的:

select a,b from my_table group by a,b;

golang如何借助第三方类oliver elastic实现es原生DSL(domain special language 特殊领域查询语言)的构造?

希望看完这篇文章,对你有所帮助。

正文

单字段聚合

先来看单字段聚合统计的es原生DSL语句的写法:

{
  "query": {
    "bool": {
      "must": [
        {
          "query_string": {
            "query": "baidu",
            "fields": [
              "sample_hash.raw",
              "program_name.raw",
              "package_name.raw"
            ]
          }
        }
      ],
      "filter": [
        {
          "range": {
            "record_time": {
              "gte": "2016-09-01",
              "lte": "2020-07-24"
            }
          }
        },
        {
          "match": {
            "sample_state": "B"
          }
        }
      ]
    }
  },
 "aggs": {
  "packageNameGroup": {
   "terms": {
    "field": "package_name.raw",
    "size": 1000,
    "min_doc_count": 1,
    "shard_min_doc_count": 0,
    "show_term_doc_count_error": false
   }
  }
 }
}

为什么aggs.packageNameGroup.field的值需要在字段末尾加上.raw呢?

先看咱们的index的mapping设置是:

curl -XGET 'event Cluster/tip_sample_info_v2/info/_mapping'

java 操作低版本es多字段聚合返回整条信息内容 elasticsearch多字段聚合_JSON

看到这个字段的定义,你第一感觉是什么?如果你对mapping的字段定义有一定的认知,你应该会有所疑惑?

为啥外层的type是text,内容type是keyword?那么这个字段到底是text,还是keyword?

看官方解释:

java 操作低版本es多字段聚合返回整条信息内容 elasticsearch多字段聚合_JSON_02

java 操作低版本es多字段聚合返回整条信息内容 elasticsearch多字段聚合_golang_03

大致意思是,针对index的mapping的字段类型的定义,可以定义为复合类型,一般就是keyword+text的组合,来达到一个字段加不加.raw后缀,有不同的含义;毕竟聚合、排序只能针对keyword类型;

 那么不加.raw可以聚合查询吗?看下面这个查询,

java 操作低版本es多字段聚合返回整条信息内容 elasticsearch多字段聚合_golang_04

报错的意思是,Fielddata这个设置默认是关闭的,你现在想针对text类型做聚合统计,那么你可以打开这个Fielddata选项;

但是,据说这个选项开启很吃资源,一般不会为了聚合,来开启Fielddata选项的,还不如给聚合的text字段设置.raw的属性为keyword;

多字段聚合

接下来,多字段聚合怎么写呢?

curl -XPOST 'event Cluster/tip_sample_info_v2/info/_search' -d '{
  "aggs": {
   "vfamilyGroup": {
    "terms": {
     "field": "vfamily.raw",
     "size": 1000,
     "min_doc_count": 1,
     "shard_min_doc_count": 0,
     "show_term_doc_count_error": false
    },
    "aggs": {
     "variantGroup": {
      "terms": {
       "field": "variant.raw",
       "min_doc_count": 1,
       "shard_min_doc_count": 0,
       "show_term_doc_count_error": false
      }
     }
    }
   }
  }
 }'

java 操作低版本es多字段聚合返回整条信息内容 elasticsearch多字段聚合_elastic_05

多字段聚合,就是aggs.terms的嵌套,类似于mysql group by 多字段;

返回的聚合结果,当然也是嵌套组合的,同一个vfamily下面可能存在多个variant,所以解析的时候是双层循环;

最后,废话不多说,上代码(针对多字段的聚合统计,上一段我的代码,是基于oliver elasitic开源包实现DSL的构造和解析es返回的聚合结果,希望对你有所帮助):

/**
	新版本:使用es的DSL原生语法,来实现聚合统计
	目的:和首页的原生es的全文检索,更加匹配;不然容易造成两者结果矛盾
 */
func GetAggregateStatistics(c iris.Context)  {
	//1.接收请求参数
	startTime:=c.FormValue("start_time")
	endTime:=c.FormValue("end_time")
	if startTime!="" {
		startTime=tool.TimeParseToTZ(startTime)
	}
	if endTime!="" {
		endTime=tool.TimeParseToTZ(endTime)
	}

	//样本状态过滤
	sampleState:=c.FormValueDefault("sample_state","")

	//搜索关键词
	keyword:=c.FormValue("keyword")

	//聚合分组的字段
	groupType := c.FormValue("group_type")

	//二次检索(聚合统计页面,点击特定文本的超链接,跳转到当前方法,在keyword检索条件下,带上二次检索的过滤)
	secondKeyValue:=c.FormValueDefault("aggregate_key_value","")
	var fieldName string = ""
	var fieldValue string = ""
	if secondKeyValue != ""{
		kvArr := strings.Split(secondKeyValue, "=")
		fieldName = kvArr[0]
		fieldValue = kvArr[1]
		if fieldName == "record_time"{ //时间筛选不加.raw,并且要转化为utc筛选
			fieldValue = tool.TimeParseToTZ(fieldValue)
		}
	}

	//迭代:在全文检索的逻辑上,融入精确检索(产品需求。。。)
	termKeyValue:=c.FormValueDefault("term_key_value","")
	var termKey string = ""
	var termValue string = ""
	if termKeyValue!=""{
		termArr := strings.Split(termKeyValue, "=")
		termKey = termArr[0]
		termValue = termArr[1]
		if termKey == "start_time" || termKey=="end_time" {
			termValue = tool.TimeParseToTZ(termValue)
		}else{
			termKey=termKey+".raw"
		}
	}

	if termKeyValue=="" &&  keyword == ""{
		_,_=c.JSON(iris.Map{
			"code":    custom.ValidateErr,
			"message": "keyword不能为空",
			"data":    []interface{}{},
		})
		return
	}

	if groupType == ""{
		_,_=c.JSON(iris.Map{
			"code":    custom.ValidateErr,
			"message": "groupType不能为空",
			"data":    []interface{}{},
		})
		return
	}

	//group字段的特属处理(record_time的es类型是date,聚合统计不能用.raw,查出来的是微妙的时间戳)
	if groupType!="record_time"{
		groupType=groupType+".raw"
	}

	//2.QueryDSL的json字符串参数拼接
	boolQuery := elastic.NewBoolQuery()

	var queryFields []string=[]string{}
	if termKeyValue!=""{
		queryFields=[]string{termKey}
	}else{
		queryFields=[]string{"variant.raw","version.raw","version_name.raw","vfamily.raw","vir_name.raw","vtag_list.raw","vtype.raw"}
	}

	if termKeyValue!=""{
		escapeTermValue:=tool.QueryStringEscape(termValue)
		termQuery:=elastic.NewTermQuery(queryFields[0],escapeTermValue)
		boolQuery.Must(termQuery)
	}else{
		//针对keyword里面的特殊字符串:,需要转移,否则es直接报错
		escapedKeyword:= tool.QueryStringEscape(keyword)
		queryStringQuery:=elastic.NewQueryStringQuery("*"+escapedKeyword+"*")
		for _,v:=range queryFields{
			queryStringQuery=queryStringQuery.Field(v)
		}
		boolQuery.Must(queryStringQuery)
	}

	//filter (样本状态和入库时间的可选过滤)
	var matchQuery *elastic.MatchQuery
	if sampleState!="" {
		matchQuery=elastic.NewMatchQuery("sample_state",sampleState)
		boolQuery.Filter(matchQuery)
	}

	var rangeQuery *elastic.RangeQuery
	if startTime!="" && endTime!="" {
		rangeQuery=elastic.NewRangeQuery("record_time").From(startTime).To(endTime)
	}else if startTime!="" && endTime==""{
		rangeQuery=elastic.NewRangeQuery("record_time").From(startTime)
	}else if startTime=="" && endTime!=""{
		rangeQuery=elastic.NewRangeQuery("record_time").To(endTime)
	}
	if rangeQuery!=nil {
		boolQuery.Filter(rangeQuery)
	}

	//二次检索(条件再次加强,特定key的特定value的匹配)
	if termKeyValue=="" && fieldName!="" && fieldValue!="" {
		secondMatchPhraseQuery :=elastic.NewMatchPhraseQuery(fieldName, fieldValue)
		boolQuery.Filter(secondMatchPhraseQuery)
	}

	//实例化es的aggs
	TermsAggregation:=elastic.NewTermsAggregation().Field(groupType).MinDocCount(1).Size(100).
		ShowTermDocCountError(false).ShardMinDocCount(0)

	//针对家族名的聚合,是多字段聚合,特殊处理
	var subAggName=""
	if groupType=="vfamily.raw" {
		//针对家族字段排除掉Unknown的值
		TermsAggregation=TermsAggregation.Exclude("Unknown")

		subAggName="temp_sub_aggs_by_variant"
		TermsAggregation.SubAggregation(subAggName, elastic.NewTermsAggregation().Field("variant.raw").OrderByCountDesc())
	}


	//elastic.Search并聚合的结果
	aggName:="temp_aggs_by_field"	//外层聚合的名称
	searchResult, err := es.GetIns().Search("tip_sample_info_v2").Type("info").Query(boolQuery).Pretty(true).
		Aggregation(aggName,TermsAggregation).
		Do(context.Background())

	//这段不要删除,用来打印基于链式的es语法,转换出来的原生query的json字符串是啥
	src, err := boolQuery.Source()
	data, err := json.Marshal(src)
	if err != nil {
		logrus.Error("marshaling to JSON failed",err)
	}
	got := string(data)
	fmt.Println("query json:",got)

	if err!=nil {
		logrus.Error("tip_sample_info_v2 原生es查询失败:",err)
		_,_=c.JSON(iris.Map{
			"code":    custom.ValidateErr,
			"message": "es index:tip_sample_info_v2 查询异常,请稍后重试",
			"data":    "",
		})
		return
	}

	//打印聚合的DSL
	src, err = TermsAggregation.Source()
	data, err = json.Marshal(src)
	if err != nil {
		logrus.Error("marshaling to JSON failed",err)
	}
	got = string(data)
	fmt.Println("aggs query json:",got)

	if err!=nil {
		logrus.Error("tip_sample_info_v2 原生聚合失败:",err)
		_,_=c.JSON(iris.Map{
			"code":    custom.ValidateErr,
			"message": "es index:tip_sample_info_v2 聚合异常,请稍后重试",
			"data":    "",
		})
		return
	}

	//处理聚合后的结果
	// 使用Terms函数和前面定义的聚合条件名称,查询结果
	agg, found := searchResult.Aggregations.Terms(aggName)
	if !found {
		logrus.Error("没有找到聚合数据")
	}

	// 遍历桶数据
	var aggsForFrontEnd []map[string]interface{}=[]map[string]interface{}{}
	var totalCount int64 =0

    //单字段聚合结果解析
	if groupType!="vfamily.raw"{
		for key, bucket := range agg.Buckets {
			// 每一个桶都有一个key值,其实就是分组的值,可以理解为SQL的group by值
			bucketValue := bucket.Key

			//当前分组的统计值
			bucketCount:=bucket.DocCount

			var tempNewRow map[string]interface{}=map[string]interface{}{}
			tempNewRow["number"]=key+1
			tempNewRow["count"]=bucketCount
			tempNewRow["value"]=bucketValue
			tempNewRow["percentage"]=0
			aggsForFrontEnd=append(aggsForFrontEnd,tempNewRow)

			totalCount=totalCount+bucketCount
			// 打印结果, 默认桶聚合查询,都是统计文档总数
			//fmt.Printf("bucket = %q 文档总数 = %d\n", bucketValue, bucket.DocCount)
			// Now access the sub-aggregates directly via the bucket.
		}
	}else{
		//针对subAgg单独写es的返回值的解析方法(多字段聚合结果解析)
		flagNum:=1
		for _, bucket := range agg.Buckets {
			bucketKey:=bucket.Key

			// Now access the sub-aggregates directly via the bucket.
			subAgg, found := bucket.Terms(subAggName)
			if !found {
				logrus.Error("没有找到内层的聚合数据")
			}
            //解析内层数据
			for _, subBucket := range subAgg.Buckets {
				// sub
				subBucketCount:=subBucket.DocCount
				totalCount=totalCount+subBucketCount

				subBucketValue:=subBucket.Key

				var tempNewRow map[string]interface{}=map[string]interface{}{}
				tempNewRow["number"]=flagNum
				tempNewRow["count"]=subBucketCount
				tempNewRow["value"]=bucketKey
				tempNewRow["variant_value"]=subBucketValue
				tempNewRow["percentage"]=0
				aggsForFrontEnd=append(aggsForFrontEnd,tempNewRow)
				flagNum++
			}
		}
		//针对多字段聚合,需要按照百分比排序+array_slice 100(多字段组合出来会超过100条)
		tool.SortByColumn("count",1,aggsForFrontEnd)
	}

	//美化输出:完善百分比、record_time的时间戳转换为日期
	dataLen:=len(aggsForFrontEnd)
	if dataLen>100{
		dataLen=100
	}
	toolFunc := tool.ToolFunc{}
	for i:=0;i<dataLen;i++{
		if groupType=="record_time"{
			aggsForFrontEnd[i]["value"]=toolFunc.TimestampToDate(int64(aggsForFrontEnd[i]["value"].(float64)))
		}
		countPercentage := fmt.Sprintf("%.2f", (float64(aggsForFrontEnd[i]["count"].(int64))/float64(totalCount)) * 100)
		aggsForFrontEnd[i]["percentage"] =  countPercentage + "%"
	}

	endData := make(map[string]interface{})
	if dataLen<100{
        endData["data"] = aggsForFrontEnd
	}else{
		endData["data"] = aggsForFrontEnd[0:100]
	}

	_,_ = c.JSON(iris.Map{
		"code":    custom.GetSuccess,
		"msg": "获取成功",
		"data":    endData,
	})
	return
}

参考文档:

https://www.elastic.co/guide/en/elasticsearch/reference/7.10/index.html

https://github.com/olivere/elastic