Clickhouse系列-第二节-基本原理

在正式开始clickhouse探秘前,我们先抛出一个问题:影响OLAP查询速度的是什么?是优秀的算法么?不可否认,算法对查询性能的影响非常大,但到了现阶段通用的算法基本上已经能够达到很高的性能了。因此,在现阶段,制约着大数据OLAP查询速度的已经不再是算法了。那么这个问题的答案是什么呢?实践是检验整理的唯一标准,我们来做个实验看一下。

实验

我们写一段代码来模拟数据库执行SELECT max(id) From tbl_a这句语句。如果不考虑前面的sql解析过程,可以简单抽象成两个步骤:

  1. 从磁盘中读取数据文件,载入内存
  2. 解析数据并找出最大的id
// 从磁盘将文件载入内存
long s = System.nanoTime();
List<String> lines = FileUtils.readLines(file,"utf8");
long e1 = System.nanoTime();
System.out.println("文件读取完成,耗时:"+((e1-s)/1e6)+" ms,开始找最大值");
// 解析并求出最大值
String maxId = lines.parallelStream()
         .map(e -> e.substring(0,e.indexOf(" ")).trim())
         .max(Comparator.comparingInt(Integer::parseInt)).orElse("未找到");
long e2 = System.nanoTime();
// 计算每个阶段的耗时
System.out.println("计算完成,耗时:"+((e2-e1)/1e6)+" ms");
System.out.println("程序运行完成,总耗时:"+((e2-s)/1e6)+" ms(不计算生成数据文件文件的时间)");

代码如上所示,使用下表的结构模拟数据表:

id姓名部门名称岗位名称
1Amy大数据部部门经理
2Tom大数据部部门经理
3John大数据部部门经理
表1 模拟表结构

使用3000万条数据作为测试,数据集大小为1.1G。最终执行结果为:

文件读取完成,耗时:4971.683747 ms
计算完成,耗时:432.261263 ms

可以看出,两者差距接近了11倍。并且,这个实验的第二步,其实CPU也有很长时间是在读取内存的。不过这个实验已经足够说明问题了。

很明显,现代OLAP需要想办法降低磁盘IO的时间。

简单分析

现代计算机体系结构中,将存储系统按照层次结构顺序排列,称为存储器层次结构(Memory Hierarchy)。由此,从磁盘上读取数据的时间远大于CPU计算的时间。可以做个粗略的计算对比:

假设一台2.4GHz的CPU+7200转的机械硬盘。那么CPU执行一条指令的时间是1/2.4G≈0.41ns。7200转机械硬盘的读取约为100MB每秒。也就是说CPU执行一条指令的时间硬盘理论上只能读取约0.041个字节。而实际上0.41ns都不足以使得磁盘开始转动。

再换种计算方法,3000万条记录,1.1G的大小,计算机从磁盘中载入这个文件需要约1126MB/100MB/S≈11.26秒。而计算机从3000万条记录上找出最大值,大约需要执行3000万次比较,每次比较约执行15条1机器指令,如果只计算执行指令的CPU时间,不考虑内存读写时间,执行3000万次比较只需要(3*10^7*15)/(2.4*10^9)≈0.019秒。两者相差接近600倍。由此可见,计算任务的大部分的时间其实都在IO上。

由此我们可以知道,CPU参与运算的时间远小于磁盘IO的时间。这种情况下,使用算法优化CPU执行的指令,会显得很不划算。

那么文章开头提出的问题的答案就呼之欲出了——降低IO来加速计算。因此,现代olap大部分都是选择降低磁盘IO来获得加速效果。

降低磁盘IO的手段

如表2所示,现代OLAP数据库一般使用两种手段降低磁盘IO时间对查询性能的影响。

手段原理代表数据库缺陷
分布式并行读取数据,降低单台服务器需要读取的数据量Hive(textfile),greenplum单独使用本质上依然需要读取所有数据,耗费资源
行变列(列存数据库)
行存数据库即使只需要某一列的数据,也必须读取所有数据(如定量中的例子)列存通过将每一列单独存储,做到按需读取hbase适合使用列比较单一的业务。
表2 降低磁盘IO时间的手段

