暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

PostgreSQL聚合的工作原理以及它是如何启发超功能设计?

PostgreSQL 聚合 vs. 函数




在 SQL 中,聚合函数和"常规"函数之间的根本区别在于,聚合从一组相关行生成单个结果,而常规函数为每行生成一个结果:

在 SQL 中,聚合从多行生成一个结果,而函数每行生成一个结果。

这并不是说一个函数不能有来自多个列的输入;他们只需要来自同一行。
另一种思考方式是,函数通常作用于行,而聚合作用于列。为了说明这一点,让我们创建一个包含两列的理论表"LNXDB_foo":
CREATE TABLE LNXDB_foo(
bar DOUBLE PRECISION,
baz DOUBLE PRECISION);
复制
插入几个值,我们可以很容易地看到发生了什么:
INSERT INTO LNXDB_foo(bar, baz) 
VALUES (1.0, 2.0), (2.0, 4.0), (3.0, 6.0);
复制

函数greatest()将生成每一行的columns ,bar和baz中最大的值:  

SELECT greatest(bar, baz) FROM LNXDB_foo; 
 greatest 
----------
        2
        4
        6
复制

而聚合max()将从每列生成最大值:

SELECT max(bar) as bar_max, max(baz) as baz_max 
FROM LNXDB_foo;

 bar_max | baz_max 
---------+---------
       3 |       6
复制
使用上述数据,以下是我们聚合某些内容时会发生的情况的图片:

max()从多行中获取最大值。

聚合从多行中获取输入并生成单个结果。这是它与函数之间的主要区别。但它是如何做到这一点的呢?

01
聚合内部机制:逐行


PostgreSQL中的聚合逐行逐行的工作。但是,聚合如何知道有关前几行的任何信息呢?

一个聚合存储了它之前看到的行的一些状态,并且当数据库观察到新行时,它会更新该内部状态

对于max()我们一直在讨论的聚合,内部状态只是我们迄今为止收集到的最大值。

当我们开始时,我们的内部状态是NULL因为我们还没有看到任何行

然后,我们得到了第一行:

由于我们的状态是NULL ,我们将其初始化为我们看到的第一个值:

现在,我们得到第二行:

我们看到bar(2.0) 的值大于当前状态 (1.0),因此我们更新了状态:

然后,下一行进入聚合:

我们将它与当前状态进行比较,获取最大值,并更新我们的状态:

最后,我们没有更多的行要处理,所以我们输出结果:

因此,总而言之,每行都进入,与我们当前的状态进行比较,然后状态被更新以反映新的最大值。然后下一行进来,重复这个过程,直到处理完所有行并输出结果。
处理每行并更新内部状态的函数有一个名称:状态转换函数(或简称"转移函数")。聚合的转换函数将当前状态和传入行中的值作为参数,并生成新状态。
它是这样定义的,其中current_value表示来自传入行的值,current_state表示在先前行上建立的当前聚合状态(如果我们还没有得到任何数据,则为 NULL),并next_state表示分析传入行后的输出:
next_state = transition_func(current_state, current_value)
复制

02
聚合内部机制:复合状态


因此,max()聚合具有一个简单的状态,只包含一个值(我们见过的最大值)。但并非所有PostgreSQL中的聚合都具有如此简单的状态。
让我们考虑平均值 ( avg)的聚合:
SELECT avg(bar) FROM LXNDB_foo;
复制
为了刷新,平均值定义为:

为了计算它,我们将sum和count存储为内部状态,并在处理行时更新状态: 
"avg()"的聚合过程,在GIF中讲述。对于 'avg()',转换函数必须更新更复杂的状态,因为 sum 和 count 在每个聚合步骤中单独存储。
但是,当我们准备输出avg的结果时,我们需要用sum除以count:  


对于某些聚合,我们可以直接输出状态 , 但对于其他聚合,我们需要在计算最终结果之前对状态执行操作。

聚合中还有另一个函数执行此计算:最终函数。处理完所有行后,最终函数将采用该状态并执行任何需要的操作来生成结果。

它是这样定义的,其中final_state表示转换函数在处理完所有行后的输出:

