Clickhouse系列-第三章-ck的优化手段之block+lsm

第二章已经向读者说明了,影响olap查询速度的瓶颈其实是在磁盘。并且也给出了两种具备代表性的优化方案,分别是分布式和列存。之后大部分的olap数据库都采用了类似的架构,那么凭什么clickhouse能从这些olap数据库中脱颖而出摘得桂冠呢?从本章开始,本系列将逐渐向读者展示clickhouse的精妙设计。

Block + LSM

其实本节的标题也可以换成批处理+预排序。clickhouse通过block的设计来实现批处理,通过lsm算法来实现预排序。我们分别来分析一下,这个组合对查询速度的影响。

首先,我们分析有序存储和无序存储对查询速度的影响。我们一般在做查询时,大致可以分为按值查询和按范围查询两种。

有序存储无序存储
按值查询1次读取1次读取
按范围查询1次读取n次读取
两种查询对的磁盘访问

从表中可以看出,在都使用了索引的情况下,如果是按值查询那么有序存储和无序存储基本都能做到一次磁盘IO就能实现数据读取。但按范围读取,因为是有序存储,因此只需要一次对磁盘的访问即可读取所有数据。而对于无序存储的数据来说,最坏的情况可能需要读取n次磁盘。

还是以一个小例子来做下说明:

SELECT avg(price) FROM orders where age between 20 and 30;

计算订单中年龄在20到30岁用户的平均订单金额。假设数仓内有1亿条记录,每条数据约1k,其中20-30岁之间的用户订单大约有10%。

在数据按照age有序存储的情况下,读取的数据量为1亿*10%*1KB≈10G。

若数据未按照age有序存储,这种情况下,读取的数据量为1亿*10%*4K*(1-27.1%)≈29.2G。两者相差接近3倍。

由此可见,整体上来说,有序的数据在查询时更占优势。因此,clickhouse在设计时使用了写入前预排序,以保证查询时能获得更快的速度。不过这也必然带来了数据写入的延时,因此clickhouse不适合用在写多读少的场景。

说完了预排序,再来说下批处理对性能的影响。clickhouse能处理的最小单位是block,block就是一群行的集合,默认最大8192行组成一个block。

其实做了预排序后再做批处理很好理解,毕竟存储到clickhouse中的数据都是有序的,而clickhouse设计出来是为了处理上百亿条记录的大数据数仓,因此一般的范围查询返回的数据量都非常大,如果每次处理1行数据的话,就会大大增加磁盘IO次的次数。当然,到目前为止,只是增加了IO次数,并没有减少数据量,因此到此时,按照block读取的优化好像显得没有必要,毕竟一次IO的时间和读取数据的时间相比,基本可以忽略不计。读者们不用着急,真正block的省时的点就在下一段。

block真正发挥威力的点其实是在压缩!对,没错,就是毫不起眼的压缩!那么压缩能节省多少数据量呢?我们还是拿clickhouse存储引擎中实际存储的数据说话。以clickhouse官方提供的hits_v1库为例,我挑选了其中的UserID列为例,使用clickhouse提供的compressor工具读取该列的数据文件,可以看到这个文件中每一个block的压缩前和压缩后的大小。

我大致看了一下,压缩率最大的一个block压缩前是130272字节,压缩后只有639字节,压缩率高达203倍!当然,这是特例,那我们统计下整个文件的block的压缩前和压缩后的大小,还是这个列为例,UserID列压缩前是70991184字节,压缩后是11596909字节,压缩比约为6.2倍!

能达到这么高压缩比,其实是列存的功劳,对于列存数据库,由于每一列单独存储,因此每个数据文件相比行存数据库来说更有规律,因此可以达到非常高的压缩率。

到这里,批处理的威力就出来了,通过压缩,再次降低了6倍的文件大小,也就是说再次减少6倍的磁盘IO时间。

这里就是clickhouse最重要的存储引擎上的优化,通过批处理+预排序,相比较于无此功能的列式数据库来说,减少了范围查询量在10%左右时大约18倍的磁盘读取时间。而若在百亿数据库中,查询量1%左右时能节省24倍的磁盘读取时间。

当然,任何架构都有两面性,在节省磁盘读取时间的情况下,也带来了如下缺点:

  1. 适合数据的大批量写入,如果写入频繁,会影响写入性能
  2. 如果范围查询的数据量大,那么性能提升会低。因此数据量太小时无法发挥最大优势。
  3. 由于按照block作为最小处理单位,因此删除单条数据性能不高。
  4. 修改的性能很差,尤其是修改了用于排序的列。因此不适合做事务型数据库。

可能有读者会问,为什么无序存储要乘以4K。这个原因是因为操作系统在读取磁盘时,依据数据局部性原理,会按照页为单位读取,每页的大小默认是4k。在unistd.h头文件中的getpagesize()可以获取本机的页面大小,这里按照默认大小进行计算。

式子中的27.1%是指的缓存命中率,命中率由需要查询的数据占所有数据的百分比r决定。在本例中按照4k的页面大小和1k的记录大小,命中率和数据占比之间的关系如下图所示:

缓存命中率和数据量占比之间的关系 y=1-r^3

不难发现,两者成负相关的相关性。