分布式是大数据早期时代使用的方案,他的原理和思想非常简单粗暴,单机处理慢,我就分成N台服务器进行计算,每一台只需要计算一小部分,最后将结果汇总。例如最早期的Hive。这种架构的优点是简单粗暴,但也有明显的缺陷,就是虽然等待时间变少了,但是整体上看,将多台机器的计算时间汇总,总耗时是大于单机处理时间的。而且也很容易发生数据倾斜问题和增加网络传输时间。其实是浪费了资源。因此随着技术的发展,基本上大家都使用了分布式和行变列混用的架构。hive在后期也增加了列存引擎。

列存数据库的原理将每一列单独存储,降低每次载入的数据量。还是以定量分析节中的例子为例,数据文件data.bin大小为1.1G,载入时间4.9s。如果将每一列单独写入一个文件,那么如果只需要计算出最大id,那就只需要载入id.bin文件,这个文件约为247MB,理论上能将读取时间减少75%左右。同样,列存也有一个很大缺陷,考虑一个极端情况,如果查询语句非常复杂,从而用到了所有的列,那么列存也无法降低读取时间。所以,OLAP适合处理大宽表,列越多,其性能对比行式数据库的提升越大。

这里插个题外话,了解了列存数据库的原理后,读者要明确,将数据从行式数据库导入到列式数据库进行分析时,请一定记得将行式数据库的星型表转换成列式数据库的大宽表。否则性能提升有可能会不明显。在我的职业生涯中,我就遇到过数仓工程师将数据从MySQL导入adb后,直接进行查询,然后责怪adb性能为什么这么差!这也是为什么数仓模型要分层的原因。另外有些读者可能会有疑惑,为什么行式数据库不搞成大宽表?这里面的原因不在本系列的范畴内,后续笔者会另外写一篇单独的文章,为读者讲透这里面的原因,敬请期待,也可以在评论区说出你的想法。

降低内存IO的手段

相比较磁盘io,内存io的优化做的不多,能提升的空间也很有限,以第一节的例子为例,载入内存后CPU计算一共花费432ms,即使做到极致,也只能减少约400ms的时间。因此,OLAP在内存IO上的优化不大。

不过clickhouse也依然做了一些令人拍案叫绝的优化,同样会在后续章节详细解析。

clickhouse就是使用了列存的数据库,类似的还有后期的hive、greenplum。那么,同样都是列存数据库,凭什么clickhouse就能比这些数据库快呢?本系列的后续部分将继续探讨clickhouse的巧思。再次强调,clickhouse并没有提出新的计算理论,只是在列存的基础上进行了多项优化。从而提现了clickhouse工程师的令人惊叹而着迷的奇思妙想。

[1] 假设比较函数如下:

int compare(int a,int b){
    return a>=b?a:b;
}

以上C语言函数未经编译器优化生成的机器码大约是15条指令,实际上将其声明为inline并打开编译器优化后会生成的机器指令会更少。但该代码也已经足够说明问题了。生成的机器码如下:

    0x100e88f50 <+0>:  pushq  %rbp
    0x100e88f51 <+1>:  movq   %rsp, %rbp
    0x100e88f54 <+4>:  movl   %edi, -0x4(%rbp)
    0x100e88f57 <+7>:  movl   %esi, -0x8(%rbp)
->  0x100e88f5a <+10>: movl   -0x4(%rbp), %eax
    0x100e88f5d <+13>: cmpl   -0x8(%rbp), %eax
    0x100e88f60 <+16>: jl     0x100e88f71               ; <+33> at main.c:12:19
    0x100e88f66 <+22>: movl   -0x4(%rbp), %eax
    0x100e88f69 <+25>: movl   %eax, -0xc(%rbp)
    0x100e88f6c <+28>: jmp    0x100e88f77               ; <+39> at main.c
    0x100e88f71 <+33>: movl   -0x8(%rbp), %eax
    0x100e88f74 <+36>: movl   %eax, -0xc(%rbp)
    0x100e88f77 <+39>: movl   -0xc(%rbp), %eax
    0x100e88f7a <+42>: popq   %rbp
    0x100e88f7b <+43>: retq