谈谈分布式Aggregation

前言

聚合操作(Aggregation)是OLAP分析查询中最常见的操作之一,可以说它是数据分析查询的基石,它对应这SQL中的GROUP BY子句,OLAP中的上卷下钻操作无非就是对于GROUP BY和WHERE条件的改变,如何能够高效的实现聚合是决定OLAP分析性能的最重要因素之一(另外几个因素包括如何减少SCAN记录数、如何高效实现JOIN等)。

从OLAP引擎的划分来看主要分成MOLAP和ROLAP两大类,MOLAP基于CUBE模型进行预计算,可以直接将Aggregation之后的结果预计算并保存,这样查询的时候只需要查询已经预计算的结果或者做少量的运算就可以了;而ROLAP则始终基于原始数据计算,计算开销相对于MOLAP更大,但是它的优势在于灵活,不受限于模型的约束。前者的代表是Apache Kylin、Druid等;后者的代表则为Impala、Presto、SparkSQL等。

在分布式环境下,要做到高效的Aggregation就需要想办法增加并行度,Hadoop的出现解决了这个问题,想想经常提到的Word Count其实就是一个计算count(1)的Aggregation运算,因此本文就以一个简单的表和数据谈谈对分布式Aggregation的理解。

单机实现

说了这么多Aggregation,那么什么是Aggregation呢?虽然很简单但是在对校招生面试的时候发现很多同学对SQL的了解还停留在提到SQL立马反应就是“SELECT * FROM xxx”。聚合简单来说就是将某一列的值或者多列的组合值按照相同的值进行分组,每一个组内做聚合运算(对应着聚合函数),典型的聚合函数包括COUNT/SUM/MIN/MAX/COUNT DISTINCT等等,单线程情况下的聚合操作可以用如下的伪代码实现:

伪代码:对于某一列A进行COUNT(1)计算。

1: 构造一个Map M
2: 对于输入的每一条记录:
3:         获取A列的值a;
4:         if(M.contains(a)) {
5:                 M.put(a, M.get(a) + 1); 
6:         } else {
7:                 M.put(a, 1);
8:         }
9: 依次输出M的每一个

这种实现在大数据场景下会存在两个问题:

  • M这个缓存会占用很大的空间;
  • 单线程执行,执行速度不能够让人满意。

分布式Aggregation

在分布式环境下,Hadoop的出现解决了这两个问题,想想入门Hadoop经常提到的“Word Count”程序其实就是一个根据单词计算COUNT(1)的聚合运算,那么Hadoop是如何利用分布式计算来解决这个问题的呢,它会把输入的记录分散到多个并行的运算节点中执行,每一个节点如上伪代码中的实现逻辑,记录的分散需要保证一条记录只会被一个计算节点处理(防止一条记录被多次处理),每一个计算节点会输出一个M,然后在经过汇总节点把所有的M进行汇总。

在介绍这个之前首先看一下分布式聚合运算的执行流程,如果要实现一个聚合函数(UDAF),需要分解成如下几个步骤:

  • Init操作,分配缓存空间等一些初始化操作,例如上面伪代码中的第一行初始化Map对象。
  • Update操作,对于每一条输入记录执行更新状态的操作,例如上面例子中的3-8行
  • Merge操作,将来自多个计算节点的输出进行再次聚合运算。
  • Finish操作,对于再次聚合运算的结果求值,得到最终结果。

示例

下面一个示例展示了详细的计算流程。本例子使用的表T数据如下,执行的SQL:

SELECT A, COUNT(1), SUM(C) FROM T GROUP BY A;

表中原始数据如下:

谈谈分布式Aggregation_第1张图片

为了描述简单,假设它被分割成两个分区被两个计算节点计算,计算流程如下图:

谈谈分布式Aggregation_第2张图片

Map-Reduce VS Data Pipeline

按照Hadoop的计算逻辑可以将整个计算分成两个阶段:Map和Reduce,Map阶段对于每一个分区的每一条记录执行Update操作,计算完成之后通过一个Shuffle过程将Map结果打散,Shuffle过程可以使用不同的散列算法实现,但是需要保证相同的key(这里是A列的值)被输出到相同的Reduce节点执行,在Reduce阶段执行Merge操作,所有的Merge操作完成之后对于每一个key执行Finish操作将结果输出(这里的Finish操作并没有执行任何计算)。

