1. 近似算法
基本思想就是在 大数据 精确性 实时性 三者之间做出权衡,一般只能选择其中的2个,有点类似于CAP。 因为对于很多应用,能够实时返回高度准确的结果要比 100% 精确结果重要得多:
精确 + 实时
数据可以存入单台机器的内存之中,我们可以随心所欲,使用任何想用的算法。结果会 100% 精确,响应会相对快速。
大数据 + 精确
传统的 Hadoop,可以处理 PB 级的数据并且为我们提供精确的答案,但它可能需要几周的时间才能为我们提供这个答案。
大数据 + 实时
近似算法为我们实时提供准确但不精确的结果。
Elasticsearch 目前支持两种近似算法( cardinality 和 percentiles )。 它们会提供准确但不是 100% 精确的结果,以牺牲一点小小的估算错误为代价,这些算法可以为我们换来高速的执行效率和极小的内存消耗。
1.1. Cardinality
用于统计某个字段的不同值的个数,也就是去重统计。例如:统计每个月销售的不同品牌数量:
curl GET ip:port/tvs/_search
{
"size" : 0,
"aggs" : {
"months" : {
"date_histogram": {
"field": "sold_date",
"interval": "month"
},
"aggs": {
"distinct_brand" : {
"cardinality" : {
"field" : "brand"
}
}
}
}
}
}
算法优化
Cardinality算法的统计结果并不一定精确,但是速度非常快,我们还可以通过调整参数来进一步优化。
precision_threshold
控制 Cardinality 算法的精确度和内存消耗,它接受 0–40000 之间的数字,更大的值还是会被当作 40000 来处理。
例如: precision_threshold 设置为 100 ,那么Elasticsearch会确保当字段唯一值在 100 以内时,会得到非常准确的结果,这个准确率几乎100%。但是,如果字段唯一值的数目高于precision_threshold,ES就会开始节省内存而牺牲准确度。
根据Elasticsearch的官方统计,precision_threshold设置为100时,对于100万个不同的字段值,统计结果的误差可以维持在 5% 以内。
curl GET ip:port/tvs/_search
{
"size" : 0,
"aggs" : {
"distinct_brand" : {
"cardinality" : {
"field" : "brand",
"precision_threshold" : 100
}
}
}
}
HyperLogLog
Cardinality 算法的底层是基于 HyperLogLog++ 算法(简称 HLL )实现的, HLL 算法会对所有 unique value 取 hash 值,通过 hash 值近似求 distinct count 。
默认情况下,如果我们的请求里包含 cardinality 统计, ELasticsearch 会实时对所有的 field value 取 hash 值。所以,一种优化思路就是在建立索引时,就将所有字段值的hash建立好。
例如:我们对brand字段再内建一个字段名为 hash ,它的类型是 murmur3 ,是一种计算 hash 值的算法:
curl PUT ip:port/tvs/
{
"mappings": {
"sales": {
"properties": {
"brand": {
"type": "text",
"fields": {
"hash": {
"type": "murmur3"
}
}
}
}
}
}
}
统计字段的 distinct value 时,直接对内置字段进行 cardinality 统计即可:
curl GET ip:port/tvs/_search
{
"size" : 0,
"aggs" : {
"distinct_brand" : {
"cardinality" : {
"field" : "brand.hash",
"precision_threshold" : 100
}
}
}
}
1.2. Percentiles
按照百分比来统计某个字段的聚合信息。
例如:记录了每次请求的访问耗时,需要统计tp50、tp90、tp99,那么用percentiles实现就非常方便。
# 创建索引
curl PUT ip:port/website
{
"mappings": {
"properties": {
"latency": {
"type": "long"
},
"province": {
"type": "keyword"
},
"timestamp": {
"type": "date"
}
}
}
}
# 录入数据
curl POST ip:port/website/logs/_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" }
按照 latency 字段的记录数百分比进行分组,然后统计组内的平均延时信息:
curl GET ip:port/website/_search
{
"size": 0,
"aggs": {
"latency_percentiles": {
"percentiles": {
"field": "latency",
"percents": [
50,
95,
99
]
}
},
"latency_avg": {
"avg": {
"field": "latency"
}
}
}
}
返回数据
{
"took": 31,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 12,
"max_score": 0,
"hits": []
},
"aggregations": {
"latency_avg": {
"value": 201.91666666666666
},
"latency_percentiles": {
"values": {
"50.0": 108.5,
"95.0": 508.24999999999983,
"99.0": 624.8500000000001
}
}
}
}
percentile_ranks
按照字段值的区间进行分组,然后统计出每个区间的占比。例如:我们需要统计:对于每个省份,有多少请求(百分比)的延时分别在200ms以内、1000ms以内:
curl GET ip:port/website/_search
{
"size": 0,
"aggs": {
"group_by_province": {
"terms": {
"field": "province"
},
"aggs": {
"latency_percentile_ranks": {
"percentile_ranks": {
"field": "latency",
"values": [
200,
1000
]
}
}
}
}
}
}
返回数据
{
"took": 38,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 12,
"max_score": 0,
"hits": []
},
"aggregations": {
"group_by_province": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "新疆",
"doc_count": 6,
"latency_percentile_ranks": {
"values": {
"200.0": 29.40613026819923,
"1000.0": 100
}
}
},
{
"key": "江苏",
"doc_count": 6,
"latency_percentile_ranks": {
"values": {
"200.0": 100,
"1000.0": 100
}
}
}
]
}
}
}
算法优化
percentile 底层采用了 TDigest 算法,该算法会使用很多节点来执行百分比的计算,但是存在误差,参与计算的节点越多就越精准。percentile 参数 compression 用来控制节点数量,默认值是 100 ,compression 越大 percentile 算法更精准。
注:
compression值越大越消耗内存,一般 compression=100 时,内存占用大约为:100 x 20 x 32 = 64KB。
2. fielddata
开启fielddata后,Elasticsearch会在执行聚合操作时,实时地将 field 对应的数据建立一份 fielddata正排索引 ,索引会被加载到 JVM 内存中,然后基于内存中的索引执行分词field的聚合操作。如果doc数量非常多,这个过程会非常消耗内存,分词的field需要按照term进行聚合,其中涉及很多复杂的算法和操作,Elasticsearch为了提升性能,对于这些操作全部是基于JVM内存进行的。
懒加载
fielddata是通过懒加载的方式加载到内存中的,所以只有对一个 analzyed field 执行聚合操作时,才会执行加载,降低了性能。
内存限制
Elasticsearch配置 indices.fielddata.cache.size 参数来限制 fielddata 对内存的使用。超出限制,清除内存已有的 fielddata 数据,但是一旦限制内存使用,又会导致频繁的 evict 和 reload ,产生大量内存碎片,同时降低IO性能。
circuit breaker
如果一次 query 操作加载的 feilddata 数据量大小超过了总内存,就会发生内存溢出, circuit breaker 会估算 query 要加载的 fielddata 大小,如果超出总内存就短路,query直接失败。可以通过以下参数进行设置:
indices.breaker.fielddata.limit:fielddata的内存限制,默认60%
indices.breaker.request.limit:执行聚合的内存限制,默认40%
indices.breaker.total.limit:综合上面两个,限制在70%以内
2.1. 优化
一般来讲,我们最好不要对string、text等可分词类型的字段进行聚合操作,因为即使进行了优化,性能开销也会非常大。如果确实有这个需求,需要对fielddata 做些优化,以提升性能。
fielddata预加载
新segment的创建(通过刷新、写入或合并等方式),启动字段预加载使那些对搜索不可见的分段里的 fielddata 提前加载。首次命中分段的查询不需要促发 fielddata 的加载,因为 fielddata 已经被载入到内存,避免了用户遇到搜索卡顿的情形。预加载是按字段启用,可以控制哪个字段预先加载:
curl POST ip:port/test_index/_mapping
{
"properties": {
"test_field": {
"type": "string",
"fielddata": {
"loading" : "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
全局序号是一个构建在 fielddata 之上的数据结构,它只占用少量内存。唯一值是跨所有分段识别的,然后将它们存入一个序号列表中,terms 聚合可以对全局序号进行聚合操作,将序号转换成真实字符串值的过程只会在聚合结束时发生一次。这会将聚合(和排序)的性能提高三到四倍。
3. 遍历算法
深度优先遍历 和 广度优先遍历 。深度优先遍历和广度优先遍历其实是图的两种基本遍历算法。
3.1. 深度优先
默认设置,先构建完整的树,然后修剪无用节点。
假设我们现在有一些关于电影的数据集,每条doc里面会有一个数组类型的字段,存储着表演该电影的所有演员名字:
{
"actors" : [
"Fred Jones",
"Mary Jane",
"Elizabeth Worthing"
]
}
先按演员分组,找到出演影片最多的10个演员;然后,对于每个子组再找出与当前演员合作最多的5个演员:
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 10
},
"aggs" : {
"costars" : {
"terms" : {
"field" : "actors",
"size" : 5
}
}
}
}
}
}
简单查询消耗大量内存,通过在内存中构建一个树来查看 terms 聚合。 actors 聚合会构建树的第一层,每个演员都有一个桶。然后,内套在第一层的每个节点之下, costar 聚合会构建第二层,每个联合出演一个桶,这意味着每部影片会生成 n * n 个桶!
上述聚合分析,只是希望得到前10位演员和与他们联合出演者,但是为了得到最终的结果,我们创建了一个有 n * n 桶的树,然后对其排序,取 top10。如果我们有 2 亿doc,想要得到前 100 位演员以及与他们合作最多的 20 位演员,可以推测,聚合出来的分组数非常大。上述这种遍历方式就是深度优先。
3.2. 广度优先
Elasticsearch 允许我们改变聚合的集合模式,深度优先的方式对于大多数聚合都能正常工作,但对于上述情形就不太适用。为了应对这些特殊的应用场景,我们应该使用另一种集合策略叫做广度优先。这种策略的工作方式有些不同,它先执行第一层聚合,然后先做修剪,再执行下一层聚合。在我们的示例中,actors 聚合会首先执行,在这个时候,我们的树只有一层,但我们已经知道了前 10 位的演员,这就没有必要保留其他的演员信息,因为它们无论如何都不会出现在前十位中。
要使用广度优先,只需简单的通过参数 collect_mode 开启:
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 10,
"collect_mode" : "breadth_first"
},
"aggs" : {
"costars" : {
"terms" : {
"field" : "actors",
"size" : 5
}
}
}
}
}
}
广度优先仅仅适用于每个组的聚合数量远小于当前总组数的情况,因为广度优先会在内存中缓存裁剪后的每个组的所有数据,如果裁剪后的每个组下的数据量非常大,广度优先就不是一个好的选择,这也是为什么深度优先作为默认策略的原因。