离职前的最后一天,又是带薪抽烟带薪划水的一天
前言:数据库怎么实现聚合?看过《数据库系统实现》第四章的话,知道有基于散列和基于排序的2种方式,散列理解成hash即可,散列和排序很容易联想到mr的shuffle和spark的shuffle,对于像我这样的初学者来说,知道为什么比知道怎么做的更重要,所以本篇只是很肤浅的介绍下它们之间实现的异同。
数据库怎么实现聚合?
如下表A,2个字段,userid和sal
b | 1 |
a | 3 |
b | 4 |
a | 7 |
c | 5 |
怎么实现select userid,sum(sal) from A group by userid?
1.基于hash
先聚合再func
创建一个hash表,key为groupby的userid,v为list(sals),scanA,
读<b,1> hash表为(<b,list(1)>)
读<a,3> hash表为(<b,list(1)>,<a,list(3)>)
读<b,4> hash表为(<b,list(1,4)>,<a,list(3)>)
读<a,7> hash表为(<b,list(1,4)>,<a,list(3,7)>)
读<c,5>hash表为(<b,list(1,4)>,<a,list(3,7)>),<c,list(5)>)
然后再执行func(list()),这里的func为sum,结果为(<b,5>,<a,10>,<c,5>)至此我们就实现了简单的聚合
边聚合边func
可以看到这样的方式先根据k聚合到一起,然后再在集合上执行func,func前聚合的阶段会比较耗内存,因为中间结果占了资源,而这些中间结果是最终结果不需要的,所以可以聚合的时候就执行func online聚合,改进流程如下
读<b,1> hash表为(<b,1>)
读<a,3> hash表为(<b,1>,<a,3>)
读<b,4> 因为hash表已存在k,所以我们先取出hash表b对应 1,然后和<b,4>执行sum,并将5更新hash表
所以hash表为(<b,5>,<a,3>)
继续读<a,7> ,同上,hash表为(<b,5>,<a,10>)
读<c,5>hash表为(<b,5>,<a,10>,<c,5>)
以上我们讨论的都是内存充足的情况,当内存不足的时候,《数据库系统实现》第四章指出三种算法实现,一趟、两趟直到多趟,下面我们来讨论内存不足的情况下基于hash是否可行?虽然已经有好多资料指出内存不足基于hash不太可行
内存不足情况下的hash
还是以上述6条为例,为了简化模型,假设内存只能容纳2条,1条占一个块(或者页亦或者别的啥专业术语),也想整点通俗易懂的图,奈何自己不会画
读<b,1> hash表为(<b,list(1)>)
读<a,3> hash表为(<b,list(1)>,<a,list(3)>)此时内存已满,2个块刷回磁盘
读<b,4> hash表为(<b,list(4)>,)
读<a,7> hash表为(<b,list(4)>,<a,list(7)>)此时内存已满,2个块刷回磁盘
读<c,5>hash表为(<c,5>)至此我们读了5个块,O(n),
接下来我们该怎么做呢?
遍历,遍历所有写回磁盘的块,一个块一个块的读,与在内存中的(<c,5>) compare,判读k是否相等,相等则更新内存中的(<c,5>),并将相等的写回一个地方A区,不相等写回一个地方B区,因为相等的A区后续不会再读了,而不相等的后续仍需遍历,以这次的<c,5>为例,遍历完另外4条后,仍需写回磁盘B,遍历完磁盘中的4个块后,将结果(<c,5>)写磁盘或到输出缓冲区
此时我们先读B区的<b,list(1)>,遍历B区的剩余3条,读到k为a的,写回B区,k为b时,更新结果<b,5>
虽说上述的场景很简单,也可看出来,极端情况下,以唯一值聚合,O(n^2)每取出一条都会去遍历B区的数据,类似嵌套循环join,当然如果有索引的话,这个流程会快很多,下面我们讨论基于排序的聚合
2.基于排序
我们假设数据本身有序,比如b树家族,或者聚合键上有索引,可以遍历索引得到有序的数据,亦或者数据无序,我们可以先排序,内存不足使用外部归并排序,具体就不展开了,不是该篇重点
数据本身有序的情况下,读一条,继续按序再读一条,如果k相等,聚合,k不相等,第一条就可以输出了,紧接着读第三条和第二条比较,同理,类似sortmergejoin,所以数据本身有序的情况下,遍历一遍即可得到最终聚合后的结果
排序vs哈希
哈希内存充足的情况下,O(n),遍历一遍,内存不足,最坏的情况是0(n^2),读一条,遍历所有剩下的,最好的情况下,n条数据,k相等,也是O(n),数据本身有序,足不足都是0(n),数据无序就看用的什么排序算法了
为什么要shuffle、不shuffle行不行?
olap常用的基础sql操作符groupby+join
select field1,聚合函数 from table group by field1
select xxx from tableA A join tableB B on A.关联字段=b.关联字段
下面我们讨论在分布式的环境下怎么实现上述2个基本操作
1.聚合+groupby
忽略副本,加入副本的话会变的比较复杂,所以忽略高可用,就2个节点,
节点A:<a,3>、<b,5>、<a,6>、<c,2>
节点B:<a,1>、<b,2>、<c,3>、<a,4>
表一部分数据在节点A,一部分在节点B
select id,count(1) from group by id
方式1
节点A、B都执行上述sql,然后汇总
节点A结果:<a,2>、<b,1>、<c,1>
节点B结果:<a,2>、<b,1>、<c,1>
最后将2个节点的结果拉到driver汇总,<a,4>、<b,2>、<c,2>
缺点:大量中间数据会拉到driver端汇总,driver容易崩,上述事例中,A、B节点的3条数据都会到driver端
方式2
改进方式1,减轻driver端压力,先把相同id的拉到同一节点,再执行聚合函数,最后汇总
相同id拉到同一节点就是shuffle,现在不讨论怎么拉到相同节点,后续怎么实现Shuffle讨论
每个id计算hash(id)%2,值为0拉到节点A,值为1拉到节点B,假设a、b拉到节点A,c拉到节点B
第一步,A、B节点互相拉取自己的数据
A:<a,3>、<b,5>、<a,6>、<a,1>、<b,2>、<a,4>
B:<c,2>、<c,3>
第二步,两个节点数据执行聚合函数
A:<a,4>、<b,2>
B:<c,2>
第三步,汇总driver返回结果
与方式1相比,driver端只拉取了3条数据,大大减轻了压力
优点:减轻了driver压力,缺点A、B互拉自己数据时有大量中间数据,对网络有比较大的压力
方式3
先每个节点聚合,再互拉自己数据,再每个聚合,最后汇总
第一步,先聚合
A:<a,2>、<b,1>、<c,1>
B: <a,2>、<b,1>、<c,1>
第二步,拉取自己数据
....
这样我们就可以减轻网络的压力,这就是map端聚合,而互拉自己数据就是shuffle
对于聚合+groupby:shuffle可以减轻driver压力,map端聚合可以减轻网络IO
2.join
节点A:表1 <a,2>,<b,3> 表2 <c,1>,<b,3>
节点B:表1 <c,2>,<a,3> 表2 <a,1>,<a,3>
接下来我们讨论怎么实现join select xxx from 表1 join 表2 on 1.id=2.id
可以按照groupby的思路先单节点关联再汇总
节点A结果:<b,(1_3,2_3)>#_前的代表表别名
节点B结果:<a,(1_3,2_1)>,<a,(1_3,2_3)>
可以看出来,先单节点关联不是我们想要的结果,所以还是先互拉自己数据再关联
接下来我们讨论到底怎么互拉自己数据,并且为了减少网络io怎么实现map端聚合
怎么实现shuffle?
对于每个节点的kv,先根据k计算pid(后续哪个节点来拉取这条数据),为了简化shuffle,A节点只从B节点拉一次数据,不涉及多核多task
方式1:
对于每个节点上面的kv,计算完pid,输出到对应缓冲区,预先起对应节点数量的缓冲区,比如A节点,起2个,一个是自己的,一个是属于节点B的,缓冲区满了就刷写文件,直到所有kv计算完pid,然后节点B拉取属于自己的文件
缺点:上述事例比较简单,实际的场景中,后续会有几百几千个task,会起同样数量的缓冲区,会占用大量内存,而且该方式没有map端聚合,网络压力大,临时文件巨多
方式2:
为了解决临时文件多的问题,我们可以后台起任务来合并属于相同节点的文件
为了实现Map端的聚合,我们可以参考第一部分数据库怎么实现聚合,一种hash,一种排序,先说基于排序的
方式3:
我去越写越长,后面就简单点写吧