Elasticsearch: 快速近似 ES|QL - 第一部分

0 阅读17分钟

作者:来自 Elastic Jan Kuipers 及 Thomas Veasey

通过 Elasticsearch 实操:深入了解我们在 Elasticsearch Labs 仓库中的示例 notebooks,开始免费云试用,或者现在就在你的本地机器上试用 Elastic。


分析工作负载通常涉及将大量数据汇总为数量少得多的统计结果。Elasticsearch 查询语言( ES|QL )通过 STATS 命令实现这一能力。这允许你选择各种聚合函数并将其应用于先前的查询结果,同时还可以按照一个或多个 ES|QL 表达式对结果进行分组。这是一个灵活的操作,结合 ES|QL 查询能力,可以对存储在 Elasticsearch 索引集合中的数据执行 MapReduce

良好用户体验的一个关键要求是这些操作能够快速完成。基于大语言模型( LLM )的 agents 也引入了新的更高带宽和推测性查询模式,这些模式可能从不同的优化策略中受益。

在这个由两部分组成的博客系列中,我们讨论了一种我们将在 Elasticsearch 和 Elastic Stack 的 9.4 版本中引入到 ES|QL 的优化方法,该方法利用了对问题的一种放宽处理。我们不再尝试获得聚合的精确值,而是允许返回近似值,并附带一些误差特征描述。近似的一个关键好处是它打破了性能与数据集大小之间的依赖关系:查询可以达到的近似精度并不取决于原始数据集的大小,而主要取决于其数据特征以及查询本身。正如我们稍后将看到的,这使我们能够实现显著的性能提升。

在下一篇博客中,我们将讨论该方法背后的理论以及我们对其统计特性所做的验证。在这里,我们将介绍语法,并说明如何通过标准 ES|QL 和查询重写来实现。你可以在流行的 ClickBench 基准测试的一个子集上探索其性能。最后,我们将讨论一些在使用查询近似时值得了解的限制和注意事项。

语法和行为

那么你实际上该如何使用它?

`

1.  SET approximation=true;
2.  // The query you want to approximate
3.  FROM data | commands | STATS x=agg(...) | commands

`AI写代码

就是这样。你只需引入一行新的 SET approximation=true; ,然后像往常一样编写你的 STATS 查询管道。下面,我们将讨论一些高级配置选项以及围绕 agg(...) 和命令的一些限制。不过,本质上,我们选择了一些默认值,使其通常能够在实现显著加速的同时提供有用的近似结果。

通过这一更改,你会在查询结果中看到一些差异。让我们通过一个具体示例来说明这一点。假设原始查询如下:

`

1.  FROM sales | WHERE @timestamp >= NOW()-1w
2.             | STATS count = COUNT() BY item_category
3.             | SORT count DESC
4.             | LIMIT 5

`AI写代码

结果可能看起来像这样:

`

1.  item_category        | count
2.  ---------------------+------
3.  Household Essentials | 5165
4.  Kitchen              | 2132
5.  Storage              | 1121
6.  Home Decor           | 877
7.  Furniture            | 357

`AI写代码

对这个查询进行近似会为每个被估计的量引入一些额外的列:

`

1.  item_category | count | _approximation_confidence_interval(count) | _approximation_certified(count)
2.  --------------+-------+-------------------------------------------+--------------------------------
3.  Essentials    | 5150  | [5100, 5250]                              | true
4.  Kitchen       | 2150  | [2100, 2200]                              | true
5.  Storage       | 1120  | [1100, 1150]                              | true
6.  Home Decor    | 880   | [860, 900]                                | true
7.  Furniture     | 330   | [310, 350]                                | true

`AI写代码

count 列现在包含一个估计值,你会看到它与上面的精确值有一些差异。_approximation_confidence_interval(count) 列默认表示 count 估计值的中心 90% 置信区间,而 _approximation_certified(count) 列表示我们是否高度确信结果及其置信区间是可靠的。概括来说,置信区间是一个区间,我们期望它以较高概率( 0.9 )包含被估计量的真实值。certified 列表示近似的分布是否按我们的预期运行。当结果未被 certified 时,它通常仍然是准确的,但我们对其分布特性的测试尚未能够确认这一点。这些内容将在我们的第二篇文章中进行更详细的讨论。

实现

在查询执行之前,近似查询会通过随机采样和外推进行重写。让我们来看一下上一节中的查询。重写后的查询中负责获得最佳估计的部分如下:

`

1.  FROM sales | SAMPLE probability
2.             | WHERE @timestamp >= NOW()-1w
3.             | STATS count = TO_LONG(COUNT() / probability) BY item_category
4.             | SORT count DESC
5.             | LIMIT 5

`AI写代码

该查询对数据的一部分进行采样,因此最终的 count 需要通过使用采样概率的倒数进行放大来外推。外推显然依赖于底层的聚合函数,我们会针对所有支持的函数进行适当处理。

为了获得采样概率,我们设置了一个由 STATS 命令处理的固定 number_of_rows。在这种情况下,概率计算如下:

`

1.  FROM sales | WHERE @timestamp >= NOW()-1w
2.             | STATS total_row_count = COUNT()
3.             | EVAL probability = number_of_rows / total_row_count

`AI写代码

该查询在最终近似查询执行之前执行。

除了这个最佳估计之外,还需要计算置信区间以及用于验证数值分布是否按预期表现的统计检验。区间是通过一种偏差校正并加速的 bootstrap 置信区间( BCa )方法的变体来计算的。因此,需要将数据划分为 B 个桶,并依次使用这些桶来计算区间。省略一些实现细节后,这个近似查询看起来如下:

`

1.  FROM sales | SAMPLE p
2.             | WHERE @timestamp >= NOW()-1w
3.             | EVAL bucketId = RANDOM(B) // B is the number of buckets
4.             | STATS count     = TO_LONG(COUNT() / p) 
5.                     count_0   = TO_LONG(COUNT() / p) WHERE bucketId==0  
6.                     (...)
7.                     count_B-1 = TO_LONG(COUNT() / p) WHERE bucketId==B-1  
8.               BY item_category
9.             | WHERE count >= 10
10.             | SORT count DESC
11.             | LIMIT 5
12.             | EVAL ci = CONFIDENCE_INTERVAL(count, count_0, ..., count_B-1),
13.                    certified = CERTIFIED(count, count_0, ..., count_B-1)
14.             | DROP bucketId, count_0, ..., count_B-1

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

为了对估计值和置信区间进行认证,需要有足够的数据,并且分桶后的数值分布应当趋近于正态分布。

某些查询可以仅使用索引中维护的汇总统计信息来高效计算。为了正确处理这些情况(在这些情况下采样反而更慢且不准确),我们更新了物理查询规划器,因为检测这类情况需要仅在数据所在位置才能获得的信息。当规划器检测到可以这样处理时,它会直接按正常方式执行查询。这类查询通常本身就很快,因此实际上没有副作用,所以在使用近似功能时不需要担心这一点;不过你会看到,这类查询的置信区间长度始终为 0,表示结果是精确的。

结果

为了探索性能提升,我们使用 ClickBench。这是一个用于数据库管理系统( DBMS )分析工作负载的基准测试。它包含大约 1 亿行数据,重点涵盖点击流与流量分析、Web 分析、机器生成数据、结构化日志以及事件数据。该基准还定义了 43 个典型的即席分析与实时仪表盘查询。

其中一些查询不适合近似计算。例如,我们不支持对分类值的 unique count 进行近似,也不支持计算度量值的最小值和最大值。我们同样不关注仅针对搜索的查询,因为 Elasticsearch 在这类场景下本身性能已经非常优秀。因此,我们在评估中排除了这些类型的查询。最后,我们还希望测试一些额外的聚合函数,比如 percentiles,这些函数在原始查询集中覆盖不足,因此我们额外添加了一些指标查询的变体来补充评估。

基准中的查询使用标准 SQL 编写,因此需要转换为 ES|QL 语法。这个转换相当直接。下面是一个示例:

`SELECT SUM(AdvEngineID), COUNT(*), AVG(ResolutionWidth) FROM hits`AI写代码

变为:

`

1.  FROM hits | STATS s = SUM(AdvEngineID),
2.                    c = COUNT(*),
3.                    a = AVG(ResolutionWidth)

