开始使用 Elasticsearch (3)

985 篇文章 591 订阅

在今天的这篇文章中,我们将来学习如何运用 Elasticsearch 来对我们的数据进行分析及一些关于 Analyzer 的介绍。在学习这个之前,我们必须完成之前的练习:

  1. 开始使用Elasticsearch (1)-- 如何对文档进行操作

  2. 开始使用Elasticsearch (2)-- 如何对数据进行搜索

我们使用前面两个练习所使用的文章建立我们的 index,并在这篇文章中进行使用。

分析数据对很多的企业非常重要。它可以帮我们很快地分析出生产,运营中出现的问题,并实时地进行纠正或报警。

开始使用Elasticsearch (3)

开始使用Elasticsearch (3)_哔哩哔哩_bilibili

Aggregation 简介

Aggregation 在中文中也被称作聚合。简单地说,Elasticsearch 中的 aggregation 聚合将你的数据汇总为指标、统计数据或其他分析。聚合可帮助您回答以下问题:

  • 我的网站的平均加载时间是多少?
  • 根据交易量,谁是我最有价值的客户?
  • 什么会被认为是我网络上的大文件?
  • 每个产品类别有多少产品?
     

聚合框架有助于基于搜索查询提供聚合数据。它基于称为聚合的简单构建块,可以组合以构建复杂的数据摘要。

聚合可以被视为在一组文档上构建分析信息的工作单元。执行的上下文定义了该文档集的内容(例如,在执行的查询的上下文中执行顶级聚合/搜索请求的过滤器)。

有许多不同类型的聚合,每个聚合都有自己的目的和输出。为了更好地理解这些类型,通常更容易将它们分为四个主要方面:

构建存储桶的一系列聚合,其中每个存储桶与密钥和文档标准相关联。执行聚合时,将在上下文中的每个文档上评估所有存储桶条件,并且当条件匹配时,文档被视为“落入”相关存储桶。在聚合过程结束时,我们最终会得到一个桶列表 - 每个桶都有一组“属于”它的文档。

常见的 Bucket 聚合如下:

        - date_histogram

        - terms

        - filter

        - range

        - global 

更多关于 Bucket 聚合的例子请参阅文章 “Elasticsearch:透彻理解 Elasticsearch 中的 Bucket aggregation”。

聚合可跟踪和计算一组文档的指标。计算从数据中提取的数值,比如最大值,最小值,平均值,基数等。

一系列聚合,它们在多个字段上运行,并根据从请求的文档字段中提取的值生成矩阵结果。与度量标准和存储区聚合不同,此聚合系列尚不支持脚本。

聚合其他聚合的输出及其关联指标的聚合,比如:

       - bucket min/max/sum/avg

       - cumulative_sum

       - moving_aggs

       - bucket_sort

更多阅读,请参阅文章 “Elasticsearch:pipeline aggregation 介绍”。


接下来是有趣的部分。由于每个存储桶( bucket )有效地定义了一个文档集(属于该 bucket 的所有文档),因此可以在 bucket 级别上关联聚合,并且这些聚合将在该存储桶的上下文中执行。这就是聚合的真正力量所在:聚合可以嵌套!

注意一:bucketing聚合可以具有子聚合(bucketing 或 metric)。 将为其父聚合生成的桶计算子聚合。 嵌套聚合的级别/深度没有硬性限制(可以在“父”聚合下嵌套聚合,“父”聚合本身是另一个更高级聚合的子聚合)。

注意二:聚合可以操作于 double 类型的上限的数据。 因此,当在绝对值大于2 ^ 53的 long 上运行时,结果可能是近似的。

Aggregation 请求是搜索 API 的一部分,它可以带有一个 query 的结构或者不带。

准备数据

为了更好地展示,我们首先来把我们之前的 twitter 的数据做一点小的修改。我们添加一个新的字段 DOB (date of birth),也就是生日的意思。同时,我们也对 province,city 及 country 字段的类型做了调整,并把它们作为 keyword。我们来做如下的操作:

DELETE twitter

PUT twitter
{
  "mappings": {
    "properties": {
      "DOB": {
        "type": "date"
      },
      "address": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "age": {
        "type": "long"
      },
      "city": {
        "type": "keyword"
      },
      "country": {
        "type": "keyword"
      },
      "location": {
        "type": "geo_point"
      },
      "message": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "province": {
        "type": "keyword"
      },
      "uid": {
        "type": "long"
      },
      "user": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      }
    }
  }
}

请注意,我们在上面添加了一个叫做 DOB 的字段。在通常的情况下,我们并不需要定义这个字段的格式而由 Elasticsearch 自动检测它的格式。在有些时候,我们可以为这个字段添加想要的格式,比如:

      "DOB": {
        "type": "date",
        "format": "yyyy-MM-dd||epoch_millis"
      }

然后,我们再次使用 bulk API 来把我们的数据导入到 Elasticsearch 中:

POST _bulk
{"index":{"_index":"twitter","_id":1}}
{"user":"张三","message":"今儿天气不错啊,出去转转去","uid":2,"age":20,"city":"北京","province":"北京","country":"中国","address":"中国北京市海淀区","location":{"lat":"39.970718","lon":"116.325747"}, "DOB": "1999-04-01"}
{"index":{"_index":"twitter","_id":2}}
{"user":"老刘","message":"出发,下一站云南!","uid":3,"age":22,"city":"北京","province":"北京","country":"中国","address":"中国北京市东城区台基厂三条3号","location":{"lat":"39.904313","lon":"116.412754"}, "DOB": "1997-04-01"}
{"index":{"_index":"twitter","_id":3}}
{"user":"李四","message":"happy birthday!","uid":4,"age":25,"city":"北京","province":"北京","country":"中国","address":"中国北京市东城区","location":{"lat":"39.893801","lon":"116.408986"}, "DOB": "1994-04-01"}
{"index":{"_index":"twitter","_id":4}}
{"user":"老贾","message":"123,gogogo","uid":5,"age":30,"city":"北京","province":"北京","country":"中国","address":"中国北京市朝阳区建国门","location":{"lat":"39.718256","lon":"116.367910"}, "DOB": "1989-04-01"}
{"index":{"_index":"twitter","_id":5}}
{"user":"老王","message":"Happy BirthDay My Friend!","uid":6,"age":26,"city":"北京","province":"北京","country":"中国","address":"中国北京市朝阳区国贸","location":{"lat":"39.918256","lon":"116.467910"}, "DOB": "1993-04-01"}
{"index":{"_index":"twitter","_id":6}}
{"user":"老吴","message":"好友来了都今天我生日,好友来了,什么 birthday happy 就成!","uid":7,"age":28,"city":"上海","province":"上海","country":"中国","address":"中国上海市闵行区","location":{"lat":"31.175927","lon":"121.383328"}, "DOB": "1991-04-01"}

我们已经成功地导入数据。我们要特别注意的是,并不是所有的字段都可以做聚合的。一般来说,具有 keyword 或者数值类型的字段是可以做聚合的。我们可以通过 _field_caps 接口来进行查询:

GET twitter/_field_caps?fields=country

上面的命令给出的结果为:

{
  "indices" : [
    "twitter"
  ],
  "fields" : {
    "country" : {
      "keyword" : {
        "type" : "keyword",
        "searchable" : true,
        "aggregatable" : true
      }
    }
  }
}

它表明这个 country 字段既是可以搜索的也是可以聚合的。如果大家不喜欢这种命令,我们可以可以直接到 twitter 索引的 index pattern 中进行查看:

我们需要为 twitter 索引创建一个 index pattern。从上面我们可以看出来 address 字段是可以搜索的,因为它是一个 text 的类型字段,但是它不可以进行聚合。同时,我们可以看出来 address.keyword 这个字段是可以同时搜索的和聚合的。

聚合操作

简单地说,聚合的语法是这样的:

"aggregations" : {
    "<aggregation_name>" : {
        "<aggregation_type>" : {
            <aggregation_body>
        }
        [,"meta" : {  [<meta_data_body>] } ]?
        [,"aggregations" : { [<sub_aggregation>]+ } ]?
    }
    [,"<aggregation_name_2>" : { ... } ]*
}

通常,我们也可以使用 aggs 来代替上面的 “aggregations”。

下面,我们来针对我们的数据来进行一些简单的操作,这样可以使得大家更加明白一些。

 range 聚合

我们可以把用户进行年龄分段,查出来在不同的年龄段的用户:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age": {
      "range": {
        "field": "age",
        "ranges": [
          {
            "from": 20,
            "to": 22
          },
          {
            "from": 22,
            "to": 25
          },
          {
            "from": 25,
            "to": 30
          }
        ]
      }
    }
  }
}

在这里,我们使用 range 类型的聚合。在上面我们定义了不同的年龄段。通过上面的查询,我们可以得到不同年龄段的 bucket。显示的结果是:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "age" : {
      "buckets" : [
        {
          "key" : "20.0-22.0",
          "from" : 20.0,
          "to" : 22.0,
          "doc_count" : 1
        },
        {
          "key" : "22.0-25.0",
          "from" : 22.0,
          "to" : 25.0,
          "doc_count" : 1
        },
        {
          "key" : "25.0-30.0",
          "from" : 25.0,
          "to" : 30.0,
          "doc_count" : 3
        }
      ]
    }
  }
}

在上面,我们也注意到,我们把 size 设置为0。这是因为针对聚合,我们并不关心返回的结果,也就是在默认情况下返回的10个文档。这样做的好处是更快的响应以及较小的负载。当然,额外的好处是可以缓存聚合的结果。在第二次进行同样的聚合时,速度会比以前快。我们可以阅读另外一篇文章 “cache 在 Elasticsearch 中的应用” 。假如我们设置为1的话,我们可以看到如下的输出:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "twitter",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "user" : "张三",
          "message" : "今儿天气不错啊,出去转转去",
          "uid" : 2,
          "age" : 20,
          "city" : "北京",
          "province" : "北京",
          "country" : "中国",
          "address" : "中国北京市海淀区",
          "location" : {
            "lat" : "39.970718",
            "lon" : "116.325747"
          },
          "DOB" : "1999-04-01"
        }
      }
    ]
  },
  "aggregations" : {
    "age" : {
      "buckets" : [
        {
          "key" : "20.0-22.0",
          "from" : 20.0,
          "to" : 22.0,
          "doc_count" : 1
        },
        {
          "key" : "22.0-25.0",
          "from" : 22.0,
          "to" : 25.0,
          "doc_count" : 1
        },
        {
          "key" : "25.0-30.0",
          "from" : 25.0,
          "to" : 30.0,
          "doc_count" : 3
        }
      ]
    }
  }
}

从这里,我们可以看出来,在我们的输出中,也看到了其中的一个文档的输出。

我们可以在 bucket 聚合之下,做 sub-aggregation:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age": {
      "range": {
        "field": "age",
        "ranges": [
          {
            "from": 20,
            "to": 22
          },
          {
            "from": 22,
            "to": 25
          },
          {
            "from": 25,
            "to": 30
          }
        ]
      },
      "aggs": {
        "avg_age": {
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
}

上面的意思是我们针对每个桶 20-22,22-25,25-30,分别计算它们的平均年龄。上面显示的结果是:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "age" : {
      "buckets" : [
        {
          "key" : "20.0-22.0",
          "from" : 20.0,
          "to" : 22.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 20.0
          }
        },
        {
          "key" : "22.0-25.0",
          "from" : 22.0,
          "to" : 25.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 22.0
          }
        },
        {
          "key" : "25.0-30.0",
          "from" : 25.0,
          "to" : 30.0,
          "doc_count" : 3,
          "avg_age" : {
            "value" : 26.333333333333332
          }
        }
      ]
    }
  }
}

从上面我们可以看出来每个桶的平均年龄是多少。

你甚至可以在 sub-aggregation  之下做更多的聚合,比如:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age": {
      "range": {
        "field": "age",
        "ranges": [
          {
            "from": 20,
            "to": 22
          },
          {
            "from": 22,
            "to": 25
          },
          {
            "from": 25,
            "to": 30
          }
        ]
      },
      "aggs": {
        "avg_age": {
          "avg": {
            "field": "age"
          }
        },
        "min_age": {
          "min": {
            "field": "age"
          }
        },
        "max_age": {
          "max": {
            "field": "age"
          }
        }
      }
    }
  }
}

在上面,我们除了得到平均的年龄,也可以得到最大及最小的年龄。上面查询的结果为:

{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "age" : {
      "buckets" : [
        {
          "key" : "20.0-22.0",
          "from" : 20.0,
          "to" : 22.0,
          "doc_count" : 1,
          "max_age" : {
            "value" : 20.0
          },
          "avg_age" : {
            "value" : 20.0
          },
          "min_age" : {
            "value" : 20.0
          }
        },
        {
          "key" : "22.0-25.0",
          "from" : 22.0,
          "to" : 25.0,
          "doc_count" : 1,
          "max_age" : {
            "value" : 22.0
          },
          "avg_age" : {
            "value" : 22.0
          },
          "min_age" : {
            "value" : 22.0
          }
        },
        {
          "key" : "25.0-30.0",
          "from" : 25.0,
          "to" : 30.0,
          "doc_count" : 3,
          "max_age" : {
            "value" : 28.0
          },
          "avg_age" : {
            "value" : 26.333333333333332
          },
          "min_age" : {
            "value" : 25.0
          }
        }
      ]
    }
  }
}

更多关于 bucket 聚合,请参阅我的文章 “Elasticsearch:透彻理解 Elasticsearch 中的 Bucket aggregation”。

Filters 聚合

在上面,我们使用 ranges 把数据分成不同的 bucket。通常这样的方法只适合字段为数字的字段。我们按照同样的思路,可以使用 filter 来对数据进行分类。在这种方法中,我们甚至可以针对非数字字段来进行建立不同的 bucket。这类聚合我们称之为 Filter aggregagation。定义一个多存储桶聚合,其中每个存储桶都与一个过滤器相关联。 每个存储桶将收集与其关联的过滤器匹配的所有文档。我们可以使用如下的例子:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "by_cities": {
      "filters": {
        "filters": {
          "beijing": {
            "match": {
              "city": "北京"
            }
          },
          "shanghai": {
            "match": {
              "city": "上海"
            }
          }
        }
      }
    }
  }
}

在上面的例子中,我们使用 filters 来分别针对 “北京” 和 “上海” 两地的文档进行统计:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "by_cities" : {
      "buckets" : {
        "beijing" : {
          "doc_count" : 5
        },
        "shanghai" : {
          "doc_count" : 1
        }
      }
    }
  }
}

上面显示来自 “北京” 的有 5 个文档,而来自 “上海” 的只有一个文档。在上面我们为每个 filter 都取了一个名字,当然,我们如果不关心名称的话,我们甚至可以有如下的简洁格式:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "by_cities": {
      "filters": {
        "filters": [
          {
            "match": {
              "city": "北京"
            }
          },
          {
            "match": {
              "city": "上海"
            }
          }
        ]
      }
    }
  }
}

上面的返回结果为:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "by_cities" : {
      "buckets" : [
        {
          "doc_count" : 5
        },
        {
          "doc_count" : 1
        }
      ]
    }
  }
}

Filter 聚合

