Clickhouse数据库基础理解
简介
如今的世界,我们每一个人生活都在数据的“洪海”之中。作为技术开发人员,我们每天都在跟数据打交道,如何处理以及如何分析这些庞大的数据量,从来都不是一件容易的事情。
每一家公司都会有自己的数据分析需求,因此提出了数据仓库以及数据湖的概念。然而,搭建一套完备的大数据平台还是有一定难度的,中小型公司如果要单独支撑起属于自己的一套数据平台,这需要专业的人才和一笔不小的成本支出。当然目前有很多云平台能够帮助我们快速搭建数据平台,并且根据用量来付费。除此之外,还有没有其它路径呢?
Clickhouse,我想至少是一种答案。它最早来自俄罗斯的yandex公司,我们可以把这个公司理解为中国的百度,全世界的Google。由于国际形式风云崛起,Clickhouse已经和俄罗斯撇了关系。
ClickHouse® is a high-performance, column-oriented SQL database management system (DBMS) for online analytical processing (OLAP).
这段话摘自官网。大意是Clickhouse是一个高性能,列式的数据库管理系统。不懂关于列式和行式区别同学可以参考这篇介绍。既然自称是DBMS了,就肯定有了非常完备的功能:DDL、DML、权限角色管理、分布式管理、数据备份恢复、数据监控等。
Clickhouse本身还提供了非常多的聚合函数,刨去常用的最大,最小,平均值计算不说,比如还有求方差,求分位,TopK计算,离散值,统计学里的求相关系数,线性回归等等。当然我们自身用的比较多的还是Bitmap位图运算,比如BitmapAnd,BitmapOr等等。关于Bitmap位图算法可以参见我之前的文章。
Clickhouse还提供常用分析模型函数(漏斗分析,留存分析,序列分析等等),再加上它本身非常快的查询速度和良好的性能,几乎满足了我们对分析场景的所有想像。
当然,它也不是没有缺点,大概列举几个:
- 由于实时查询,几乎没有预处理的特性,Clickhouse非常吃资源,直接影响着查询速度。
- 比较适合处理宽表场景。
- 非常有限更新和删除功能,并且不支持事务。
- 计算和存储是在一块的,因此组件之间有耦合,有较高的运维成本。
- 由于更新迭代速度快,因此实际使用过程中,可能会有一些坑。
快的秘诀
关于为什么这么快,官网中总结了几点,大致就是:存储是面向列的,所以字符串和字符串在一起,数字和数字在一起,这样数据就可以很好的压缩,压缩后的数据同时也带来了IO优势;还有就是向量化的查询,这个也好理解,按列来处理数据必然能够很好的利用CPU缓存行(就好比遍历一个数组),极大提高了速度,关于缓存行可以页参见我之前的文章介绍,同时支持现代处理器的SIMD指令。
上述说的其实并不是Clickhouse特有,很多面向列式存储的数据库都支持上述特点。那到底是什么让它号称“独特”的快呢?答案就是底层的算法。光Hashtable就用30多种实现,来适应不同的场景。并且整个团队敢于试错,来尝试最新最好的算法。嗯,不说了,大家赶紧回去恶补算法吧。
MergeTree表引擎
Clickhouse支持非常多表引擎,甚至可以指定外部存储Mysql、Hive、MongoDB、Kafka、RabbitMQ、S3作为表引擎。但是作为“看家本领”还是合并树(MergeTree)引擎,这肯定是实际应用场景中最多的引擎。这里我简单介绍一下几种引擎,我再怎么介绍都没有官网来的详细,这里简单介绍是为了我们后续的存储结构介绍做准备。
MergeTree最重要的含义就是Merge(合并)。当大批量数据写入时候(没错,Clickhouse建议一次至少写入1000行数据,最好是10000-20000行的写入),这些数据会做好排序(按照排序键-Order by),然后写入Parts,最终多个Parts会在后台合并(Merge)成Blocks(块)。“块”,是Clickhouse存储中非常重要的概念,下文会继续涉及。
MergeTree家族还有一些衍生功能的引擎,我们一次带过简介一下:
- ReplacingMergeTree:由于MergeTree的主键(Primary Key),没有一般我们理解的那种唯一约束,所以没有去重的功能,因此这个表引擎就是来弥补这个缺陷。多行相同的排序键(Order By,不是主键)会在后台合并数据的时候进行去重。由于是后台去重,所以你们办法预知发生这个时间点。值得注意的是,这里的去重都是针对同一个分区,因此Clickhouse不绝对保证没有重复项。
- SummingMergeTree:对于相同的排序键(Order By),后台会将指定的字段(建表时指定)进行sum(和)操作。这样达到了去重的目的,又做了SUM聚合操作,一举两得!这对于降低存储和后续聚合查询都有益。但是上面说的缺点,它也有,不同分区还是有相同的重复项。因此,我们在做聚合查询时,不要忘记了这一点,GROUP BY操作还是少不了。
- AggregatingMergeTree:上面的SUM聚合引擎,明显不能满足我们的需求,如果我想要按照平均聚合,最大值或者最小值聚合,或者其它的聚合呢?没错,这就是AggregatingMergeTree存在的含义,对于相同的排序键(Order By),你可以指定Clickhouse支持的多达几十种聚合函数来进行聚合操作。这个表引擎经常和物化视图一起使用(官方文档就有详细例子,不赘述),这是因为AggregatingMergeTree以二进制的形式存储中间状态结果,你们办法直接对它进行写入数据操作。
- CollapsingMergeTree:折叠合并树。Clickhouse支持update和delete是有限的,并且实际生产环境如果直接对数据库进行update操作将严重影响性能,因此这个引擎就是个折衷方法。当你需要修改某一行数据时,你只需要重新插入一行,对于相同的排序键,它会根据Sign标志列,来对数据进行删除操作,sign可以是1或者-1,-1和1的数据行形成配对,相互抵消进行删除。合并规则如下:
- 如果sign=1和sign=-1的数据行一样多,并且最后一行是 sign=1,则保留第一行sign=-1和最后一行sign=1的数据。
- 如果sign=1比sign=-1的数据多,则保留最后一行sign=1的数据。
- 如果sign=-1比sign=1的数据多,则保留第一行sign=-1的数据。
- 其它情况,则什么也不保留。
- 如果-1和1的行数差值超过了1行,则Clickhouse会记录这种逻辑错误情况到日志里,但并不影响合并。
- VersionedCollapsingMergeTree:这个和上述的折叠树很相似,只是加了Version字段来控制顺序,这样就不受写入顺序影响。具体做法就是Clickhouse会隐式地将Version字段加入到排序键的最后。
- GraphiteMergeTree:这个引擎一般是用来存储时序数据的,比如监控数据。关于Graphite!
数据存储结构及索引
了解Clickhouse的存储结构,将非常有利于我们对索引的理解,对查询的理解以及表结构的设计优化。下面我们创建一个MergeTree引擎的表,这个表用来记录用户的行为数据:
CREATE TABLE user_event( `name` String, # 用户名 `age` UInt8, # 用户年纪 `gender` UInt8, # 用户性别,用0、1表示男,女 `eventTime` DateTime # 行为发生时间 ) ENGINE = MergeTree PARTITION BY toYYYYMM(eventTime) ORDER BY (name, age) SETTINGS index_granularity = 8192
- Partition by: 设置分区键,不同的分区数据存储在不同目录中。它是在写入时,自动创建的,这一点不同于传统的数据库的分区概念。上面的例子是按照日期的月份进行分区。在实际的分析业务场景中,按日期来分区是个很好的实践经验,这样你能够很好的管理数据,比如删除清理历史过期的数据,对历史数据进行备份迁移。当然你也可以拿其他维度来分区,但是有一点需要记住,分区的粒度不应该太细,否则插入时,对数据进行细粒度切割会影响写入性能,并且太多的分区将不利于管理。对于日期而言,利用按月或者按周分区都是不错的选择,如果不指定分区,所有的数据都在一个分区中。
- Order by: 排序键!它影响者数据实际存储的顺序,这里我们按照用户名的字典序排序,相同用户名按照年龄来排序。这个值非常重要,甚至影响最后数据的压缩率,进一步影响着数据磁盘占用,后续我们将用例子表明。
- Primary key:主键,即主键索引。上述例子我并没有把主键写出来,如果不指定,这里隐式的表明主键就是和排序键一样。如果指定的话,就必须是排序键的一个子序列!比如(s1,s2,s3)的排序键,主键必须是(s1),(s1,s2), (s1,s2,s3)。
当然还有TTL和Sample By可以指定,分别是过期时间和采样率的指定。最后就是Settings,这里我们只用了索引粒度(index_granularity,默认就是8192),因为它比较重要!
往表里面随机插入1000万条数据之后,我们看到数据目录下的存储结构如下图:

没错!每一列单独一个文件存储,真正的列式存储!就像Clickhouse的logo一样,几根竖着的薯条,而且还蘸着番茄酱。
- .bin文件是用来真正存储数据的。
- .mrk文件记录着块(Block)真实物理位移(offset)地址。这是一个非常重要的设计,它将主键索引和实际存储地址剥离开。mrk2表示版本2的文件格式。
- Primary.idx是主键索引文件。
Clickhouse的索引是稀疏索引(Sparse Index),这一点不同于传统数据库的稠密索引,关于两者区别,可以参考这篇文章。稀疏索引有点像跳表中的Index,它间隔一段距离索引某一条记录,那这个间隔的粒度是多少呢?答案就是上述的index_granularity。也就是说每隔8192条记录,生成一条主键索引。 按照上面的例子,我们的主键索引是(name,age),它等同于排序键,那么1000万条数据,大约需要1220条索引即可。clickhouse会把这个主键索引的所有记录加载在内存中,提升查询性能,因此主键索引不宜过大。
主键索引的结构大概如下:

单个索引值的记录分别代表着一组粒度中的第一条数据的值,即每隔8192条记录的第一条记录。
通过主键索引,我们可以定位出我们需要查找的数据的mark位置,这个mark索引位置将引导我们从.mrk文件中,查询到相应的块的偏移地址。
mrk文件大概的结构:

mrk文件中有压缩的块偏移地址,也有压缩后的偏移地址。根据第一个地址,就可以从.bin实际存储数据的文件中,找出指定位置的压缩块数据,再将压缩的块数据解压之后,通过第二个偏移地址,就能定位到具体的粒度组,找到了这一组数据,Clickhouse会将这一组8192条数据都load进入引擎进行处理,也就是说8192是Clickhouse引擎处理数据的最小粒度,类比于cpu缓存处理数据的单位字(word),操作系统管理内存的页(Page)。而一个“块”默认最小为64KB,最大为1MB,分别由min_compress_block_size与max_compress_block_size参数控制。
让我们再做个总结:
.bin文件中是按照块(block)存储的,里面有许多的“块”数据。一个“块”里又有多个不同“粒度组(index_granularity控制)”的数据,因此需要两个偏移量地址,就能定位到数据。这也能很好的理解了上面说的,.mrk文件很好的将主键索引和实际存储地址剥离开。
接下来,我们以上述user_event表的真实例子,考虑如下主键查询:
select count(1) from user_event where name = 'bbb'; #结果如下:Query id: 2b75faa6-57ce-47ef-a415-a5f9ae6cad45 ┌─count()─┐ │ 1 │ └─────────┘ 1 rows in set. Elapsed: 0.003 sec. Processed 40.96 thousand rows, 573.44 KB (14.30 million rows/s., 200.23 MB/s.)
查询只用了3毫秒,处理了40960条记录。同时我们通过Explain来验证我们想法:

上面很好的解释了对于1221个粒度组,主键索引过滤出了5个”粒度组”数据,也就是说只处理了这5个粒度组数据,5*8192=40960。
索引扩展
从上面的例子我们看出了主键索引对查询的巨大帮助,接下来我们再看下仅仅用主键索引的age来过滤的情况:
select count(1) from user_event where age = 30; # 结果如下:Query id: ac522679-bfab-4a50-bebe-34da3869dab8 ┌─count()─┐ │ 153790 │ └─────────┘ 1 rows in set. Elapsed: 0.005 sec. Processed 10.00 million rows, 10.00 MB (2.00 billion rows/s., 2.00 GB/s.)
虽然只花了5毫秒,但是却是全表扫描,处理了1000万条数据,这并不是我们想看到的。那时因为主键索引对于我们的查询几乎没有帮助,每一条稀疏索引都有可能包括年龄为30岁的记录。
接下来我们做个优化,调整下主键索引顺序,用(age,name)来进行存储数据,为此我们新建一个表user_event_1来存储数据,将user_event数据原样导入。
接下来在user_event_1查询一下:
SELECT count(1)FROM user_event_1WHERE age = 30 Query id: 2bb1adc8-a35f-4e6e-a985-05f905356b65 ┌─count()─┐ │ 153790 │ └─────────┘ 1 rows in set. Elapsed: 0.002 sec. Processed 188.42 thousand rows, 188.42 KB (78.43 million rows/s., 78.43 MB/s.)
不出所料,只处理了18万条数据。两张表存储着一模一样的数据,按常理理解它们所占用的磁盘大小应该是一样的,我们看下结果:
SELECT table, name, data_compressed_bytes, data_uncompressed_bytes FROM system.columns WHERE (database = 'test') AND (name = 'name') Query id: 14264074-c7d4-4036-ac11-37cdc14a9c0e ┌─table────────┬─name─┬─data_compressed_bytes─┬─data_uncompressed_bytes─┐ │ user_event │ name │ 60157659 │ 60000000 │ │ user_event_1 │ name │ 60251396 │ 60000000 │ └──────────────┴──────┴───────────────────────┴─────────────────────────┘
对于name字段,两张表占据的大小是相差无几的,压缩的字节数就是实际存储在硬盘的大小。
我们再看下age字段:
SELECT table, name, data_compressed_bytes, data_uncompressed_bytes FROM system.columns WHERE (database = 'test') AND (name = 'age') Query id: a05aa7a4-2db7-41f9-8c4b-23061798b123 ┌─table────────┬─name─┬─data_compressed_bytes─┬─data_uncompressed_bytes─┐ │ user_event │ age │ 10043188 │ 10000000 │ │ user_event_1 │ age │ 46037 │ 10000000 │ └──────────────┴──────┴───────────────────────┴─────────────────────────┘
这个字段存储磁盘的大小就相差太多,为什么会导致压缩大小极大的差异呢?
要回答这个问题,我们就要明白基本的压缩编码算法,压缩率一般是受数据冗余度影响的,也就是说如果数据中重复的数据很多或者数据顺序性很好,那么压缩率就会非常高。
对于我们例子中的age字段来说,它的重复性是比较高的,即基数比较低,而name的重复性不高,可以说几乎每一条记录都不一样。当按照(name,age)排序时,name会导致后面的age数据在存储过程并不怎么有序,可以说age存储的非常紊乱。但是按照(age,name)排序就不一样,age按照顺序排列之后,相同年龄的name字段也是按照有序排列的,这对于压缩来说都是好消息。
上述我们解释了两者存储差别巨大的原因,因此我们实际业务中,要非常谨慎地考量主键和排序键,基数大的字段能带来良好的查询性能,用的不好却容易导致压缩率的问题。
这里我提供几个建议应对实际的业务场景:
- 对于复合排序键来说,我们倾向于将低基数的字段放在前面(比如例子里的年龄age或者性别gender),这样能避免序列紊乱问题,但是实际场景还是要具体分析!
- 如果可能话,单独指定primary key,来减少内存消耗。有些时候复合排序键后面的字段可能对查询的帮助并不是很大,特别是排序键后面的字段基数(cardinality)比较低的情况,此时去掉这些字段反而能减少内存消耗,对性能影响不大。
- 对于复合排序键中,如果有两个以上都是基数很高的情况下,可以复制一个实体表来用不同的排序顺序存储数据,或者利用物化视图指定不同的排序顺序,或者利用Projections投影。这也是官方比较推荐的方法!
总结
这篇文章对Clickhouse做了比较基础的介绍,旨在帮助大家理解一些Clickhouse的本质,但终究比较浅显,起到抛砖引玉作用。更多的主题还是需要大家阅读官方文档,相比之前,如今的官方文档已经丰满了很多,能够比较全面的了解Clickhouse。希望这篇文章能够帮助大家了解Clickhouse大致的存储结构和原理,这页是我们了解一个新数据库的核心,这些内容对于阅读官方文档和实际工作都是具有指导性的。