上面的计算流程可以做到完全分布式化,计算时可以同时启动M个Map和N个Reduce计算节点执行计算,但是Hadoop的计算流程存在一个问题,那就是Shuffle过程,Shuffle需要首先将Map的计算结果在本地持久化,然后再从持久化的结果中读取打散发送到下游的Reduce节点,一旦数据落地,性能必然会有所下降,这么做的原因是为了可靠性,一旦计算结果落地,这个Map节点的任务就可以完成了,不用关心Reduce什么时候来读取数据,即使Reduce计算过程中挂了,Map的结果也不需要重新计算了,重新启动一个Reduce计算节点重新读取一次数据就可以了;另外,Map计算节点和Reduce计算节点是顺序的,Reduce只能等到所有的Map都结束之后才能启动执行,这无疑降低了计算的并行度。

在Impala实现的过程中使用了不同的处理方式,它不会对需要Shuffle的数据进行任何的数据持久化,并且直接通过数据流水线(data pipeline)的方式进行并行计算,不持久化需要Shuffle的数据意味着Map的结果会直接通过网络发送到对应的Reduce计算节点,data pipeline意味着Map和Reduce过程可以同时进行计算,这又是怎么做到的呢?

首先为了确定哪些计算可以并行,Impala计算引擎会把计算节点分成BlockingNode和NonBlockingNode,BlockingNode表示需要等到处理完全部的输入数据之后才能产生本节点的输出;而后者则不需要等待。典型的BlockingNode有Sort操作和Merge-Aggregation节点(也就是Aggregation的Merge阶段),NonBlockingNode则可以并行的执行,例如上图中的Map阶段的Update操作,它其实是Map操作的第一个阶段,它不需要等到所有的输入都处理完再输出,那就意味着Update的结果可以边计算边进行Shffle到下一个计算节点(Merge)。

所以从Impala执行Aggregation的流程可以看出,它可以增大计算的并行度,但是什么时机将需要Shuffle的数据进行输出呢,有两个极端:1、每一条都输出,那相当于Update操作不执行任何计算了;2、所有的都计算完成再输出,那就回到了Hadoop的处理模式了。所以需要在这两者之间寻找一个平衡。

COUNT DISTINCT困惑

上面介绍完了分布式Aggregation的执行流程,分别介绍了基于Map-Reduce和data pipeline的计算模式,但是还有一个常用的比较消耗资源的聚合函数:COUNT DISTINCT。看上去它也是类似于COUNT/SUM之类的聚合函数,但是它和COUNT/SUM之类的聚合函数的主要区别在于COUNT/SUM之类的状态保存在最终的值里面,而COUNT DISTINCT的计算状态和最终的值是两个东西,计算状态是什么?就是Shuffle的信息,Merge依赖于每一个key和它对应的计算状态;最终的值则是Finish操作执行计数之后的结果值。

COUNT DISTINCT聚合函数的计算状态无非就是每一个key对应的一大串去重之后的成员值,这又有什么难的呢?试想一下上面的T表有1亿条记录,A列的不同的值只有10个,我们要计算COUNT DISTINCT的B列包含2千万个不同的值,Map的输出结果很可能是下图的形式。

谈谈分布式Aggregation_第3张图片

这些数据需要经过shuffle之后传递到Reduce,Reduce阶段再根据相同的key进行merge,这会带来三个问题:

  • shuffle数据量非常巨大;
  • 占用大量内存,也就是每一个set都至少需要保存所有成员;
  • merge操作的代价很大,因为此类的merge说白了就是两个set求并集,需要遍历两个set。

可以说这个问题困扰着各个大数据生态圈的SQL引擎,典型的优化方法有两类:

  • 使用基于基数统计的HyperLogLog算法实现非精确的COUNT DISTINCT,通常情况下它能够在不占用大量内存的情况把错误率维持在2%设置1%以内;
  • 对每一个成员进行编码,然后使用bitmap存储成员列表而不在使用set,这种方式可以计算精确的COUNT DISTINCT,但是这需要额外的数据编码,实现起来也比较复杂。

Apache Kylin使用了这两种方法实现COUNT DISTINCT。但是Impala并没有用其中任何一种方案,而是另辟蹊径。

COUNT DISTINCT in Impala

在介绍Impala中的COUNT DISTINCT实现之前,先看一下如下的两个SQL:

Q1:SELECT A, COUNT(DISTINCT B), SUM(C) FROM T GROUP BY A;

Q2:SELECT A, COUNT(1), SUM(SC) FROM (SELECT A, COUNT(1), SUM(C) AS SC 
    FROM T GROUP BY A, B) TT GROUP BY A;