在当前文档集上下文中定义与指定过滤器匹配的所有文档的单个存储桶。 通常,这将用于将当前聚合上下文缩小到一组特定的文档。这类聚合被称之为 Filter aggregation。你也可以理解为是上面的 Filters aggregation 的特殊情况,在它里面只含有一个 filter 的 Filters aggregation。我们以一个例子来讲:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "beijing": {
      "filter": {
        "match": {
          "city": "北京"
        }
      },
      "aggs": {
        "avg_age": {
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
}

在上面,我们针对 “北京” 的文档,并求一个平均年龄。上面查询的结果为:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "beijing" : {
      "doc_count" : 5,
      "avg_age" : {
        "value" : 24.6
      }
    }
  }
}

date_range 聚合

我们可以使用 date_range 来统计在某个时间段里的文档数:

POST twitter/_search
{
  "size": 0,
  "aggs": {
    "birth_range": {
      "date_range": {
        "field": "DOB",
        "format": "yyyy-MM-dd",
        "ranges": [
          {
            "from": "1989-01-01",
            "to": "1990-01-01"
          },
          {
            "from": "1991-01-01",
            "to": "1992-01-01"
          }
        ]
      }
    }
  }
}

在上面我们查询出生年月(DOB)从1989-01-01到1990-01-01及从1991-01-01到1992-01-01的文档。显示的结果是:

  "aggregations" : {
    "birth_range" : {
      "buckets" : [
        {
          "key" : "1989-01-01-1990-01-01",
          "from" : 5.99616E11,
          "from_as_string" : "1989-01-01",
          "to" : 6.31152E11,
          "to_as_string" : "1990-01-01",
          "doc_count" : 1
        },
        {
          "key" : "1991-01-01-1992-01-01",
          "from" : 6.62688E11,
          "from_as_string" : "1991-01-01",
          "to" : 6.94224E11,
          "to_as_string" : "1992-01-01",
          "doc_count" : 1
        }
      ]
    }

terms 聚合

我们也可以通过 terms 聚合来查询某一个关键字出现的频率。在如下的 terms 聚合中,我们想寻找在所有的文档出现 ”Happy birthday” 里按照城市进行分类的一个聚合。

GET twitter/_search
{
  "query": {
    "match": {
      "message": "happy birthday"
    }
  },
  "size": 0,
  "aggs": {
    "city": {
      "terms": {
        "field": "city",
        "size": 10
      }
    }
  }
}

注意这里的 10 指的是前 10 名的城市。聚合的结果是:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "city" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "北京",
          "doc_count" : 2
        },
        {
          "key" : "上海",
          "doc_count" : 1
        }
      ]
    }
  }
}

在上面,我们可以看出来,在所有的含有 "Happy birthday" 的文档中,有两个是来自北京的,有一个是来自上海。

在正常的情况下,聚合是按照 doc_count 来进行排序的,也就是说哪一个 key 的 doc_count 越多,那么它就排在第一位,以后依次排序。如果你想按照 key 进行排序的话,你可以尝试如下的方法:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "top_cities": {
      "terms": {
        "field": "city",
        "order": {
          "_key": "asc"
        }
      }
    }
  }
}

上面返回的结果是:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "top_cities" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "上海",
          "doc_count" : 1
        },
        {
          "key" : "北京",
          "doc_count" : 5
        }
      ]
    }
  }
}

在这里,我们看到和之前不一样的排序。doc_count 为 1 的 “上海” 反而排到前面去了,虽然这个和我们之前的 asc (上升)排序是有点不太一致的地方 (按照拼音 shanghai 应该比  beijing 要大)。这个和语言的处理有关,但是我们确实看到我们是可以控制这个排序的。你也可以使用 _count 来进行升序的排列:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "top_cities": {
      "terms": {
        "field": "city",
        "order": {
          "_count": "asc"
        }
      }
    }
  }
}

你也可以通过在 terms 聚合中指定顺序来使用 nested aggreation 的结果进行排序,而不是按计数对结果进行排序:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "top_cities": {
      "terms": {
        "field": "city",
        "order": {
          "avg_age": "desc"
        }
      },
      "aggs": {
        "avg_age": {
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
}

在上面排序是按照 nested aggregation 的结果 avg_age 来按照降序来排列的。显示的结果为:

  "aggregations" : {
    "top_cities" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "上海",
          "doc_count" : 1,
          "avg_age" : {
            "value" : 28.0
          }
        },
        {
          "key" : "北京",
          "doc_count" : 5,
          "avg_age" : {
            "value" : 24.6
          }
        }
      ]
    }
  }

我们也可以使用 script 来生成一个在索引里没有的术语来进行统计。比如,我们可以通过如下的 script 来生成一个对文档人出生年份的统计:

POST twitter/_search
{
  "size": 0,
  "aggs": {
    "birth_year": {
      "terms": {
        "script": {
          "source": "2019 - doc['age'].value"
        }, 
        "size": 10
      }
    }
  }
}

在上面,我们通过脚本:

        "script": {
          "source": "2019 - doc['age'].value"
        }

根据年龄来生成出生的年月来进行统计:

  "aggregations" : {
    "birth_year" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "1989",
          "doc_count" : 1
        },
        {
          "key" : "1991",
          "doc_count" : 1
        },
        {
          "key" : "1993",
          "doc_count" : 1
        },
        {
          "key" : "1994",
          "doc_count" : 1
        },
        {
          "key" : "1997",
          "doc_count" : 1
        },
        {
          "key" : "1999",
          "doc_count" : 1
        }
      ]
    }
  }

在上面我们可以看到 key 为1991,1993,1994等。这些 key 在我们原有的字段中根本就不存在。

在实际的使用中,我们甚至可以通过查询来替换我们的 key。我们用一个例子来进行展示:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "city_distribution": {
      "terms": {
        "field": "city",
        "size": 2,
        "script": {
          "source": """
            if(params.replace.containsKey(_value)) {
              params.replace[_value]
            } else {
              "Unknown city"
            }
          """,
          "params": {
            "replace": {
              "上海": "Shanghai",
              "北京": "Beijing"
            }
          }
        }
      }
    }
  }
}

在上面,我们可能想把中文的 key 改为 英文的 key,这样在我们的统计中是 Beijing 及 Shanghai 而不是 “北京” 及 “上海”。请注意上面的 _value 指的是 city 字段的值。运行上面的聚合,我们可以看到:

{
  "aggregations": {
    "city_distribution": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Beijing",
          "doc_count": 5
        },
        {
          "key": "Shanghai",
          "doc_count": 1
        }
      ]
    }
  }
}

如我们所愿,我们的 key 现在变成英文的了,而不再是中文的。

Histogram Aggregation

基于多桶值源的汇总,可以应用于从文档中提取的数值或数值范围值。 它根据值动态构建固定大小(也称为间隔)的存储桶。

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_distribution": {
      "histogram": {
        "field": "age",
        "interval": 2
      }
    }
  }
}

显示结果:

  "aggregations" : {
    "age_distribution" : {
      "buckets" : [
        {
          "key" : 20.0,
          "doc_count" : 1
        },
        {
          "key" : 22.0,
          "doc_count" : 1
        },
        {
          "key" : 24.0,
          "doc_count" : 1
        },
        {
          "key" : 26.0,
          "doc_count" : 1
        },
        {
          "key" : 28.0,
          "doc_count" : 1
        },
        {
          "key" : 30.0,
          "doc_count" : 1
        }
      ]
    }
  }

上面显示从 20-22 年龄段,有一个文档。从 22-24 也有一个文档。

同样地,我们也可以进行 sub aggregation,并按照 sub aggregation 的值进行排序:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_distribution": {
      "histogram": {
        "field": "age",
        "interval": 2,
        "order": {
          "avg_age": "desc"
        }
      },
      "aggs": {
        "avg_age": {
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
}

上面返回的结果是:

{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "age_distribution" : {
      "buckets" : [
        {
          "key" : 30.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 30.0
          }
        },
        {
          "key" : 28.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 28.0
          }
        },
        {
          "key" : 26.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 26.0
          }
        },
        {
          "key" : 24.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 25.0
          }
        },
        {
          "key" : 22.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 22.0
          }
        },
        {
          "key" : 20.0,
          "doc_count" : 1,
          "avg_age" : {
            "value" : 20.0
          }
        }
      ]
    }
  }
}