`AI写代码

当用 ES|QL 重写时。

在运行所有基准测试时,我们使用一个 Elastic Cloud Hosted 实例,配置为 870GB 磁盘、29GB 内存和 4 个 vCPU,本质上等同于一个 Amazon Elastic Compute Cloud( EC2 ) i3.xlarge 实例。在下面的结果中,我们只是比较开启和关闭查询近似的 ES|QL 表现。关于不同硬件配置和数据存储的更广泛结果可以在这里找到。即使在显著受限的测试硬件上(与最小配置的 vCPU 数匹配),我们的近似方法仍然能够在性能上与更大的系统竞争。

我们将每个查询及其近似版本以随机顺序各运行五次,并在每次运行之间清空查询缓存。我们报告五次运行的平均执行时间。虽然清空缓存应该足以避免 “第二次运行更快” 的大多数优势,但我们仍然希望避免任何可能的预热效应,因此选择交替执行。

结果分为四类:

  • 使用索引汇总统计信息重写的查询(3 个查询)。
  • 执行表现良好的查询(13 个查询)。
  • 高基数分区的查询(7 个查询)。
  • 过滤条件较严格的查询(12 个查询)。

大致来说,对于这四类,近似查询分别是:等价(1);更快且准确(2);更快但不可靠(3);以及相比精确查询略慢(4)。

对于第 1 类,规划器会自动检测到可以使用汇总统计信息执行查询,因此最终执行方式与原始查询相同。为了做到这一点,我们需要仅在数据节点上才能获得的信息,因此只有在估计采样概率之后才进行重写。由于这一过程非常高效,开销很小(约 10–15%)。在两种情况下,结果都是精确的。

第 2 类查询在平均情况下,如果估计数值并计算置信区间,会快约 23×;如果只进行数值估计,则快约 72×,你可以通过以下方式选择后者:SET approximation={"confidence_level":null}。这些总体数字掩盖了近似对性能影响的显著差异。下表展示了我们观察到的不同加速范围中的部分查询示例:

查询基线 / 毫秒带 CI 的近似 / 毫秒不带 CI 的近似 / 毫秒
3172514515
104340172156
133291261063821
214673932842139
2225250564785019

对应的查询如下:

`

1.  // Query 3
2.  FROM hits | STATS s = SUM(AdvEngineID),
3.                    c = COUNT(*),
4.                    a = AVG(ResolutionWidth)

6.  // Query 10
7.  FROM hits | STATS s = SUM(AdvEngineID),
8.                    c = COUNT(*),
9.                    a = AVG(ResolutionWidth) BY RegionID
10.            | SORT c DESC
11.            | LIMIT 10

13.  // Query 13
14.  FROM hits | WHERE SearchPhrase != ""
15.            | STATS c = COUNT(*) BY SearchPhrase
16.            | SORT c DESC
17.            | LIMIT 10

19.  // Query 21
20.  FROM hits | WHERE URL != ""
21.            | STATS l = AVG(LENGTH(URL)), c = COUNT(*) BY CounterID
22.            | WHERE c > 100000
23.            | SORT l DESC
24.            | LIMIT 25

26.  // Query 22
27.  FROM hits | WHERE Referer != ""
28.            | GROK Referer """%{URIPROTO}://(?:www\.)?%{URIHOST:k}"""
29.            | WHERE k IS NOT NULL
30.            | STATS l = AVG(LENGTH(Referer)), c = COUNT(*) BY k
31.            | WHERE c > 100000
32.            | SORT l DESC
33.            | LIMIT 25

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

我们将在下一篇博客中回到近似的准确性问题,但为了让你有一个直观感受,下面我们展示了查询 13 在一次运行中的精确值与近似值:

对于第 3 类,我们平均获得约 11× 的加速。然而,该类别中的查询结果可能会遗漏一些分区,并且往往存在较大的估计误差。在这种情况下,近似仍然是有价值的,尤其是在 agents 工作流的上下文中,但如果对准确性要求较高,则需要比默认值更大的采样规模。正如我们在下一节中讨论的,我们提供了一个 API 来显式控制采样大小。如果源数据集足够大,可以增加采样规模,同时近似仍然能够带来显著的性能提升。下表展示了该类别中的一些查询示例:

查询基线 / 毫秒带 CI 的近似 / 毫秒不带 CI 的近似 / 毫秒
1582561187124
17706412109982

对应的查询如下:

`

1.  // Query 15
2.  FROM hits | STATS c = COUNT(*) BY UserID, SearchPhrase
3.            | SORT c DESC
4.            | LIMIT 10

6.  // Query 17
7.  FROM hits | EVAL m = DATE_EXTRACT("minute_of_hour", EventTime)
8.            | STATS c = COUNT(*) BY UserID, m, SearchPhrase 
9.            | SORT c DESC
10.            | LIMIT 10

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

最后,第 4 类查询使用选择性过滤条件,并最终会被精确执行,但由于查询重写阶段的额外工作,它们会稍微变慢。通常这些查询本身执行就很快,因此绝对的性能下降很小。在我们的测试环境中,平均比 “不使用采样” 的情况慢约 14%,或 370ms。

限制与最佳实践

这里有必要明确指出一些限制。特别是,目前以下查询不受支持:

  • 使用 TS source 命令的查询。
  • 使用 FORK 或 JOIN 处理命令的查询。
  • 使用两个或以上 STATS 命令的 pipeline。
  • ABSENT、PRESENT、DISTINCT_COUNT、MIN、MAX、TOP、ST_CENTROID_AGG 和 ST_EXTENT_AGG 聚合函数。

我们计划在未来版本中解除部分限制,例如支持对使用 TS、FORK 和 JOIN 的查询进行近似处理;但其中一些限制是本质性的。例如,尽管已有一些关于估计指标数据集的最小值和最大值或分类数据集中唯一值数量的方法(例如这篇论文),但它们需要显式或隐式地对分布做出某些假设。总之,我们认为自动提供这些统计量的估计值过于容易被误用。

对于高级用户,我们提供另一种方式:ES|QL 支持直接使用 SAMPLE 命令。这允许用户对任意查询获得“点估计”,但不会对采样影响进行修正,也不会量化误差。例如:

`FROM data | SAMPLE 0.01 | STATS DISTINCT_COUNT(value)`AI写代码

计算 value 字段在大约 1/100 数据集样本上的唯一值计数。可以调整采样概率,从而观察该估计如何逐渐收敛;或者使用更复杂的估计方法,通过 STATS COUNT() BY value 来估计数据的频率分布。

在某些情况下,采样会更有问题。如果查询中应用了非常严格的过滤条件,那么采样的价值就很小,因为匹配的行本来就很少。在这种情况下,我们会发现需要采样过大比例的数据行才能在重写阶段完成估计,此时系统会回退为不使用采样执行查询,其结果是精确的。然而,用于确定采样比例的搜索过程本身也会带来额外开销。因此,用户仍然需要付出一定的性能成本,虽然低于原始查询成本,但却无法获得收益。如果你事先知道查询预计只会匹配较少的数据行,最好直接不使用近似执行。

第二种情况仅在对某个表达式进行 STATS 分组时出现。如果该表达式的基数非常高,那么即使扫描了大量行,每个统计分组可能仍然只基于少量数据计算。一些情况比其他情况更难处理。例如按 count 升序排序(即寻找最稀有的分区),如果“热门分区”需要采样几乎整个数据集才能发现,那么在单次查询中可能无法估计。对于这种情况,可以先估计高频分区,并通过更新查询将其有效排除。总体而言,低频分区可能在采样过程中丢失,其统计估计误差也可能较大。需要注意的是,对于样本数量少于 10 的统计结果,我们不会进行估计,而是直接从结果集中移除。在极高基数的 BY 子句情况下,例如某个字段在每一行都是唯一值,这意味着查询可能返回空结果。

如果你发现近似查询结果不够准确,可以选择增加采样规模。默认情况下,STATS 使用的采样规模为 1,000,000(当涉及分组时),否则为 100,000。目前这一点需要手动配置,我们提供如下 API:

`

1.  SET approximation={"rows":12345678};
2.  FROM data | commands | STATS x=agg(...) | commands

`AI写代码

有时,一些函数会显著改变其所作用量的分布特性。一个人为构造的例子如下:

`FROM data | STATS sl = SUM(length) | EVAL csl = COS(sl)`AI写代码

如果估计值 sl 的变化远大于 2π,那么我们预计 csl 的分布在区间 [-1, 1] 内主要是平坦的,并且在两个端点附近会出现峰值。在这种情况下,中心置信区间的概念可能并不特别有用,因为分布的众数几乎完全落在大多数中心置信区间之外。无论如何,仅通过观察 csl 的样本,我们的标准置信区间机制无法可靠刻画该分布,并且会低估 csl 的变异性。然而,我们的统计检验应该能够检测到这个问题,因此结果不会被认证(certified)。

最后需要指出的是,Elasticsearch 实现了一些查询优化策略,而这些策略理想情况下需要考虑采样正在发生这一事实。这些优化会在 Lucene 层对查询进行重写,而这种预处理本身可能相对昂贵。在需要处理每一行数据时,通过构建合适的数据结构来加速昂贵的字符串匹配操作是合理的,但如果只处理其中很小一部分数据,那么这种权衡就不同了。这一点我们计划在未来进行改进。

结论

在这篇博客中,我们介绍了一种我们正在引入 ES|QL 的新型查询优化方式,它通过放宽结果必须精确这一约束,实现了显著更快的查询。我们在 ClickBench 上发现,相较于精确计算,我们能够在最多快 100 倍的情况下仍然准确估计查询值及其置信区间,而仅计算数值本身时最多可快 250 倍。此外,我们预计随着数据集规模增长,这种优势还会进一步扩大,因为近似的精度与数据集大小无关。该功能支持 ES|QL 的多种特性,只需在查询前加上 SET approximation=true; 即可启用。

除了提供点估计之外,我们还会估计置信区间,并指示我们认为用于计算这些区间的基本假设是否成立。如果结果可靠,我们可以对其进行认证(certify)。我们将在下一篇文章中解释该特性的理论基础,并讨论其准确性的测试方法。

原文:www.elastic.co/search-labs…