头图

聚集算子 (Aggregation) 是数据库最常用的功能之一。在上次的直播中,我们主要介绍了Parallel HashAggregation 的基本实现细节。

本文根据直播文字整理,作者现任HashData内核研发工程师。

NO.1聚集算子概述
常见的聚集算子实现有两种,一种是基于哈希表的实现(哈希聚集),另一种则是基于排序的实现(分组聚集)。不过这两种方法通常来说都是针对有group by的查询而言的。

对于没有group by的查询来说,我们只需要扫描一遍表,并对每一个元组进行 count\sum\min等累积计算操作即可,这种情况在PG中被称为 plain (朴素)聚集。而count\sum\min这样的函数,我们称为聚集函数。

在Postgres中,聚集函数的计算最多可能被分为3个步骤,分别是转换函数、合并函数和后处理函数;其中,合并函数在并行聚集中才会用到。

NO.2转换函数、后处理函数
我们先通过最简单的朴素聚集来介绍一下聚集算子的基本思想和转换函数、后处理函数的作用;我们用下面这个求tenk1表中所有数据ten列均值的 SQL 查询为例进行讨论。

select avg(ten) from tenk1;

explain select avg(ten) from tenk1;
                           QUERY PLAN
-----------------------------------------------------------------
 Aggregate  (cost=470.00..470.01 rows=1 width=32)
   ->  Seq Scan on tenk1  (cost=0.00..445.00 rows=10000 width=4)
(2 rows)

如果手动来写一段程序来实现这个功能,很容易写出类似于下面的代码:

select avg(ten) from tenk1;

explain select avg(ten) from tenk1;
                           QUERY PLAN
-----------------------------------------------------------------
 Aggregate  (cost=470.00..470.01 rows=1 width=32)
   ->  Seq Scan on tenk1  (cost=0.00..445.00 rows=10000 width=4)
(2 rows)

事实上,Postgres 对聚集算子的实现方式是类似的。由于 Postgres 采用的是火山模型,我们将会从下层算子中一条条读出元组,随后在聚集算子中将每个元组聚集所需要的信息积累到一个状态变量AggState中。而累积的过程,本质上就是将新元组的值和累积的状态进行某种运算,运算的具体实现就是转换函数。
比如,在我们的例子中想要求的是表中ten列的均值,所需要累积的状态就包括此前扫描元组的总数和扫描元组ten列之和,其中对每个元组ten列进行累加和计数的工作就是转换函数所实现的。而最后,扫描完整张表,我们有了所有元组数量和ten列之和,要求均值还需要进行一次平均操作,即用总和除以总数,这一步就是用后处理函数实现的。
显然,不是每种聚集都需要后处理函数,比如min\max只需要转换函数就足够完成整个聚集过程。
图片
完成这两步之后,整个朴素聚集过程也就完成了,最终执行器会将聚集的结果返回给上层算子;整个流程大致如上图所示:右侧的 int4_avg_accum 和 int8_avg 就是 postgres 在执行例子中的查询时真正调用的 transfn 和 finalfn。

NO.3合并函数与并行聚集
既然有了转换函数和后处理函数我们已经可以完成对聚集算子的正确实现,那合并函数是用来做什么的呢?
首先,我们先要了解PG并行聚集的思想和其底层需要依赖的并行扫描。
聚集算子本身是一个CPU密集型的算子,如果我们的聚集是基于单进程实现的,那就无法利用现代CPU多核架构的能力,可能导致执行过程阻塞在CPU上。即使还没有达到IO设备的性能瓶颈,也无法提升整个执行过程速度。
对此,解决的方案就是实现并行聚集。其实在PG14中,并行聚集已经默认打开;如果表足够大,或者像下面这样调整一些GUC,同样的查询就会生成并行聚集的查询计划了。

SET parallel_setup_cost = 0;
SET parallel_tuple_cost = 0;
SET min_parallel_table_scan_size = 0;
explain select avg(ten) from tenk1;
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=397.10..397.11 rows=1 width=32)
   ->  Gather  (cost=397.08..397.09 rows=2 width=32)
         Workers Planned: 3
         ->  Partial Aggregate  (cost=397.08..397.09 rows=1 width=32)
               ->  Parallel Seq Scan on tenk1  (cost=0.00..386.67 rows=4167 width=4)
(5 rows)

例子中的并行查询计划可以认为也只包括两个部分,分别是下层的顺序扫描算子和上层的聚集算子。但这次,上层的聚集算子则不再是简单的一阶段聚集即可完成的,而是分成了部分聚集(Partial Aggregate)和最终聚集(Finalize Aggregate)两个阶段完成。示意图如下:
图片
在PG的实现中,并行算子底层都需要依赖并行扫描,因此我们会根据查询计划,由Leader进程先启动若干个工作进程进行并行扫描。通常Leader也会和进程一起工作。

随后,为了更高效地利用CPU资源,我们不会直接像简单的select * from tenk; 语句这样,直接对并行扫描出来的元组直接进行收集,然后用唯一的 Leader 进程进行后续的聚集计算。而更好的方式是,我们可以让每个工作进程直接对自己收集的一部分元组先进行一次聚集计算,得到一个部分聚集的结果;再将这个部分聚集的结果汇总到Leader进程,由Leader进程进行最终聚集。这样,每个工作进程就可以最大化地利用系统 CPU 资源了。

这里要强调一下,仅仅汇聚(Gather)部分聚集的结果是不正确的,因此最终聚集是必须存在的。在朴素聚集的例子中这件事其实是很好理解的,因为每个工作进程只扫描到了一部分数据,不管是做SUM\MIN\MAX这样不需要后处理函数的聚集还是AVG这样需要后处理函数的聚集,每个工作进程聚集出来的结果也只会是全表聚集结果的一部分。我们除了将每个工作进程的聚集结果汇聚起来,当然还需要对他们进行合并,这就是所谓合并函数的用武之地了。

不同的聚集函数对应的合并函数显然也是不一样的。比如在我们的例子中求的是均值,则合并函数的工作就应该是收集每个 Worker 所部分聚集的count和sum,并分别对这两者进行累加;最终再交由后处理函数进行计算。对于本例, PG 最终会采用int4_avg_combine进行合并。

Datum
int4_avg_combine(PG_FUNCTION_ARGS)
{
    state1 = (Int8TransTypeData *) ARR_DATA_PTR(transarray1);
    state2 = (Int8TransTypeData *) ARR_DATA_PTR(transarray2);

    state1->count += state2->count;
    state1->sum += state2->sum;

    PG_RETURN_ARRAYTYPE_P(transarray1);
}

所以整个执行流程大致如下:在gather之前,PG 共有N个Worker并行扫描和部分聚集(Leader进程也可参与工作);在gather 之后,所有算子都在 Leader进程中执行,我们也通常称这种做法为两阶段聚集。这里还有一点需要注意,就是在部分聚集的时候,我们是不能执行后处理函数的。我们可以通过 aggstate->aggsplit区分是否需要在聚集算子中执行后处理函数。
图片

NO.4Group By
不过,朴素聚集只是聚集算子中非常特殊的一种情况。在大部分情况下,我们的查询都是带有group by关键字的。以实验所用的查询为例,我们希望返回的不再是全表的均值,而是按照stringu1字段的第一个字母分类之后,每个类别ten列的均值。由于在我们的数据集中,每一个首字母都出现了,所以最终会返回26行数据;并且数据整体分布比较均衡,每个首字母的均值都在4.5左右。

select left(stringu1, 1) as FirstLetter, avg(ten) from tenk1 group by left(stringu1, 1);
 firstletter |        avg
-------------+--------------------
 N           | 5.0000000000000000
 O           | 4.0000000000000000
 V           | 5.0000000000000000
 L           | 5.0000000000000000
 Z           | 4.9895833333333333
 M           | 4.0000000000000000
 D           | 5.0000000000000000
 Q           | 4.0104166666666667
 G           | 4.0000000000000000
 X           | 4.9947916666666667
 J           | 5.0000000000000000
 P           | 5.0000000000000000
 I           | 4.0000000000000000
 U           | 4.0000000000000000
 K           | 4.0000000000000000
 A           | 4.0000000000000000
 Y           | 3.9895833333333333
 R           | 5.0104166666666667
 W           | 3.9947916666666667
 E           | 4.0000000000000000
 B           | 5.0000000000000000
 C           | 4.0000000000000000
 T           | 5.0052083333333333
 H           | 5.0000000000000000
 F           | 5.0000000000000000
 S           | 4.0052083333333333