result = final_func(final_state)
复制
而且,通过视频:
当聚合扫描行时,其转换函数会更新其内部状态。聚合扫描完所有行后,其最终函数将生成一个结果,该结果将返回给用户。

03
提高聚合函数的性能


这里需要注意的一件有趣的事情是:转换函数被调用的次数比最终函数多很多:转换函数每行调用一次,而最终函数每组调用一次。
现在,在每次调用的基础上,转换函数本身并不比最终函数昂贵 - 但是由于进入聚合的行通常比输出的行多几个数量级,因此转换函数步骤很快就会成为最昂贵的部分。当您以高速率摄取大量时间序列数据时,尤其如此。优化聚合转换函数调用对于提高性能非常重要。
幸运的是,PostgreSQL已经有了优化聚合的方法。

04
并行化和组合功能


因为转换函数是在每一行上运行的,所以一些有进取心的PostgreSQL开发人员问道:如果我们并行化转换函数计算会怎么样?
让我们重新审视一下转换函数和最终函数的定义:
next_state = transition_func(current_state, current_value)
result = final_func(final_state)
复制
我们可以通过实例化转换函数的多个副本,并将行的子集传递给每个实例来并行运行它。然后,每个并行聚合将在它看到的行子集上运行转换函数,从而生成多个(部分)状态,每个并行聚合一个状态。
但是,由于我们需要对整个数据集进行聚合,因此我们无法对每个并行聚合单独运行最终函数,因为它们只有一些行。
所以,现在我们最终陷入了一些困境:我们有多个部分聚合状态,最终函数仅用于处理单个最终状态 - 就在我们将结果输出给用户之前。
为了解决这个问题,我们需要一种新型的函数,它采用两个部分状态并将它们组合成一个,以便最终函数可以完成其工作。这(恰当地)被称为组合函数。
我们可以对并行化聚合时创建的所有分部状态迭代运行 combine 函数。
combined_state = combine_func(partial_state_1, partial_state_2)
复制
例如,在 中avg,combine 函数会将计数和总和相加。
在这里,突出显示了 combine 函数(我们又添加了几行来说明并行聚合。
然后,在获得所有并行聚合的组合状态后,我们运行最终函数并得到结果。

05
重复数据删除


并行化和组合函数是降低调用聚合成本的一种方法,但不是唯一的方法。
另一种降低聚合成本的内置 PostgreSQL 优化出现在如下语句中:
SELECT avg(bar), avg(bar) / 2 AS half_avg FROM LNXDB_foo;
复制
PostgreSQL将优化此语句,仅评估"avg(bar)"计算一次,然后使用该结果两次。
而且,如果我们有不同的聚合具有相同的转移函数,但不同的最终函数呢?PostgreSQL通过在所有行上调用转换函数,然后执行两个最终函数来进一步优化!相当整洁!

TimescaleDB 超函数中的两步聚合



在 TimescaleDB 中,聚合函数实现了两步聚合设计模式。这概括了 PostgreSQL 内部聚合 API,并通过聚合、访问器和汇总函数将其公开给用户。

作为复习,当谈论两步聚合设计模式时,指的是以下约定,其中有一个内部聚合调用:和外部访问器调用:

内部聚合调用返回内部状态,就像 PostgreSQL 聚合中的转换函数一样。
外部访问器调用采用内部状态并向用户返回结果,就像 PostgreSQL 中的最终函数一样。
我们还为每个聚合定义了rollup特殊函数,这些函数的工作方式与 PostgreSQL 组合函数非常相似。

PostgreSQL内部聚合API及其TimescaleDB超函数的等效项。

为什么使用两步聚合设计模式



向用户公开两步聚合设计模式而不是将其保留为内部结构有四个基本原因:

  • 允许多参数聚合重用状态,使其更高效
  • 清晰地区分影响聚合和访问器的参数,使性能影响更易于理解和预测
  • 在连续聚合和窗口函数中实现易于理解的汇总,结果逻辑一致(这是我们对连续聚合最常见的请求之一)
  • 随着需求的变化,可以更轻松地对连续聚合中的缩减采样数据进行回顾性分析,但数据已经消失了
这有点理论化,所以让我们深入研究并解释每一个。

01
重用状态


PostgreSQL非常擅长优化语句(正如我们在本文前面看到的那样,通过图片🙌),但你必须以一种它可以理解的方式给它一些东西。
例如,当我们谈到重复数据删除时,我们看到 PostgreSQL 可以“找出”一个语句在查询中多次出现的时间(即,avg(bar)),并且只运行该语句一次以避免冗余工作:
SELECT avg(bar), avg(bar) / 2 AS half_avg FROM LNXDB_foo;
复制
这是有效的,因为"avg(bar)"多次出现而没有变化。
但是,如果我以稍微不同的方式编写方程并将除法移动到括号内,这样表达式avg(bar)就不会重复得那么整齐,PostgreSQL无法弄清楚如何优化它:
SELECT avg(bar), avg(bar / 2) AS half_avg FROM LNXDB_foo;
复制
它不知道除法是可交换的,或者这两个查询是等价的。
对于数据库开发人员来说,这是一个复杂的问题,因此,作为PostgreSQL用户,您需要确保以数据库可以理解的方式编写查询。
由数据库无法理解的等效语句(或者在您编写的特定情况下相等,但在一般情况下不相等)引起的性能问题可能是作为用户要解决的一些最棘手的SQL优化。
因此,当我们设计API时,我们试图让用户难以无意中编写低性能代码:换句话说,默认选项应该是 高性能选项。
接下来
,将一个简单的表定义为:
CREATE TABLE LXNDB_foo(
ts timestamptz, 
val DOUBLE PRECISION);
复制
让我们看一个示例,说明我们如何在百分位近似超函数中使用两步聚合来允许 PostgreSQL 优化性能。
SELECT 
    approx_percentile(0.1, percentile_agg(val)) as p10, 
    approx_percentile(0.5, percentile_agg(val)) as p50, 
    approx_percentile(0.9, percentile_agg(val)) as p90 
FROM LXNDB_foo;
复制
被视为与以下情况相同:
SELECT 
    approx_percentile(0.1, pct_agg) as p10, 
    approx_percentile(0.5, pct_agg) as p50, 
    approx_percentile(0.9, pct_agg) as p90 
FROM (SELECT percentile_agg(val) as pct_agg FROM LXNDB_foo) pct;
复制
这种调用约定允许我们使用相同的聚合,以便在后台,PostgreSQL可以对相同的聚合进行重复数据删除调用(因此速度更快)。
现在,让我们将其与一步聚合方法进行比较。
PostgreSQL 不能在这里对聚合调用进行重复数据删除,因为聚合中的额外参数approx_percentile随每次调用而变化:

因此,即使所有这些函数都可以使用在所有行上构建的相同近似值,PostgreSQL也无法知道这一点。两步聚合方法使我们能够构建调用,以便PostgreSQL可以优化我们的代码,并且它使开发人员能够了解何时会更昂贵,何时不会。具有不同输入的多个不同聚合将很昂贵,而同一聚合的多个访问器将便宜得多。

02
清晰地区分聚合/访问器参数


我们还选择了两步聚合方法,因为我们的一些聚合本身可以采用多个参数或选项,并且它们的访问器也可以采用选项:
SELECT
 approx_percentile(0.5, uddsketch(1000, 0.001, val)) as median,--1000 buckets, 0.001 target err
    approx_percentile(0.9, uddsketch(1000, 0.001, val)) as p90, 
    approx_percentile(0.5, uddsketch(100, 0.01, val)) as less_accurate_median -- modify the terms for the aggregate get a new approximationFROM LNXDB_foo;
复制
这是uddsketch的一个例子,这是一种用于百分位近似的高级聚合方法,可以采用自己的参数。
想象一下,如果参数在一个集合中混在一起:
-- NB: THIS IS AN EXAMPLE OF AN API WE DECIDED NOT TO USE, IT DOES NOT WORKSELECT
    approx_percentile(0.5, 1000, 0.001, val) as medianFROM LXNDB_foo;
复制
很难理解哪个参数与功能的哪个部分相关。
相反,两步法非常清晰地将参数与访问器与聚合分开,其中聚合函数在最终函数的输入中的括号中定义:
SELECT
approx_percentile(0.5, uddsketch(1000, 0.001, val)) as medianFROM LXNDB_foo;
复制
通过明确哪个是哪个,用户可以知道如果他们更改聚合的输入,他们将获得更多(昂贵的)聚合节点,=而更改访问器的输入成本更低。
因此,这些是我们公开API的前两个原因 - 以及它允许开发人员执行的操作。最后两个原因涉及连续聚合以及它们与超函数的关系,因此,首先,快速回顾一下它们是什么。

时间刻度数据库中的两步聚合 + 连续聚合



TimescaleDB包括一个称为连续聚合的功能,它指在使对非常大的数据集的查询运行得更快。TimescaleDB 连续聚合,并将聚合查询的结果以增量方式存储在后台,因此在运行查询时,只需计算已更改的数据,而无需计算整个数据集。

在上面对组合函数的讨论中介绍了如何对每一行进行计算转移函数的昂贵工作,并将行拆分为多个并行聚合以加快计算速度。
TimescaleDB 连续聚合执行类似的操作,只是它们会随着时间的推移而不是在同时运行的并行进程之间分散计算工作。连续聚合计算过去某个时间插入的行子集的转移函数,存储结果,然后在查询时,我们只需要计算最近尚未计算的一小部分时间的原始数据。
当设计 TimescaleDB 超功能时,我们希望它们在连续聚合中运行良好,甚至为用户开辟新的可能性。
假设从上面的简单表中创建一个连续聚合,以 15 分钟的增量计算总和、平均值和百分位数(后者使用超函数):
CREATE MATERIALIZED VIEW foo_15_min_aggWITH (timescaledb.continuous)AS SELECT id,
    time_bucket('15 min'::interval, ts) as bucket,
    sum(val),
    avg(val),
    percentile_agg(val)FROM LNXDB_fooGROUP BY id, time_bucket('15 min'::interval, ts);
复制
然后,如果我回来后想将其重新聚合到几个小时或几天,而不是15分钟的时间段,或者需要跨所有 ID 聚合我的数据,该怎么办?我可以为哪些聚合执行此操作,哪些聚合不能执行此操作?

01
逻辑上一致的汇总


我们想要通过两步聚合解决的问题之一是如何向用户传达何时"okay"重新聚合,何时不可以(通过"okay",我的意思是,您将从重新聚合的数据中获得与直接对原始数据运行聚合相同的结果。
例如:
SELECT sum(val) FROM tab;-- is equivalent to:SELECT sum(sum) FROM 
    (SELECT id, sum(val) 
    FROM tab    GROUP BY id) s;
复制
但:
SELECT avg(val) FROM tab;-- is NOT equivalent to:SELECT avg(avg) FROM 
    (SELECT id, avg(val) 
    FROM tab    GROUP BY id) s;
复制
为什么重新聚合可以用于sum但不能用于avg?


从技术上讲,在以下情况下重新聚合在逻辑上是一致的:
  • 聚合返回内部聚合状态。sum 的内部聚合状态是(sum),而平均值是(sum, count)。
  • 聚合的组合和转换功能是等效的。对于sum(),状态和操作是相同的。对于count(),状态是相同的,但转移和组合功能对它们执行不同的操作。sum()的转换函数将传入的值添加到状态,它的组合函数将两个状态加在一起,或总和的总和。相反,count()s 转换函数为每个传入值递增状态,但它的 combine 函数将两个状态加在一起,或计数的总和。
但是,您必须对每个聚合的内部有深入的(有时甚至是晦涩难懂的)知识,才能知道哪些符合上述标准 - 因此,哪些可以重新聚合。
使用两步聚合方法,我们可以通过在聚合允许时公开我们的等效组合函数来传达何时重新聚合在逻辑上是一致的。
我们称之为函数rollup()。Rollup()从聚合中获取多个输入并将它们组合成一个值。
我们所有可以组合的聚合都具有rollup将来自两组不同行的聚合输出组合的功能。(从技术上讲,它rollup()是一个聚合函数,因为它作用于多行。为了清楚起见,我将它们称为汇总函数以将它们与基本聚合区分开来)。然后您可以在组合输出上调用访问器!
因此,使用我们创建的连续聚合来获得我们的 1 天重新聚合percentile_agg变得如此简单:
SELECT id, 
    time_bucket('1 day'::interval, bucket) as bucket, 
    approx_percentile(0.5, rollup(percentile_agg)) as medianFROM foo_15_min_aggGROUP BY id, time_bucket('1 day'::interval, bucket);
复制
(出于这个原因,建议您在不调用访问器函数的情况下创建连续聚合。然后,只需在顶部创建视图,或将访问器调用放入查询中)。

02
使用连续聚集体的回顾性分析


当我们创建一个连续的聚合时,我们正在定义一个数据视图,然后我们可能会在很长一段时间内陷入困境。
例如,我们可能有一个数据保留策略,该策略在 X 个时间段后删除基础数据。如果我们想回过头来重新计算任何东西,如果不是不可能的话,这可能是具有挑战性的,因为我们已经"丢弃"了数据。
但是,我们知道,在现实世界中,您并不总是知道需要提前分析什么。
因此,我们将超函数设计为使用两步聚合方法,因此它们可以更好地与连续聚合集成。因此,用户将聚合状态存储在连续聚合视图中并修改访问器函数,而无需重新计算可能难以(或不可能)重建的旧状态(因为数据已存档、已删除等)。
两步聚合设计还允许对连续聚合具有更大的灵活性。例如,让我们采用一个连续聚合,其中我们执行两步聚合的聚合部分,如下所示:
CREATE MATERIALIZED VIEW foo_15_min_aggWITH (timescaledb.continuous)AS SELECT id,
    time_bucket('15 min'::interval, ts) as bucket,
    percentile_agg(val)FROM fooGROUP BY id, time_bucket('15 min'::interval, ts);
复制
当我们第一次创建聚合时,我们可能只想得到中位数:
SELECT
    approx_percentile(0.5, percentile_agg) as medianFROM foo_15_min_agg;
复制
但后来,我们决定我们也想知道第95百分位。
幸运的是,我们不必修改连续聚合;我们只需在原始查询中修改访问器函数的参数,以从聚合状态返回所需的数据:
SELECT
    approx_percentile(0.5, percentile_agg) as median,
    approx_percentile(0.95, percentile_agg) as p95FROM foo_15_min_agg;
复制
然后,如果一年后,我们也想要第99百分位,我们也可以这样做:
SELECT
    approx_percentile(0.5, percentile_agg) as median,
    approx_percentile(0.95, percentile_agg) as p95,
    approx_percentile(0.99, percentile_agg) as p99FROM foo_15_min_agg;
复制
这只是触及了表面。最终,我们的目标是提供高水平的开发人员生产力,以增强其他PostgreSQL和TimescaleDB功能,如聚合重复数据删除和连续聚合。

两步聚合设计如何影响超函数代码的示例



为了说明两步聚合设计模式如何影响我们对超函数的思考和编码方式,让我们看一下时间加权平均函数族。

时间加权平均值的等式如下:

正如我们在上表中所指出的:
  • time_weight()是TimescaleDB超函数的聚合,对应于PostgreSQL内部API中的转换函数。
  • average()是访问器,它对应于 PostgreSQL 最终函数。
  • rollup()for re-aggregation 对应 PostgreSQL 的 combine 函数。
该time_weight()函数返回一个聚合类型,该类型必须可供系列中的其他函数使用。
在这种情况下,我们决定使用TimeWeightSummary这样定义的类型(在伪代码中):
TimeWeightSummary = (w_sum, first_pt, last_pt)
复制
w_sum是加权和(曲线下区域的另一个名称),first_pt和last_pt是输入到time_weight()聚合的行中的第一个和最后一个(时间、值)对。
以下是对这些元素的图形描述,它建立在我们如何推导出时间加权平均理论描述的基础上:

我们存储在"TimeWeightSummary"表示形式的值的描述。

因此,time_weight()聚合在接收图表中的每个点时执行所有计算,并为它“看到”的第一个点和最后一个点之间的时间段 (ΔT) 建立一个加权和。然后输出TimeWeightSummary.
的average()存取器函数进行简单的计算,以从所述返回时间加权平均值TimeWeightSummary(在伪代码,其中pt.time()返回从点的时间):
func average(TimeWeightSummary tws) 
-> float {
delta_t = tws.last_pt.time - tws.first_pt.time;
time_weighted_average = tws.w_sum / delta_t;
return time_weighted_average;
}
复制
但是,当我们构建time_weight超函数时,确保rollup()函数按预期工作有点困难——并且引入了影响我们TimeWeightSummary数据类型设计的约束。
为了理解汇总函数,让我们使用我们的图形示例并想象time_weight()函数TimeWeightSummaries从不同的时间区域返回两个,如下所示:

当我们有多个 TimeWeightSummaries 代表图的不同区域时会发生什么情况。
该rollup()函数需要接收并返回相同的TimeWeightSummary数据类型,以便我们的average()访问器能够理解它。(这反映了 PostgreSQL 的 combine 函数如何从转换函数中获取两个状态,然后返回一个状态以供最终函数处理)。
我们还希望rollup()输出与我们计算time_weight()了所有基础数据一样。输出应该是TimeWeightSummary代表整个区域的。
在TimeWeightSummary我们的输出也应考虑在这两个加权和状态之间的差距区域:

注意间隔!(在一个TimeWeightSummary和下一个之间)。
间隙区域很容易获得,因为我们有最后1点和前2w_sum点 - 这与我们通过time_weight()对它们运行聚合得到的相同。
因此,整个rollup()函数需要做这样的事情(从 中w_sum()提取加权和TimeWeightSummary):
func rollup(TimeWeightSummary tws1, TimeWeightSummary tws2) 
-> TimeWeightSummary {
w_sum_gap = time_weight(tws1.last_pt, tws2.first_pt).w_sum;
w_sum_total = w_sum_gap + tws1.w_sum + tws2.w_sum;
return TimeWeightSummary(w_sum_total, tws1.first_pt, tws2.last_pt);
}
复制
从图形上看,这意味着我们最终会得到一个TimeWeightSummary代表整个区域的单个:

组合 .TimeWeightSummary
这就是两步聚合设计方法最终影响我们的时间加权平均超函数的实际实现的方式。上面的解释有点压缩,但它们应该让您更具体地了解time_weight()聚合、average()访问器和rollup()函数是如何工作的。
原文链接:
https://blog.timescale.com/blog/how-postgresql-aggregation-works-and-how-it-inspired-our-hyperfunctions-design-2/


PG考试咨询



往期回顾

2022年中国PostgreSQL考试认证计划

PGCCC,公众号:PostgreSQL考试认证中心通知:2022年中国PostgreSQL考试认证计划
永远都不晚:PostgreSQL认证专家(培训考试-广州站)
PGCCC,公众号:PostgreSQL考试认证中心永远都不晚:PostgreSQL认证专家(培训考试-广州站)
开班通知-PCP认证专家(上海站)培训开班1106
PGCCC,公众号:PostgreSQL考试认证中心
开班通知-PCP认证专家(上海站)培训开班1106

PostgreSQL-PCP认证专家-北京站-精彩花絮

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL认证专家考试(培训)(10月16日北京站)精彩花絮

PostgreSQL-PCP认证专家-成都站

公众号:PostgreSQL考试认证中心开班通知-PCP认证专家(成都站)培训开班1016

PostgreSQL-PCP认证专家考试-北京站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL认证专家考试(培训)-北京站-成功举办

PostgreSQL-PCA认证考试-贵阳站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL PCA+PCP认证考试在贵阳成功举办

PostgreSQL-PCP认证专家考试-上海站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL PCP认证考试(上海站)成功举办

PostgreSQL认证专家考试-学员考试总结

薛晓刚,公众号:PostgreSQL考试认证中心难考的PostgreSQL认证考试

PostgreSQL-PCM认证大师考试-天津站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL-PCM认证大师考试(天津站)成功举办

如何在工业和信息化部教育与考试中心官网查询证书

PG考试认证中心,公众号:PostgreSQL考试认证中心如何在工业和信息化部教育与考试中心查询PostgreSQL证书

中国PostgreSQL考试认证体系

PG考试认证中心,公众号:PostgreSQL考试认证中心中国PostgreSQL考试认证体系


文章转载自PostgreSQL考试认证中心,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论