随着数据科技的进步,数据分析师早已不再满足于传统的T+1式报表或需要提前设置好维度与指标的OLAP查询。数据分析师更希望使用可以支持任意指标、任意维度并秒级给出反馈的大数据Ad-hoc查询系统。这对大数据技术来说是一项非常大的挑战,传统的大数据查询引擎根本无法做到这一点。由俄罗斯的Yandex公司开源的ClickHouse脱颖而出。在第一届易观OLAP大赛中,在用户行为分析转化漏斗场景里,ClickHouse比Spark快了近10倍。clickhouse之所以快,取决于二个方面
SIMD全称Single Instruction Multiple Data,单指令多数据流,能够复制多个操作数,并把它们打包在大型寄存器的一组指令集。以同步方式,在同一时间内执行同一条指令。
在这众多的表引擎中,又属合并树(MergeTree)表引擎及其家族系列(*MergeTree)最为强大,在生产环境的绝大部分场景中,都会使用此系列的表引擎。MergeTree在写入一批数据时,数据总会以数据片段的形式写入磁盘,且数据片段不可修改。为了避免片段过多,ClickHouse会通过后台线程,定期合并这些数据片段,属于相同分区的数据片段会被合成一个新的片段。这种数据片段往复合并的特点,也正是合并树名称的由来。
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster] ( name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1], name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2], ... ) ENGINE = MergeTree() ORDER BY expr [PARTITION BY expr] [PRIMARY KEY expr] [SAMPLE BY expr] [TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...] [SETTINGS name=value, ...
这里说明一下MergeTree引擎的主要参数:
- 必填选项
- ENGINE:引擎名字,MergeTree引擎无参数。
- ORDER BY:排序键,可以由一列或多列组成,决定了数据以何种方式进行排序,例如ORDER BY(CounterID, EventDate)。 如果没有显示指定PRIMARY KEY,那么将使用ORDER BY作为PRIMARY KEY。通常只指定ORDER BY即可。
- 选填选项
- PARTITION BY:分区键,指明表中的数据以何种规则进行分区。分区是在一个表中通过指定的规则划分而成的逻辑数据集。分区可以按任意标准进行,如按月、按日或按事件类型。为了减少需要操作的数据,每个分区都是分开存储的。
- PRIMARY KEY:主键,设置后会按照主键生成一级索引(primary.idx),数据会依据索引的设置进行排序,从而加速查询性能。默认情况下,PRIMARY KEY与ORDER BY设置相同,所以通常情况下直接使用ORDER BY设置来替代主键设置。
- SAMPLE BY:数据采样设置,如果显示配置了该选项,那么主键配置中也应该包括此配置。例如 ORDER BY CounterID / EventDate / intHash32(UserID)、SAMPLE BY intHash32(UserID)。
- TTL:数据存活时间,可以为某一字段列或者一整张表设置TTL,设置中必须包含Date或DateTime字段类型。如果设置在列上,那么会删除字段中过期的数据。如果设置的是表级的TTL,那么会删除表中过期的数据。如果设置了两种类型,那么按先到期的为准。例如,TTL createtime + INTERVAL 1 DAY,即一天后过期。使用场景包括定期删除数据,或者定期将数据进行归档。
- index_granularity:索引间隔粒度。MergeTree索引为稀疏索引,每index_granularity个数据产生一条索引。index_granularity默认设置为8092。
- enable_mixed_granularity_parts:是否启动index_granularity_bytes来控制索引粒度大小。
- index_granularity_bytes:索引粒度,以字节为单位,默认10Mb
- merge_max_block_size:数据块合并最大记录个数,默认8192。
- merge_with_ttl_timeout:合并频率最小时间间隔,默认1天。
MergeTree表引擎中的数据是拥有物理存储的,数据会按照分区目录的形式保存到磁盘之上,其完整的存储结构如图所示:
从图中可以看出,一张数据表的完整物理结构分为3个层级,依次是数据表目录、分区目录及各分区下具体的数据文件。接下来就逐一介绍它们的作用。
分区目录,余下各类数据文件(primary.idx、[Column].mrk、[Column]. bin等)都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到同一个分区目录,而不同分区的数据,永远不会被合并在一起。
CREATE TABLE test.test ( id UInt64, type UInt8, create_time DateTime ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(create_time) ORDER BY (id) SETTINGS index_granularity = 4; insert into test.test(id, type, create_time) VALUES (1, 1, toDateTime('2021-03-01 00:00:00'));
会出现目录名为20210301_8_8_0。
PartitionID:分区id,例如20210301。
MinBlockNum:最小分区块编号,自增类型,从1开始向上递增。每产生一个新的目录分区就向上递增一个数字。
MaxBlockNum:最大分区块编号,新创建的分区MinBlockNum等于MaxBlockNum的编号。
Level:合并的层级,被合并的次数。合并次数越多,层级值越大。
此分区的分区id为20210301,当前分区的MinBlockNum和MinBlockNum均为8,而level为0,表示此分区没有合并过。
数据分区规则由分区ID决定,分区ID由PARTITION BY分区键决定。根据分区键字段类型,ID生成规则可分为:
- 未定义分区键:没有定义PARTITION BY,默认生成一个目录名为all的数据分区,所有数据均存放在all目录下。
- 整型分区键:分区键为整型,那么直接用该整型值的字符串形式做为分区ID。
- 日期类分区键:分区键为日期类型,或者可以转化成日期类型。
- 其他类型分区键:String、Float类型等,通过128位的Hash算法取其Hash值作为分区ID。
合并目录名称的变化过程如图所示:
目录中的文件主要包括bin文件、mrk文件、primary.idx文件以及其他相关文件
- bin文件:数据文件,存储的是某一列的数据。数据表中的每一列都对应一个与其字段名相同的bin文件,例如id.bin存储的是表test中id列的数据。
- mrk文件:标记文件,每一列都对应一个与其字段名相同的标记文件,标记文件在idx索引文件和bin数据文件之间起到了桥梁作用。以mrk2结尾的文件,表示该表启用了自适应索引间隔。
- primary.idx文件:主键索引文件,用于加快查询效率。
- count.txt:数据分区中数据总记录数。上述20210301_8_8_0的数据分区中,该文件中的记录总数为1。
- columns.txt:表中所有列数的信息,包括字段名和字段类型。
- partion.dat:用于保存分区表达式的值。上述20210301_8_8_0的数据分区中该文件中的值为20210301。
- minmax_create_time.idx:分区键的最大最小值。
- checksums.txt:校验文件,用于校验各个文件的正确性。存放各个文件的size以及hash值。
假设根据一索引查找一行数据id在id.bin的移偏量p,如何确定此行其它列c1的数据?
- 可以根据columns获取id列的大小,用现在p去计算出当前行数n。
- 可以根据columns获取c1列的大小,再结果行数n,算出当前行对应c1.bin的偏移量,即可获取数据
MergeTree的主键使用PRIMARY KEY定义,待主键定义之后,MergeTree会依据index_granularity间隔(默认8192行),为数据表生成一级索引并保存至primary.idx文件内,索引数据按照PRIMARY KEY排序。主键不指定默认为ORDER BY。
ps: ORDER BY = PRIMARY KEY + 其它字段。 当你有些字段仅用于排序,不想用于搜索。来分别设置不同此,以节省空间
如果使用多个主键,例如ORDER BY (CounterID, EventDate),则每间隔8192行可以同时取CounterID与EventDate两列的值作为索引值,具体如图所示。
如果把MergeTree比作一本书,primary.idx一级索引好比这本书的一级章节目录,.bin文件中的数据好比这本书中的文字,那么数据标记(.mrk)会为一级章节目录和具体的文字之间建立关联。
即各个数据文件xxx.bin,都会根据index_granularity(默认8192行)去生成一个mark,分别记录在xxx.mrk里面。这样子在primary中如果确定了mark,就可以分别在xxx.mrk获取不同列在xxx.bin的偏移量
除了一级索引之外,MergeTree同样支持二级索引。二级索引又称跳数索引,由数据的聚合信息构建而成。根据索引类型的不同,其聚合信息的内容也不同。跳数索引的目的与一级索引一样,也是帮助查询时减少数据扫描的范围。
如果在建表语句中声明了跳数索引,则会额外生成相应的索引与标记文件(skp_idx_[Column].idx与skp_idx_[Column].mrk). 并理解index_granularity和index_granularity两参数,其规则大致是如下
- 首先,按照index_granularity粒度间隔将数据划分成n段,总共有[0 ,n-1]个区间(n = total_rows / index_granularity,向上取整)。
- 接着,根据索引定义时声明的表达式,从0区间开始,依次按index_granularity粒度从数据中获取聚合信息,每次向前移动1步(n+1),聚合信息逐步累加。最后,当移动granularity次区间时,则汇总并生成一行跳数索引数据。
以minmax索引为例,它的聚合信息是在一个index_granularity区间内数据的最小和最大极值。以下图为例,假设index_granularity=8192且granularity=3,则数据会按照index_granularity划分为n等份,MergeTree从第0段分区开始,依次获取聚合信息。当获取到第3个分区时(granularity=3),则汇总并会生成第一行minmax索引(前3段minmax极值汇总后取值为[1 , 9]),如图所示。
数据查询的本质,可以看作一个不断减小数据范围的过程。在最理想的情况下,MergeTree首先可以依次借助分区索引、一级索引和二级索引,将数据扫描范围缩至最小。然后再借助数据标记,将需要解压与计算的数据范围缩至最小。
如果一条查询语句没有指定任何WHERE条件,或是指定了WHERE条件,但条件没有匹配到任何索引(分区索引、一级索引和二级索引),那么MergeTree就不能预先减小数据范围。在后续进行数据查询时,它会扫描所有分区目录,以及目录内索引段的最大区间。虽然不能减少数据范围,但是MergeTree仍然能够借助数据标记,以多线程的形式同时读取多个压缩数据块,以提升性能。
下图所示是一张MergeTree表在写入数据时,它的分区目录、索引、标记和压缩数据的生成过程。
MergeTree(合并树)系列表引擎是ClickHouse核心的存储引擎。其及其扩展提供各种功能
- ReplacingMergeTree:在后台数据合并期间,对具有相同排序键的数据进行去重操作。
- SummingMergeTree:当合并数据时,会把具有相同主键的记录合并为一条记录。根据聚合字段设置,该字段的值为聚合后的汇总值,非聚合字段使用第一条记录的值,聚合字段类型必须为数值类型
- AggregatingMergeTree:在同一数据分区下,可以将具有相同主键的数据进行聚合。
- CollapsingMergeTree:在同一数据分区下,对具有相同主键的数据进行折叠合并。
- VersionedCollapsingMergeTree:基于CollapsingMergeTree引擎,增添了数据版本信息字段配置选项。在数据依据ORDER BY设置对数据进行排序的基础上,如果数据的版本信息列不在排序字段中,那么版本信息会被隐式的作为ORDER BY的最后一列从而影响数据排序。
- GraphiteMergeTree:用来存储时序数据库Graphites的数据。
ClickHouse并不像其他分布式系统那样,拥有高度自动化的分片功能;它提供了本地表与分布式表的概念;一张本地表等同于一个数据分片。而分布式表是张逻辑表,本身不存储任何数据,它是本地表的访问代理,其作用类似分库中间件。借助分布式表,能够代理访问多个数据分片,从而实现分布式查询。当然,也可以在应用层实现数据分发。
ps: ClickHouse的集群是表维度的。
ClickHouse的数据副本一般通过ReplicatedMergeTree复制表系列引擎实现,副本之间借助ZooKeeper实现数据的一致性。
CREATE TABLE repl_test123 ( `id` String, `price` Float64, `create_time` DateTime ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/repl_test123','{replica}') PARTITION BY toYYYYMM(create_time) ORDER BY id
分片是指将数据拆分,将其分散在不同的实例上的过程。每个分片只负责总数据的一部分,通过一个名为Distributed的引擎进行操作。
CREATE TABLE test_clsuter_all on cluster test_cluster_0_repl ( `id` String, `price` Float64, `create_time` DateTime ) ENGINE = Distributed('test_cluster_0_repl','default','test_clsuter_a', rand())
Distributed引擎表,其自身不存储数据,而是作为数据分片的代理,自动路由数据到集群中的各个节点,其中<分片键>参数要求返回整型类型的取值,即按照分片键的规则将数据分布到各个节点,如:
以四节点实现多分片和双副本为例:
在每个节点创建一个数据表,作为一个数据分片,使用ReplicatedMergeTree表引擎实现数据副本,而分布表作为数据写入和查询的入口。这是最常见的集群实现方式。如果资源紧缺,也可采用
数据库中的 视图(View) 指的是通过一张或多张表查询出来的 逻辑表 ,本身只是一段 SQL 的封装并 不存储数据。
而 物化视图(Materialized View) 与普通视图不同的地方在于它是一个查询结果的数据库对象(持久化存储),非常趋近于表;
ClickHouse中的物化视图可以挂接在任意引擎的基础表上,而且会自动更新数据,它可以借助 MergeTree 家族引擎(SummingMergeTree、Aggregatingmergetree等),得到一个实时的预聚合,满足快速查询;但是对 更新 与 删除 操作支持并不好,更像是个插入触发器。
CREATE [MATERIALIZED] VIEW [IF NOT EXISTS] [db.]table_name [TO[db.]name] [ENGINE = engine] [POPULATE] AS SELECT ...
假设有一个日志表 login_user_log 来记录每次登录的用户信息,现在需要按用户所属地为维度来统计每天的登录次数。正常的聚合SQL如下:city为用户所属地,login_date为登录时间
select city, login_date, count(1) login_cnt from login_user_log group by city, login_date
改成物化视图
CREATE TABLE login_user_log_base ( city String, login_date Date, login_cnt UInt32 ) ENGINE = SummingMergeTree() ORDER BY (city, login_date) CREATE MATERIALIZED VIEW if not exists login_user_log_mv TO login_user_log_base AS SELECT city, login_date, count(1) login_cnt from login_user_log group by city, login_date
使用 TO 关键字关联 物化视图 与 基础表,需要自己初始化历史数据。
《官方文档》
《clickhouse》
《ClickHouse入门实践–MergeTree原理解析》
《常见ClickHouse集群部署架构》