仔细分析可以发现这两个SQL执行结果是相同的,按照这样的思路我们可以把COUNT DISTINCT计算转换成两层的COUNT计算,计算流程如下图:

谈谈分布式Aggregation_第4张图片

Impala的COUNT DISTINCT实现就是将Q1中的查询隐式得转换成Q2,于是肯定有读者会纳闷为什么要做这样的优化呢?

分析

这里的流程和上面计算COUNT/SUM的区别在于第一阶段Update是根据A和B聚合的,但是Shuffle是根据A进行的,然后在Reduce阶段的Merge分成两阶段,第一阶段的Merge是按照A、B进行COUNT(1)和SUM(C)的聚合运算,正如Q2中子查询一样,接着再执行Merge-2阶段,该阶段对于SUM再次执行Merge操作(参见UDAF的四个阶段),对COUNT执行Update操作,最后执行Finish操作,这里同样没有执行任何计算而直接输出。

Impala的COUNT DISTINCT实现流程看上去和Map-Reduce很相似,只不过增加了一步,但是使用过Impala的都会发现,COUNT DISTINCT聚合函数的速度还是非常快的,通过分析这两个流程的每一步,结合Map-Reduce计算COUNT DISTINCT的三个问题,我们逐一分析(下面时间复杂度的分析都是基于二叉树实现map和set):

  • Shuffle数据量,Map-Reduce的方式需要把每一个A的成员和它对应的B的成员值进行Shuffle,而Impala的方式变成了A和B的组合在Update之后进行Shuffle,总的来看Shuffle的数据量是没有什么差别的,都需要传输O(M * N)大小的数据,M和N分别是A个B的成员数。
  • 内存占用情况:采用Impala处理COUNT DISTINCT不存在A对应的一个个set了,但是它在做Merge操作的时候需要按照A和B的组合作为key,这占据的内存其实和Map-Reduce的方式相差不大的,都是需要一个O(M * N)大小的Map。
  • 聚合运算速度,Map-Reduce的方式对于每一个输入需要遍历其中一个set将成员逐个的插入到另外一个set中,总的时间复杂度为O(X * log(Y) * K),其中Y为较大set的成员个数,X为较小set的成员个数,K为Map阶段输出的记录数。但是Impala方式的实现分成两阶段,第一阶段需要找到A和B组合对应的key进行加一,第二阶段则需要找到A对应的key进行加一,总的时间复杂度为O(log(M * N)) + O(log(M)) = O(log(M * N))。其中M为A的成员个数,N为B的成员个数。

从上面的分析可以看出,两种实现网络传输的数据量和占用的内存大小是相差不大的,比较大的区别在于merge阶段的时间复杂度,通过对两个时间复杂度进行分析可以很容易的看到Impala的实现方式时间复杂度要低非常多,查询速度更快也是理所当然的了。

对于Map-Reduce的Merge实现可以有一种更好的基于最小堆的实现方式,聚合操作可以从O(X*log(Y))降低到O(X +Y)的时间复杂度,其中Y为较大set的成员个数,X为较小set的成员个数,这个有机会再做介绍。

缺陷

介绍完了Impala中COUNT DISTINCT的实现,可能有人要问了,为什么其他的SQL引擎不使用这种方式实现呢?这是因为它有一个非常大的弊端:只支持在一层查询中出现一个COUNT DISTINCT运算,例如在Impala中执行如下的SQL:

SELECT A, COUNT(DISTINCT B), COUNT(DISTINCT C) FROM T GROUP BY A;

按照前面的转换逻辑,在Merge-2阶段将无法选择使用哪些列进行聚合,无论使用A/B还是使用A/C都会丢失A和另外一列的组合信息,如果直接使用A进行聚合,则得到的是SELECT A, COUNT(DISTINCT B,C) FROM T GROUP BY A的执行结果。

最后还需要说明的是在Impala的实现中,Merge和Merge-2阶段都会被设置成是BlockingNode,可能是考虑到Merge之后的数据量一般比较小了,没有和下一个节点并行执行的必要性了。

总结

在大数据SQL引擎中(大体上都是OLAP引擎),Aggregation和Join的计算性能直接影响着查询速度,本文主要介绍了笔者在使用MapReduce和Impala系统中对实现Aggregation操作的理解,最后详细介绍了Impala能够实现高速的单个DISTINCT COUNT查询的原理,希望能够能够对于读者有点帮助和启发,如果本文中笔者的理解有所偏差,也请多多指正。

你可能感兴趣的:(impala,OLAP)