(26 rows)

那对于带有group by的查询,我们又应该如何实现聚集算子呢?其本质就是要把列值相同的行放在一起,这样我们就可以分门别类的进行状态累积了;类似的需求在哪里出现过呢?

目前,各个语言基础库中都会实现的Map<Key, Value>字典类,经常有两种实现方式,一种是基于哈希思想的,一种是基于平衡树或者跳表这样的有序集合;其本质可以认为就是实现了分门别类的功能。因此,对于group by聚集这样的要求,也有两种主流的实现方式;一种是基于哈希表,我们称为哈希聚集;另一种则要求先对元组进行排序,我们称为分组聚集。

NO.5哈希聚集
哈希聚集的实现和朴素聚集的主要区别就在于在算子中建立一张哈希表。

非并行的哈希聚集整体上也可以分为两个步骤:第一个步骤执行转移函数,第二个步骤执行后处理函数并输出元组到上层算子。

在第一个步骤中,我们依旧逐行扫描元组。与朴素聚集直接累积状态不同,我们首先要计算元组的哈希值并探查哈希表。如果发现记录不存在,则需要先在哈希表中插入一条新的记录。反之,则基于当前元组和哈希表中找到的记录执行转换函数,更新累积状态至哈希表中。

第二个步骤,则是直接遍历哈希表,依次对每个状态进行后处理,并逐行输出最后的聚集结果给上层算子使用。
图片
上图为CMU-15445 课件中的一张图,很好地阐述了这个过程。在这个例子中,我们要求的是选修CMU各个课程的平均分,就需要将每个同学的考试成绩按照课程编号进行分类。我们以课程编号为键,为每门课累积的状态为值建立一张哈希表。然后遍历所有的考试成绩,累积count和sum到哈希表;这个过程就是在不断地执行转换函数。当全表扫描完毕,遍历所建的哈希表,调用后处理函数也就是求解平均值,再输出给上层算子即完成了整个哈希聚集的过程。

NO.6分组聚集
分组聚集基于排序思想进行分类,其思路其实和朴素聚集更加接近,从PG的代码实现中也可见一斑。两者共享着大部分的代码,唯一的区别在于分组聚集会先对元组进行排序(通常是加一个 Sort 算子作为下层节点)。

        switch (node->phase->aggstrategy)
        {
            case AGG_HASHED:
                if (!node->table_filled)
                    agg_fill_hash_table(node);
                /* FALLTHROUGH */
            case AGG_MIXED:
                result = agg_retrieve_hash_table(node);
                break;
            // 可以看到朴素聚集和分组聚集走的是同一段代码逻辑;哈希聚集则与之不同
            case AGG_PLAIN:
            case AGG_SORTED:
                result = agg_retrieve_direct(node);
                break;
        }

排序之后,所有键相同的元素自然会排列在一起;我们只需从头到尾遍历一遍,用和朴素聚集一样的方式进行累积状态,然后在每次扫描到和上一个元素不同的键时截断并进行后再发给上层节点即可;两者的代码区别很有限,因此复用一样的代码逻辑也就很好理解了。

NO.7小结
就哈希聚集和分组聚集这两种算法来说,通常在表比较大的时候,排序的代价是比较高的,因此代价模型常常会选出哈希聚集作为聚集方式。不过,这也并非绝对,比如在下层算子本身已经保证有序性的前提下,我们不需要付出额外的代价进行排序;而分组聚集算子本身只需要依次遍历输出即可,不需要像哈希表这样占据大量的内存空间,也更不会有spill to disk的额外成本,在这种时候就常常成为更优的选择。

而关于并行,事实上,哈希聚集和分组聚集都可以成为partial agg或者finalize agg的选择;他们和并行本身是正交的,可以组合使用。

感兴趣的同学可以思考一下:相比于朴素聚集,FinalizeGroupAggregate在实现时有哪些需要注意的地方?与朴素聚集的实现又有什么区别?欢迎大家留言讨论。


酷克数据HashData
83 声望33 粉丝

酷克数据是中国领先的云原生数据仓库软件公司,致力以领先技术降低大数据分析的门槛和成本,我们发起了CloudberryDB开源项目,企业级数据仓库 HashData Lightning、云数仓HashData Enterprise、AI工具箱HashML,...