序号 | 内容 | 链接地址 |
---|---|---|
1 | SpringBoot整合Elasticsearch7.6.1 | https://blog.csdn.net/miaomiao19971215/article/details/105106783 |
2 | Elasticsearch Filter执行原理 | https://blog.csdn.net/miaomiao19971215/article/details/105487446 |
3 | Elasticsearch 倒排索引与重建索引 | https://blog.csdn.net/miaomiao19971215/article/details/105487532 |
4 | Elasticsearch Document写入原理 | https://blog.csdn.net/miaomiao19971215/article/details/105487574 |
5 | Elasticsearch 相关度评分算法 | https://blog.csdn.net/miaomiao19971215/article/details/105487656 |
6 | Elasticsearch Doc values | https://blog.csdn.net/miaomiao19971215/article/details/105487676 |
7 | Elasticsearch 搜索技术深入 | https://blog.csdn.net/miaomiao19971215/article/details/105487711 |
8 | Elasticsearch 聚合搜索技术深入 | https://blog.csdn.net/miaomiao19971215/article/details/105487885 |
9 | Elasticsearch 内存使用 | https://blog.csdn.net/miaomiao19971215/article/details/105605379 |
10 | Elasticsearch ES-Document数据建模详解 | https://blog.csdn.net/miaomiao19971215/article/details/105720737 |
bucket: 又被称作桶,满足特定条件的文档集合,可以看作是一个数据分组。聚合开始后,Elasticsearch会根据文档的值计算出文档究竟符合哪个桶,如果匹配,则将文档放入相应的桶。当所有的文档都经过计算后,再分别对每个桶进行聚合操作。
metric: 又被称作指标,对桶内的文档进行进行聚合分析操作,操作有若干种类别,如:求和、最大值、最小值、平均值等,通过对桶内文档进行相应的操作,我们可以得到想要的指标。
准备数据:
PUT /cars
{
"mappings": {
"properties": {
"price": {
"type": "long"
},
"color": {
"type": "keyword"
},
"brand": {
"type": "keyword"
},
"model": {
"type": "keyword"
},
"sold_date": {
"type": "date"
},
"remark" : {
"type" : "text",
"analyzer" : "ik_max_word"
}
}
}
}
POST /cars/_bulk
{
"index": {
}}
{
"price" : 258000, "color" : "金色", "brand":"大众", "model" : "大众迈腾", "sold_date" : "2017-10-28","remark" : "大众中档车" }
{
"index": {
}}
{
"price" : 123000, "color" : "金色", "brand":"大众", "model" : "大众速腾", "sold_date" : "2017-11-05","remark" : "大众神车" }
{
"index": {
}}
{
"price" : 239800, "color" : "白色", "brand":"标志", "model" : "标志508", "sold_date" : "2017-05-18","remark" : "标志品牌全球上市车型" }
{
"index": {
}}
{
"price" : 148800, "color" : "白色", "brand":"标志", "model" : "标志408", "sold_date" : "2017-07-02","remark" : "比较大的紧凑型车" }
{
"index": {
}}
{
"price" : 1998000, "color" : "黑色", "brand":"大众", "model" : "大众辉腾", "sold_date" : "2017-08-19","remark" : "大众最让人肝疼的车" }
{
"index": {
}}
{
"price" : 218000, "color" : "红色", "brand":"奥迪", "model" : "奥迪A4", "sold_date" : "2017-11-05","remark" : "小资车型" }
{
"index": {
}}
{
"price" : 489000, "color" : "黑色", "brand":"奥迪", "model" : "奥迪A6", "sold_date" : "2018-01-01","remark" : "政府专用?" }
{
"index": {
}}
{
"price" : 1899000, "color" : "黑色", "brand":"奥迪", "model" : "奥迪A 8", "sold_date" : "2018-02-12","remark" : "很贵的大A6。。。" }
只是简单的对数据进行聚合分组,计算每组中元素的个数,不做复杂的聚合统计。在ES中,最基础的聚合就是terms,相当于SQL中的count。ES内默认会参考doc_count值,也就是参考_count元数据,根据每组元素的个数,执行降序排列,我们也可以根据_key元数据进行排序,_key指的是用来分组的字段对应的值(字典顺序)。 terms不支持多字段聚合分组,原因在于多字段聚合分组,其实就是在一个聚合分组的基础上再次执行聚合分组,terms无法统计组内的元素个数。
举例: 在cars索引中根据color统计车辆的销量情况,并按照元素个数进行倒序排序。
GET /cars/_search
{
"size":0,
"aggs": {
"group_by_color_mmr": {
"terms": {
"field": "color",
"order": {
"_count": "desc"
}
}
}
}
}
得到的结果为:
"aggregations" : {
"group_by_color_mmr" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "黑色",
"doc_count" : 3
},
{
"key" : "白色",
"doc_count" : 2
},
{
"key" : "金色",
"doc_count" : 2
},
{
"key" : "红色",
"doc_count" : 1
}
]
}
如果使用_key进行排序,实际上就是在对"黑色"、“白色”、“金色”、"红色"这几个单词进行排序。
先根据color进行分组,在此基础之上,再对组内的元素针对brand进行分组,最后再通过price进行聚合统计,计算出每组品牌车辆的平均价格。嵌套多层聚合的手法可以实现多字段聚合。但在多层嵌套聚合时,需要注意不得非直系亲属的聚合字段进行排序。多次嵌套聚合的方式又被称作下钻分析,而水平定义就是在同一层指定多个分组方式,具体语法如下:
GET /index_name/type_name/_search
{
"aggs" : {
"定义分组名称(最外层)": {
"分组策略如:terms、avg、sum": {
"field" : "根据哪一个字段分组",
"其他参数" : ""
},
"aggs" : {
"分组名称1" : {
},
"分组名称2" : {
}
}
}
}
}
请看下面这个例子:
GET cars/_search
{
"size": 0,
"aggs": {
"group_by_color": {
"terms": {
"field": "color",
"order": {
"_key": "desc"
}
},
"aggs": {
"avg_by_price_color": {
"avg": {
"field": "price"
}
},
"group_by_brand": {
"terms": {
"field": "brand",
"order": {
"avg_by_price": "desc"
}
},
"aggs": {
"avg_by_price": {
"avg": {
"field": "price"
}
}
}
}
}
}
}
}
可以使用别的聚合字段作为当前分组bucket的排序依据,但是一定不能跨代。比如group_by_color中不能使用avg_by_price进行排序,因为已经隔了1代,不再是"直系亲属"的关系了。如果强行使用,会报错:
“Invalid aggregator order path [avg_by_price]. The provided aggregation [avg_by_price] either does not exist, or is a pipeline aggregation and cannot be used to sort the buckets.”
无效的聚合排序字段[avg_by_price]. 打算用来排序的[avg_by_price]字段要么不存在,要么是管道聚合,不能被当前分组(bucket)用来排序。
举例,统计不同color中车辆价格的最大值、最小值以及价格总和。
GET cars/_search
{
"size": 0,
"aggs": {
"group_by_color": {
"terms": {
"field": "color",
"order": {
"_count": "desc"
}
},
"aggs": {
"max_price": {
"max": {
"field": "price"
}
},
"min_price": {
"min": {
"field": "price"
}
},
"sum_price": {
"sum": {
"field": "price"
}
}
}
}
}
}
分组后,可能需要对组内的数据进行排序,并选择其中排名较高的数据,那么可以使用top_hits来实现。top_hits中的属性size代表每组中需要展示多少条数据(默认10条),sort代表组内使用什么字段和规则进行排序(默认使用_doc的asc规则),_source代表结果中包含document中的哪些字段(默认包含全部字段)。
注意: _doc指的是数据真正存储到ES中的顺序,不一定是插入数据的顺序,这个元数据字段由ES来维护。
对车辆品牌分组,为每组中的车辆根据价格倒序排序,只取价格最高的那一台车辆,并展示车辆的型号和价格。
GET cars/_search
{
"size" : 0,
"aggs": {
"group_by_brand": {
"terms": {
"field": "brand"
},
"aggs": {
"top_car": {
"top_hits": {
"size": 1,
"sort": [
{
"price": {
"order": "desc"
}
}
],
"_source": {
"includes": ["model", "price"]
}
}
}
}
}
}
}
如果说terms是通过某个字段进行分组,那么histogram就是按照给定的区间进行分组。
比如以100万为一个区间间隔,统计不同范围内车辆的销售量和平均价格。那么在使用histogram时,field指定为price,区间范围是100万,此时ES会将price划分成若干区间: [0,10000000),[10000000, 20000000)等等,依此类推,histogram和terms一样,也会统计每个区间内数据的数量,也允许嵌套aggs实现其它聚合统计操作。
GET cars/_search
{
"size" : 0,
"aggs": {
"histogram_by_price": {
"histogram": {
"field": "price",
"interval": 1000000
},
"aggs": {
"avg_by_price": {
"avg": {
"field": "price"
}
},
"max_by_price": {
"max": {
"field": "price"
}
}
}
}
}
}
data_histogram用于对date类型的field进行分组,比如每年的销量、每个月的销量统计。
data_histogram包含以下属性字段:
GET cars/_search
{
"size": 0,
"aggs": {
"histogram)by_date": {
"date_histogram": {
"field": "sold_date",
"interval": "month",
"min_doc_count": 1,
"format": "yyyy-MM-dd",
"extended_bounds": {
"min": "2017-01-01",
"max": "2020-04-06"
}
},
"aggs": {
"sum_by_price": {
"sum": {
"field": "price"
}
}
}
}
}
}
如果min_doc_count等于0或不设置,将会把不含元素的bucket也展示出来,结果如下:
"aggregations" : {
"histogram)by_date" : {
"buckets" : [
{
"key_as_string" : "2017-01-01",
"key" : 1483228800000,
"doc_count" : 0,
"sum_by_price" : {
"value" : 0.0
}
},
{
"key_as_string" : "2017-02-01",
"key" : 1485907200000,
"doc_count" : 0,
"sum_by_price" : {
"value" : 0.0
}
},
{
"key_as_string" : "2017-03-01",
"key" : 1488326400000,
"doc_count" : 0,
"sum_by_price" : {
"value" : 0.0
}
},
...
在聚合统计时,有时需要需要对比部分数据和总体数据。global用于定义一个全局的bucket,这个bucket分组会忽略query条件,在所有的document中进行聚合统计,写法: “global”: {}
举例,统计大众品牌下的车辆平均价格和所有车辆车辆的平均价格。
GET cars/_search
{
"size": 0,
"query": {
"match": {
"brand": "大众"
}
},
"aggs": {
"dazhong_avg_price": {
"avg": {
"field": "price"
}
},
"all_avg_price": {
"global": {
},
"aggs": {
"all_of_price": {
"avg": {
"field": "price"
}
}
}
}
}
}
得到的结果如下:
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"dazhong_avg_price" : {
"value" : 793000.0
},
"all_avg_price" : {
"doc_count" : 8,
"all_of_price" : {
"value" : 671700.0
}
}
}
对聚合统计的数据进行排序。默认情况下,一般是根据聚合统计的doc_value和key联合实现排序的,其中doc_value降序,而key升序。
如果有多层aggs,那么在执行下钻聚合时,也可以根据子代的聚合数据实现排序,注意,一定不要跨组排序。
GET /cars/_search
{
"aggs": {
"group_by_brand": {
"terms": {
"field": "brand"
},
"aggs": {
"group_by_color": {
"terms": {
"field": "color",
"order": {
"sum_of_price": "desc"
}
},
"aggs": {
"sum_of_price": {
"sum": {
"field": "price"
}
}
}
}
}
}
}
}
search搜索相当于sql中的where条件,aggs相当于group,显然这两个语法使可以结合起来使用的。
比如: 搜索"大众"品牌中,每个季度的销售量和销售总额。
GET cars/_search
{
"query": {
"match": {
"brand": "大众"
}
},
"aggs": {
"date_of_group": {
"date_histogram": {
"field": "sold_date",
"format": "yyyy-MM-dd",
"calendar_interval": "quarter",
"min_doc_count": 1,
"order": {
"_count": "desc"
}
},
"aggs": {
"sum_by_price": {
"sum": {
"field": "price"
}
}
}
}
}
}
filter可以和aggs连用,实现相对复杂的过滤聚合分析。
比如,统计售价在10~50万之间的车辆的平均价格。
GET cars/_search
{
"query": {
"constant_score": {
"filter": {
"range": {
"price": {
"gte": 100000,
"lte": 500000
}
}
}
}
},
"aggs": {
"avg_by_price": {
"avg": {
"field": "price"
}
}
}
}
filter可以写在aggs中,能够实现在query搜索的范围内进行过滤后,再执行聚合操作。filter的范围决定了聚合的范围。
比如,统计"大众"品牌汽车最近一年的销售总额。 now是ES的内部变量,代表当前时间, y对应年,M对应月,d对应日。(yyyy-MM-dd HH:mm:ss 有需要自己对应着往上套用)
GET cars/_search
{
"query": {
"match": {
"brand": "大众"
}
},
"aggs": {
"filter_sold_date": {
"filter": {
"range": {
"sold_date": {
"gte": "now-3y"
}
}
},
"aggs": {
"sum_price": {
"sum": {
"field": "price"
}
}
}
}
}
}
在ES中,如果需要去重重复,可以使用cardinality,类似sql中的distinct。
比如,统计每年销售的汽车的品牌数量。
GET cars/_search
{
"size": 0,
"aggs": {
"group_by_date": {
"date_histogram": {
"field": "sold_date",
"format": "yyyy-MM-dd",
"interval": "year",
"min_doc_count": 1
},
"aggs": {
"count_of_brand": {
"cardinality": {
"field": "brand"
}
}
}
}
}
}
cardinality有一定的错误率,它的执行性能非常高,一般都在100毫秒以内(不用考虑数据量级),其错误率也可以通过参数来进行控制。
cardinality语法中可以增加precision_threshold,这个参数用于定义去除重复字段中unique value的数量,默认值为100。
比如现在有50万台车,希望通过brand(品牌)进行去重。如果把precision_threshold设置成100,则代表最多有100个不同的品牌。若真实情况下不同品牌的数量不超过100,则去重后计算出的结果几乎不会有任何误差。(反之,超过的越多则误差越大)
cardinality算法会占用一定的内存空间,占用空间的大小大致参考以下公式:
占用的内存空间 = precision_threshold * 8(Byte)
根据公式,上述案例中,cardinality算法占用的空间大小为: 100 * 8 = 800个字节。我们可以根据真实服务器的配置来调整precision_threshold,这个数值的取值范围是0~40000,超过40000则按照40000来处理。(因此,不能为了避免误差,盲目的填写precision_threshold)
经官方测试,当precision_threshold=100,对应过滤字段的unique value的数量为百万级别时,最终过滤的错误率为5%以内。
如何优化cardinality算法呢?
cardinality底层使用的算法是HyperLogLog++算法,简称HLL算法,它本质上就是对所有的unique value计算hash值,再通过hash值使用类似distinct的手法计算最终去重的结果。由于不能保证hash值绝对不重复,所以cardinality计算的结果可能会有误差。
所谓的对cardinality进行优化,实际上就是想办法优化HLL算法的性能。算法本身虽然没有办法优化,但我们可以调整计算hash值的时机。比如在创建index时,专门创建一个字段,用于存放hash值。新增document时,直接对待过滤的字段计算hash值并保存到hash字段中。这样一来,在使用cardinality进行去重时,就不需要再次计算待去重字段的hash值了,从而提升了cardinality的性能。(虽然这么做会降低数据写入的效率,且并不会对误差有所改善)
所以是否使用cardinality优化,需要我们在数据的写入损耗与过滤时节省的hash计算时长之间做出权衡。
以上优化的方案需要通过安装插件提供支持: mapper-murmur3 plugin。该插件可以从官网获取,注意插件与ES的版本号保持一致。
官方介绍地址: 点我 下载路径: 点我
根据实际的安装路径,在每一个ES节点下,执行以下命令(比如windows):
bin\elasticsearch-plugin install file:///C:/mapper-murmur3-xxx.zip
执行后需要重启ES。
卸载命令:
bin\elasticsearch-plugin remove file:///C:/mapper-murmur3-xxx.zip
使用时,需要在定义index时新增类型为murmur3的子字段。具体写法如下:
PUT /cars
{
"mappings": {
"sales": {
"properties": {
"brand": {
"type": "keyword",
"fields": {
"custom_brand_hash" : {
"type": "murmur3"
}
}
},
... 省略其他字段
}
}
}
}
新增和修改数据的方式照旧,只不过在进行去重聚合时,不再使用"brand"字段,而是使用子字段"custom_brand_hash“。
GET /cars/_search
{
"aggs": {
"group_by_date": {
"date_histogram": {
"field": "sold_date",
"interval": "month",
"min_doc_count": 1
},
"aggs": {
"count_of_brand": {
"cardinality": {
"field": "brand.custom_brand_hash"
}
}
}
}
}
}
ES有percentile api,用于计算百分比数据,在项目中一般用于统计请求的响应时长。
比如PT99: 指的是99%的请求能达到多少毫秒的响应时长,计算时会抽样99%的数据进行统计。
这里很容易陷入PT99比PT50计算出的响应时长长的陷阱。实际场景中,请求的响应时长不会均匀分布,可能有10%的请求响应平均时长为1000毫秒,50%的请求响应平均时长为300毫秒,40%的请求响应平均时长为50毫秒。统计时会对数据进行抽样对比,现在考虑PT50和PT99,请问是抽样获取50%的数据时包含50毫秒响应时长请求的概率大,还是抽样获取99%的数据时概率大?显然后者概率更大,由于抽样获取响应时长短的数据概率更大,压低了整体数据的响应时长,所以通常而言,PT99比PT50计算出的响应时长更短。(就算有人会反问,如果90%的响应时长是1000毫秒,10%的请求响应时长为50毫秒怎么办?没关系,此时PT99还是比PT50响应时长短,因为PT99抽样获取到50毫秒请求的概率要比PT50大得多,压低了整体数据的响应时长)
举例, 统计出50%,90%以及99%的请求花费的响应时长。
初始化数据:
PUT /test_percentiles
{
"mappings": {
"properties": {
"latency": {
"type": "long"
},
"province": {
"type": "keyword"
},
"timestamp": {
"type": "date"
}
}
}
}
POST /test_percentiles/_bulk
{
"index": {
}}
{
"latency" : 105, "province" : "江苏", "timestamp" : "2016-10-28" }
{
"index": {
}}
{
"latency" : 83, "province" : "江苏", "timestamp" : "2016-10-29" }
{
"index": {
}}
{
"latency" : 92, "province" : "江苏", "timestamp" : "2016-10-29" }
{
"index": {
}}
{
"latency" : 112, "province" : "江苏", "timestamp" : "2016-10-28" }
{
"index": {
}}
{
"latency" : 68, "province" : "江苏", "timestamp" : "2016-10-28" }
{
"index": {
}}
{
"latency" : 76, "province" : "江苏", "timestamp" : "2016-10-29" }
{
"index": {
}}
{
"latency" : 101, "province" : "新疆", "timestamp" : "2016-10-28" }
{
"index": {
}}
{
"latency" : 275, "province" : "新疆", "timestamp" : "2016-10-29" }
{
"index": {
}}
{
"latency" : 166, "province" : "新疆", "timestamp" : "2016-10-29" }
{
"index": {
}}
{
"latency" : 654, "province" : "新疆", "timestamp" : "2016-10-28" }
{
"index": {
}}
{
"latency" : 389, "province" : "新疆", "timestamp" : "2016-10-28" }
{
"index": {
}}
{
"latency" : 302, "province" : "新疆", "timestamp" : "2016-10-29" }
发起请求:
GET /test_percentiles/_search
{
"size": 0,
"aggs": {
"percentiles_latency": {
"percentiles": {
"field": "latency",
"percents": [
50,
90,
99
]
}
}
}
}
聚合结果,50%的请求响应时长为108.5毫秒,90%的请求响应时长约为468毫秒, 而99%的请求响应时长约为654毫秒:
"aggregations" : {
"percentiles_latency" : {
"values" : {
"50.0" : 108.5,
"90.0" : 468.5000000000002,
"99.0" : 654.0
}
}
}
商业项目中,一般要求PT99最好能达到200毫秒以内,PT90要求500毫秒,而PT50要求1秒。
SLA是Service Level Agreement的缩写,意思是提供服务的标准。大家谈论的网站延迟的SLA,指的是这个网站中所有请求的访问延时。一般而言,大型公司要求SLA保持在200毫秒以内。如果延时超过1秒,通常会被标记成A级故障,代表这个网站有严重的性能问题。
percentile_ranks与上一个章节中的percentile的统计方向正好相反,前者指定请求延时时长,希望统计出满足延时标准的请求数量百分比,而后者指定了请求数量百分比,希望统计出给定范围内能够达到的请求延时时长。
keyed参数,默认值为true,用于拼接聚合条件和统计结果,起到简化输出的作用,具体应用方式请看下面的例子。
举例,分别统计访问延时在200毫秒和1000毫秒以内的请求数量占比。
请求语法,以下语法中会将latency划分成三个区间,[0,200), [200, 1000), [1000,∞)
GET test_percentiles/_search
{
"size": 0,
"aggs": {
"percentile_ranks_latency": {
"percentile_ranks": {
"field": "latency",
"values": [
200,
1000
],
"keyed": true
}
}
}
}
执行结果:
"aggregations" : {
"percentile_ranks_latency" : {
"values" : [
{
"key" : 200.0,
"value" : 64.57055214723927
},
{
"key" : 1000.0,
"value" : 100.0
}
]
}
}
如果将keyed设置成true或不写,则输出以下结果:
"aggregations" : {
"percentile_ranks_latency" : {
"values" : {
"200.0" : 64.57055214723927,
"1000.0" : 100.0
}
}
}
}
percentile_ranks的很常用,经常用于区域占比统计,比如电商中的价格区间统计(某一个品牌中,不同价格区间的手机数量占比)。
percentiles和percentiles_ranks的底层都是采用TDigest算法,就是用很多的节点来执行百分比计算,计算过程也是一种近似估计,有一定的错误率。节点越多,结果越精确(内存消耗越大)。
ES提供了参数compression用于限制节点的最大数目,限制为: 20 * compression。这个参数的默认值为100,也就是默认提供2000个节点。一个节点大约使用32个字节的内存,所以在最坏的情况下(例如有大量数据有序的存入),默认设置会生成一个大小为64KB的TDigest算法空间。在实际应用中,数据会更随机,也就没有必要用这么多的节点,所以TDigest使用的内存会更少。
GET test_percentiles/_search
{
"size": 0,
"aggs": {
"group_by_province": {
"terms": {
"field": "province"
},
"aggs": {
"percentile_ranks_latency": {
"percentile_ranks": {
"field": "latency",
"values": [
200,
1000
],
"keyed" : false,
"tdigest" : {
"compression" : 200
}
}
},
"percentiles_latency" : {
"percentiles": {
"field": "latency",
"percents": [
50,
90,
99
],
"keyed" : false,
"tdigest" : {
"compression" : 200
}
}
}
}
}
}
}
ES内部是如何执行聚合的?是否是通过倒排索引实现的聚合分析?
在ES中,进行聚合统计的时候,是不使用倒排索引的,因为使用倒排索引实现聚合统计的代价太高。
比如针对custom_field字段 有如下倒排索引:
Term | Doc_1 | Doc_2 | Doc_3 |
---|---|---|---|
brown | x | x | |
dog | x | x | |
fox | x | x |
如果我们想获得包含brown的文档列表,且对custom_field字段分组统计文档数量,则可以使用如下搜索语句:
GET /my_index/_search
{
"query" : {
"match" : {
"custom_field" : "brown"
}
},
"aggs" : {
"popular_terms": {
"terms" : {
"field" : "custom_field"
}
}
}
}
查询部分简单又高效。我们首先在倒排索引中找到 brown ,然后找到包含 brown 的文档。我们可以快速看到 Doc_1 和 Doc_2 包含 brown 这个 token。对于聚合部分,我们需要找到 Doc_1 和 Doc_2 里所有唯一的词项。 如果用倒排索引来实现这个功能,代价很高,因为需要把所有的Terms全部扫描一遍,挨个检查词条是否在Doc_1或Doc_2的custom_field字段中存在,随着词条数量和文档数量的增加,代价会越来越大,执行的时间也会越来越长。
Doc values 通过转置两者间的关系来解决这个问题。倒排索引将词条映射到包含它们的文档,doc values(正排索引) 将文档映射到它们包含的词条:
Doc | Values |
---|---|
Doc_1 | brown fox |
Doc_2 | brown dog |
Doc_3 | dog fox |
当数据被转置之后,想要收集到 Doc_1 和 Doc_2 的唯一 token 会非常容易。获得每个文档行,获取所有的词项,然后求两个集合的并集。
因此,搜索和聚合是相互紧密缠绕的。搜索使用倒排索引查找文档,聚合操作收集和聚合 doc values 里的数据。
如果字段的数据类型是Long,Date或keyword等,那么在数据录入时,ES会为这些字段自动创建正排索引(index-time)。正排索引和倒排索引类似,也有缓存应用(内存级别的缓存、OS Cache),如果内存不足时,doc values会写入磁盘文件。
ES大部分的操作都是基于系统缓存(OS Cache)进行的,而不是JVM。ES官方建议不要给JVM分配太多的内存空间,这样会导致GC的开销过大(需要到达一定的数据量,GC才会进行回收,此外,数据越多,那么新生代老年代的数据也就越多,GC每次需要扫描的数据也会变多。)通常来说,给JVM分配的内存不要超过服务器物理内存的1/4,剩余的内存空间供lucence作为OS Cache使用。毕竟ES中的倒排索引和正排索引都可以使用OS Cache来缓存,OS Cache越大,能够缓存的热数据越多,ES的搜索性能提升的越明显。
ES为了能够在缓存中尽可能的保存多的doc value和倒排索引,会使用压缩技术来实现doc value和倒排索引的数据压缩。技术手段有许多种,如: 合并相同值、table encoding压缩、最大公约数、offset压缩等等。
如果确定索引绝对不需要doc values,可以在创建索引时关闭doc values,但要保证索引绝对不会做聚合、排序、父子关系处理以脚本处理。关闭的方法如下:
PUT test_index
{
"mappings": {
"my_type" : {
"properties": {
"custom_field" : {
"type": "keyword",
"doc_values" : false
}
}
}
}
}
如果document中field的类型是text,那么在默认情况下是不能执行聚合分析的。比如下面的例子中,remark的类型是text:
Tips: 再次说明,size指的是分组后组的数量,也可以说是bucket的数量。
GET /cars/_search
{
"size": 0,
"aggs": {
"group_of_remark": {
"terms": {
"field": "reark",
"size": 2
}
}
}
}
执行后的部分错误信息为:
Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [remark] in order to load field data by uninverting the inverted index. Note that this can use significant memory.
text类型字段没有对聚合、排序等操作做相应的优化,因此这些操作在默认情况下是不能对text类型的字段使用的。请使用keyword字段来代替text类型字段。还有一种做法,就是将对应字段的fielddata的值设置成true,以便通过倒排索引来加载该字段的数据。请注意,这样做可能会占用大量的内存。
如果必须在text类型的字段上使用聚合操作,则有两种实现方案:
第一种方案不过是在创建索引并设置mapping时,增加keyword子字段,这里不再赘述。下面看第二种方案的实现方法(重点看remark字段):
DELETE cars
PUT /cars
{
"mappings": {
"properties": {
"price": {
"type": "long"
},
"color": {
"type": "keyword"
},
"brand": {
"type": "keyword"
},
"model": {
"type": "keyword"
},
"sold_date": {
"type": "date"
},
"remark": {
"type": "text",
"analyzer": "ik_max_word",
"fielddata": true
}
}
}
}
POST /cars/_bulk
{
"index": {
}}
{
"price" : 258000, "color" : "金色", "brand":"大众", "model" : "大众迈腾", "sold_date" : "2017-10-28","remark" : "大众中档车" }
{
"index": {
}}
{
"price" : 123000, "color" : "金色", "brand":"大众", "model" : "大众速腾", "sold_date" : "2017-11-05","remark" : "大众神车" }
{
"index": {
}}
{
"price" : 239800, "color" : "白色", "brand":"标志", "model" : "标志508", "sold_date" : "2017-05-18","remark" : "标志品牌全球上市车型" }
{
"index": {
}}
{
"price" : 148800, "color" : "白色", "brand":"标志", "model" : "标志408", "sold_date" : "2017-07-02","remark" : "比较大的紧凑型车" }
{
"index": {
}}
{
"price" : 1998000, "color" : "黑色", "brand":"大众", "model" : "大众辉腾", "sold_date" : "2017-08-19","remark" : "大众最让人肝疼的车" }
{
"index": {
}}
{
"price" : 218000, "color" : "红色", "brand":"奥迪", "model" : "奥迪A4", "sold_date" : "2017-11-05","remark" : "小资车型" }
{
"index": {
}}
{
"price" : 489000, "color" : "黑色", "brand":"奥迪", "model" : "奥迪A6", "sold_date" : "2018-01-01","remark" : "政府专用?" }
{
"index": {
}}
{
"price" : 1899000, "color" : "黑色", "brand":"奥迪", "model" : "奥迪A 8", "sold_date" : "2018-02-12","remark" : "很贵的大A6。。。" }
此时,再次使用remark字段进行聚合,就不会报错了,执行结果如下:
"aggregations" : {
"group_of_remark" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 18,
"buckets" : [
{
"key" : "车",
"doc_count" : 4
},
{
"key" : "大众",
"doc_count" : 3
},
{
"key" : "的",
"doc_count" : 3
},
{
"key" : "车型",
"doc_count" : 2
},
{
"key" : "6",
"doc_count" : 1
},
{
"key" : "a6",
"doc_count" : 1
},
{
"key" : "上市",
"doc_count" : 1
},
{
"key" : "专用",
"doc_count" : 1
},
{
"key" : "中档",
"doc_count" : 1
},
{
"key" : "中档车",
"doc_count" : 1
}
]
}
}
在默认情况下,不分词的字段类型(data、long、keyword)会自动创建正排索引,所以支持聚合分析、排序、父子数据关系以及脚本操作等。但text类型字段不会创建正排索引,因为分词后再创建正排索引,需要占用的空间太大,由于没有正排索引的支持,text类型字段也就不支持聚合分析了。
如果为text类型的字段开启fielddata,那么在对这个字段进行聚合分析时,ES会一次性将倒排索引逆转并加载到内存中,建立一份类似doc values的fielddata正排索引,最后,基于这个内存中的正排索引来进行聚合分析。
正常情况下,remark(keyword)正排索引,如下图所示:
Doc | Values |
---|---|
1 | 大众中档车 |
2 | 大众神车 |
3 | 标志品牌全球上市车型 |
但基于fielddata创建的正排索引,如下图所示(猜测,尚未验证):
Doc | Values(车) | Values(大众) | Values(车型) |
---|---|---|---|
1 | 车 | 大众 | |
2 | 车 | 大众 | |
3 | 车型 |
以上Values只写了一部分,随着remark中数据量增多,内容越来越丰富,会导致基于fielddata创建的正排索引的Values越来越多(从表格上来看就是列越来越多)。
fielddata存储在内存中,从结构上来看,就可以很容易的发现,这种数据结构很占用内存。此外,如果fielddata使用磁盘来进行存储,会因为数据量和数据结构的原因,产生非常多的segment file,搜索或聚合使用时,为了打开这些文件,IO的开销也会非常大,因此不推荐使用fielddata实现text类型聚合操作。fielddata是在针对这个字段进行聚合分析时,才会逆转倒排索引并加载到内存中,因此是一个在查询时生成的正排索引(query-time)。
由于fielddata对内存(堆内存)的开销非常大,因此ES提供了相关参数来设置内存限制,在每一个ES节点的配置文件config/elasticsearch.yml中增加配置:
indices.fielddata.cache.size:30%
默认情况下,ES对fielddata没有任何限制,如果对fielddata增加了配置信息,代表一旦fieldata在内存中的占比超过了限制,则ES会借助GC清除内存中所有的fielddata数据,也就伴随着频繁的evict和reload(清除内存和重新加载fieldata数据至内存),由于数据本身存储在segment file中,为了取出数据,还需要打开并读取文件,因此IO开销增大,又由于频繁使用GC,内存碎片也会增多,但如果不配置参数,又会严重的消耗堆内存,最终抛出OutOfMemoryError。所以fielddata不推荐使用。
GET (index)/_stats/fielddata?fields=*
执行结果如下:
"_all" : {
"primaries" : {
"fielddata" : {
"memory_size_in_bytes" : 1760,
"evictions" : 0,
"fields" : {
"remark" : {
"memory_size_in_bytes" : 1760
}
}
}
},
"total" : {
"fielddata" : {
"memory_size_in_bytes" : 3520,
"evictions" : 0,
"fields" : {
"remark" : {
"memory_size_in_bytes" : 3520
}
}
}
}
}
memory_size_in_bytes: 在内存中占用了多大空间,单位: 字节
eviction: 回收了多少个字节
主分片占用了1760个字节,再加上副本分片也占用了1760个字节,因此总计占用了3520个字节。
GET _nodes/stats/indices/fielddata?fields=*
执行结果如下:
{
"_nodes" : {
"total" : 1,
"successful" : 1,
"failed" : 0
},
"cluster_name" : "elasticsearch",
"nodes" : {
"rPIG8hcwT5av908OvQrmPP" : {
"timestamp" : 1587115242615,
# 节点的名称
"name" : "xxxxxMacBook-Pro.local",
"transport_address" : "127.0.0.1:9300",
"host" : "127.0.0.1",
"ip" : "127.0.0.1:9300",
"roles" : [
"ingest",
"master",
"data",
"ml"
],
"attributes" : {
"ml.machine_memory" : "xxxxxxxx",
"xpack.installed" : "true",
"ml.max_open_jobs" : "20"
},
"indices" : {
"fielddata" : {
# 占用内存大小
"memory_size_in_bytes" : 1280,
# 回收的数据大小
"evictions" : 0,
"fields" : {
"remark" : {
"memory_size_in_bytes" : 1280
}
}
}
}
}
}
}
nodes能够会呈现多个节点的信息
如果一次聚合操作需要加载的fielddata的数据量超过了给JVM分配的最大堆内存,则会抛出OOM错误,这个时候circuit breaker短路器就派上用场了。ES在加载fielddata之前,短路器会自行估算本次需要加载的fieldata大小,如果加载后超过允许的内存容量上限,则使本次聚合操作请求短路并直接返回错误响应,不会产生OOM导致ES的某个节点宕机。
想要使用circuit breaker短路器,需要在config/elasticsearch.yml中增加配置:
# fielddata的内存限制,默认60%
indices.breaker.fielddata.limit : 60%
# 执行聚合操作时,往往配合搜索一起使用,本参数规定搜索部分涉及到的数据量在堆内存中占比的上限 默认40%
indices.breaker.request.limit: 40%
# 综合上述两个限制,总计内存限制多少。默认无限制。
indices.breaker.total.limit: 70%
我们可以在创建index时,为fielddata增加filter过滤器,实现一个更加细颗粒度的内存控制。
做法如下:在创建index时,定义field字段开启fielddata并指定参数。
min: 只有聚合分析的数据在至少min(比如1%)的document中出现过,才会加载fielddata到内存中。
min_segment_size: 只有segment中保存的document数量大于限制值min_segment_size(比如500)时,才会加载fielddata到内存中,参与聚合计算。如果一个segment中只有少量的文档,那么它的词频会非常粗略且没有任何意义。小的segment很快会被Merge操作合并到大的segment中。
max: 只有聚合分析的数据在少于限制值(比如10%)中的document内出现过,才会加载到fielddata。这样做可以过滤掉一些不必要的词条,比如停用词(“是”、“和”、“的”)
PUT index_name
{
"mappings": {
"properties": {
"test_text_type_field": {
"type": "text",
"fielddata": true,
"fielddata_frequency_filter": {
"min": 0.001,
"max": 0.1,
"min_segment_size": 500
}
}
}
}
}
test_text_type_field是自定义字段的名称,fielddata_frequency_filter中添加fielddata的filter过滤器的相关配置。
前文说到,fielddata是一个query-time生成的正排索引,如果某个索引中必须使用fielddata,又希望提升聚合效率,则可以使用fielddata预加载。做法很简单,就是将fielddata的生成时机由query-time提前到index-time,即在写入document的过程中,创建对应字段的fielddata正排索引并存放到内存中(Field Data Cache)
由于预加载是在写入document时所作的操作,势必会降低index写入数据的效率,此外需要额外的存储fielddata对应的正排索引,因此对内存(特别是堆内存)有着不小的压力,从而也就加大了GC的工作量。预加载的fielddata只有在触发GC的时候,才会清除,否则始终在内存中保存。
做法: 使用eager_global_ordinals (默认false)
PUT index_name
{
"mappings": {
"properties": {
"test_text_type_field": {
"type": "text",
"fielddata": true,
"fielddata_frequency_filter": {
"min": 0.001,
"max": 0.1,
"min_segment_size": 500
},
"eager_global_ordinals": true
}
}
}
}
在商业项目中做聚合分析时,很有可能出现海量bucket。比如: 统计汽车销量前5的品牌,以及每个品牌中销量前10的车型。
GET /cars/_search
{
"size": 0,
"aggs": {
"group_by_brands": {
"terms": {
"field": "brand",
"order": {
"_count": "desc"
},
"size": 5
},
"aggs": {
"group_by_model": {
"terms": {
"field": "model",
"order": {
"_count": "desc"
},
"size": 10
}
}
}
}
}
}
从实现层面上分析,上述聚合分析操作是按照“深度优先"的方式执行的。何为深度优先?深度优先就是将所有的bucket全部统计(分组)出来后,再执行操作,计算指标。比如这个统计汽车销量的例子中,假如有500个汽车品牌,每个品牌有50个汽车型号,那么相当于有500x50=25000个bucket,而获取指标时,需要对每一个桶进行计算操作,显然bucket的数量越多,聚合整体的执行效率就会越低。所以需要考虑优化。(注意: 在聚合过程中,Elasticsearch会为bucket创建树结构,而树比较占用内存)
优化的方向很明确——不要分出这么多的桶。如果能在500个车型中过滤出销量前5的品牌,再对这5个品牌针对车型进行分桶,就只需要5*50=250个桶,后续计算指标时,只需要在250个桶中执行计算逻辑即可,这种做法被称作"广度优先",所谓广度优先,核心思想是逐层聚合。在当前层的聚合结果的基础上,再执行下一层的聚合操作。 实现广度优先的做法很简单,只需要增加参数"collect_mode",它的默认值是"depth_first",即深度优先,而广度优先对应的参数值为"breadth_first"。
GET /cars/_search
{
"size": 0,
"aggs": {
"group_by_brands": {
"terms": {
"field": "brand",
"order": {
"_count": "desc"
},
"size": 3,
"collect_mode": "breadth_first"
},
"aggs": {
"group_by_model": {
"terms": {
"field": "model",
"order": {
"_count": "desc"
},
"size": 3
}
}
}
}
}
}
depth_first深度优先和breadth_first广度优先模式都有各自的优缺点,深度优先的聚合统计的精确度高,但bucket数量较多,在大数据量的情况下,可能会遇到海量bucket导致内存压力过大和执行效率过低的问题。广度优先通过逐层聚合,使得bucket数量较少,但其聚合的精度比深度优先低。
所谓的易并行聚合算法,就是若干节点中同时计算一个聚合结果,再返回给协调节点进行计算,得到最终的结果。比如聚合计算中的max,min等api。
下面以执行max api为例来描述易并行聚合算法的执行过程。首先,客户端发起包含max api的聚合请求,请求被发送至Elasticsearch集群中的协调节点node2,node2根据请求推导出参与本次聚合统计的数据所在shard的下标(比如此时推导出shard0,shard1,shard2,shard3上都有目标数据)。接着,node2会将请求分别转发至所有推导出的shard,让每个shard计算自身的max最大值,并返回给协调节点node2。至此,node2会收到来自node0~node3上的4个max值,最后再对这4个max值进行比较,取得最大值并返回给客户端。
易并行聚合算法是Elasticsearch中最基本的聚合算法,执行效率较高。
易并行算法属于精确算法(在合适的场景和api下使用)。
有些聚合分析算法很难使用易并行算法解决,比如:count(distinct)排除重复的统计,这时候ES会采用近似聚合算法来进行计算。近似聚合算法不完全准确,但是效率非常高,一般而言,效率是精准算法的数十倍。
比如计算count(distinct),假设总共有4个节点,每个节点上有一个(主)分片,每个shard中都有10万条非重复数据,不能保证不同节点中的数据完全不同,因此在精确计算时,每一个节点都需要把10万条数据发送给协调节点,不仅传输效率慢(I/O压力大),并且4*10=40万条数据存储在协调节点上,给节点对应的堆内存也带来了极大的压力。使用近似聚合算法后,每个分片会自行进行近似聚合计算,并将结果返回给协调节点,最终由协调节点统计数据结果。之前说过,由于不能保证不同节点中的数据完全不同,因此这种预估可能会产生误差。
近似聚合算法一般会有5%左右的错误率,但延迟能控制在100毫秒左右。精确算法虽然不会有任何的误差,但执行时长就不好说了,可能执行几毫秒,也可能需要执行若干小时才能返回结果,具体需要看数据量和算法的复杂度。
三角选择原则: 精准度、实时性、大数据。在选择计算原则时,很难保证所有指标都达标,一般都有一个取舍,通常选取三角选择原则中的两项: