目录
基于流行程度推荐(Recommending Based on Popularity)
基于统计的推荐(Recommending Based on Statistics)
1.14.文档值和字段数据(Doc Values and Fielddata)
分析字符串和 Fielddata(Analyzed strings and Fielddata)
高基数内存的影响(High-Cardinality Memory Implications)
监控 fielddata(Monitoring fielddata)
深度优先与广度优先(Depth-First Versus Breadth-First)
1.简介
聚合的桶操作和度量。
- 桶(Buckets)
满足特定条件的文档的集合
- 指标(Metrics)
对桶内的文档进行统计计算
1.1.桶
桶 简单来说就是满足特定条件的文档的集合:
- 一个雇员属于 男性 桶或者 女性 桶
- 奥尔巴尼属于 纽约 桶
- 日期2014-10-28属于 十月 桶
当聚合开始被执行,每个文档里面的值通过计算来决定符合哪个桶的条件。如果匹配到,文档将放入相应的桶并接着进行聚合操作。
桶也可以被嵌套在其他桶里面,提供层次化的或者有条件的划分方案。
1.2.指标
桶能划分文档到有意义的集合,但是最终是对这些桶内的文档进行一些指标的计算。分桶是一种达到目的的手段:它提供了一种给文档分组的方法来让可以计算感兴趣的指标。
大多数 指标 是简单的数学运算(例如最小值、平均值、最大值,还有汇总),这些是通过文档的值来计算。
1.3.桶和指标的组合
聚合 是由桶和指标组成的。 聚合可能只有一个桶,可能只有一个指标,或者可能两个都有。也有可能有一些桶嵌套在其他桶里面。
例如,通过所属国家来划分文档(桶),然后计算每个国家的平均薪酬(指标)。
由于桶可以被嵌套,我们可以实现非常多并且非常复杂的聚合:
- 通过国家划分文档(桶)
- 然后通过性别划分每个国家(桶)
- 然后通过年龄区间划分每种性别(桶)
- 最后,为每个年龄区间计算平均薪酬(指标)
每个 <国家, 性别, 年龄>
组合的平均薪酬。所有的这些都在一个请求内完成并且只遍历一次数据!
1.4.聚合
【聚合测试】
POST /cars/transactions/_bulk
{ "index": {}}
{ "price" : 10000, "color" : "red", "make" : "honda", "sold" : "2014-10-28" }
{ "index": {}}
{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }
{ "index": {}}
{ "price" : 30000, "color" : "green", "make" : "ford", "sold" : "2014-05-18" }
{ "index": {}}
{ "price" : 15000, "color" : "blue", "make" : "toyota", "sold" : "2014-07-02" }
{ "index": {}}
{ "price" : 12000, "color" : "green", "make" : "toyota", "sold" : "2014-08-19" }
{ "index": {}}
{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }
{ "index": {}}
{ "price" : 80000, "color" : "red", "make" : "bmw", "sold" : "2014-01-01" }
{ "index": {}}
{ "price" : 25000, "color" : "blue", "make" : "ford", "sold" : "2014-02-12" }
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : { // 聚合操作被置于顶层参数 aggs
之下
"popular_colors" : { // 可以为聚合指定一个名称
"terms" : { // 定义单个桶的类型 terms
"field" : "color"
}
}
}
}
在本例中,我们定义了一个单 terms
桶。 这个 terms
桶会为每个碰到的唯一词项动态创建新的桶。
聚合是在特定搜索结果背景下执行的, 这也就是说它只是查询请求的另外一个顶层参数。
【结果】
{
...
"hits": {
"hits": [] // 因为设置了 size
参数,所以不会有 hits 搜索结果返回。
},
"aggregations": {
"popular_colors": { // 聚合是作为 aggregations
字段的一部分被返回的
"buckets": [
{
"key": "red", // 每个桶的 key
都与 color
字段里找到的唯一词对应。它总会包含 doc_count
字段,包含该词项的文档数量。
"doc_count": 4 // 每个桶的数量代表该颜色的文档数量。
},
{
"key": "blue",
"doc_count": 2
},
{
"key": "green",
"doc_count": 2
}
]
}
}
}
响应包含多个桶,每个对应一个唯一颜色。每个桶也包括 聚合进
该桶的所有文档的数量。
前面的这个例子完全是实时执行的:一旦文档可以被搜到,它就能被聚合。这也就意味着我们可以直接将聚合的结果源源不断的传入图形库,然后生成实时的仪表盘。 不久,你又销售了一辆银色的车,我们的图形就会立即动态更新银色车的统计信息。
1.5.添加度量指标
平均度量
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"colors": {
"terms": {
"field": "color"
},
"aggs": { // 为度量新增 aggs
层
"avg_price": { // 为度量指定名字: avg_price
"avg": {
"field": "price" // 为 price
字段定义 avg
度量。
}
}
}
}
}
}
【说明】
这个新的聚合层让我们可以将 avg
度量嵌套置于 terms
桶内。实际上,这就为每个颜色生成了平均价格。
正如 颜色
的例子,给度量起一个名字( avg_price
)这样可以稍后根据名字获取它的值。最后,指定度量本身( avg
)以及计算平均值的字段( price
):
{
...
"aggregations": {
"colors": {
"buckets": [
{
"key": "red",
"doc_count": 4,
"avg_price": {
"value": 32500
}
},
{
"key": "blue",
"doc_count": 2,
"avg_price": {
"value": 20000
}
},
{
"key": "green",
"doc_count": 2,
"avg_price": {
"value": 21000
}
}
]
}
}
...
}
1.6.嵌套桶
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"colors": {
"terms": {
"field": "color"
},
"aggs": {
"avg_price": { // avg_price
度量仍然保持原位
"avg": {
"field": "price"
}
},
"make": { // 加入到了 color
颜色桶中
"terms": {
"field": "make" // 聚合是 terms
桶,它会为每个汽车制造商生成唯一的桶
}
}
}
}
}
}
【说明】
我们可能会观察到之前例子中的 avg_price
度量完全没有变化,还在原来的位置。 一个聚合的每个 层级 都可以有多个度量或桶, avg_price
度量每种颜色汽车的平均价格。它与其他的桶和度量相互独立。
{
...
"aggregations": {
"colors": {
"buckets": [
{
"key": "red",
"doc_count": 4,
"make": { // 新的聚合嵌入在每个颜色桶中
"buckets": [
{
"key": "honda", // 按不同制造商分解的每种颜色下车辆信息
"doc_count": 3
},
{
"key": "bmw",
"doc_count": 1
}
]
},
"avg_price": {
"value": 32500 // 维持不变
}
},
...
}
[每个汽车生成商计算最低和最高的价格]
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"colors": {
"terms": {
"field": "color"
},
"aggs": { // 增加另外一个嵌套的 aggs
层级
"avg_price": { "avg": { "field": "price" }
},
"make" : {
"terms" : {
"field" : "make"
},
"aggs" : {
"min_price" : { "min": { "field": "price"} }, // 包括 min
最小度量
"max_price" : { "max": { "field": "price"} } // max
最大度量
}
}
}
}
}
}
[结果]
{
...
"aggregations": {
"colors": {
"buckets": [
{
"key": "red",
"doc_count": 4,
"make": {
"buckets": [
{
"key": "honda",
"doc_count": 3,
"min_price": {
"value": 10000
},
"max_price": {
"value": 20000
}
},
{
"key": "bmw",
"doc_count": 1,
"min_price": {
"value": 80000
},
"max_price": {
"value": 80000
}
}
]
},
"avg_price": {
"value": 32500
}
},
...
1.7.条形图
【举例】直方图
GET /cars/transactions/_search
{
"size" : 0,
"aggs":{
"price":{
"histogram":{ // 一个数值字段以及一个定义桶大小间隔
"field": "price",
"interval": 20000
},
"aggs":{
"revenue": {
"sum": { // 用来显示每个区间内的总收入
"field" : "price"
}
}
}
}
}
}
【结果】
{
...
"aggregations": {
"price": {
"buckets": [
{
"key": 0,
"doc_count": 3,
"revenue": {
"value": 37000
}
},
{
"key": 20000,
"doc_count": 4,
"revenue": {
"value": 95000
}
},
{
"key": 80000,
"doc_count": 1,
"revenue": {
"value": 80000
}
}
]
}
}
}
[最受欢迎 10 种汽车以及它们的平均售价、标准差这些信息创建一个条形图]
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"makes": {
"terms": {
"field": "make",
"size": 10
},
"aggs": {
"stats": {
"extended_stats": {
"field": "price"
}
}
}
}
}
}
按受欢迎度返回制造商列表以及它们各自的统计信息。
1.8.按时间统计
可以用通常的 histogram 进行时间分析吗?
从技术上来讲,是可以的。
通常的 histogram bucket(桶)是可以处理日期的。 但是它不能自动识别日期。
而用 date_histogram ,可以指定时间段如 1 个月 ,它能聪明地知道 2 月的天数比 12 月少。
date_histogram 还具有另外一个优势,即能合理地处理时区,这可以使你用客户端的时区进行图标定制,而不是用服务器端时区。
通常的 histogram 会把日期看做是数字,这意味着你必须以微秒为单位指明时间间隔。
另外聚合并不知道日历时间间隔,使得它对于日期而言几乎没什么用处。
【举例】每月销售多少台汽车
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"sales": {
"date_histogram": {
"field": "sold",
"interval": "month", // 时间间隔要求是日历术语
"format": "yyyy-MM-dd" // 提供日期格式以便 buckets 的键值便于阅读
}
}
}
}
[结果]
{
...
"aggregations": {
"sales": {
"buckets": [
{
"key_as_string": "2014-01-01",
"key": 1388534400000,
"doc_count": 1
},
{
"key_as_string": "2014-02-01",
"key": 1391212800000,
"doc_count": 1
},
{
"key_as_string": "2014-05-01",
"key": 1398902400000,
"doc_count": 1
},
{
"key_as_string": "2014-07-01",
"key": 1404172800000,
"doc_count": 1
},
{
"key_as_string": "2014-08-01",
"key": 1406851200000,
"doc_count": 1
},
{
"key_as_string": "2014-10-01",
"key": 1412121600000,
"doc_count": 1
},
{
"key_as_string": "2014-11-01",
"key": 1414800000000,
"doc_count": 2
}
]
...
}
返回空 Buckets
注意到结果末尾处的奇怪之处了吗?
是的,结果没错。 我们的结果少了一些月份! date_histogram
(和 histogram
一样)默认只会返回文档数目非零的 buckets。
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"sales": {
"date_histogram": {
"field": "sold",
"interval": "month",
"format": "yyyy-MM-dd",
"min_doc_count" : 0, // 这个参数强制返回空 buckets。
"extended_bounds" : { // 这个参数强制返回整年。
"min" : "2014-01-01",
"max" : "2014-12-31"
}
}
}
}
}
[说明]
这两个参数会强制返回一年中所有月份的结果,而不考虑结果中的文档数目。 min_doc_count
非常容易理解:它强制返回所有 buckets,即使 buckets 可能为空。
extended_bounds
参数需要一点解释。 min_doc_count
参数强制返回空 buckets,但是 Elasticsearch 默认只返回你的数据中最小值和最大值之间的 buckets。
扩展例子
[构建聚合以便按季度展示所有汽车品牌总销售额。同时按季度、按每个汽车品牌计算销售总额,以便可以找出哪种品牌最赚钱]
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"sales": {
"date_histogram": {
"field": "sold",
"interval": "quarter", // 把时间间隔从 month
改成了 quarter
"format": "yyyy-MM-dd",
"min_doc_count" : 0,
"extended_bounds" : {
"min" : "2019-01-01",
"max" : "2019-12-31"
}
},
"aggs": {
"per_make_sum": {
"terms": {
"field": "make"
},
"aggs": {
"sum_price": {
"sum": { "field": "price" } // 计算每种品牌的总销售金额
}
}
},
"total_sum": {
"sum": { "field": "price" } // 计算所有全部品牌的汇总销售金额
}
}
}
}
}
【结果】
{
....
"aggregations": {
"sales": {
"buckets": [
{
"key_as_string": "2014-01-01",
"key": 1388534400000,
"doc_count": 2,
"total_sum": {
"value": 105000
},
"per_make_sum": {
"buckets": [
{
"key": "bmw",
"doc_count": 1,
"sum_price": {
"value": 80000
}
},
{
"key": "ford",
"doc_count": 1,
"sum_price": {
"value": 25000
}
}
]
}
},
...
}
1.9.范围限定的聚合
聚合可以与搜索请求同时执行,但是需要理解一个新概念: 范围 。 默认情况下,聚合与查询是对同一范围进行操作的,也就是说,聚合是基于查询匹配的文档集合进行计算的。
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"colors" : {
"terms" : {
"field" : "color"
}
}
}
}
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"query" : {
"match_all" : {}
},
"aggs" : {
"colors" : {
"terms" : {
"field" : "color"
}
}
}
}
因为聚合总是对查询范围内的结果进行操作的,所以一个隔离的聚合实际上是在对 match_all
的结果范围操作,即所有的文档。
【举例】范围
GET /cars/transactions/_search
{
"query" : {
"match" : {
"make" : "ford"
}
},
"aggs" : {
"colors" : {
"terms" : {
"field" : "color"
}
}
}
}
【结果】
{
...
"hits": {
"total": 2,
"max_score": 1.6931472,
"hits": [
{
"_source": {
"price": 25000,
"color": "blue",
"make": "ford",
"sold": "2014-02-12"
}
},
{
"_source": {
"price": 30000,
"color": "green",
"make": "ford",
"sold": "2014-05-18"
}
}
]
},
"aggregations": {
"colors": {
"buckets": [
{
"key": "blue",
"doc_count": 1
},
{
"key": "green",
"doc_count": 1
}
]
}
}
}
看上去这并没有什么,但却对高大上的仪表盘来说至关重要。 加入一个搜索栏可以将任何静态的仪表板变成一个实时数据搜索设备。 这让用户可以搜索数据,查看所有实时更新的图形(由于聚合的支持以及对查询范围的限定)。 这是 Hadoop 无法做到的!
全局桶
通常聚合是在查询范围内的,但有时要搜索它的子集,而聚合的对象却是 所有 数据。
全局 桶包含 所有 的文档,它无视查询的范围。
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"query" : {
"match" : {
"make" : "ford" // 聚合操作在查询范围内(例如:所有文档匹配 ford )
}
},
"aggs" : {
"single_avg_price": {
"avg" : { "field" : "price" }
},
"all": {
"global" : {}, // global
全局桶没有参数
"aggs" : {
"avg_price": {
"avg" : { "field" : "price" } // 聚合操作针对所有文档
}
}
}
}
}
single_avg_price 度量计算是基于查询范围内所有文档。avg_price 度量是嵌套在 全局
桶下的,这意味着它完全忽略了范围并对所有文档进行计算。聚合返回的平均值是所有汽车的平均售价
1.10.过滤和聚合
聚合范围限定还有一个自然的扩展就是过滤。因为聚合是在查询结果范围内操作的,任何可以适用于查询的过滤器也可以应用在聚合上。
过滤
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"query" : {
"constant_score": {
"filter": {
"range": {
"price": {
"gte": 10000
}
}
}
}
},
"aggs" : {
"single_avg_price": {
"avg" : { "field" : "price" }
}
}
}
从根本上讲,使用 non-scoring
查询和使用 match
查询没有任何区别。查询(包括了一个过滤器)返回一组文档的子集,聚合正是操作这些文档。使用 filtering query
会忽略评分,并有可能会缓存结果数据等等。
过滤桶
GET /cars/transactions/_search
{
"size" : 0,
"query":{
"match": {
"make": "ford"
}
},
"aggs":{
"recent_sales": {
"filter": { // 使用 过滤
桶在 查询
范围基础上应用过滤器
"range": {
"sold": {
"from": "now-1M"
}
}
},
"aggs": {
"average_price":{
"avg": {
"field": "price" // avg
度量只会对 ford 和上个月售出的文档计算平均售价
}
}
}
}
}
}
因为 filter
桶和其他桶的操作方式一样,所以可以随意将其他桶和度量嵌入其中。所有嵌套的组件都会 "继承" 这个过滤,这可以按需针对聚合过滤出选择部分。
后过滤器
"只过滤搜索结果,不过滤聚合结果呢?" 答案是使用 post_filter
。
它是接收一个过滤器的顶层搜索请求元素。这个过滤器在查询 之后 执行(这正是该过滤器的名字的由来:它在查询之后 post 执行)。正因为它在查询之后执行,它对查询范围没有任何影响,所以对聚合也不会有任何影响。
【举例】允许用户搜索汽车同时可以根据颜色来过滤
GET /cars/transactions/_search
{
"size" : 0,
"query": {
"match": {
"make": "ford"
}
},
"post_filter": { // post_filter
元素是 top-level 而且仅对命中结果进行过滤。
"term" : {
"color" : "green"
}
},
"aggs" : {
"all_colors": {
"terms" : { "field" : "color" }
}
}
}
[说明]
查询
部分找到所有的 ford 汽车,然后用 terms
聚合创建一个颜色列表。因为聚合对查询范围进行操作,颜色列表与福特汽车有的颜色相对应。
最后, post_filter
会过滤搜索结果,只展示绿色 ford 汽车。这在查询执行过 后 发生,所以聚合不受影响。
性能考虑(Performance consideration)
当需要对搜索结果和聚合结果做不同的过滤时,才应该使用 post_filter ,有时用户会在普通搜索使用 post_filter 。
不要这么做!post_filter 的特性是在查询 之后 执行,任何过滤对性能带来的好处(比如缓存)都会完全失去。
在需要不同过滤时, post_filter 只与聚合一起使用。
post_filter
只影响搜索结果。
1.11.多桶排序
多值桶( terms
、 histogram
和 date_histogram )动态生成很多桶。
默认的,桶会根据 doc_count
降序排列。
内置排序
这些排序模式是桶 固有的 能力:它们操作桶生成的数据。 它们共享相同的语法,但是根据使用桶的不同会有些细微差别。
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"colors" : {
"terms" : {
"field" : "color",
"order": {
"_count" : "asc" // 用关键字 _count
,可以按 doc_count
值的升序排序。
}
}
}
}
}
聚合中引入了一个 order 对象, 它允许我们可以根据以下几个值中的一个值进行排序:
_count
按文档数排序。对 terms 、 histogram 、 date_histogram 有效。
_term
按词项的字符串值的字母顺序排序。只在 terms 内使用。
_key
按每个桶的键值数值排序(理论上与 _term 类似)。 只在 histogram 和 date_histogram 内使用。
按度量排序
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"colors" : {
"terms" : {
"field" : "color",
"order": {
"avg_price" : "asc" // 桶按照计算平均值的升序排序
}
},
"aggs": {
"avg_price": {
"avg": {"field": "price"} // 计算每个桶的平均售价
}
}
}
}
}
采用这种方式用任何度量排序,只需简单的引用度量的名字。不过有些度量会输出多个值。 extended_stats
度量是一个很好的例子:它输出好几个度量值。
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"colors" : {
"terms" : {
"field" : "color",
"order": {
"stats.variance" : "asc" // 使用 .
符号,根据兴趣的度量进行排序
}
},
"aggs": {
"stats": {
"extended_stats": {"field": "price"}
}
}
}
}
}
基于“深度”度量排序
定义更深的路径,将度量用尖括号( >
)嵌套起来,像这样: my_bucket>another_bucket>metric
。
需要提醒的是嵌套路径上的每个桶都必须是 单值 的。 filter
桶生成 一个单值桶:所有与过滤条件匹配的文档都在桶中。 多值桶(如:terms
)动态生成许多桶,无法通过指定一个确定路径来识别。
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"colors" : {
"histogram" : {
"field" : "price",
"interval": 20000,
"order": {
"red_green_cars>stats.variance" : "asc" // 按照嵌套度量的方差对桶的直方图进行排序
}
},
"aggs": {
"red_green_cars": {
"filter": { "terms": {"color": ["red", "green"]}}, // 使用单值过滤器 filter
,使用嵌套排序
"aggs": {
"stats": {"extended_stats": {"field" : "price"}} // 按照生成的度量对统计结果进行排序
}
}
}
}
}
}
[说明]
stats
度量是 red_green_cars
聚合的子节点,而 red_green_cars
又是 colors
聚合的子节点。 为了根据这个度量排序,定义了路径 red_green_cars>stats.variance
。可以这么做,因为 filter
桶是个单值桶。
1.12.近似聚合
分布式存储数据
不是所有的算法都像获取最大值这样简单。更加复杂的操作则需要在算法的性能和内存使用上做出权衡。
大数据、精确性和实时性。
- 精确 + 实时
数据可以存入单台机器的内存之中,可以随心所欲,使用任何想用的算法。结果会 100% 精确,响应会相对快速。
- 大数据 + 精确
传统的 Hadoop。可以处理 PB 级的数据并且为我们提供精确的答案,但它可能需要几周的时间提供这个答案。
- 大数据 + 实时
近似算法提供准确但不精确的结果。
Elasticsearch 目前支持两种近似算法(cardinality
和 percentiles
)。它们会提供准确但不是 100% 精确的结果。 以牺牲一点小小的估算错误为代价,这些算法可以为我们换来高速的执行效率和极小的内存消耗。
对于 大多数 应用领域,能够 实时 返回高度准确的结果要比 100% 精确结果重要得多。
统计去重后的数量
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"distinct_colors" : {
"cardinality" : {
"field" : "color"
}
}
}
【结果】
...
"aggregations": {
"distinct_colors": {
"value": 3
}
}
...
【举例】每月有多少颜色的车被售出
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"months" : {
"date_histogram": {
"field": "sold",
"interval": "month"
},
"aggs": {
"distinct_colors" : {
"cardinality" : { // cardinality
度量是一个近似算法
"field" : "color"
}
}
}
}
}
}
学会权衡
cardinality
度量是一个近似算法。 它是基于 HyperLogLog++ (HLL)算法的。 HLL 会先输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。
这个算法的 特性 :
-
可配置的精度,用来控制内存的使用(更精确 = 更多内存)。
-
小的数据集精度是非常高的。
-
通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关。
要配置精度,必须指定 precision_threshold
参数的值。 这个阈值定义了在何种基数水平下我们希望得到一个近乎精确的结果。
【举例】
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"distinct_colors" : {
"cardinality" : {
"field" : "color",
"precision_threshold" : 100 // precision_threshold
接受 0–40,000 之间的数字,更大的值还是会被当作 40,000 来处理
}
}
}
}
【说明】
确保当字段唯一值在 100 以内时会得到非常准确的结果。尽管算法是无法保证这点的,但如果基数在阈值以下,几乎总是 100% 正确的。高于阈值的基数会开始节省内存而牺牲准确度,同时也会对度量结果带入误差。
对于指定的阈值,HLL 的数据结构会大概使用 precision_threshold * 8
字节的内存,所以就必须在牺牲内存和获得额外的准确度间做平衡。
在实际应用中, 100
的阈值可以在唯一值为百万的情况下仍然将误差维持 5% 以内。
速度优化
如果想要获得唯一值的数目, 通常 需要查询整个数据集合(或几乎所有数据)。 所有基于所有数据的操作都必须迅速,原因是显然的。 HyperLogLog 的速度已经很快了,它只是简单的对数据做哈希以及一些位操作。
但如果速度对我们至关重要,可以做进一步的优化。 因为 HLL 只需要字段内容的哈希值,在索引时就预先计算好。 就能在查询时跳过哈希计算然后将哈希值从 fielddata 直接加载出来。
预先计算哈希值只对内容很长或者基数很高的字段有用,计算这些字段的哈希值的消耗在查询时是无法忽略的。
尽管数值字段的哈希计算是非常快速的,存储它们的原始值通常需要同样(或更少)的内存空间。
这对低基数的字符串字段同样适用,Elasticsearch 的内部优化能够保证每个唯一值只计算一次哈希。
基本上说,预先计算并不能保证所有的字段都更快,它只对那些具有高基数和/或者内容很长的字符串字段有作用。
需要记住的是,预计算只是简单的将查询消耗的时间提前转移到索引时,并非没有任何代价,
区别在于你可以选择在 什么时候 做这件事,要么在索引时,要么在查询时。
要想这么做,我们需要为数据增加一个新的多值字段。我们先删除索引,再增加一个包括哈希值字段的映射,然后重新索引
DELETE /cars/
PUT /cars/
{
"mappings": {
"transactions": {
"properties": {
"color": {
"type": "string",
"fields": {
"hash": {
"type": "murmur3" // 多值字段的类型是 murmur3
,这是一个哈希函数
}
}
}
}
}
}
}
POST /cars/transactions/_bulk
{ "index": {}}
{ "price" : 10000, "color" : "red", "make" : "honda", "sold" : "2014-10-28" }
{ "index": {}}
{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }
{ "index": {}}
{ "price" : 30000, "color" : "green", "make" : "ford", "sold" : "2014-05-18" }
{ "index": {}}
{ "price" : 15000, "color" : "blue", "make" : "toyota", "sold" : "2014-07-02" }
{ "index": {}}
{ "price" : 12000, "color" : "green", "make" : "toyota", "sold" : "2014-08-19" }
{ "index": {}}
{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }
{ "index": {}}
{ "price" : 80000, "color" : "red", "make" : "bmw", "sold" : "2014-01-01" }
{ "index": {}}
{ "price" : 25000, "color" : "blue", "make" : "ford", "sold" : "2014-02-12" }
当执行聚合时,使用 color.hash
字段而不是 color
字段:
GET /cars/transactions/_search
{
"size" : 0,
"aggs" : {
"distinct_colors" : {
"cardinality" : {
"field" : "color.hash" // 注意指定的是哈希过的多值字段,而不是原始字段。
}
}
}
}
cardinality
度量会读取 "color.hash"
里的值(预先计算的哈希值),取代动态计算原始值的哈希。
单个文档节省的时间是非常少的,但是如果你聚合一亿数据,每个字段多花费 10 纳秒的时间,那么在每次查询时都会额外增加 1 秒,如果我们要在非常大量的数据里面使用 cardinality
,我们可以权衡使用预计算的意义,是否需要提前计算 hash,从而在查询时获得更好的性能,做一些性能测试来检验预计算哈希是否适用于你的应用场景。
百分位计算
Elasticsearch 提供的另外一个近似度量就是 percentiles
百分位数度量。 百分位数展现某以具体百分比下观察到的数值
百分位数通常用来找出异常。在(统计学)的正态分布下,第 0.13 和 第 99.87 的百分位数代表与均值距离三倍标准差的值。任何处于三倍标准差之外的数据通常被认为是不寻常的,因为它与平均值相差太大。
在此场景下,一个常用的度量方法就是平均响应延时。 但这并不是一个好的选择(尽管很常用),因为平均数通常会隐藏那些异常值, 中位数有着同样的问题。 我们可以尝试最大值,但这个度量会轻而易举的被单个异常值破坏。
百分位度量
POST /website/logs/_bulk
{ "index": {}}
{ "latency" : 100, "zone" : "US", "timestamp" : "2014-10-28" }
{ "index": {}}
{ "latency" : 80, "zone" : "US", "timestamp" : "2014-10-29" }
{ "index": {}}
{ "latency" : 99, "zone" : "US", "timestamp" : "2014-10-29" }
{ "index": {}}
{ "latency" : 102, "zone" : "US", "timestamp" : "2014-10-28" }
{ "index": {}}
{ "latency" : 75, "zone" : "US", "timestamp" : "2014-10-28" }
{ "index": {}}
{ "latency" : 82, "zone" : "US", "timestamp" : "2014-10-29" }
{ "index": {}}
{ "latency" : 100, "zone" : "EU", "timestamp" : "2014-10-28" }
{ "index": {}}
{ "latency" : 280, "zone" : "EU", "timestamp" : "2014-10-29" }
{ "index": {}}
{ "latency" : 155, "zone" : "EU", "timestamp" : "2014-10-29" }
{ "index": {}}
{ "latency" : 623, "zone" : "EU", "timestamp" : "2014-10-28" }
{ "index": {}}
{ "latency" : 380, "zone" : "EU", "timestamp" : "2014-10-28" }
{ "index": {}}
{ "latency" : 319, "zone" : "EU", "timestamp" : "2014-10-29" }
数据有三个值:延时、数据中心的区域以及时间戳。
GET /website/logs/_search
{
"size" : 0,
"aggs" : {
"load_times" : {
"percentiles" : { // percentiles 度量被应用到 latency 延时字段。
"field" : "latency"
}
},
"avg_load_time" : {
"avg" : {
"field" : "latency" // 为了比较,对相同字段使用 avg 度量
}
}
}
}
[说明]
默认情况下,percentiles
度量会返回一组预定义的百分位数值: [1, 5, 25, 50, 75, 95, 99]
。它们表示了人们感兴趣的常用百分位数值,极端的百分位数在范围的两边,其他的一些处于中部。在返回的响应中,我们可以看到最小延时在 75ms 左右,而最大延时差不多有 600ms。与之形成对比的是,平均延时在 200ms 左右, 信息并不是很多:
...
"aggregations": {
"load_times": {
"values": {
"1.0": 75.55,
"5.0": 77.75,
"25.0": 94.75,
"50.0": 101,
"75.0": 289.75,
"95.0": 489.34999999999985,
"99.0": 596.2700000000002
}
},
"avg_load_time": {
"value": 199.58333333333334
}
}
所以显然延时的分布很广
GET /website/logs/_search
{
"size" : 0,
"aggs" : {
"zones" : {
"terms" : {
"field" : "zone" // 根据区域将延时分到不同的桶中
},
"aggs" : {
"load_times" : {
"percentiles" : { // 计算每个区域的百分位数值
"field" : "latency",
"percents" : [50, 95.0, 99.0] // percents 参数接受了返回的一组百分位数
}
},
"load_avg" : {
"avg" : {
"field" : "latency"
}
}
}
}
}
}
[结果]
...
"aggregations": {
"zones": {
"buckets": [
{
"key": "eu",
"doc_count": 6,
"load_times": {
"values": {
"50.0": 299.5,
"95.0": 562.25,
"99.0": 610.85
}
},
"load_avg": {
"value": 309.5
}
},
{
"key": "us",
"doc_count": 6,
"load_times": {
"values": {
"50.0": 90.5,
"95.0": 101.5,
"99.0": 101.9
}
},
"load_avg": {
"value": 89.66666666666667
}
}
]
}
}
...
百分位等级
另外有一个紧密相关的度量叫 percentile_ranks
percentiles
度量: 落在某个百分比以下的所有文档的最小值。
[举例]
GET /website/logs/_search
{
"size" : 0,
"aggs" : {
"zones" : {
"terms" : {
"field" : "zone"
},
"aggs" : {
"load_times" : {
"percentile_ranks" : { // 接受一组希望分级的数值
"field" : "latency",
"values" : [210, 800]
}
}
}
}
}
}
[结果]
"aggregations": {
"zones": {
"buckets": [
{
"key": "eu",
"doc_count": 6,
"load_times": {
"values": {
"210.0": 31.944444444444443,
"800.0": 100
}
}
},
{
"key": "us",
"doc_count": 6,
"load_times": {
"values": {
"210.0": 100,
"800.0": 100
}
}
}
]
}
}
percentile_ranks
度量提供了与 percentiles
相同的信息,但它以不同方式呈现,如果我们对某个具体数值更关心,使用它会更方便。
学会权衡
和基数一样,计算百分位需要一个近似算法。 朴素的 实现会维护一个所有值的有序列表, 但当我们有几十亿数据分布在几十个节点时,这几乎是不可能的。
percentiles
使用一个 TDigest 算法,(由 Ted Dunning 在 Computing Extremely Accurate Quantiles Using T-Digests 里面提出的)。 与 HyperLogLog 一样,不需要理解完整的技术细节,但有必要了解算法的特性:
- 百分位的准确度与百分位的 极端程度 相关,也就是说 1 或 99 的百分位要比 50 百分位要准确。这只是数据结构内部机制的一种特性,但这是一个好的特性,因为多数人只关心极端的百分位。
- 对于数值集合较小的情况,百分位非常准确。如果数据集足够小,百分位可能 100% 精确。
- 随着桶里数值的增长,算法会开始对百分位进行估算。它能有效在准确度和内存节省之间做出权衡。 不准确的程度比较难以总结,因为它依赖于 聚合时数据的分布以及数据量的大小。
与 cardinality
类似,我们可以通过修改参数 compression
来控制内存与准确度之间的比值。
TDigest 算法用节点近似计算百分比:节点越多,准确度越高(同时内存消耗也越大),这都与数据量成正比。 compression
参数限制节点的最大数目为 20 * compression
。
因此,通过增加压缩比值,可以以消耗更多内存为代价提高百分位数准确性。更大的压缩比值会使算法运行更慢,因为底层的树形数据结构的存储也会增长,也导致操作的代价更高。默认的压缩比值是 100
。
一个节点大约使用 32 字节的内存,所以在最坏的情况下(例如,大量数据有序存入),默认设置会生成一个大小约为 64KB 的 TDigest。 在实际应用中,数据会更随机,所以 TDigest 使用的内存会更少。
1.13.通过聚合发现异常指标
significant_terms聚合可以在你数据集中找到一些 异常 的指标。它分析统计你的数据并通过对比正常数据找到可能有异常频次的指标。
它是通过分析统计信息来工作的, 需要为数据设置一个阀值让它们更有效。
【举例】
1.在 elasticsearch.yml
配置文件中增加以下配置, 以便加入到白名单中:
repositories.url.allowed_urls: ["http://download.elastic.co/*"]
2.重启 Elasticsearch
3.运行以下快照命令
PUT /_snapshot/sigterms // 注册一个新的只读地址库,并指向快照
{
"type": "url",
"settings": {
"url": "http://download.elastic.co/definitiveguide/sigterms_demo/"
}
}
GET /_snapshot/sigterms/_all // (可选)检查库内关于快照的详细信息
POST /_snapshot/sigterms/snapshot/_restore // 开始还原过程。会在集群中创建两个索引: mlmovies
和 mlratings
。
GET /mlmovies,mlratings/_recovery // (可选)使用 Recovery API 监控还原过程。
【测试】
GET mlmovies/_search
{
"took": 4,
"timed_out": false,
"_shards": {...},
"hits": {
"total": 10681,
"max_score": 1,
"hits": [
{
"_index": "mlmovies",
"_type": "mlmovie",
"_id": "2",
"_score": 1,
"_source": {
"offset": 2,
"bytes": 34,
"title": "Jumanji (1995)"
}
},
....
基于流行程度推荐(Recommending Based on Popularity)
采取的首个策略就是基于流行程度向用户推荐影片。
GET mlmovies/_search
{
"query": {
"match": {
"title": "Talladega Nights"
}
}
}
...
"hits": [
{
"_index": "mlmovies",
"_type": "mlmovie",
"_id": "46970", // Talladega Nights 的 ID 是 46970
。
"_score": 3.658795,
"_source": {
"offset": 9575,
"bytes": 74,
"title": "Talladega Nights: The Ballad of Ricky Bobby (2006)"
}
},
...
GET mlratings/_search
{
"size" : 0, // 这次查询 mlratings
, 将结果内容 大小设置
为 0 因为我们只对聚合的结果感兴趣
"query": {
"filtered": {
"filter": {
"term": {
"movie": 46970 // 对影片 Talladega Nights 的 ID 使用过滤器。
}
}
}
},
"aggs": {
"most_popular": {
"terms": {
"field": "movie", // 使用 terms
桶找到最流行的影片。
"size": 6
}
}
}
}
[结果]
{
...
"aggregations": {
"most_popular": {
"buckets": [
{
"key": 46970,
"key_as_string": "46970",
"doc_count": 271
},
{
"key": 2571,
"key_as_string": "2571",
"doc_count": 197
},
{
"key": 318,
"key_as_string": "318",
"doc_count": 196
},
{
"key": 296,
"key_as_string": "296",
"doc_count": 183
},
{
"key": 2959,
"key_as_string": "2959",
"doc_count": 183
},
{
"key": 260,
"key_as_string": "260",
"doc_count": 90
}
]
}
}
...
将得到的结果转换成原始影片名
GET mlmovies/_search
{
"query": {
"filtered": {
"filter": {
"ids": {
"values": [2571,318,296,2959,260]
}
}
}
}
}
提供最流行影片的前五名列表
GET mlratings/_search
{
"size" : 0,
"aggs": {
"most_popular": {
"terms": {
"field": "movie",
"size": 5
}
}
}
}
基于统计的推荐(Recommending Based on Statistics)
GET mlratings/_search
{
"size" : 0,
"query": {
"filtered": {
"filter": {
"term": {
"movie": 46970
}
}
}
},
"aggs": {
"most_sig": {
"significant_terms": {
"field": "movie",
"size": 6
}
}
}
}
[结果]
...
"aggregations": {
"most_sig": {
"doc_count": 271, // 展现了前景特征组里文档的数量
"buckets": [
{
"key": 46970, // 每个桶里面列出了聚合的键值
"key_as_string": "46970",
"doc_count": 271, // 桶内文档的数量 doc_count
"score": 256.549815498155,
"bg_count": 271 // 背景文档的数量,表示该值在整个统计背景里出现的频度
},
{
"key": 52245,
"key_as_string": "52245",
"doc_count": 59,
"score": 17.66462367106966,
"bg_count": 185
},
{
"key": 8641,
"key_as_string": "8641",
"doc_count": 107,
"score": 13.884387742677438,
"bg_count": 762
},
{
"key": 58156,
"key_as_string": "58156",
"doc_count": 17,
"score": 9.746428133759462,
"bg_count": 28
},
{
"key": 52973,
"key_as_string": "52973",
"doc_count": 95,
"score": 9.65770100311672,
"bg_count": 857
},
{
"key": 35836,
"key_as_string": "35836",
"doc_count": 128,
"score": 9.199001116457955,
"bg_count": 1610
}
]
...
1.14.文档值和字段数据(Doc Values and Fielddata)
聚合使用一个叫 doc values 的数据结构。Doc values 可以使聚合更快、更高效并且内存友好。
Doc values 的存在是因为倒排索引只对某些操作是高效的。 倒排索引的优势 在于查找包含某个项的文档,而对于从另外一个方向的相反操作并不高效,即:确定哪些项是否存在单个文档里,聚合需要这种次级的访问模式。
[倒排索引]
Term Doc_1 Doc_2 Doc_3
------------------------------------
brown | X | X |
dog | X | | X
dogs | | X | X
fox | X | | X
foxes | | X |
in | | X |
jumped | X | | X
lazy | X | X |
leap | | X |
over | X | X | X
quick | X | X | X
summer | | X |
the | X | | X
------------------------------------
获得所有包含 brown 的文档的词的完整列表
GET /my_index/_search
{
"query" : {
"match" : {
"body" : "brown"
}
},
"aggs" : {
"popular_terms": {
"terms" : {
"field" : "body"
}
}
}
}
[说明]
查询部分简单又高效。倒排索引是根据项来排序的,所以首先在词项列表中找到 brown ,然后扫描所有列,找到包含 brown 的文档。 Doc_1
和 Doc_2
包含 brown 这个 token。
然后,对于聚合部分,我们需要找到 Doc_1
和 Doc_2
里所有唯一的词项。 用倒排索引做这件事情代价很高: 迭代索引里的每个词项并收集 Doc_1
和 Doc_2
列里面 token。这很慢而且难以扩展:随着词项和文档的数量增加,执行时间也会增加。
Doc values 通过转置两者间的关系来解决这个问题。倒排索引将词项映射到包含它们的文档,doc values 将文档映射到它们包含的词项:
Doc Terms
-----------------------------------------------------------------
Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the
Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer
Doc_3 | dog, dogs, fox, jumped, over, quick, the
-----------------------------------------------------------------
当数据被转置之后,想要收集到 Doc_1
和 Doc_2
的唯一 token 会非常容易。获得每个文档行,获取所有的词项,然后求两个集合的并集。
因此,搜索和聚合是相互紧密缠绕的。搜索使用倒排索引查找文档,聚合操作收集和聚合 doc values 里的数据。
Doc values 不仅可以用于聚合。 任何需要查找某个文档包含的值的操作都必须使用它。 除了聚合,还包括排序,访问字段值的脚本,父子关系处理
深入理解 Doc Values
Doc Values
是 "快速、高效并且内存友好"
【工作原理】
Doc Values
是在索引时与 倒排索引
同时生成。也就是说 Doc Values
和 倒排索引
一样,基于 Segement
生成并且是不可变的。同时 Doc Values
和 倒排索引
一样序列化到磁盘,这样对性能和扩展性有很大帮助。
Doc Values
通过序列化把数据结构持久化到磁盘,可以充分利用操作系统的内存,而不是 JVM
的 Heap
。 当 working set
远小于系统的可用内存,系统会自动将 Doc Values
驻留在内存中,使得其读写十分快速;不过,当其远大于可用内存时,系统会根据需要从磁盘读取 Doc Values
,然后选择性放到分页缓存中。很显然,这样性能会比在内存中差很多,但是它的大小就不再局限于服务器的内存了。如果是使用 JVM
的 Heap
来实现那么只能是因为 OutOfMemory
导致程序崩溃了。
因为 Doc Values 不是由 JVM 来管理,所以 Elasticsearch 实例可以配置一个很小的 JVM Heap,这样给系统留出来更多的内存。
同时更小的 Heap 可以让 JVM 更加快速和高效的回收。
之前,我们会建议分配机器内存的 50% 来给 JVM Heap。
但是对于 Doc Values,这样可能不是最合适的方案了。 以 64gb 内存的机器为例,可能给 Heap 分配 4-16gb 的内存更合适,而不是 32gb。
列式存储的压缩
从广义来说,Doc Values
本质上是一个序列化的 列式存储 。列式存储 适用于聚合、排序、脚本等操作。
而且,这种存储方式也非常便于压缩,特别是数字类型。这样可以减少磁盘空间并且提高访问速度。现代 CPU
的处理速度要比磁盘快几个数量级(尽管即将到来的 NVMe 驱动器正在迅速缩小差距)。所以我们必须减少直接存磁盘读取数据的大小,尽管需要额外消耗 CPU
运算用来进行解压。
要了解它如何压缩数据的,来看一组数字类型的 Doc Values
:
Doc Terms
-----------------------------------------------------------------
Doc_1 | 100
Doc_2 | 1000
Doc_3 | 1500
Doc_4 | 1200
Doc_5 | 300
Doc_6 | 1900
Doc_7 | 4200
-----------------------------------------------------------------
按列布局意味着我们有一个连续的数据块: [100,1000,1500,1200,300,1900,4200]
。因为我们已经知道他们都是数字(而不是像文档或行中看到的异构集合),所以我们可以使用统一的偏移来将他们紧紧排列。
而且,针对这样的数字有很多种压缩技巧。你会注意到这里每个数字都是 100 的倍数,Doc Values
会检测一个段里面的所有数值,并使用一个 最大公约数 ,方便做进一步的数据压缩。
如果我们保存 100
作为此段的除数,我们可以对每个数字都除以 100,然后得到: [1,10,15,12,3,19,42]
。现在这些数字变小了,只需要很少的位就可以存储下,也减少了磁盘存放的大小。
Doc Values
在压缩过程中使用如下技巧。它会按依次检测以下压缩模式:
-
如果所有的数值各不相同(或缺失),设置一个标记并记录这些值
-
如果这些值小于 256,将使用一个简单的编码表
-
如果这些值大于 256,检测是否存在一个最大公约数
-
如果没有存在最大公约数,从最小的数值开始,统一计算偏移量进行编码
你会发现这些压缩模式不是传统的通用的压缩方式,比如 DEFLATE
或是 LZ4
。 因为列式存储的结构是严格且良好定义的,我们可以通过使用专门的模式来达到比通用压缩算法(如 LZ4 )更高的压缩效果。
你也许会想 "好吧,貌似对数字很好,不知道字符串怎么样?" 通过借助顺序表(ordinal table),String 类型也是类似进行编码的。
String 类型是去重之后存放到顺序表的,通过分配一个 ID,然后通过数字类型的 ID 构建 Doc Values。
这样 String 类型和数值类型可以达到同样的压缩效果。
顺序表本身也有很多压缩技巧,比如固定长度、变长或是前缀字符编码等等。
禁用 Doc Values
Doc Values
默认对所有字段启用,除了 analyzed strings
。也就是说所有的数字、地理坐标、日期、IP 和不分析( not_analyzed
)字符类型都会默认开启。
analyzed strings
暂时还不能使用 Doc Values
。文本经过分析流程生成很多 Token
,使得 Doc Values
不能高效运行。
因为 Doc Values
默认启用,你可以选择对你数据集里面的大多数字段进行聚合和排序操作。但是如果你知道你永远也不会对某些字段进行聚合、排序或是使用脚本操作? 尽管这并不常见,但是你可以通过禁用特定字段的 Doc Values
。这样不仅节省磁盘空间,也许会提升索引的速度。
要禁用 Doc Values
,在字段的映射(mapping)设置 doc_values: false
即可。
【举例】
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"session_id": {
"type": "string",
"index": "not_analyzed",
"doc_values": false // 这个字段将不能被用于聚合、排序以及脚本操作
}
}
}
}
}
[让一个字段可以被聚合,通过禁用倒排索引,使它不能被正常搜索]
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"customer_token": {
"type": "string",
"index": "not_analyzed",
"doc_values": true, // 启用来允许聚合
"index": "no" // 索引被禁用了,这让该字段不能被查询/搜索
}
}
}
}
}
聚合与分析
分析是怎么影响聚合的呢?
分析影响聚合中使用的 tokens ,并且 doc values 不能使用于 分析字符串。
[举例]
POST /agg_analysis/data/_bulk
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New Jersey" }
{ "index": {}}
{ "state" : "New Mexico" }
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New York" }
GET /agg_analysis/data/_search
{
"size" : 0,
"aggs" : {
"states" : {
"terms" : {
"field" : "state"
}
}
}
}
[结果]
{
...
"aggregations": {
"states": {
"buckets": [
{
"key": "new",
"doc_count": 5
},
{
"key": "york",
"doc_count": 3
},
{
"key": "jersey",
"doc_count": 1
},
{
"key": "mexico",
"doc_count": 1
}
]
}
}
}
这个结果不是我们想要的,原因是聚合是基于倒排索引创建的,倒排索引是 后置分析( post-analysis )的。
当我们把这些文档加入到 Elasticsearch 中时,字符串 "New York"
被分析/分析成 ["new", "york"]
。这些单独的 tokens ,都被用来填充聚合计数,所以我们最终看到 new
的数量而不是 New York
。
【修正后】
DELETE /agg_analysis/
PUT /agg_analysis
{
"mappings": {
"data": {
"properties": {
"state" : {
"type": "string",
"fields": {
"raw" : {
"type": "string",
"index": "not_analyzed" // 显式映射 state 字段并包括一个 not_analyzed
辅字段
}
}
}
}
}
}
}
POST /agg_analysis/data/_bulk
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New Jersey" }
{ "index": {}}
{ "state" : "New Mexico" }
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New York" }
GET /agg_analysis/data/_search
{
"size" : 0,
"aggs" : {
"states" : {
"terms" : {
"field" : "state.raw" // 聚合针对 state.raw 字段而不是 state
}
}
}
}
[合理的结果]
{
...
"aggregations": {
"states": {
"buckets": [
{
"key": "New York",
"doc_count": 3
},
{
"key": "New Jersey",
"doc_count": 1
},
{
"key": "New Mexico",
"doc_count": 1
}
]
}
}
}
分析字符串和 Fielddata(Analyzed strings and Fielddata)
Doc values 不支持 analyzed
字符串字段,因为它们不能很有效的表示多值字符串。 Doc values 最有效的是,当每个文档都有一个或几个 tokens 时, 但不是无数的,分析字符串(想象一个 PDF ,可能有几兆字节并有数以千计的独特 tokens)。
出于这个原因,doc values 不生成分析的字符串,然而,这些字段仍然可以使用聚合,那怎么可能呢?
答案是一种被称为 fielddata 的数据结构。与 doc values 不同,fielddata 构建和管理 100% 在内存中,常驻于 JVM 内存堆。这意味着它本质上是不可扩展的,有很多边缘情况下要提防。
从历史上看,fielddata 是 所有 字段的默认设置。
但是 Elasticsearch 已迁移到 doc values 以减少 OOM 的几率。
分析的字符串是仍然使用 fielddata 的最后一块阵地。
最终目标是建立一个序列化的数据结构类似于 doc values ,可以处理高维度的分析字符串,逐步淘汰 fielddata。
高基数内存的影响(High-Cardinality Memory Implications)
避免分析字段的另外一个原因就是:高基数字段在加载到 fielddata 时会消耗大量内存。 分析的过程会经常(尽管不总是这样)生成大量的 token,这些 token 大多都是唯一的。 这会增加字段的整体基数并且带来更大的内存压力。
因此,在聚合字符串字段之前,请评估情况:
- 这是一个
not_analyzed
字段吗?如果是,可以通过 doc values 节省内存 。 - 否则,这是一个
analyzed
字段,它将使用 fielddata 并加载到内存中。这个字段因为 ngrams 有一个非常大的基数?如果是,这对于内存来说极度不友好。
限制内存使用
一旦分析字符串被加载到 fielddata ,他们会一直在那里,直到被驱逐(或者节点崩溃)。由于这个原因,留意内存的使用情况,了解它是如何以及何时加载的,怎样限制对集群的影响是很重要的。
Fielddata 是 延迟 加载。如果你从来没有聚合一个分析字符串,就不会加载 fielddata 到内存中。此外,fielddata 是基于字段加载的, 这意味着只有很活跃地使用字段才会增加 fielddata 的负担。
然而,这里有一个令人惊讶的地方。假设你的查询是高度选择性和只返回命中的 100 个结果。大多数人认为 fielddata 只加载 100 个文档。
实际情况是,fielddata 会加载索引中(针对该特定字段的) 所有的 文档,而不管查询的特异性。逻辑是这样:如果查询会访问文档 X、Y 和 Z,那很有可能会在下一个查询中访问其他文档。
与 doc values 不同,fielddata 结构不会在索引时创建。相反,它是在查询运行时,动态填充。这可能是一个比较复杂的操作,可能需要一些时间。 将所有的信息一次加载,再将其维持在内存中的方式要比反复只加载一个 fielddata 的部分代价要低。
JVM 堆 是有限资源的,应该被合理利用。 限制 fielddata 对堆使用的影响有多套机制,这些限制方式非常重要,因为堆栈的乱用会导致节点不稳定(感谢缓慢的垃圾回收机制),甚至导致节点宕机(通常伴随 OutOfMemory 异常)。
选择堆大小(Choosing a Heap Size)
在设置 Elasticsearch 堆大小时需要通过 $ES_HEAP_SIZE 环境变量应用两个规则:
不要超过可用 RAM 的 50%
Lucene 能很好利用文件系统的缓存,它是通过系统内核管理的。如果没有足够的文件系统缓存空间,性能会受到影响。
此外,专用于堆的内存越多意味着其他所有使用 doc values 的字段内存越少。
不要超过 32 GB
如果堆大小小于 32 GB,JVM 可以利用指针压缩,这可以大大降低内存的使用:每个指针 4 字节而不是 8 字节。
Fielddata的大小
indices.fielddata.cache.size
控制为 fielddata 分配的堆空间大小。 当你发起一个查询,分析字符串的聚合将会被加载到 fielddata,如果这些字符串之前没有被加载过。如果结果中 fielddata 大小超过了指定 大小
,其他的值将会被回收从而获得空间。
默认情况下,设置都是 unbounded ,Elasticsearch 永远都不会从 fielddata 中回收数据。
这个默认设置是刻意选择的:fielddata 不是临时缓存。它是驻留内存里的数据结构,必须可以快速执行访问,而且构建它的代价十分高昂。如果每个请求都重载数据,性能会十分糟糕。
一个有界的大小会强制数据结构回收数据。
这个设置是一个安全卫士,而非内存不足的解决方案。
如果没有足够空间可以将 fielddata 保留在内存中,Elasticsearch 就会时刻从磁盘重载数据,并回收其他数据以获得更多空间。
内存的回收机制会导致重度磁盘I/O,并且在内存中生成很多垃圾,这些垃圾必须在晚些时候被回收掉。
设想我们正在对日志进行索引,每天使用一个新的索引。通常我们只对过去一两天的数据感兴趣,尽管我们会保留老的索引,但我们很少需要查询它们。不过如果采用默认设置,旧索引的 fielddata 永远不会从缓存中回收! fieldata 会保持增长直到 fielddata 发生断熔(请参阅 断路器),这样我们就无法载入更多的 fielddata。
这个时候,我们被困在了死胡同。但我们仍然可以访问旧索引中的 fielddata,也无法加载任何新的值。相反,我们应该回收旧的数据,并为新值获得更多空间。
为了防止发生这样的事情,可以通过在 config/elasticsearch.yml
文件中增加配置为 fielddata 设置一个上限:
indices.fielddata.cache.size: 20%
可以设置堆大小的百分比,也可以是某个值,例如: 5gb
有了这个设置,最久未使用(LRU)的 fielddata 会被回收为新数据腾出空间。
可能发现在线文档有另外一个设置: indices.fielddata.cache.expire 。
这个设置 永远都不会 被使用!它很有可能在不久的将来被弃用。
这个设置要求 Elasticsearch 回收那些 过期 的 fielddata,不管这些值有没有被用到。
这对性能是件 很糟糕 的事情。回收会有消耗性能,它刻意的安排回收方式,而没能获得任何回报。
没有理由使用这个设置:我们不能从理论上假设一个有用的情形。目前,它的存在只是为了向前兼容。
只在很有以前提到过这个设置,但不幸的是网上各种文章都将其作为一种性能调优的小窍门来推荐。
它不是。永远不要使用!
监控 fielddata(Monitoring fielddata)
无论是仔细监控 fielddata 的内存使用情况, 还是看有无数据被回收都十分重要。高的回收数可以预示严重的资源问题以及性能不佳的原因。
Fielddata 的使用可以被监控:
-
按索引使用
indices-stats
API :GET /_stats/fielddata?fields=* -
按节点使用[
nodes-stats
API] :GET /_nodes/stats/indices/fielddata?fields=* -
按索引节点:GET /_nodes/stats/indices/fielddata?level=indices&fields=*
使用设置 ?fields=*
,可以将内存使用分配到每个字段。
断路器
机敏的读者可能已经发现 fielddata 大小设置的一个问题。fielddata 大小是在数据加载 之后 检查的。 如果一个查询试图加载比可用内存更多的信息到 fielddata 中会发生什么?答案:会碰到 OutOfMemoryException 。
Elasticsearch 包括一个 fielddata 断熔器 ,这个设计就是为了处理上述情况。 断熔器通过内部检查(字段的类型、基数、大小等等)来估算一个查询需要的内存。它然后检查要求加载的 fielddata 是否会导致 fielddata 的总量超过堆的配置比例。
如果估算查询的大小超出限制,就会 触发 断路器,查询会被中止并返回异常。这都发生在数据加载 之前 ,也就意味着不会引起 OutOfMemoryException 。
可用的断路器(Available Circuit Breakers)
Elasticsearch 有一系列的断路器,它们都能保证内存不会超出限制:
indices.breaker.fielddata.limit
fielddata 断路器默认设置堆的 60% 作为 fielddata 大小的上限。
indices.breaker.request.limit
request 断路器估算需要完成其他请求部分的结构大小,例如创建一个聚合桶,默认限制是堆内存的 40%。
indices.breaker.total.limit
total 揉合 request 和 fielddata 断路器保证两者组合起来不会使用超过堆内存的 70%。
断路器的限制可以在文件 config/elasticsearch.yml
中指定,可以动态更新一个正在运行的集群:
PUT /_cluster/settings
{
"persistent" : {
"indices.breaker.fielddata.limit" : "40%" // 这个限制是按对内存的百分比设置的。
}
}
最好为断路器设置一个相对保守点的值。 记住 fielddata 需要与 request
断路器共享堆内存、索引缓冲内存和过滤器缓存。Lucene 的数据被用来构造索引,以及各种其他临时的数据结构。 正因如此,它默认值非常保守,只有 60% 。过于乐观的设置可能会引起潜在的堆栈溢出(OOM)异常,这会使整个节点宕掉。
另一方面,过度保守的值只会返回查询异常,应用程序可以对异常做相应处理。异常比服务器崩溃要好。这些异常应该也能促进我们对查询进行重新评估:为什么单个查询需要超过堆内存的 60% 之多?
在 Fielddata的大小 中,关于给 fielddata 的大小加一个限制,从而确保旧的无用 fielddata 被回收的方法。
indices.fielddata.cache.size 和 indices.breaker.fielddata.limit 之间的关系非常重要。
如果断路器的限制低于缓存大小,没有数据会被回收。为了能正常工作,断路器的限制 必须 要比缓存大小要高。
值得注意的是:断路器是根据总堆内存大小估算查询大小的,而 非 根据实际堆内存的使用情况。 这是由于各种技术原因造成的(例如,堆可能看上去是满的但实际上可能只是在等待垃圾回收,这使我们难以进行合理的估算)。但作为终端用户,这意味着设置需要保守,因为它是根据总堆内存必要的,而 不是 可用堆内存。
Fielddata 的过滤
PUT /music/_mapping/song
{
"properties": {
"tag": {
"type": "string",
"fielddata": { // 允许配置 fielddata 处理该字段的方式
"filter": {
"frequency": { // 允许基于项频率过滤加载 fielddata
"min": 0.01, // 只加载那些至少在本段文档中出现 1% 的项
"min_segment_size": 500 // 忽略任何文档个数小于 500 的段
}
}
}
}
}
}
有了这个映射,只有那些至少在 本段 文档中出现超过 1% 的项才会被加载到内存中。我们也可以指定一个 最大
词频,它可以被用来排除 常用 项,比如 停用词 。
这种情况下,词频是按照段来计算的。这是实现的一个限制:fielddata 是按段来加载的,所以可见的词频只是该段内的频率。但是,这个限制也有些有趣的特性:它可以让受欢迎的新项迅速提升到顶部。
min_segment_size
参数要求 Elasticsearch 忽略某个大小以下的段。 如果一个段内只有少量文档,它的词频会非常粗略没有任何意义。 小的分段会很快被合并到更大的分段中,某一刻超过这个限制,将会被纳入计算。
通过频次来过滤项并不是唯一的选择,我们也可以使用正则式来决定只加载那些匹配的项。
例如,我们可以用 regex 过滤器 处理 twitte 上的消息只将以 # 号开始的标签加载到内存中。
这假设我们使用的分析器会保留标点符号,像 whitespace 分析器。
Fielddata 过滤对内存使用有 巨大的 影响,权衡也是显而易见的:我们实际上是在忽略数据。但对于很多应用,这种权衡是合理的,因为这些数据根本就没有被使用到。内存的节省通常要比包括一个大量而无用的长尾项更为重要。
预加载 fielddata
Elasticsearch 加载内存 fielddata 的默认行为是 延迟 加载 。 当 Elasticsearch 第一次查询某个字段时,它将会完整加载这个字段所有 Segment 中的倒排索引到内存中,以便于以后的查询能够获取更好的性能。
对于小索引段来说,这个过程的需要的时间可以忽略。但如果我们有一些 5 GB 的索引段,并希望加载 10 GB 的 fielddata 到内存中,这个过程可能会要数十秒。
有三种方式可以解决这个延时高峰:
- 预加载 fielddata
- 预加载全局序号
- 缓存预热
预加载 fielddata(Eagerly Loading Fielddata)
第一个工具称为 预加载 (与默认的 延迟加载相对)。随着新分段的创建(通过刷新、写入或合并等方式), 启动字段预加载可以使那些对搜索不可见的分段里的 fielddata 提前 加载。
这就意味着首次命中分段的查询不需要促发 fielddata 的加载,因为 fielddata 已经被载入到内存。避免了用户遇到搜索卡顿的情形。
预加载是按字段启用的,所以我们可以控制具体哪个字段可以预先加载:
PUT /music/_mapping/_song
{
"tags": {
"type": "string",
"fielddata": {
"loading" : "eager" // 设置 fielddata.loading: eager
可以告诉 Elasticsearch 预先将此字段的内容载入内存中
}
}
}
Fielddata 的载入可以使用 update-mapping
API 对已有字段设置 lazy
或 eager
两种模式。
预加载只是简单的将载入 fielddata 的代价转移到索引刷新的时候,而不是查询时,从而大大提高了搜索体验。
体积大的索引段会比体积小的索引段需要更长的刷新时间。
通常,体积大的索引段是由那些已经对查询可见的小分段合并而成的,所以较慢的刷新时间也不是很重要。
全局序号(Global Ordinals)
有种可以用来降低字符串 fielddata 内存使用的技术叫做 序号 。
设想我们有十亿文档,每个文档都有自己的 status
状态字段,状态总共有三种: status_pending
、 status_published
、 status_deleted
。如果我们为每个文档都保留其状态的完整字符串形式,那么每个文档就需要使用 14 到 16 字节,或总共 15 GB。
取而代之的是我们可以指定三个不同的字符串,对其排序、编号:0,1,2。
Ordinal | Term
-------------------
0 | status_deleted
1 | status_pending
2 | status_published
序号字符串在序号列表中只存储一次,每个文档只要使用数值编号的序号来替代它原始的值。
Doc | Ordinal
-------------------------
0 | 1 # pending
1 | 1 # pending
2 | 2 # published
3 | 0 # deleted
这样可以将内存使用从 15 GB 降到 1 GB 以下!
但这里有个问题,记得 fielddata 是按分 段 来缓存的。如果一个分段只包含两个状态( status_deleted
和 status_published
)。那么结果中的序号(0 和 1)就会与包含所有三个状态的分段不一样。
如果我们尝试对 status
字段运行 terms
聚合,我们需要对实际字符串的值进行聚合,也就是说我们需要识别所有分段中相同的值。一个简单粗暴的方式就是对每个分段执行聚合操作,返回每个分段的字符串值,再将它们归纳得出完整的结果。 尽管这样做可行,但会很慢而且大量消耗 CPU。
取而代之的是使用一个被称为 全局序号 的结构。 全局序号是一个构建在 fielddata 之上的数据结构,它只占用少量内存。唯一值是 跨所有分段 识别的,然后将它们存入一个序号列表中,正如我们描述过的那样。
现在, terms
聚合可以对全局序号进行聚合操作,将序号转换成真实字符串值的过程只会在聚合结束时发生一次。这会将聚合(和排序)的性能提高三到四倍。
构建全局序号(Building global ordinals)
全局序号分布在索引的所有段中,所以如果新增或删除一个分段时,需要对全局序号进行重建。 重建需要读取每个分段的每个唯一项,基数越高(即存在更多的唯一项)这个过程会越长。
全局序号是构建在内存 fielddata 和 doc values 之上的。实际上,它们正是 doc values 性能表现不错的一个主要原因。
和 fielddata 加载一样,全局序号默认也是延迟构建的。首个需要访问索引内 fielddata 的请求会促发全局序号的构建。由于字段的基数不同,这会导致给用户带来显著延迟这一糟糕结果。一旦全局序号发生重建,仍会使用旧的全局序号,直到索引中的分段产生变化:在刷新、写入或合并之后。
预构建全局序号(Eager global ordinals)
单个字符串字段 可以通过配置预先构建全局序号
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"fielddata": {
"loading" : "eager_global_ordinals" // 暗示着 fielddata 是预加载的
}
}
}
正如 fielddata 的预加载一样,预构建全局序号发生在新分段对于搜索可见之前。
序号的构建只被应用于字符串。
数值信息(integers(整数)、geopoints(地理经纬度)、dates(日期)等等)不需要使用序号映射,因为这些值自己本质上就是序号映射。
因此,我们只能为字符串字段预构建其全局序号。
也可以对 Doc values 进行全局序号预构建:
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"doc_values": true,
"fielddata": {
"loading" : "eager_global_ordinals" // 这种情况下,fielddata 没有载入到内存中,而是 doc values 被载入到文件系统缓存中。
}
}
}
与 fielddata 预加载不一样,预建全局序号会对数据的 实时性 产生影响,构建一个高基数的全局序号会使一个刷新延时数秒。 选择在于是每次刷新时付出代价,还是在刷新后的第一次查询时。如果经常索引而查询较少,那么在查询时付出代价要比每次刷新时要好。如果写大于读,那么在选择在查询时重建全局序号将会是一个更好的选择。
针对实际场景优化全局序号的重建频次。
如果我们有高基数字段需要花数秒钟重建,增加 refresh_interval 的刷新的时间从而可以使我们的全局序号保留更长的有效期,
这也会节省 CPU 资源,因为我们重建的频次下降了。
索引预热器(Index Warmers)
预热器早于 fielddata 预加载和全局序号预加载之前出现,它们仍然有其存在的理由。一个索引预热器允许我们指定一个查询和聚合须要在新分片对于搜索可见之前执行。 这个想法是通过预先填充或 预热缓存 让用户永远无法遇到延迟的波峰。
原来,预热器最重要的用法是确保 fielddata 被预先加载,因为这通常是最耗时的一步。现在可以通过前面讨论的那些技术来更好的控制它,但是预热器还是可以用来预建过滤器缓存,当然我们也还是能选择用它来预加载 fielddata。
[注册一个预热器]
PUT /music/_warmer/warmer_1 // 预热器被关联到索引( music
)上,使用接入口 _warmer
以及 ID ( warmer_1
)
{
"query" : {
"bool" : {
"filter" : {
"bool": {
"should": [ // 为三种最受欢迎的曲风预建过滤器缓存
{ "term": { "tag": "rock" }},
{ "term": { "tag": "hiphop" }},
{ "term": { "tag": "electronics" }}
]
}
}
}
},
"aggs" : {
"price" : {
"histogram" : {
"field" : "price", // 字段 price 的 fielddata 和全局序号会被预加载。
"interval" : 10
}
}
}
}
预热器是根据具体索引注册的, 每个预热器都有唯一的 ID ,因为每个索引可能有多个预热器。
然后我们可以指定查询,任何查询。它可以包括查询、过滤器、聚合、排序值、脚本,任何有效的查询表达式都毫不夸张。 这里的目的是想注册那些可以代表用户产生流量压力的查询,从而将合适的内容载入缓存。
当新建一个分段时,Elasticsearch 将会执行注册在预热器中的查询。执行这些查询会强制加载缓存,只有在所有预热器执行完,这个分段才会对搜索可见。
与预加载类似,预热器只是将冷缓存的代价转移到刷新的时候。当注册预热器时,做出明智的决定十分重要。
为了确保每个缓存都被读入,我们 可以 加入上千的预热器,但这也会使新分段对于搜索可见的时间急剧上升。
实际中,我们会选择少量代表大多数用户的查询,然后注册它们。
优化聚合查询
terms
桶基于我们的数据动态构建桶;它并不知道到底生成了多少桶。 大多数时候对单个字段的聚合查询还是非常快的, 但是当需要同时聚合多个字段时,就可能会产生大量的分组,最终结果就是占用 es 大量内存,从而导致 OOM 的情况发生。
[举例]
假设我们现在有一些关于电影的数据集,每条数据里面会有一个数组类型的字段存储表演该电影的所有演员的名字。
{
"actors" : [
"Fred Jones",
"Mary Jane",
"Elizabeth Worthing"
]
}
如果我们想要查询出演影片最多的十个演员以及与他们合作最多的演员
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 10
},
"aggs" : {
"costars" : {
"terms" : {
"field" : "actors",
"size" : 5
}
}
}
}
}
}
这会返回前十位出演最多的演员,以及与他们合作最多的五位演员。这看起来是一个简单的聚合查询,最终只返回 50 条数据!
但是, 这个看上去简单的查询可以轻而易举地消耗大量内存,我们可以通过在内存中构建一个树来查看这个 terms
聚合。actors
聚合会构建树的第一层,每个演员都有一个桶。然后,内套在第一层的每个节点之下, costar
聚合会构建第二层,每个联合出演一个桶。这意味着每部影片会生成 n2 个桶!
用真实点的数据,设想平均每部影片有 10 名演员,每部影片就会生成 102 == 100 个桶。如果总共有 20,000 部影片,粗率计算就会生成 2,000,000 个桶。
现在,记住,聚合只是简单的希望得到前十位演员和与他们联合出演者,总共 50 条数据。为了得到最终的结果,我们创建了一个有 2,000,000 桶的树,然后对其排序,取 top10。
在 2 万条数据下执行任何聚合查询都是毫无压力的。如果我们有 2 亿文档,想要得到前 100 位演员以及与他们合作最多的 20 位演员,作为查询的最终结果会出现什么情况呢?
可以推测聚合出来的分组数非常大,会使这种策略难以维持。世界上并不存在足够的内存来支持这种不受控制的聚合查询。
深度优先与广度优先(Depth-First Versus Breadth-First)
Elasticsearch 允许我们改变聚合的 集合模式 ,就是为了应对这种状况。
之前展示的策略叫做 深度优先 ,它是默认设置, 先构建完整的树,然后修剪无用节点。 深度优先 的方式对于大多数聚合都能正常工作,但对于如我们演员和联合演员这样例子的情形就不太适用。
为了应对这些特殊的应用场景,我们应该使用另一种集合策略叫做 广度优先 。
这种策略的工作方式有些不同,它先执行第一层聚合, 再 继续下一层聚合之前会先做修剪。
【广度优先】
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 10,
"collect_mode" : "breadth_first" // 按聚合来开启 breadth_first
。
},
"aggs" : {
"costars" : {
"terms" : {
"field" : "actors",
"size" : 5
}
}
}
}
}
}
广度优先仅仅适用于每个组的聚合数量远远小于当前总组数的情况下,因为广度优先会在内存中缓存裁剪后的仅仅需要缓存的每个组的所有数据,以便于它的子聚合分组查询可以复用上级聚合的数据。
广度优先的内存使用情况与裁剪后的缓存分组数据量是成线性的。对于很多聚合来说,每个桶内的文档数量是相当大的。 想象一种按月分组的直方图,总组数肯定是固定的,因为每年只有12个月,这个时候每个月下的数据量可能非常大。这使广度优先不是一个好的选择,这也是为什么深度优先作为默认策略的原因。
针对上面演员的例子,如果数据量越大,那么默认的使用深度优先的聚合模式生成的总分组数就会非常多,但是预估二级的聚合字段分组后的数据量相比总的分组数会小很多所以这种情况下使用广度优先的模式能大大节省内存,从而通过优化聚合模式来大大提高了在某些特定场景下聚合查询的成功率。