近似聚合
如果所有的数据都在一台机器上,那么生活会容易许多。 CS201 课上教的经典算法就足够应付这些问题。如果所有的数据都在一台机器上,那么也就不需要像 Elasticsearch 这样的分布式软件了。不过一旦我们开始分布式存储数据,就需要小心地选择算法。
有些算法可以分布执行,到目前为止讨论过的所有聚合都是单次请求获得精确结果的。这些类型的算法通常被认为是 高度并行的 ,因为它们无须任何额外代价,就能在多台机器上并行执行。比如当计算 max 度量时,以下的算法就非常简单:
- 把请求广播到所有分片。
- 查看每个文档的 price 字段。如果 price > current_max ,将 current_max 替换成 price 。
- 返回所有分片的最大 price 并传给协调节点。
- 找到从所有分片返回的最大 price 。这是最终的最大值。
这个算法可以随着机器数的线性增长而横向扩展,无须任何协调操作(机器之间不需要讨论中间结果),而且内存消耗很小(一个整型就能代表最大值)。
不幸的是,不是所有的算法都像获取最大值这样简单。更加复杂的操作则需要在算法的性能和内存使用上做出权衡。对于这个问题,我们有个三角因子模型:大数据、精确性和实时性。
我们需要选择其中两项:
精确 + 实时
数据可以存入单台机器的内存之中,我们可以随心所欲,使用任何想用的算法。结果会 100% 精确,响应会相对快速。
大数据 + 精确
传统的 Hadoop。可以处理 PB 级的数据并且为我们提供精确的答案,但它可能需要几周的时间才能为我们提供这个答案。
大数据 + 实时
近似算法为我们提供准确但不精确的结果。
Elasticsearch 目前支持两种近似算法( cardinality 和 percentiles )。 它们会提供准确但不是 100% 精确的结果。 以牺牲一点小小的估算错误为代价,这些算法可以为我们换来高速的执行效率和极小的内存消耗。
对于 大多数 应用领域,能够 实时 返回高度准确的结果要比 100% 精确结果重要得多。乍一看这可能是天方夜谭。有人会叫 “我们需要精确的答案!” 。但仔细考虑 0.5% 误差所带来的影响:
- 99% 的网站延时都在 132ms 以下。
- 0.5% 的误差对以上延时的影响在正负 0.66ms 。
- 近似计算的结果会在毫秒内返回,而“完全正确”的结果就可能需要几秒,甚至无法返回。
只要简单的查看网站的延时情况,难道我们会在意近似结果是 132.66ms 而不是 132ms 吗?当然,不是所有的领域都能容忍这种近似结果,但对于绝大多数来说是没有问题的。接受近似结果更多的是一种 文化观念上 的壁垒而不是商业或技术上的需要。
统计去重后的数量
Elasticsearch 提供的首个近似聚合是 cardinality (注:基数)度量。 它提供一个字段的基数,即该字段的 distinct 或者 unique 值的数目。 你可能会对 SQL 形式比较熟悉:
SELECT COUNT(DISTINCT color)
FROM cars
去重是一个很常见的操作,可以回答很多基本的业务问题:
- 网站独立访客是多少?
- 卖了多少种汽车?
- 每月有多少独立用户购买了商品?
我们可以用 cardinality 度量确定经销商销售汽车颜色的数量:
GET /cars/_search
{
"size": 0,
"aggs": {
"distinct_colors": {
"cardinality": {
"field": "color.keyword"
}
}
}
}
返回的结果表明已经售卖了三种不同颜色的汽车:
"aggregations": {
"distinct_colors": {
"value": 3
}
可以让我们的例子变得更有用:每月有多少颜色的车被售出?为了得到这个度量,我们只需要将一个 cardinality 度量嵌入一个 date_histogram :
GET /cars/_search
{
"size": 0,
"aggs": {
"months": {
"date_histogram": {
"field": "sold",
"calendar_interval": "month"
},
"aggs": {
"distinct_colors": {
"cardinality": {
"field": "color.keyword"
}
}
}
}
}
}
学会权衡
正如我们本章开头提到的, cardinality 度量是一个近似算法。 它是基于 HyperLogLog++ (HLL)算法的。 HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。
我们不需要理解技术细节(如果确实感兴趣,可以阅读这篇论文), 但我们最好应该关注一下这个算法的 特性 :
- 可配置的精度,用来控制内存的使用(更精确 = 更多内存)。
- 小的数据集精度是非常高的。
- 我们可以通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关。
要配置精度,我们必须指定 precision_threshold 参数的值。 这个阈值定义了在何种基数水平下我们希望得到一个近乎精确的结果。参考以下示例:
GET /cars/_search
{
"size": 0,
"aggs": {
"distinct_colors": {
"cardinality": {
"field": "color.keyword",
"precision_threshold": 100
}
}
}
}
- precision_threshold 接受 0–40,000 之间的数字,更大的值还是会被当作 40,000 来处理。
示例会确保当字段唯一值在 100 以内时会得到非常准确的结果。尽管算法是无法保证这点的,但如果基数在阈值以下,几乎总是 100% 正确的。高于阈值的基数会开始节省内存而牺牲准确度,同时也会对度量结果带入误差。
对于指定的阈值,HLL 的数据结构会大概使用 precision_threshold * 8 字节的内存,所以就必须在牺牲内存和获得额外的准确度间做平衡。
在实际应用中, 100 的阈值可以在唯一值为百万的情况下仍然将误差维持 5% 以内。
速度优化
如果想要获得唯一值的数目, 通常 需要查询整个数据集合(或几乎所有数据)。 所有基于所有数据的操作都必须迅速,原因是显然的。 HyperLogLog 的速度已经很快了,它只是简单的对数据做哈希以及一些位操作。
但如果速度对我们至关重要,可以做进一步的优化。 因为 HLL 只需要字段内容的哈希值,我们可以在索引时就预先计算好。 就能在查询时跳过哈希计算然后将哈希值从 fielddata 直接加载出来。
预先计算哈希值只对内容很长或者基数很高的字段有用,计算这些字段的哈希值的消耗在查询时是无法忽略的。
尽管数值字段的哈希计算是非常快速的,存储它们的原始值通常需要同样(或更少)的内存空间。这对低基数的字符串字段同样适用,Elasticsearch 的内部优化能够保证每个唯一值只计算一次哈希。
基本上说,预先计算并不能保证所有的字段都更快,它只对那些具有高基数和/或者内容很长的字符串字段有作用。需要记住的是,预计算只是简单的将查询消耗的时间提前转移到索引时,并非没有任何代价,区别在于你可以选择在 什么时候 做这件事,要么在索引时,要么在查询时。
要想这么做,我们需要为数据增加一个新的多值字段。我们先删除索引,再增加一个包括哈希值字段的映射,然后重新索引:
PUT /cars/
{
"mappings": {
"properties": {
"color": {
"type": "text",
"fields": {
"hash": {
"type": "murmur3"
}
}
}
}
}
}
POST /cars/_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" }
- “type”: “murmur3”:这个子字段的类型是 Murmur3 哈希类型。Murmur3 哈希类型可以用于快速查找和比较哈希值。
现在当我们执行聚合时,我们使用 color.hash 字段而不是 color 字段:
GET /cars/_search
{
"size": 0,
"aggs": {
"distinct_colors": {
"cardinality": {
"field": "color.hash"
}
}
}
}
- 注意我们指定的是哈希过的多值字段,而不是原始字段。
现在 cardinality 度量会读取 “color.hash” 里的值(预先计算的哈希值),取代动态计算原始值的哈希。
单个文档节省的时间是非常少的,但是如果你聚合一亿数据,每个字段多花费 10 纳秒的时间,那么在每次查询时都会额外增加 1 秒,如果我们要在非常大量的数据里面使用 cardinality ,我们可以权衡使用预计算的意义,是否需要提前计算 hash,从而在查询时获得更好的性能,做一些性能测试来检验预计算哈希是否适用于你的应用场景。
百分位计算
Elasticsearch 提供的另外一个近似度量就是 percentiles 百分位数度量。 百分位数展现某以具体百分比下观察到的数值。例如,第95个百分位上的数值,是高于 95% 的数据总和。
百分位数通常用来找出异常。在(统计学)的正态分布下,第 0.13 和 第 99.87 的百分位数代表与均值距离三倍标准差的值。任何处于三倍标准差之外的数据通常被认为是不寻常的,因为它与平均值相差太大。
更具体的说,假设我们正运行一个庞大的网站,一个很重要的工作是保证用户请求能得到快速响应,因此我们就需要监控网站的延时来判断响应是否能保证良好的用户体验。
在此场景下,一个常用的度量方法就是平均响应延时。 但这并不是一个好的选择(尽管很常用),因为平均数通常会隐藏那些异常值, 中位数有着同样的问题。 我们可以尝试最大值,但这个度量会轻而易举的被单个异常值破坏。
在图 Figure 40, “Average request latency over time” 查看问题。如果我们依靠如平均值或中位数这样的简单度量,就会得到像这样一幅图 Figure 40, “Average request latency over time” 。

一切正常。 图上有轻微的波动,但没有什么值得关注的。 但如果我们加载 99 百分位数时(这个值代表最慢的 1% 的延时),我们看到了完全不同的一幅画面,如图 Figure 41, “Average request latency with 99th percentile over time” 。

令人吃惊!在上午九点半时,均值只有 75ms。如果作为一个系统管理员,我们都不会看他第二眼。 一切正常!但 99 百分位告诉我们有 1% 的用户碰到的延时超过 850ms,这是另外一幅场景。 在上午4点48时也有一个小波动,这甚至无法从平均值和中位数曲线上观察到。
这只是百分位的一个应用场景,百分位还可以被用来快速用肉眼观察数据的分布,检查是否有数据倾斜或双峰甚至更多。
百分位度量
让我加载一个新的数据集(汽车的数据不太适用于百分位)。我们要索引一系列网站延时数据然后运行一些百分位操作进行查看:
POST /website/_bulk
{
"index": {
}}
{
"latency" : 100, "zone" : "US", "timestamp" : "2014-10-28" }
{
"index": {
}}
{
"latency" : 80, "zone" : "US", "timestamp" : "2014-10-29" }
{
"index": {
}}
{
"latency" : 99, "zone"

本文围绕Elasticsearch展开,介绍了近似聚合算法,如cardinality和percentiles,可牺牲小误差换取高速执行和低内存消耗。还阐述了统计去重数量、百分位计算的方法及权衡要点,以及Doc Values和Fielddata的使用、优化,包括内存限制、过滤、预加载等,最后提及聚合查询优化策略。
最低0.47元/天 解锁文章
1185

被折叠的 条评论
为什么被折叠?