显然这个是按照 avg_age 的值进行降序排列的。

date_histogram

这种聚合类似于正常的直方图,但只能与日期或日期范围值一起使用。 由于日期在 Elasticsearch 中内部以 long 类型值表示,因此也可以但不准确地对日期使用正常的直方图。

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_distribution": {
      "date_histogram": {
        "field": "DOB",
        "interval": "year"
      }
    }
  }
}

请注意:在新的一些版本中,interval 不再适用。取而代之的是 calendar_interval。你需要改写你的搜索为如下的格式:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "age_distribution": {
      "date_histogram": {
        "field": "DOB",
        "calendar_interval": "year"
      }
    }
  }
}

在上面我们使用 DOB 来作为 date_histogram 的字段来进行聚合统计。我们按照每隔一年这样的时间间隔来进行。显示结果:

  "aggregations" : {
    "age_distribution" : {
      "buckets" : [
        {
          "key_as_string" : "1989-01-01T00:00:00.000Z",
          "key" : 599616000000,
          "doc_count" : 1
        },
        {
          "key_as_string" : "1990-01-01T00:00:00.000Z",
          "key" : 631152000000,
          "doc_count" : 0
        },
        {
          "key_as_string" : "1991-01-01T00:00:00.000Z",
          "key" : 662688000000,
          "doc_count" : 1
        },
        {
          "key_as_string" : "1992-01-01T00:00:00.000Z",
          "key" : 694224000000,
          "doc_count" : 0
        },
        {
          "key_as_string" : "1993-01-01T00:00:00.000Z",
          "key" : 725846400000,
          "doc_count" : 1
        },
        {
          "key_as_string" : "1994-01-01T00:00:00.000Z",
          "key" : 757382400000,
          "doc_count" : 1
        },
        {
          "key_as_string" : "1995-01-01T00:00:00.000Z",
          "key" : 788918400000,
          "doc_count" : 0
        },
        {
          "key_as_string" : "1996-01-01T00:00:00.000Z",
          "key" : 820454400000,
          "doc_count" : 0
        },
        {
          "key_as_string" : "1997-01-01T00:00:00.000Z",
          "key" : 852076800000,
          "doc_count" : 1
        },
        {
          "key_as_string" : "1998-01-01T00:00:00.000Z",
          "key" : 883612800000,
          "doc_count" : 0
        },
        {
          "key_as_string" : "1999-01-01T00:00:00.000Z",
          "key" : 915148800000,
          "doc_count" : 1
        }
      ]
    }

上面的结果显示 DOB 从 1989-01-01 到 1990-01-01 有一个文档。从 1990-01-01 到 1991-01-01 区间没有一个文档。

估计日期直方图聚合将产生的桶数非常困难。 通常,生成大量存储桶并存在内存和性能问题是很常见的,不仅在 Elasticsearch 端,而且在应用程序级别。 为了防止这个问题,Elasticsearch 引入了一个日期直方图聚合,它可以自动调整间隔以生成所需数量的桶:auto_date_histogram

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "age_distribution": {
      "auto_date_histogram": {
        "field": "DOB",
        "buckets": 10,
        "format": "yyy-MM-dd"
      }
    }
  }
}

上面的聚合生成的结果为:

{
  "aggregations": {
    "age_distribution": {
      "buckets": [
        {
          "key_as_string": "1989-01-01",
          "key": 599616000000,
          "doc_count": 3
        },
        {
          "key_as_string": "1994-01-01",
          "key": 757382400000,
          "doc_count": 2
        },
        {
          "key_as_string": "1999-01-01",
          "key": 915148800000,
          "doc_count": 1
        }
      ],
      "interval": "5y"
    }
  }
}

如上所示,在返回的结果中,我们可以看到 interval 被自动计算为 5y,也即5年。

cardinality 聚合

我们也可以使用 cardinality 聚合来统计到底有多少个城市:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "number_of_cities": {
      "cardinality": {
        "field": "city"
      }
    }
  }
}

运行上面的查询,我们可以看到结果是:

{
  "took" : 6,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "number_of_cities" : {
      "value" : 2
    }
  }
}

它显示我们有两个城市:北京 及 上海。它们在文档中虽然出现多次,但是从唯一性上,只有两个城市。

Metric 聚合

我们可以使用 Metrics 来统计我们的数值数据,比如我们想知道所有用户的平均年龄是多少?我们可以用下面的聚合:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "average_age": {
      "avg": {
        "field": "age"
      }
    }
  }
}

我们的返回的结果是:

  "aggregations" : {
    "average_age" : {
      "value" : 25.166666666666668
    }
  }

所有人的平均年龄是 25.166666666666668岁。

我们可以针对这个字段做其它的指标聚合,比如 sum:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "sum_age": {
      "sum": {
        "field": "age"
      }
    }
  }
}

上面的命令查询所有年龄相加的结果:

{
  "aggregations": {
    "sum_age": {
      "value": 151
    }
  }
}

我们还可以查询这些文档里的年龄最大的值:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "max_age": {
      "max": {
        "field": "age"
      }
    }
  }
}

上面的命令返回:

{
  "aggregations": {
    "max_age": {
      "value": 30
    }
  }
}

上面表明,文档中 age 的最大值为 30。我们还可以得到 age 的最小值:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "min_age": {
      "min": {
        "field": "age"
      }
    }
  }
}

上面的命令返回:

{
  "aggregations": {
    "min_age": {
      "value": 20
    }
  }
}

除了上面的最为基本的指标聚合,我们还可以使用脚本来进行指标聚合。我们来使用 script 来实现 age 的2倍相加的结果:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "two_times_age_sum": {
      "sum": {
        "script": {
          "source": "doc['age'].value * 2"
        }
      }
    }
  }
}

在上面,我们把 age 都乘以 2,再进行相加。这样得到的聚合为:

{
  "aggregations": {
    "two_times_age_sum": {
      "value": 302
    }
  }
}

这个显然是我们之前的没有乘以2 的聚合的两倍。这个脚本在实际的使用中非常有用。比如,我们的字段中有价格及数量,那么我们很容通过这个脚本算出来总价:数量 x 价钱。

在 Elastic Stack 7.11 的发布版之后,我们可以使用 runtime fields 来实现同样的功能:

GET twitter/_search?filter_path=aggregations
{
  "size": 0,
   "runtime_mappings": {
    "age.corrected": {
      "type": "double",
      "script": {
        "source": "emit(doc['age'].value * params.correction)",
        "params": {
          "correction": 2.0
        }
      }
    }
  },
  "aggs": {
    "two_times_age_sum": {
      "sum": {
        "field": "age.corrected"
      }
    }
  }
}

在上面,我们定义了一个叫做 age.corrected 的运行时字段。这种方法是在未来的版本中被推荐的方法。如果你想了解更关于 runtime fields 的知识,请参考文章 “Elastic:开发者上手指南” 中的 runtime field 系列文章。上面命令返回的结果和上一个脚本命令是一样的。

我们也可以对只在北京的用户文档进行统计:

POST twitter/_search
{
  "size": 0,
  "query": {
    "match": {
      "city": "北京"
    }
  },
  "aggs": {
    "average_age_beijing": {
      "avg": {
        "field": "age"
      }
    }
  }
}

上面我们先查询到所有在北京的用户,然后再对这些文档进行求年龄的平均值。返回的结果:

  "aggregations" : {
    "average_age_beijing" : {
      "value" : 24.6
    }
  }

聚合通常在查询搜索结果上执行。 Elasticsearch 提供了一个特殊的 global 聚合,该全局全局对所有文档执行,而不受查询的影响。

POST twitter/_search
{
  "size": 0,
  "query": {
    "match": {
      "city": "北京"
    }
  },
  "aggs": {
    "average_age_beijing": {
      "avg": {
        "field": "age"
      }
    },
    "average_age_all": {
      "global": {},
      "aggs": {
        "age_global_avg": {
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
}

在上面我们在 average_age_all 里添加了一个 gobal 的聚合,这个平均值将会使用所有的6个文档而不是限于在这个查询的5个北京的文档。返回的结果是:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "average_age_beijing" : {
      "value" : 24.6
    },
    "average_age_all" : {
      "doc_count" : 6,
      "age_global_avg" : {
        "value" : 25.166666666666668
      }
    }
  }
}

我们也可以对整个年龄进行一个统计,比如:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_stats": {
      "stats": {
        "field": "age"
      }
    }
  }
}

统计的结果如下:

  "aggregations" : {
    "age_stats" : {
      "count" : 6,
      "min" : 20.0,
      "max" : 30.0,
      "avg" : 25.166666666666668,
      "sum" : 151.0
    }
  }

在这里,我们可以看到到底有多少条数据,并且最大,最小的,平均值及加起来的合都在这里一起显示。

如果你想了解更多的细节,你可以使用 extended_stats:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_stats": {
      "extended_stats": {
        "field": "age"
      }
    }
  }
}

上面返回的结果为:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "age_stats" : {
      "count" : 6,
      "min" : 20.0,
      "max" : 30.0,
      "avg" : 25.166666666666668,
      "sum" : 151.0,
      "sum_of_squares" : 3869.0,
      "variance" : 11.472222222222248,
      "std_deviation" : 3.3870669054835996,
      "std_deviation_bounds" : {
        "upper" : 31.940800477633868,
        "lower" : 18.392532855699468
      }
    }
  }
}

对于一些不知道上面的 std_derivation 和 varianace 的开发者来说,我们举如下的一个例子:

假如你有一个系列的数据: 32,111,138,28,59,77,97,那么它的平均值为 (32+111+138+28+59+77+97) / 7 = 77.4。针对每个值,找出和平均值之间的差异:

 32 - 77.4 = -45.4
111 - 77.4 =  33.6
138 - 77.4 =  60.6
 28 - 77.4 = -49.4
 59 - 77.4 = -18.4
 77 - 77.4 = - 0.4
 97 - 77.4 =  19.6

针对上面的每个值,他们的平方值为:

(-45.4)^2 = 2061.16
 (33.6)^2 = 1128.96
 (60.6)^2 = 3672.36
(-49.4)^2 = 2440.36
(-18.4)^2 =  338.56
(- 0.4)^2 =    0.16
 (19.6)^2 =  384.16

variance 的值其实就是上面的差值平方和的平均值:

variance = (2061.16+1128.96+3672.36+2440.36+338.56+0.16+384.16) / 7 = 1432.2

那么 std_derivation 的值其实就是上面值的平方根值:std_derivation = sqrt(variance) = sqrt(1432.2) = 37.84。
如果你想对多个指标进行统计并显示它们之间的关系,你可以使用 matrix_stats

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "matrix_stats": {
      "matrix_stats": {
        "fields": ["age", "uid"]
      }
    }
  }
}

上面,我们把两个整型字段 uid 及 age 放入进行统计。上面的查询显示的结果为:

{
  "took" : 7,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "matrix_stats" : {
      "doc_count" : 6,
      "fields" : [
        {
          "name" : "uid",
          "count" : 6,
          "mean" : 4.5,
          "variance" : 3.5,
          "skewness" : 0.0,
          "kurtosis" : 1.7314285714285715,
          "covariance" : {
            "uid" : 3.5,
            "age" : 5.7
          },
          "correlation" : {
            "uid" : 1.0,
            "age" : 0.8211574455173661
          }
        },
        {
          "name" : "age",
          "count" : 6,
          "mean" : 25.166666666666668,
          "variance" : 13.76666666666667,
          "skewness" : -0.143450283024544,
          "kurtosis" : 1.8030533098042432,
          "covariance" : {
            "uid" : 5.7,
            "age" : 13.76666666666667
          },
          "correlation" : {
            "uid" : 0.8211574455173661,
            "age" : 1.0
          }
        }
      ]
    }
  }
}

我们也可以只得到这个年龄的最大值:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_max": {
      "max": {
        "field": "age"
      }
    }
  }
}

显示的结果:

  "aggregations" : {
    "age_max" : {
      "value" : 30.0
    }
  }

我们也可以在同一个请求里聚合多个指标,比如在如下的请求中,我们可以同时得到最大及最小的值:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "Min": {
      "min": {
        "field": "age"
      }
    },
    "Max": {
      "max": {
        "field": "age"
      }
    }
  }
}

聚合通常适用于从聚合文档集中提取的值。 可以使用聚合体内的字段键从特定字段提取这些值,也可以使用脚本提取这些值。我们可以通过 script 的方法来对我们的 aggregtion 结果进行重新计算:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "average_age_1.5": {
      "avg": {
        "field": "age",
        "script": {
          "source": "_value * params.correction",
          "params": {
            "correction": 1.5
          }
        }
      }
    }
  }
}

上面的这个聚合可以帮我们计算平均值再乘以 1.5 倍的结果。运行一下的结果如下:

  "aggregations" : {
    "average_age_1.5" : {
      "value" : 37.75
    }
  }

显然我们的结果是之前的 25.166666666666668 的1.5倍。

我们也可以直接使用 script 的方法来进行聚合。在这种情况下,我们可以不指定特定的 field。我们可能把很多项进行综合处理,并把这个结果来进行聚合:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "average_2_times_age": {
      "avg": {
        "script": {
          "source": "doc['age'].value * params.times",
          "params": {
            "times": 2.0
          }
        }
      }
    }
  }
}

在这里我们完全没有使用 field 这个项。我们直接使用 script 来形成我们的聚合:

  "aggregations" : {
    "average_2_times_age" : {
      "value" : 50.333333333333336
    }
  }

Percentile aggregation

百分位数(percentile)表示观察值出现一定百分比的点。 例如,第 95 个百分位数是大于观察值的 95% 的值。该聚合针对从聚合文档中提取的数值计算一个或多个百分位数。 这些值可以从文档中的特定数字字段中提取,也可以由提供的脚本生成。

百分位通常用于查找离群值。 在正态分布中,第 0.13 和第 99.87 个百分位数代表与平均值的三个标准差。 任何超出三个标准偏差的数据通常被视为异常。这在统计的角度是非常有用的。

假如我们考虑如下的一个系列的数据:

77, 78, 85, 86, 86, 86, 8787, 88, 94, 99, 103, 111

在上面的 13 个数据中,它的中间位数据为 87,这是因为这个系列的数据是奇数个。我们再来考虑下面偶数个系列的数据:

77, 78, 85, 86, 86, 86, 87, 87, 94, 98, 99, 103

那么显然上面数据的中间为: (86 + 87) / 2 = 86.5。

我们现在来通过一个简单的例子来展示 Percentile aggregation 的用法:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_quartiles": {
      "percentiles": {
        "field": "age",
        "percents": [
          25,
          50,
          75,
          100
        ]
      }
    }
  }
}

在上面,我们使用了以叫做 age 的字段。它是一个数值的字段。我们通过 percentile aggregation 可以得到 25%,50% 及75% 的人在什么范围。显示结果是: 

  "aggregations" : {
    "age_quartiles" : {
      "values" : {
        "25.0" : 22.0,
        "50.0" : 25.5,
        "75.0" : 28.0,
        "100.0" : 30.0
      }
    }
  }

我们可以看到25%的人平均年龄是低于22.0岁,而50%的人的年龄是低于25.5岁,而所有的人的年龄都是低于30岁的。这里的50%的年龄和我们之前计算的平均年龄是不一样的。

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "avarage_age": {
      "avg": {
        "field": "age"
      }
    }
  }
}

这个平均年龄是:

  "aggregations" : {
    "avarage_age" : {
      "value" : 25.166666666666668
    }
  }

在上面,我们能够查出来,50%的人的年龄是多少,但是在实际的应用中,我们有时也很希望知道满足我们的 SLA (Service Level Aggreement) 百分比是多少,这样你可以找到自己服务的差距,比如达到一个标准的百分比是多少。针对我们的例子,我们可以使用 Percentile Ranks Aggregation。我们使用如下的查询:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "age_40_percentage": {
      "percentile_ranks": {
        "field": "age",
        "values": [
          40
        ]
      }
    }
  }
}

上面返回的结果是:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 6,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "age_40_percentage" : {
      "values" : {
        "40.0" : 62.5
      }
    }
  }
}

上面的结果表明,有62.5%的人的年龄是在40岁以下的。在我们的实际应用中,比如我们输入一个指标,我们可以看出来有多少比例是在那个指标以内的。这样我们可以看出来我们的 SLA 是否满足条件。

我们可以使用 string stats 聚合来对 string 类型的数据进行统计。

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "message_stats": {
      "string_stats": {
        "field": "message.keyword"
      }
    }
  }
}

上面显示的结果为:

  "aggregations" : {
    "message_stats" : {
      "count" : 6,
      "min_length" : 9,
      "max_length" : 37,
      "avg_length" : 18.166666666666668,
      "entropy" : 5.406152357759698
    }
  }

它表明 message 字段的最短长度为 9,最长长度为 37,而它的平均长度为 18.17。
 

更为复杂的聚合

我们可以结合上面的 bucket 聚合及 metric 聚合形成更为复杂的搜索:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "cities": {
      "terms": {
        "field": "city",
        "order": {
          "average_age": "desc"
        }, 
        "size": 5
      },
      "aggs": {
        "average_age": {
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
}

在上面,我们首先通过 terms 来生成每个城市的桶聚合,让后在每个桶里计算所有文档的平均年龄。在正常的情况下,这个排序是按照每个城市里文档的多少由多到少来排序的。在我们上面的搜索中,我们特意添加 average_age 来进行降序排序。这样返回的结果如下:

  "aggregations" : {
    "cities" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "上海",
          "doc_count" : 1,
          "average_age" : {
            "value" : 28.0
          }
        },
        {
          "key" : "北京",
          "doc_count" : 5,
          "average_age" : {
            "value" : 24.6
          }
        }
      ]
    }

上面显示,有两个城市:上海及北京。在上海城市中有1个文档,而在北京城市里有5个文档。同时,我们也计算出来每个城市的平均年龄。由于我们使用了 average_age 来进行降排序,在我们的结果中,我们可以看到 “上海” 城市排在前面,这是因为上海城市的平均年龄比北京的平均年龄高。

geo distance 聚合

Elasticsearch 允许您针对 GeoPoint 执行聚合:地理距离聚合。使用文档中可用的 location 字段,我们将聚合以下五个范围内的其他文档:

  • 不到 10 公里
  • 从 10 公里到 20 公里
  • 从 20 公里到 50 公里
  • 从 50 公里到 100 公里
  • 100 公里以上

我们执行如下的操作:

POST twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "NAME": {
      "geo_distance": {
        "field": "location",
        "origin": {
          "lat": 39.970718,
          "lon": 116.325747
        },
        "ranges": [
          {
            "to": 10
          },
          {
            "from": 10,
            "to": 20
          },
          {
            "from": 20,
            "to": 50
          },
          {
            "from": 50,
            "to": 100
          },
          {
            "from": 100
          }
        ]
      }
    }
  }
}

上面统计的结果为:

{
  "aggregations": {
    "NAME": {
      "buckets": [
        {
          "key": "*-10.0",
          "from": 0,
          "to": 10,
          "doc_count": 1
        },
        {
          "key": "10.0-20.0",
          "from": 10,
          "to": 20,
          "doc_count": 0
        },
        {
          "key": "20.0-50.0",
          "from": 20,
          "to": 50,
          "doc_count": 0
        },
        {
          "key": "50.0-100.0",
          "from": 50,
          "to": 100,
          "doc_count": 0
        },
        {
          "key": "100.0-*",
          "from": 100,
          "doc_count": 5
        }
      ]
    }
  }
}

Geo bounds 聚合

有一组与查询匹配的文档是一个非常常见的场景,你需要知道包含它们的框; 这种情况的解决方案是地理边界指标聚合。比如,我们使用如下的查询:

POST twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "box": {
      "geo_bounds": {
        "field": "location",
        "wrap_longitude": true
      }
    }
  }
}

上面的聚合结果是:

{
  "aggregations": {
    "box": {
      "bounds": {
        "top_left": {
          "lat": 39.970717993564904,
          "lon": 116.32574695162475
        },
        "bottom_right": {
          "lat": 31.175926965661347,
          "lon": 121.38332798145711
        }
      }
    }
  }
}

地理边界聚合是一种度量聚合,能够计算存储桶中所有文档的框。它允许你使用以下参数:

  • field:这是包含文档地理点的字段。
  • wrap_longitude:这是一个可选参数,指定是否应允许边界框与国际日期变更线重叠(默认为 true)。

返回的框(正方形)由两个地理点给出:左上角和右下角。我们可以参照文章 “Elasticsearch:如何制作 GeoJSON 文件并进行地理位置搜索” 制作如下的的一个 GeoJSON 文档,并使用 Maps 绘制在地图上。

sample.json

{
    "type": "FeatureCollection",
    "features": [
    {
        "type": "Feature",
        "properties": {},
        "geometry": {
            "type": "Polygon",
            "coordinates": [
                [
                      [
                        116.32574695162475,
                        39.970717993564904
                      ],
                      [
                        121.38332798145711,
                        39.970717993564904
                      ],
                      [
                        121.38332798145711,
                        31.175926965661347
                      ],
                      [
                        116.32574695162475,
                        31.175926965661347
                      ],
                      [
                        116.32574695162475,
                        39.970717993564904
                      ]              
                ]
            ]
        }
    }
]
}

从上面的图中,我们可以看出来所有的 6 个文档都在上面显示的方框里。

Geo centroid 聚合

如果你有很多地理定位事件并且你需要知道这些事件的中心,则地理质心聚合允许你计算此地理点。
常见的场景可能如下:

  • 在 Twitter 监控事件(例如地震或海啸)期间:通过监控前 n 个事件的推文来检测事件的中心。
  • 拥有具有坐标的文档:找到这些文档的共同中心。

要执行地理质心聚合,我们将执行以下步骤:

POST twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "centroid": {
      "geo_centroid": {
        "field": "location"
      }
    }
  }
}

上面的聚合返回的结果是:

{
  "aggregations": {
    "centroid": {
      "location": {
        "lat": 38.43021182343364,
        "lon": 117.2277724603191
      },
      "count": 6
    }
  }
}

上面表示我们所有的 twitter 文档的地理中心位置是 (117.228, 38.430)。如果在地图上显示就是:

如上所示,所有 twiter 文档的地理中心处于上面的红点的位置。它更靠近北京一点,这是因为北京的文档比较多的缘故。地理质心聚合是一种度量聚合,可以计算一桶文档的地理点质心。 它允许你在包含文档地理点的字段中仅定义一个参数。 返回的结果是一个地理点,它是文档分布的质心。例如,如果你的文档包含地震事件,通过使用地理质心聚合,你可以计算地震的震中。

geotile 聚合适用于文档桶,并返回包含匹配文档的所有瓦片。必须提供的唯一强制参数是 field,它是用于计算数据的地理点映射字段。 为了控制生成的图块,其他可用参数如下:

  • precision(默认 6):即从 0 到 29 的缩放级别。29 值是一个高精度值,覆盖大约 10 cm x 10 cm 的土地。 通常,更常用的值在 6 到 12 之间。
  • size(默认 10,000):这是要返回的最大瓦片块数。
  • bounds:边界框查询,用于过滤桶以减少返回的瓦片数量。 这对于减少聚合的内存消耗非常有用。

Geo tile 聚合

使用 Elasticsearch 在地图上显示数据是 Elasticsearch 用户之间非常常见的模式。 最常用的地图格式之一是瓦片格式,其中地图被分成几个小的正方形部分,当需要渲染位置时,服务器会获取该位置附近的瓦片。
除了商业解决方案,OpenStreetMap (https://www.openstreetmap.org/) 地图是最常用的,而且很多 Kibana 地图都是基于它们的瓦片服务器。
OpenStreetMap 是开源的,你可以通过 Docker (https://switch2osm.org/serving-tiles/using-a-dockercontainer/) 轻松提供自己的瓦片服务器。 geotile 网格聚合允许以用于单元格 “{zoom}/{x}/{y}” 的标准地图图块格式返回具有地理点或地理形状的文档桶。

要在 twitter 中可用的 location 字段上执行 geotile_grid 聚合,我们将执行以下步骤:

POST twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "tiles": {
      "geotile_grid": {
        "field": "location",
        "precision": 5
      }
    }
  }
}

上面的命令返回的结果是:

{
  "aggregations": {
    "tiles": {
      "buckets": [
        {
          "key": "5/26/12",
          "doc_count": 5
        },
        {
          "key": "5/26/13",
          "doc_count": 1
        }
      ]
    }
  }
}

如果我们想在 OpenStreetMap 地图上搜索第一个值 “5/26/12”,地址将是以下地址:https://a.tile.openstreetmap.org/5/26/12.png

我们启动 Elastic Maps:

我们按照上面的地址输入我们之前得到的参数:https://a.tile.openstreetmap.org/5/26/12.png。我们得到如下的地图:

当然,如果我们设置更高的精度,我们可以得到更小的瓦片图。precision 最低为0,最高的精度为29。我们设置精度为18来看看:

POST twitter/_search?filter_path=aggregations
{
  "size": 0,
  "aggs": {
    "tiles": {
      "geotile_grid": {
        "field": "location",
        "precision": 18
      }
    }
  }
}

上面聚合显示的结果为:

{
  "aggregations": {
    "tiles": {
      "buckets": [
        {
          "key": "18/219460/107159",
          "doc_count": 1
        },
        {
          "key": "18/215881/99319",
          "doc_count": 1
        },
        {
          "key": "18/215841/99333",
          "doc_count": 1
        },
        {
          "key": "18/215838/99343",
          "doc_count": 1
        },
        {
          "key": "18/215808/99509",
          "doc_count": 1
        },
        {
          "key": "18/215777/99270",
          "doc_count": 1
        }
      ]
    }
  }
}

我们来显示第一个瓦片 https://a.tile.openstreetmap.org/18/219460/107159.png

Missing 聚合

我们可以通过这个聚合来统计出来缺失某个字段的文档个数。我们先添加如下的一个文档:

PUT twitter/_doc/7
{
  "user": "张三",
  "message": "今儿天气不错啊,出去转转去",
  "uid": 2,
  "city": "北京",
  "province": "北京",
  "country": "中国",
  "address": "中国北京市海淀区",
  "location": {
    "lat": "39.970718",
    "lon": "116.325747"
  },
  "DOB": "1999-04-01"
}

在上面的文档中,我们故意漏掉 age 这个字段。我们使用如下的聚合来查询有多少文档缺失 age 这个字段:

GET twitter/_search
{
  "size": 0,
  "aggs": {
    "total_missing_age": {
      "missing": {
        "field": "age"
      }
    }
  }
}

上面聚合显示的结果为:

{
  "took" : 603,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 7,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "total_missing_age" : {
      "doc_count" : 1
    }
  }
}

上面显示一个没有 age 这个字段的文档。

如果你想对 aggregation 有更多的了解的话,那么可以阅读我的另外文章:

Analyzer 简介

我们知道 Elasticsearch 可以实现秒级的搜索速度,其中很重要的一个原因就当一个文档被存储的时候,同时它也对文档的数据进行了索引(indexing)。这样在以后的搜索中,就可以变得很快。简单地说,当一个文档进入到 Elasticsearch 时,它会经历如下的步骤:

中间的那部分就叫做 Analyzer。我们可以看出来,它分为三个部分:Char Filters, Tokenizer 及 Token Filters。它们的作用分别如下:

  • Char Filters: 字符过滤器的工作是执行清除任务,例如剥离 HTML 标记。
  • Tokenizer: 下一步是将文本拆分为称为标记的术语。 这是由 tokenizer 完成的。 可以基于任何规则(例如空格)来完成拆分。 有关 tokennizer 的更多详细信息,请访问以下 URL:Tokenizer reference | Elasticsearch Guide [7.15] | Elastic
  • Token filters: 一旦创建了 token,它们就会被传递给 token filters,这些过滤器会对 token 进行规范化。 Token filters 可以更改 token,删除术语或向 token 添加术语。

Elasticsearch 已经提供了比较丰富的 analyzer。我们可以自己创建自己的 token analyzer,甚至可以利用已经有的 char filters,tokenizer 及 token filters 来重新组合成一个新的 analyzer,并可以对文档中的每一个字段分别定义自己的 analyzer。如果大家对 analyzer 比较感兴趣的话,请参阅我们的网址 Built-in analyzer reference | Elasticsearch Guide [7.15] | Elastic

你也可以阅读我的另外一篇关于 analyzer 的文章 “Elasticsearch: analyzer”。

在默认的情况下,standard analyzer 是 Elasticsearch 的默认分析器:

  • 没有 Char Filter
  • 使用 standard tokonizer
  • 把字符串变为小写,同时有选择地删除一些 stop words 等。默认的情况下 stop words 为 _none_,也即不过滤任何 stop words。

下面我们简单地展示一下我们的 analyzer 是如何实现的。

GET twitter/_analyze
{
  "text": [
    "Happy Birthday"
  ],
  "analyzer": "standard"
}

在上面的接口中,我们使用标准的 analyzer 来对字符串 "Happy birthday" 来分析,那么如下就是我我们看到的结果。

我们可以看到有两个 token:  happy 和 birthday。两个 token 都变成小写的了。同时我们也可以看到它们在文档中的位置信息。

如果感兴趣的同学,我也可以尝试一下 english analyzer:

GET twitter/_analyze
{
  "text": [
    "Happy Birthday"
  ],
  "analyzer": "english"
}

显示的结果是:

{
  "tokens" : [
    {
      "token" : "happi",
      "start_offset" : 0,
      "end_offset" : 5,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "birthdai",
      "start_offset" : 6,
      "end_offset" : 14,
      "type" : "<ALPHANUM>",
      "position" : 1
    }
  ]
}

我们可以看出来有完全不一样的结果。这里显示的词根。同样地,我们可以使用 whitespace analyzer:

GET twitter/_analyze
{
  "text": [
    "Happy Birthday"
  ],
  "analyzer": "whitespace"
}

显示的结果为:

{
  "tokens" : [
    {
      "token" : "Happy",
      "start_offset" : 0,
      "end_offset" : 5,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "Birthday",
      "start_offset" : 6,
      "end_offset" : 14,
      "type" : "word",
      "position" : 1
    }
  ]
}

在这里,我们可以看出来没有经过大小写的处理。这对于精确的搜索是有用的,比如我们想区分字母的大小写。

很多人很好奇,想知道中文字的切割时怎么样的。我们下面来做一个简单的实验。

GET twitter/_analyze
{
  "text": [
    "生日快乐"
  ],
  "analyzer": "standard"
}

那么下面就是我们想看到的结果:

