clickhouse-数据库引擎

发布于:2024-12-19 ⋅ 阅读:(12) ⋅ 点赞:(0)

1、数据库引擎和表引擎

数据库引擎默认是Ordinary,在这种数据库下面的表可以是任意类型引擎。
生产环境中常用的表引擎是MergeTree系列,也是官方主推的引擎。
MergeTree是基础引擎,有主键索引、数据分区、数据副本、数据采样、删除和修改等功能,
ReplacingMergeTree有了去重功能,
SummingMergeTree有了汇总求和功能,
AggregatingMergeTree有聚合功能,
CollapsingMergeTree有折叠删除功能,
VersionedCollapsingMergeTree有版本折叠功能,
GraphiteMergeTree有压缩汇总功能。
在这些的基础上还可以叠加Replicated和Distributed。Integration系列用于集成外部的数据源,常用的有HADOOP,MySQL。

2、MaterializeMySQL

2.1、基本概述

MySQL 的用户群体很大,为了能够增强数据的实时性,很多解决方案会利用 binlog 将数据写入到 ClickHouse。为了能够监听 binlog 事件,我们需要用到类似 canal 这样的第三方中间件,这无疑增加了系统的复杂度。

ClickHouse 20.8.2.3 版本新增加了 MaterializeMySQL 的 database 引擎,该 database 能映 射 到 MySQL 中 的 某 个 database , 并 自 动 在 ClickHouse 中 创 建 对 应 的ReplacingMergeTree。ClickHouse 服务做为 MySQL 副本,读取 Binlog 并执行 DDL 和 DML 请求,实现了基于 MySQL Binlog 机制的业务数据库实时同步功能。

2.2、特点

MaterializeMySQL 同时支持全量和增量同步,在 database 创建之初会全量同步MySQL 中的表和数据,之后则会通过 binlog 进行增量同步;

MaterializeMySQL database 为其所创建的每张 ReplacingMergeTree 自动增加了_sign 和 _version 字段。

其中,_version 用作 ReplacingMergeTree 的 ver 版本参数,每当监听到 insert、update 和 delete 事件时,在 databse 内全局自增。而 _sign 则用于标记是否被删除,取值 1 或者 -1。

目前 MaterializeMySQL 支持如下几种 binlog 事件:

MYSQL_WRITE_ROWS_EVENT:_sign = 1,_version ++;
MYSQL_DELETE_ROWS_EVENT:_sign = -1,_version ++;
MYSQL_UPDATE_ROWS_EVENT:新数据 _sign = 1;
MYSQL_QUERY_EVENT: 支持 CREATE TABLE 、DROP TABLE 、RENAME TABLE 等。

2.3、使用细则

DDL 查询

MySQL DDL 查询被转换成相应的 ClickHouse DDL 查询(ALTER, CREATE, DROP, RENAME)。如果 ClickHouse 不能解析某些 DDL 查询,该查询将被忽略。

数据复制

MaterializeMySQL 不支持直接插入、删除和更新查询,而是将 DDL 语句进行相应转换:

MySQL INSERT 查询被转换为 INSERT with _sign=1;
MySQL DELETE 查询被转换为 INSERT with _sign=-1;
MySQL UPDATE 查询被转换成 INSERT with _sign=1 和 INSERT with _sign=-1。

SELECT 查询

如果在 SELECT 查询中没有指定_version,则使用 FINAL 修饰符,返回_version 的最大值对应的数据,即最新版本的数据;
如果在 SELECT 查询中没有指定_sign,则默认使用 WHERE _sign=1,即返回未删除状态(_sign=1)的数据。

索引转换

ClickHouse 数据库表会自动将 MySQL 主键和索引子句转换为ORDER BY 元组;
ClickHouse 只有一个物理顺序,由 ORDER BY 子句决定。如果需要创建新的物理顺序,请使用物化视图。

2.4、案例实操

MySQL 开启 binlog 和 GTID 模式,确保 MySQL 开启了 binlog 功能,且格式为 ROW 打开/etc/my.cnf,在[mysqld]下添加:

server-id=1
log-bin=mysql-bin
binlog_format=ROW

开启 GTID 模式,如果如果 clickhouse 使用的是 20.8 prestable 之后发布的版本,那么 MySQL 还需要配置开启 GTID 模式, 这种方式在 mysql 主从模式下可以确保数据同步的一致性(主从切换时)。

gtid-mode=on
enforce-gtid-consistency=1 # 设置为主从强一致性
log-slave-updates=1 # 记录日志

GTID 是 MySQL 复制增强版,从 MySQL 5.6 版本开始支持,目前已经是 MySQL 主流复制模式。它为每个 event 分配一个全局唯一 ID 和序号,我们可以不用关心 MySQL 集群主从拓扑结构,直接告知 MySQL 这个 GTID 即可。

重启 MySQL:

sudo systemctl restart mysqld

准备 MySQL 表和数据

CREATE DATABASE testck;
CREATE TABLE `testck`.`t_organization` (
	`id` int(11) NOT NULL AUTO_INCREMENT,
	`code` int NOT NULL,
	`name` text DEFAULT NULL,
	`updatetime` datetime DEFAULT NULL,
	PRIMARY KEY (`id`),
	UNIQUE KEY (`code`)
) ENGINE=InnoDB;

INSERT INTO testck.t_organization (code,name,updatetime) VALUES(1000,'Realinsight',NOW());
INSERT INTO testck.t_organization (code,name,updatetime) VALUES(1001, 'Realindex',NOW());
INSERT INTO testck.t_organization (code,name,updatetime) VALUES(1002,'EDT',NOW());

CREATE TABLE `testck`.`t_user` (
	`id` int(11) NOT NULL AUTO_INCREMENT,
	`code` int,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB;

INSERT INTO testck.t_user (code) VALUES(1);

开启 ClickHouse 物化引擎

set allow_experimental_database_materialize_mysql=1;

创建复制管道

ClickHouse 中创建MaterializeMySQL 数据库

CREATE DATABASE test_binlog ENGINE = MaterializeMySQL('hadoop1:3306','testck','root','000000');
其中 4 个参数分别是 MySQL 地址、databse、username 和 password。

查看 ClickHouse 的数据:

use test_binlog;
show tables;
select * from t_organization;
select * from t_user;

修改数据,在 MySQL 中修改数据:

update t_organization set name = CONCAT(name,'-v1') where id = 1

查看 clickhouse 日志可以看到 binlog 监听事件,查询clickhouse:

select * from t_organization;

删除数据,MySQL 删除数据:

DELETE FROM t_organization where id = 2;

ClicKHouse,日志有 DeleteRows 的 binlog 监听事件,查看数据:

select * from t_organization;

在刚才的查询中增加 _sign 和 _version 虚拟字段:select *,_sign,_version from t_organization order by _sign desc,_version desc;

在查询时,对于已经被删除的数据,_sign=-1,ClickHouse 会自动重写 SQL,将 _sign =-1 的数据过滤掉;

对于修改的数据,则自动重写 SQL,为其增加 FINAL 修饰符。

select * from t_organization
等同于
select * from t_organization final where _sign = 1

删除表

在 mysql 执行删除表:drop table t_user; 此时在 clickhouse 处会同步删除对应表,如果查询会报错:

show tables;
select * from t_user;
DB::Exception: Table scene_mms.scene doesn't exist.. 

mysql 新建表,clickhouse 可以查询到:

CREATE TABLE `testck`.`t_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`code` int,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

INSERT INTO testck.t_user (code) VALUES(1);

#ClickHouse 查询
show tables;
select * from t_user;

3、表引擎

表引擎
表引擎的使用
表引擎决定了如何存储表的数据。包括:

数据的存储方式和位置,写到哪里以及从哪里读取数据;
支持哪些查询以及如何支持;
并发数据访问;
索引的使用(如果存在);
是否可以执行多线程请求;
数据复制参数;
表引擎的使用方式就是必须显式在创建表时定义该表使用的引擎,以及引擎使用的相关参数。

引擎的名称大小写敏感。

3.1、TinyLog

以列文件的形式保存在磁盘上,不支持索引,没有并发控制。一般保存少量数据的小表,生产环境上作用有限。可以用于平时练习测试用。

3.2、Memory

内存引擎:数据以未压缩的原始形式直接保存在内存当中,服务器重启数据就会消失。读写操作不会相互阻塞,不支持索引。简单查询下有非常非常高的性能表现(超过10G/s)。一般用到它的地方不多,除了用来测试,就是在需要非常高的性能,同时数据量又不太大(上限大概1亿行)的场景

3.3、MergeTree

ClickHouse中最强大的表引擎当属MergeTree(合并树)引擎及该系列(MergeTree)中的其他引擎,支持索引和分区,地位可以相当于innodb之于Mysql。

基本sql,建表语句:

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],
    ...
    INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
    INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) 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, ...]

PARTITION BY:分区键,用于指定数据以何种标准进行分区。分区键可以是单个列字段、元组形式的多个列字段、列表达式。如果不声明分区键,则ClickHouse会生成一个名为all的分区。合理使用数据分区,可以有效减少查询时数据文件的扫描范围。
ORDER BY 决定了每个分区中数据的排序规则;主键必须是order by字段的前缀字段;在ReplactingmergeTree中,order by相同的被认为是重复的数据;在SummingMergeTree中作为聚合的维度列;

PRIMARY KEY 决定了一级索引(primary.idx),默认情况下,主键与排序键(ORDER BY)相同,所以通常使用ORDER BY代为指定主键。一般情况下,在单个数据片段内,数据与一级索引以相同的规则升序排序。与其他数据库不同,MergeTree主键允许存在重复数据;
SAMPLE BY:抽样表达式,用于声明数据以何种标准进行采样。抽样表达式需要配合SAMPLE子查询使用;
SETTINGS:index_granularity:索引粒度,默认值8192。也就是说,默认情况下每隔8192行数据才生成一条索引;
SETTINGS:index_granularity_bytes:在19.11版本之前,ClickHouse只支持固定大小的索引间隔(index_granularity)。在新版本中增加了自适应间隔大小的特性,即根据每一批次写入数据的体量大小,动态划分间隔大小。而数据的体量大小,由index_granularity_bytes参数控制,默认10M;
SETTINGS:enable_mixed_granularity_parts:设置是否开启自适应索引间隔的功能,默认开启。

例子:

create table t_order_mt(
    id UInt32,
    sku_id String,
    total_amount Decimal(16,2),
    create_time Datetime
) engine = MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id,sku_id);

插入数据:

insert into t_order_mt values
(101,'sku_001',1000.00,'2020-06-01 12:00:00') ,
(102,'sku_002',2000.00,'2020-06-01 11:00:00'),
(102,'sku_004',2500.00,'2020-06-01 12:00:00'),
(102,'sku_002',2000.00,'2020-06-01 13:00:00'),
(102,'sku_002',12000.00,'2020-06-01 13:00:00'),
(102,'sku_002',600.00,'2020-06-02 12:00:00');

查询数据:

1)根据日期分区,2020-06-01、2020-06-02共两个分区;

2)主键可重复;

3)分区内根据id和sku_id排序。

分区(可选)

分区的目的主要是降低扫描的范围,优化查询速度,如果不填,只会使用一个分区。分区后,面对涉及跨分区的查询统计,ClickHouse会以分区为单位并行处理。

文件结构

在前面安装时,就介绍过,配置文件中表明了默认的数据存储位置是/var/lib/clickhouse,因此结构如下:

里面有两个文件夹很重要:metadatadata。metadata保存了数据库的元数据,每个库的每个表都会记录表结构信息:

ATTACH TABLE _ UUID 'c51df0c7-bae7-4abb-b8a8-d2a5523cdb26'
(
    `id` UInt32,
    `sku_id` String,
    `total_amount` Decimal(16, 2),
    `create_time` DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(create_time)
PRIMARY KEY id
ORDER BY (id, sku_id)
SETTINGS index_granularity = 8192

基本上和建表语句差不多。

data保存了每个库的表数据:

20200601_1_1_0、20200602_2_2_0共两个分区目录。

分区目录命名格式:PartitionId_MinBlockNum_MaxBlockNum_Level,分表代表分区值最小分区块编号、最大分区块编号、合并层级。而每一个分区目录下都包含如下文件:

-rw-r-----. 1 clickhouse root 259 8月  29 03:02 checksums.txt
-rw-r-----. 1 clickhouse root 118 8月  29 03:02 columns.txt
-rw-r-----. 1 clickhouse root   1 8月  29 03:02 count.txt
-rw-r-----. 1 clickhouse root 189 8月  29 03:02 data.bin
-rw-r-----. 1 clickhouse root 144 8月  29 03:02 data.mrk3
-rw-r-----. 1 clickhouse root  10 8月  29 03:02 default_compression_codec.txt
-rw-r-----. 1 clickhouse root   8 8月  29 03:02 minmax_create_time.idx
-rw-r-----. 1 clickhouse root   4 8月  29 03:02 partition.dat
-rw-r-----. 1 clickhouse root   8 8月  29 03:02 primary.idx

data.bin:数据文件;(其实每一个列都会有一个bin文件)
data.mrk3:标记文件,标记文件在idx索引文件和bin数据文件之间起到了桥梁作用;(每一个列都会有一个mrk文件)
count.txt:有几条数据;
default_compression_codec.txt:默认压缩格式;
columns.txt:列的信息;
primary.idx:主键索引文件;
partition.dat与minmax_[Column].idx:如果使用了分区键,则会额外生成这2个文件,均使用二进制存储。partition.dat保存当前分区下分区表达式最终生成的值;minmax索引用于记录当前分区下分区字段对应原始数据的最小值和最大值。以t_order_mt的20200601分区为例,partition.dat中的值为20200601,minmax索引中保存的值为2020-06-01 12:00:002020-06-01 13:00:00

分区命名

PartitionId:数据分区规则由分区ID决定,分区ID由partition by分区键决定。根据分区键字段类型,ID生成规则可分为:

未定义分区键:没有定义partition by,默认生成一个目录名为all的数据分区,所有数据均存放在all目录下;
整型分区键:分区键为整型,直接用该整型值的字符串形式作为分区ID;
日期类分区键:分区键为日期类型,或者可以转换为日期类型;
其他类型分区键:String、Float类型等,通过128位的Hash算法取其Hash值作为分区ID。

MinBlockNum:最小分区块编号,自增类型,从1开始向上递增。每产生一个新的目录分区就向上递增一个数字;

MaxBlockNum:最大分区块编号,新创建的分区MinBlockNum等于MaxBlockNum的编号;

Level:合并的层级,被合并的次数。合并次数越多,层级值越大。对于每一个新创建的分区目录而言,其初始值均为0。以分区为单位,如果相同分区发生合并动作,则在相应分区内计数+1。

分区合并
任何一个批次的数据写入都会产生一个临时分区,也就是写入一个新的分区目录,不会纳入任何一个已有的分区。写入后的某个时刻(大概10-15分钟后),ClickHouse会自动执行合并操作(等不及也可以手动通过optimize执行),把临时分区的数据,合并到已有分区中,已经存在的旧分区目录并不会立即被删除,而是在之后的某个时刻通过后台任务被删除(默认8分钟)。

MinBlockNum:取同一分区内所有目录中最小的MinBlockNum值
MaxBlockNum:取同一分区内所有目录中最大的MaxBlockNum值
Level:取同一分区内最大Level值+1

合并过程:

手动合并:

optimize table xxxx final;

在如上的基础上,在插入数据:

insert into t_order_mt values
(101,'sku_001',1000.00,'2020-06-01 12:00:00') ,
(102,'sku_002',2000.00,'2020-06-01 11:00:00'),
(102,'sku_004',2500.00,'2020-06-01 12:00:00'),
(102,'sku_002',2000.00,'2020-06-01 13:00:00'),
(102,'sku_002',12000.00,'2020-06-01 13:00:00'),
(102,'sku_002',600.00,'2020-06-02 12:00:00');

查看数据并没有纳入任何分区,而是新产生了一个分区:

在看数据文件:

也多加了两个分区文件。手动optimize之后:

optimize table t_order_mt final;

再查询:

可以看到一个PartitionId的数据就已经合并成功了。而也会新产生两个文件代表新的分区数据。

主键(可选)
ClickHouse中的主键,和其他数据库不太一样,**它只提供了数据的一级索引,但是却不是唯一约束。**这就意味着是可以存在相同primary key的数据。

主键的设定主要依据是查询语句中的where条件。根据条件通过对主键进行某种形式的二分查找,能够定位到对应的index granularity,避免了全表扫描。

index granularity:直接翻译的话就是索引粒度,指在稀疏索引中两个相邻索引对应数据的间隔。ClickHouse中的MergeTree默认是8192。官方不建议修改这个值,除非该列存在大量重复值,比如在一个分区中几万行才有一个不同数据。

稀疏索引的好处就是可以用很少的索引数据,定位更多的数据,代价就是只能定位到索引粒度的第一行,然后再进行进行一点扫描。

order by(必须)
order by设定了分区内的数据按照哪些字段顺序进行有序保存。order by是MergeTree中唯一一个必填项,甚至比primary key还重要,因为当用户不设置主键的情况,很多处理会依照order by的字段进行处理。

要求:主键必须是order by字段的前缀字段。

比如order by字段是(id,sku_id),那么主键必须是id或者(id,sku_id)。

一级索引

MergeTree的主键使用PRIMARY KEY定义,待主键定义之后,MergeTree会依据index_granularity间隔(默认8192行),为数据表生成一级索引并保存至primary.idx文件内,索引数据按照PRIMARY KEY排序。相比使用PRIMARY KEY定义,更为常见的是通过ORDER BY指代主键。在此种情形下,PRIMARY KEY与ORDER BY定义相同,所以索引(primary.idx)和数据(.bin)会按照完全相同的规则排序。

稀疏索引
primary.idx文件内的一级索引采用稀疏索引实现

稀疏索引与稠密索引的区别:
 

在稠密索引中每一行索引标记都会对应到一行具体的数据记录;而在稀疏索引中,每一行索引标记对应的是一段数据,而不是一行。由于稀疏索引占用空间小,所以primary.idx内的索引数据常驻内存。

索引粒度

index_granularity表示索引的粒度,默认8192

数据以index_granularity的粒度被标记成多个小的区间,其中每个区别最多index_granularity行数据。MergeTree使用MarkRange表示一个具体的区间,并通过start和end表示其具体的范围

索引数据的生成规则
由于是稀疏索引,所以MergeTree需要间隔index_granularity行数据才会生成一条索引记录,其索引值会依据声明的主键字段获取

使用CounterID作为主键

该表使用年月分区(PARTITION BY toYYYYMM(EventDate)),所以2014年3月份的数据最终会被划分到同一个分区目录内。如果使用CounterID作为主键(ORDER BY CounterID),则每间隔8192行数据就会取一次CounterID的值作为索引值,索引数据最终会被写入primary.idx文件进行保存

例如第0(8192 ∗ 0)行CounterID取值57,第8192(8192 ∗ 1)行CounterID取值1635,而第16384(8192 ∗ 2)行CounterID取值3266,最终索引数据将会是5716353266

使用CounterID和EventDate作为主键

如果使用多个主键,例如ORDER BY(CounterID,EventDate),则每间隔8192行可以同时取CounterID与EventDate两列的值作为索引值,如上图所示。

索引的查询过程

生成查询条件区间,首先,将查询条件转换为条件区间,例如下面的例子:

WHERE ID = 'A003'
['A003', 'A003']

WHERE ID > 'A000'
('A000', +inf)

WHERE ID < 'A188'
(-inf, 'A188')

WHERE ID LIKE 'A006%'
['A006', 'A007')

递归交集判断

以递归的形式,依次对MarkRange的数值区间与条件区间做交集判断。从最大区间[A000, +inf)开始:如果不存在交集,则直接通过剪枝算法优化此整段MarkRange;
如果存在交集,且MarkRange步长大于8(end-start),则将此区间进一步拆分成8个子区间(merge_tree_coarse_index_granularity指定,默认值为8),并重复此规则,继续做递归交集判断;如果存在交集,且MarkRange不可再分解(步长小于8),则记录MarkRange并返回。合并MarkRange区间,将最终匹配的MarkRange聚在一起,合并它们的范围:

MergeTree通过递归的形式持续向下拆分区间,最终将MarkRange定位到最细的粒度,以帮助在后续读取数据的时候,能够最小化扫描数据的范围。以上图为例,当查询条件WHERE ID='A003’的时候,最终只需要读取[A000, A003]和[A003, A006]两个区间的数据,它们对应MarkRange(start:0,end:2)范围,而其他无用的区间都被裁剪掉了。因为MarkRange转换的数值区间是闭区间,所以会额外匹配到临近的一个区间。
 

二级索引

目前在ClickHouse的官网上二级索引的功能在v20.1.2.4之前是被标注为实验性的,在这个版本之后默认是开启的

**老版本使用二级索引前需要增加设置:**是否允许使用实验性的二级索引(v20.1.2.4开始,这个参数已被删除,默认开启)

set allow_experimental_data_skipping_indices=1;

如果在建表语句中声明了跳数索引,则会额外生成相应的索引与标记文件(skp_idx_[Column].idx与skp_idx_[Column].mrk)。

创建测试表:

create table t_order_mt2(
id UInt32,
sku_id String,
total_amount Decimal(16,2),
create_time Datetime,
INDEX a total_amount TYPE minmax GRANULARITY 5
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);

INDEX a total_amount TYPE minmax GRANULARITY 5:

a表示索引名称;

total_amount表示索引字段;
TYPE表示索引类型:
minmax 存储指定一段数据内的最小和最大极值(如果表达式是 tuple ,则存储 tuple 中每个元素的极值),这些信息用于跳过数据块,类似主键。
set(max_rows) 存储指定表达式的不重复值(不超过 max_rows 个,max_rows=0 则表示『无限制』)。这些信息可用于检查数据块是否满足 WHERE 条件。
ngrambf_v1(n, size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed) 存储一个包含数据块中所有 n元短语(ngram) 的 布隆过滤器。只可用在字符串上。 可用于优化 equals , like 和 in 表达式的性能。
n – 短语长度。
size_of_bloom_filter_in_bytes – 布隆过滤器大小,字节为单位。(因为压缩得好,可以指定比较大的值,如 256 或 512)。
number_of_hash_functions – 布隆过滤器中使用的哈希函数的个数。
random_seed – 哈希函数的随机种子。
tokenbf_v1(size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed) 跟 ngrambf_v1 类似,但是存储的是token而不是ngrams。Token是由非字母数字的符号分割的序列。
bloom_filter(bloom_filter([false_positive]) – 为指定的列存储布隆过滤器
GRANULARITY 5 是设定二级索引对于一级索引粒度的粒度。
minmax索引的聚合信息是在一个index_granularity区间内数据的最小和最大值。以下图为例,假设index_granularity=8192且granularity=3,则数据会按照index_granularity划分为n等份,MergeTree从第0段分区开始,依次获取聚合信息。当获取到第3个分区时(granularity=3),则汇总并会生成第一行minmax索引(前3段minmax汇总后取值为[1, 9]):

minmax索引的聚合信息是在一个index_granularity区间内数据的最小和最大值。以下图为例,假设index_granularity=8192且granularity=3,则数据会按照index_granularity划分为n等份,MergeTree从第0段分区开始,依次获取聚合信息。当获取到第3个分区时(granularity=3),则汇总并会生成第一行minmax索引(前3段minmax汇总后取值为[1, 9]):
 

插入数据:

insert into t_order_mt2 values
(101,'sku_001',1000.00,'2020-06-01 12:00:00') ,
(102,'sku_002',2000.00,'2020-06-01 11:00:00'),
(102,'sku_004',2500.00,'2020-06-01 12:00:00'),
(102,'sku_002',2000.00,'2020-06-01 13:00:00'),
(102,'sku_002',12000.00,'2020-06-01 13:00:00'),
(102,'sku_002',600.00,'2020-06-02 12:00:00');

对比效果:

clickhouse-client --send_logs_level=trace <<< 
'select * from t_order_mt2 where total_amount > toDecimal32(900., 2)';

日志中可以看到二级索引能够为非主键字段的查询发挥作用,分区下文件skp_idx_a.idxskp_idx_a.mrk3为跳数索引文件:

checksums.txt  count.txt  data.mrk3      minmax_create_time.idx  primary.idx    skp_idx_a.mrk3
columns.txt    data.bin   default_compression_codec.txt  partition.dat           skp_idx_a.idx

数据TTL

MergeTree提供了可以管理数据表或者列的生命周期的功能。

列级TTL

当列中的值过期时, ClickHouse会将它们替换成该列数据类型的默认值。如果数据片段中列的所有值均已过期,则ClickHouse 会从文件系统中的数据片段中删除此列。TTL的列必须是日期类型且不能为主键。

create table t_order_mt3(
  id UInt32,
  sku_id String,
  total_amount Decimal(16,2) TTL create_time+interval 10 SECOND,
  create_time Datetime 
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);

插入数据(请根据实际时间修改数据,注意是linux的时间):

insert into t_order_mt3 values
(106,'sku_001',1000.00,'2022-08-29 04:14:20'),
(107,'sku_002',2000.00,'2022-08-29 04:14:20'),
(110,'sku_003',600.00,'2022-08-29 04:14:20');

手动合并,查看效果:到期后,指定的字段数据归0:

optimize table t_order_mt3 final;
select * from t_order_mt3;

表级TTL

表可以设置一个用于移除过期行的表达式,以及多个用于在磁盘或卷上自动转移数据片段的表达式。当表中的行过期时,ClickHouse 会删除所有对应的行。对于数据片段的转移特性,必须所有的行都满足转移条件。

下面的这条语句是数据会在create_time之后10秒丢失:

alter table t_order_mt3 MODIFY TTL create_time + INTERVAL 10 SECOND;
[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]

TTL 规则的类型紧跟在每个 TTL 表达式后面,它会影响满足表达式时(到达指定时间时)应当执行的操作:

DELETE - 删除过期的行(默认操作);
TO DISK 'aaa' - 将数据片段移动到磁盘 aaa;
TO VOLUME 'bbb' - 将数据片段移动到卷 bbb.
GROUP BY - 聚合过期的行
涉及判断的字段必须是Date或者Datetime类型,推荐使用分区的日期字段

能够使用的时间周期:

SECOND
MINUTE
HOUR
DAY
WEEK
MONTH
QUARTER
YEAR

数据存储,各列独立存储
在MergeTree中,数据按列存储。而具体到每个列字段,数据也是独立存储的,每个列字段都拥有一个与之对应的.bin数据文件。数据文件以分区目录的形式被组织存放,所以在.bin文件中只会保存当前分区片段内的这一部分数据。按列独立存储的设计优势显而易见:

可以更好地进行数据压缩(相同类型的数据放在一起,对压缩更加友好);

能够最小化数据扫描的范围。

数据是经过压缩的,目前支持LZ4、ZSTD、Multiple和Delta几种算法,默认使用LZ4算法;其次,数据会事先依照ORDER BY的声明排序;最后,数据是以压缩数据块的形式被组织并写入.bin文件中的

压缩数据块
一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小,如下图所示:

MergeTree在数据具体的写入过程中,会按照索引粒度(默认情况下,每次取8192行),按批次获取数据并进行处理。如果把一批数据的未压缩大小设为size,则整个写入过程遵循以下规则:

单个批次数据size < 64KB:如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到size >= 64KB时,生成下一个压缩数据块;
单个批次数据64KB <= size <= 1MB:如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块;
单个批次数据size > 1MB:如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个压缩数据块。剩余数据继续依照上述规则执行。此时,会出现一个批次数据生成多个压缩数据块的情况。

一个.bin文件是由1至多个压缩数据块组成的,每个压缩块大小在64KB~1MB之间。多个压缩数据块之间,按照写入顺序首尾相接,紧密地排列在一起。

数据标记

数据标记的生成规则

从上图中可以发现,数据标记和索引区间是对齐的,均按照index_granularity的粒度间隔。

数据标记文件与.bin文件一一对应,每一个列字段[Column].bin文件都有一个与之对应的[Column].mrk数据标记文件,用于记录数据在.bin文件中的偏移量信息

一行标记数据使用一个元组表示,元组内包含两个整型数值的偏移量信息。它们分别表示此段数据区间内,在对应的.bin压缩文件中,压缩数据块的起始偏移量;以及将该数据压缩块解压后,其未压缩数据的起始偏移量:

每一行标记数据都表示了一个片段的数据(默认8192行)在.bin压缩文件中的读取位置信息。标记数据与一级索引数据不同,它并不能常驻内存,而是使用LRU(最近最少使用)缓存策略加快其取用速度。

数据标记的工作方式

下图为为hits_v1测试表的JavaEnable字段及其标记数据与压缩数据的对应关系:

JavaEnable字段的数据类型为UInt8,所以每行数值占用1字节。而hits_v1数据表的index_granularity粒度为8192,所以一个索引片段的数据大小恰好是8192B。按照压缩数据块的生成规则,如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到size >= 64KB时,生成下一个压缩数据块。因此在JavaEnable的标记文件中,每8行标记数据对应1个压缩数据块(1B*8192=8192B,64KB=65536B,65536/8192=8)。所以,从上图能够看到,其左侧的标记数据中,8行数据的压缩文件偏移量都是相同的,因为这8行标记都指向了同一个压缩数据块。而在这8行的标记数据中,它们的解压缩数据块中的偏移量,则依次按照8192B(每行数据1B,每一个批次8192行数据)累加,当累加达到65536(64KB)时则置0。因为根据规则,此时会生成下一个压缩数据块。

1)读取压缩数据块

上下相邻的两个压缩文件中的起始偏移量,构成了与获取当前标记对应的压缩数据块的偏移量区间。由当前标记数据开始,向下寻找,直到找到不同的压缩文件偏移量为止。此时得到的一组偏移量区间即时压缩数据块在.bin文件中的偏移量。如上图所示,读取右侧.bin文件中[0, 12016](8+12000+8=12016)字节数据,就能获得第0个压缩数据块。

压缩数据块被整个加载到内存之后,会进行解压,在这之后就进入具体数据的读取环节了。

2)读取数据

在读取解压后的数据时,MergeTree并不需要一次性扫描整段解压数据,它可以根据需要,以index_granularity的粒度加载特定的一小段

上下相邻两个解压缩数据块中的起始偏移量,构成了与获取当前标记对应的数据的偏移区间。通过这个区间能够在它的压缩块被解压之后,依照偏移量按需读取数据,如上图所示,通过[0, 8192]能够读取压缩数据块0中的第一个数据片段。

数据标记与压缩数据块的对应关系
由于压缩数据块的划分,与一个间隔(index_granularity)内的数据大小相关,每个压缩数据块的体积都被严格控制在64KB~1MB。而一个间隔(index_granularity)的数据,又只会产生一行数据标记。那么根据一个间隔内数据的实际字节大小,数据标记和压缩数据块之间会产生三种不同的对应关系:

1)多对一

多个数据标记对应一个压缩数据块,当一个间隔(index_granularity)内的数据未压缩大小size小于64KB时,会出现这种对应关系

以hits_v1测试表的JavaEnable字段为例。JavaEnable数据类型为UInt8,大小为1B,则一个间隔内数据大小为8192B。所以在此种情形下,每8个数据标记会对应同一个压缩数据块


2)一对一

一个数据标记对应一个压缩数据块,当一个间隔(index_granularity)内的数据未压缩大小size大于等于64KB且小于等于1MB时,会出现这种对应关系。

以hits_v1测试表的URLHash字段为例。URLHash数据类型为UInt64,大小为8B,则一个间隔内数据大小为65536B,恰好等于64KB。所以在此种情形下,数据标记与压缩数据块是一对一的关系。


3)一对多

一个数据标记对应多个压缩数据块,当一个间隔(index_granularity)内的数据未压缩大小size直接大于1MB时,会出现这种对应关系。

以hits_v1测试表的URL字段为例。URL数据类型为String,大小根据实际内容而定。

小节
写入过程
数据写入的第一步是生成分区目录,伴随着每一批数据的写入,都会生成一个新的分区目录。在后续的某一时刻,属于相同分区的目录会依照规则合并到一起;接着,按照index_granularity索引粒度,会分别生成primary.idx一级索引(如果声明了二级索引,还会创建二级索引文件)、每一个列字段的.mrk数据标记和.bin压缩数据文件:

从分区目录201403_1_34_3能够得知,该分区数据共分34批写入,期间发生过3次合并。在数据写入的过程中,依据index_granularity的粒度,依次为每个区间的数据生成索引、标记和压缩数据块。

查询过程
在最理想的情况下,MergeTree首先可以依次借助分区索引、一级索引和二级索引,将数据扫描范围缩至最小。然后再借助数据标记,将需要解压与计算的数据范围缩至最小

如果一条查询语句没有指定任何WHERE条件,或是指定了WHERE条件,但条件没有匹配到任何索引(分区索引、一级索引和二级索引),那么MergeTree就不能预先减小数据范围。在后续进行数据查询时,它会扫描所有分区目录,以及目录内索引段的最大区间。虽然不能减少数据范围,但是MergeTree仍然能够借助数据标记,以多线程的形式同时读取多个压缩数据块,以提升性能。

3.4、ReplacingMergeTree

ReplacingMergeTree是MergeTree的一个变种,它存储特性完全继承MergeTree,只是多了一个去重的功能,去重时机。只有同一批插入(新版本)或合并分区时才会进行去重。合并会在未知的时间在后台进行,所以你无法预先作出计划。有一些数据可能仍未被处理;

1)去重范围。如果表经过了分区,去重只会在分区内部进行去重,不能执行跨分区的去重;

2)实际上是使用order by字段作为唯一键进行去重;

3)认定重复的数据保留,版本字段值最大的;

4)如果版本字段相同则按插入顺序保留最后一笔

所以ReplacingMergeTree能力有限,ReplacingMergeTree适用于在后台清除重复的数据以节省空间,但是它不保证没有重复的数据出现。

create table t_order_rmt(
    id UInt32,
    sku_id String,
    total_amount Decimal(16,2) ,
    create_time Datetime 
) engine =ReplacingMergeTree(create_time)
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);

ReplacingMergeTree()填入的参数为版本字段,重复数据保留版本字段值最大的。如果不填版本字段,默认按照插入顺序保留最后一条

插入数据:

insert into t_order_rmt values
(101,'sku_001',1000.00,'2020-06-01 12:00:00') ,
(102,'sku_002',2000.00,'2020-06-01 11:00:00'),
(102,'sku_004',2500.00,'2020-06-01 12:00:00'),
(102,'sku_002',2000.00,'2020-06-01 13:00:00'),
(102,'sku_002',12000.00,'2020-06-01 13:00:00'),
(102,'sku_002',600.00,'2020-06-02 12:00:00');

执行查询操作:

select * from t_order_rmt;

3.5、SummingMergeTree

对于不查询明细,只关心以维度进行汇总聚合结果的场景。如果只使用普通的MergeTree的话,无论是存储空间的开销,还是查询时临时聚合的开销都比较大

ClickHouse为了这种场景,提供了一种能够预聚合的引擎SummingMergeTree。

以SummingMergeTree()中指定的列作为汇总数据列;
可以填写多列必须数字列,如果不填,以所有非维度列(除了order by的列之外)且为数字列的字段为汇总数据列;
以order by的列为准,作为维度列;
其他的列按插入顺序保留第一行;
不在一个分区的数据不会被聚合;
只有在同一批次插入(新版本)或分片合并时才会进行聚合

create table t_order_smt(
    id UInt32,
    sku_id String,
    total_amount Decimal(16,2) ,
    create_time Datetime 
) engine =SummingMergeTree(total_amount)
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id,sku_id );

插入数据:

insert into t_order_smt values
(101,'sku_001',1000.00,'2020-06-01 12:00:00'),
(102,'sku_002',2000.00,'2020-06-01 11:00:00'),
(102,'sku_004',2500.00,'2020-06-01 12:00:00'),
(102,'sku_002',2000.00,'2020-06-01 13:00:00'),
(102,'sku_002',12000.00,'2020-06-01 13:00:00'),
(102,'sku_002',600.00,'2020-06-02 12:00:00');

执行查询操作: