为啥他能这么快呢?设计如此=-=
ClickHouse设计的初衷是to filter and aggregate data as fast as possible。
再优秀的系统的设计也只能对各个指标进行取舍,无法兼得,而ClickHouse的最重要的指标和任务就是做好这样的一件事:尽可能快的过滤和聚合数据。实际上,这也是传统的OLAP系统所追求的指标。体现到语义上,就是实现GROUP BY
查询的实现。
为了实现这一目的,ClickHouse在上层设计上做出了如下的优化:
然而许多其他的数据库使用的相似的优化策略,为什么ClickHouse能够脱颖而出呢?另一个值得关注的点是其底层实现细节。一个观点是:ClickHouse并没有太多的新的方法或者理论,而是将前人的研究成果应用到了OLAP这个领域。因此可以说,ClickHouse是一个非常优秀及经典的工程化项目,是人类计算机工程的结晶。
列式存储能够显著的降低磁盘IO。对于OLAP场景中,每次只关心一个大宽表中的某几列的场景,相比于行式存储,列式存储仅需获取需要的数据,磁盘的IO理论上能够降低为所关心的列与所有列之比。因此,越是宽的表,行式存储的优势越明显。
同样,使用ClickHouse时,也一定要使用列式存储数据库的方式来查询数据,查询时指定具体的列,否则性能提升会不明显。
ClickHouse使用Block
实现批处理。Block
是ClickHouse中的数据最小处理单元,表示内存中表的子集(chunk)的容器,是由三元组: (IColumn, IDataType, 列名)
构成的集合。在查询执行期间,数据是按 Block
进行处理的。如果我们有一个 Block
,那么就有了数据(在 IColumn
对象中),有了数据的类型信息告诉我们如何处理该列,同时也有了列名(来自表的原始列名,或人为指定的用于临时计算结果的名字)。
当我们遍历一个块中的列进行某些函数计算时,会把结果列加入到块中,但不会更改函数参数中的列,因为操作是不可变的。之后,不需要的列可以从块中删除,但不是修改。这对于消除公共子表达式非常方便。
ClickHouse会对插入的数据进行预排序(基于LSM算法)。这里设计的原因是针对大数据量场景,为了处理上百亿条记录的数据,一般的查询返回的数据量都非常大,如果数据是无序的,对于按字段聚合或者范围查询的场景,会大大增加磁盘IO次的次数。
to be discussed
这里预排序是对每个字段都预排序吗?如果按照字段A进行范围筛选,获取相应的字段B进行计算,按照ClickHouse的列式存储和预排序,这里的流程是如何实现的?ClickHouse是如何根据排序后的A,找到对应行的B的呢?
数据压缩是提升ClickHouse性能的一个关键。我们发现,前面介绍的列存、分块和预排序,实际上都对压缩有着一定的好处,
由于ClickHouse实现了在插入时进行了预排序,因此其索引做的非常简单,无需像MySQL需要设置单独的索引文件索引数据的位置,因为其数据本身就是有序的。
ClickHouse的索引包括一级索引,标记和二级索引。一级索引记录每一个block第一个值。例如一组一亿行的数据,主键范围从1~100,000,000。存储到ClickHouse后按照8192行为一个block,那么一共有12208个block。索引为1,8193,16635……在查询时只需要就可以根据值确定到需要读取哪几个block了。但是仅仅靠定位到具体哪个block还是不够,因为我们仍不清楚这个block在文件的哪个位置,因此就有了标记来记录block在文件中的偏移量。由于一级索引非常小,1亿条数据只需要1万多行的索引,因此一级索引可以常驻内存,加速查找。
ClickHouse使用C++进行开发,通过向量化的实现进行数据的高效查询。主要包括提升CPU 缓存利用率,以及使用 SIMD CPU 指令。
Clickhouse在所有能够提高CPU计算效率的地方,都大量的使用了SIMD,频繁调用的基础函数,大量的进行可并行计算,将工程上的优化做到了极致。
以下的代码是一个例子,对于一个简单的额大小写转换的方法,也调用了SIMD命令来进行优化。
template <char not_case_lower_bound, char not_case_upper_bound>struct LowerUpperImpl{
public:
static void array( char * src, char * src_end, char * dst) {
//32
const auto flip_case_mask = 'A' ^ 'a';
#ifdef __SSE2__
const auto bytes_sse = sizeof(__m128i);
const auto src_end_sse = src_end - (src_end - src) % bytes_sse;
const auto v_not_case_lower_bound = _mm_set1_epi8(not_case_lower_bound - 1);
const auto v_not_case_upper_bound = _mm_set1_epi8(not_case_upper_bound + 1);
const auto v_flip_case_mask = _mm_set1_epi8(flip_case_mask);
for (; src < src_end_sse; src += bytes_sse, dst += bytes_sse){
//_mm_loadu_si128表示:Loads 128-bit value;即加载128位值。
//一次性加载16个连续的8-bit字符
const auto chars = _mm_loadu_si128(reinterpret_cast<const __m128i *>(src));
//_mm_and_si128(a,b)表示:将a和b进行与运算,即r=a&b
//_mm_cmpgt_epi8(a,b)表示:分别比较a的每个8bits整数是否大于b的对应位置的8bits整数,若大于,则返回0xff,否则返回0x00。
//_mm_cmplt_epi8(a,b)表示:分别比较a的每个8bits整数是否小于b的对应位置的8bits整数,若小于,则返回0xff,否则返回0x00。
//下面的一行代码对这128位的寄存器并行操作了3遍,最后得到一个128位数,对应位置上是0xff的,表示
//那个8-bit数在 [case_lower_bound, case_upper_bound]范围之内的,其余的被0占据的位置,是不在操作范围内的数。
const auto is_not_case = _mm_and_si128(_mm_cmpgt_epi8(chars, v_not_case_lower_bound), _mm_cmplt_epi8(chars, v_not_case_upper_bound));
//每个0xff位置与32进行与操作,原来的oxff位置变成32,也就是说,每个在 [case_lower_bound, case_upper_bound]范围区间的数,现在变成了32,其他的位置是0
const auto xor_mask = _mm_and_si128(v_flip_case_mask, is_not_case);
//将源chars内容与xor_mask进行异或,符合条件的字节可能从uppercase转为lowercase,也可能从lowercase转为uppercase,不符合区间的仍保留原样。
const auto cased_chars = _mm_xor_si128(chars, xor_mask);
//将结果集存到dst中
_mm_storeu_si128(reinterpret_cast<__m128i *>(dst), cased_chars);
}
#endif
#ifndef __SSE2__
for (; src < src_end; ++src, ++dst)
if (*src >= not_case_lower_bound && *src <= not_case_upper_bound)
*dst = *src ^ flip_case_mask;
else
*dst = *src;
#endif
}
};
ClickHouse 可以利用所有可用的 CPU 内核和磁盘来执行单个查询。 不仅在单个服务器上,而且还包括集群的所有 CPU 内核和磁盘。
ClickHouse将数据划分为多个partition,每个partition再进一步划分为多个index granularity,然后通过多个CPU核心分别处理其中的一部分来实现并行数据处理。
在这种设计下,单条Query就能利用整机所有CPU。极致的并行处理能力,极大的降低了查询延时。
除了优秀的单机并行处理能力,ClickHouse还提供了可线性拓展的分布式计算能力。ClickHouse会自动将查询拆解为多个task下发到集群中,然后进行多机并行处理,最后把结果汇聚到一起。
在存在多副本的情况下,ClickHouse还提供了多种query下发策略。
这个问题实际上是LSM算法的特点,为了实现高效的追加写和预排序,必然要在其他方面有一些牺牲。
LSM算法的几个核心步骤:
在于数据写入存储系统前首先记录日志,防止系统崩溃。
记录完日志后在内存中以供使用,当内存达到极限后写入磁盘,记录合并次数Level为0(L=0)。已经写入磁盘的文件不可变。
每过一段时间将磁盘上L和L+1的文件合并
我们用一个示例来展示下整个过程
T=0时刻,数据库为空。
T=1时刻,clickhouse收到一条500条insert的插入请求,这500条数据时乱序的。此时,clickhouse开始插入操作。首先将500条插入请求一次性写入日志。接着在内存中进行排序,排序完成后将有序的结果写入磁盘,此时L=0;
T=2时刻,clickhouse收到一条800条insert的插入请求,这800条数据时乱序的。此时,clickhouse开始插入操作。首先将800条插入请求一次性写入日志。接着在内存中进行排序,排序完成后将有序的结果写入磁盘,此时L=0;
T=3时刻,clickhouse开始合并,此时此刻,磁盘上存在两个L=0的文件。这两个文件每个文件内部有序,但可能存在重合。(例如第一批500条的范围是300-400,第二批800条数据的范围是350-700)。因此需要合并。clickhouse在后台完成合并后,产生了一个新的L=1的文件。将两个L=0的文件标记为删除。
T=4时刻,clickhouse开始清理,将两个被标记为删除的文件真正地物理删除。
T=5时刻,clickhouse收到一条100条insert的插入请求,这100条数据时乱序的。此时,clickhouse开始插入操作。首先将100条插入请求一次性写入日志。接着在内存中进行排序,排序完成后将有序的结果写入磁盘,此时L=0;
T=6时刻,clickhouse开始合并,此时此刻,磁盘上存在1个L=0的文件和1个L=1的文件。这两个文件每个文件内部有序,但不存在重合。(例如L0文件的范围是100-200,L1文件的范围是300-700)。因此不需要合并。clickhouse在后台将L=0的文件升级成L=1,此时数据库内存在两个L=1的互不重合的文件。
以上过程来自Clickhouse系列-番外-LSM算法 - 知乎 (zhihu.com)
如上述过程,这里整个系统必然会频繁的进行Compaction(合并),写入量越大,Compaction的过程越频繁。而compaction是一个compare & merge的过程,非常消耗CPU和存储IO,在高吞吐的写入情形下,大量的compaction操作占用大量系统资源,必然带来整个系统性能断崖式下跌,对应用系统产生巨大影响,当然我们可以禁用自动Major Compaction,在每天系统低峰期定期触发合并,来避免这个问题。
文章wxlog如何使用clickhouse支撑百万亿行日志存储与检索 - WXG技术能力提升 - KM平台 (woa.com)就提到了对合并过程的调优。
由于按照block作为最小处理单位,因此删除单条数据性能不高。
修改的性能很差,尤其是修改了用于排序的列。
不支持事务
因为采用了并行处理机制,即使一个查询,也会用服务器一半的CPU去执行,所以ClickHouse不能支持高并发的使用场景,默认单查询使用CPU核数为服务器核数的一半,安装时会自动识别服务器核数,可以通过配置文件修改该参数。