{
  "tokens" : [
    {
      "token" : "生",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<IDEOGRAPHIC>",
      "position" : 0
    },
    {
      "token" : "日",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "<IDEOGRAPHIC>",
      "position" : 1
    },
    {
      "token" : "快",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "<IDEOGRAPHIC>",
      "position" : 2
    },
    {
      "token" : "乐",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "<IDEOGRAPHIC>",
      "position" : 3
    }
  ]
}

我们可以看到有四个 token,并且它们的 type 也有所变化。

GET twitter/_analyze
{
  "text": [
    "Happy.Birthday"
  ],
  "analyzer": "simple"
}

显示的结果是:

{
  "tokens" : [
    {
      "token" : "happy",
      "start_offset" : 0,
      "end_offset" : 5,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "birthday",
      "start_offset" : 6,
      "end_offset" : 14,
      "type" : "word",
      "position" : 1
    }
  ]
}

我们可以看到在我们的字符串中的 "." 也被正确认识,并作为分隔符把 Happy.Birthday 切割为两个 token。

GET twitter/_analyze
{
  "text": ["Happy Birthday"],
  "tokenizer": "keyword"
}

当我们使用 keyword 分析器时,我们可以看到上面的整个字符串无论有多长,都被当做是一个 token。这个对我们的 term 相关的搜索及聚合是有很大的用途的。上面的分析结果显示:

{
  "tokens" : [
    {
      "token" : "Happy Birthday",
      "start_offset" : 0,
      "end_offset" : 14,
      "type" : "word",
      "position" : 0
    }
  ]
}

我么也可以使用 filter 处理我们的 token,比如:

GET twitter/_analyze
{
  "text": ["Happy Birthday"],
  "tokenizer": "keyword",
  "filter": ["lowercase"]
}

经过上面的处理,我们的 token 变成为:

{
  "tokens" : [
    {
      "token" : "happy birthday",
      "start_offset" : 0,
      "end_offset" : 14,
      "type" : "word",
      "position" : 0
    }
  ]
}

我们也可以使用单独使用 tokenizer 来分析我们的文字:

standard tokenizer

POST _analyze
{
  "tokenizer": "standard",
  "text": "Those who dare to fail miserably can achieve greatly."
}

它将生成如下的 token:

[Those, who, dare, to, fail, miserably, can, achieve, greatly]

keyword tokenizer

POST _analyze
{
  "tokenizer": "keyword",
  "text": "Los Angeles"
}

上面返回的结果是:

{
  "tokens" : [
    {
      "token" : "Los Angeles",
      "start_offset" : 0,
      "end_offset" : 11,
      "type" : "word",
      "position" : 0
    }
  ]
}

在这里值得注意的是当我们在写入文档和进行搜索时,我们都需要用到 analyzer。搜索的过程其实就是把经过分析的文字变成相应的 tokens 并进行匹配而得到的结果:

为了方便大家对比,我也创建了一个很小的 Python 应用。它是针对 Elastic Stack 8.x 而写的:

analyzers.py

from elasticsearch import Elasticsearch
 
# Connect to the elastic cluster
# Password for the 'elastic' user generated by Elasticsearch
es = Elasticsearch("https://elastic:ba1u**GF*n4_wcgy0ETO@localhost:9200",
                    ca_certs="/Users/liuxg/elastic/elasticsearch-8.2.0/config/certs/http_ca.crt",
                    verify_certs=True)

print(es)
es.info()

analyzers = ["english", "standard", "simple", "whitespace", "stop", "keyword", "pattern", "fingerprint"]

for analyzer in analyzers:
    res = es.indices.analyze( analyzer = analyzer, text = ["This is Xiaoguo Liu from Beijing China, and I am developer.100"])
    tokens = [ sample['token'] for c, sample in enumerate(res['tokens']) ]
    print("\n")
    print("Analyzer : {}".format(analyzer))
    print('tokens: {}'.format([sample['token'] for c, sample in enumerate(res['tokens'])]))
    print(len([sample['token'] for c, sample in enumerate(res['tokens'])]))    

你需要根据自己的 Elasticsearch 安装的路径对上面的连接部分进行修改。上面我们使用了 analyzers 列表来罗列了常见的一些 analyzers。运行上面的应用,我们可以看到:

Analyzer : english
tokens: ['xiaoguo', 'liu', 'from', 'beij', 'china', 'i', 'am', 'develop', '100']
9


Analyzer : standard
tokens: ['this', 'is', 'xiaoguo', 'liu', 'from', 'beijing', 'china', 'and', 'i', 'am', 'developer', '100']
12


Analyzer : simple
tokens: ['this', 'is', 'xiaoguo', 'liu', 'from', 'beijing', 'china', 'and', 'i', 'am', 'developer']
11


Analyzer : whitespace
tokens: ['This', 'is', 'Xiaoguo', 'Liu', 'from', 'Beijing', 'China,', 'and', 'I', 'am', 'developer.100']
11


Analyzer : stop
tokens: ['xiaoguo', 'liu', 'from', 'beijing', 'china', 'i', 'am', 'developer']
8


Analyzer : keyword
tokens: ['This is Xiaoguo Liu from Beijing China, and I am developer.100']
1


Analyzer : pattern
tokens: ['this', 'is', 'xiaoguo', 'liu', 'from', 'beijing', 'china', 'and', 'i', 'am', 'developer', '100']
12

如何使用 Analyzer

我们可以在一个索引的 mapping 中来定义字段的 analyzer,比如:

PUT my-index-000001
{
  "mappings": {
    "properties": {
      "text": { 
        "type": "text",
        "fields": {
          "english": { 
            "type":     "text",
            "analyzer": "english"
          }
        }
      }
    }
  }
}

在上面,我们的索引的字段 text 在默认的情况下使用的是 standard analyzer,也就是说,上面的命令等同于:

PUT my-index-000001
{
  "mappings": {
    "properties": {
      "text": { 
        "type": "text",
        "analyzer": "standard", 
        "fields": {
          "english": { 
            "type":     "text",
            "analyzer": "english"
          }
        }
      }
    }
  }
}

上面的 text 字段使用的是默认的 standard 分词器,而 text.english 使用的是 english 分词器。我们尝试写入如下的文档:

PUT my-index-000001/_doc/1
{
  "text": "quick brown fox"
}

PUT my-index-000001/_doc/2
{
  "text": "quick brown foxes"
}

我们使用如下的命令来进行搜索:

GET my-index-000001/_search
{
  "query": {
    "multi_match": {
      "query": "quick brown foxes",
      "fields": [
        "text",
        "text.english"
      ],
      "type": "most_fields"
    }
  }
}

上面搜索的结果为:

{
  "took": 650,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 1.6047549,
    "hits": [
      {
        "_index": "my-index-000001",
        "_id": "2",
        "_score": 1.6047549,
        "_source": {
          "text": "quick brown foxes"
        }
      },
      {
        "_index": "my-index-000001",
        "_id": "1",
        "_score": 0.9116078,
        "_source": {
          "text": "quick brown fox"
        }
      }
    ]
  }
}

在上面:

  • text 字段使用的是 standard 分词器
  • text.english 字段使用的是 english 分词器
  • 我们分别写入两个文档,一个含有 fox,而另外一个含有 foxes
  • 我们针对两个字段 text 及 text.english 进行查询并结合分数。它的使用的是 multi-matchmost_fields 计分方法。

大家如果对 anaylyzer 感兴趣的话,可以读更多的资料:Built-in analyzer reference | Elasticsearch Guide [8.6] | Elastic

大家可以参阅我更及进一步的学习文档:Elasticsearch: analyzer

至此,我们基本上已经完成了对 Elasticsearch 最基本的了解。上面所有的 script 可以在如下的地址下载:

https://github.com/liu-xiao-guo/es-scripts-7.3

如果你想了解更多关于Elastic Stack相关的知识,请参阅我们的官方网站:Elastic Stack and Product Documentation | Elastic

  • 28
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 32
    评论
评论 32
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值