前言
今天开始学习 ClickHouse ,一种 OLAP 数据库,实时数仓中用到的比较多;
1、ClickHouse 入门
ClickHouse 是俄罗斯的 Yandex(搜索引擎公司)在 2016 年开源的列式存储数据库(HBase 也是列式存储,所以它俩经常放在一起比较 ),使用 C++ 语言编写,主要用于在线分析处理查询(OLAP,更适合一次写入多次读写**)**,能够使用 SQL 查询实时生成分析数据报告。
昨天已经安装好了,启停命令:
bash
sudo clickhouse start
sudo clickhouse status
sudo clickhouse stop
# 客户端连接(不需要sudo)
clickhouse-client -m
1.1、ClickHouse 的特点
首先,CK官网的这段话是非常值得理解品味的:
在一个真正的列式数据库管理系统中,除了数据本身外不应该存在其他额外的数据。这意味着为了避免在值旁边存储它们的长度<<number>>(HBase 没有字段类型,都是字节数组的格式),你必须支持固定长度数值类型。例如,10亿个UInt8类型的数据在未压缩的情况下大约消耗1GB左右的空间,如果不是这样的话,这将对CPU的使用产生强烈影响。即使是在未压缩的情况下,紧凑的存储数据也是非常重要的,因为解压缩的速度主要取决于未压缩数据的大小。
这是非常值得注意的,因为在一些其他系统中也可以将不同的列分别进行存储,但由于对其他场景进行的优化,使其无法有效的处理分析查询。例如: HBase,BigTable,Cassandra,HyperTable。在这些系统中,你可以得到每秒数十万的吞吐能力,但是无法得到每秒几亿行的吞吐能力。
1.1.1、列式存储
列式存储的特点我们很清楚(在数仓的 DW 层我们经常使用 ORC 格式存储):
列式数据库更适合 OLAP 数据库的原因:
行式:

列式:
所以我们可以发现:
- 针对分析类查询,通常只需要读取表的一小部分列。在列式数据库中你可以只读取你需要的数据。例如,如果只需要读取100列中的5列,这将帮助你最少减少20倍的I/O消耗。
- 由于数据总是打包成批量读取的,所以压缩是非常容易的。同时数据按列分别存储这也更容易压缩。这进一步降低了I/O的体积。
- 由于I/O的降低,这将帮助更多的数据被系统缓存。
1.1.2、DBMS 功能
覆盖了标准 SQL 的大部分语法以及各种函数,用户管理、权限管理、数据的备份与恢复;
1.1.3、多样化的引擎
和 MySQL 类似,ClickHouse 把表级的存储引擎插件化,根据表的不同需求可以设定不同的存储引擎。目前包括合并树(Merge Tree,常用) 、日志 、接口 和其他四大类 20 多种引擎。
1.1.4、高吞吐
ClickHouse 采用类LSM Tree 的结构,数据写入后定期在后台 Compaction。通过类 LSM tree 的结构,ClickHouse 在数据导入时全部是顺序 append 写(Kafka 高效的原因之一就是顺序写),写入后数据段不可更改(通过版本标记覆盖旧数据),在后台 compaction 时也是多个段 merge sort 后顺序写回磁盘。顺序写的特性,充分利用了磁盘的吞吐能力,即便在 HDD(普通磁盘)上也有着优异的写入性能。
官方公开 benchmark 测试显示能够达到 50MB-200MB/s 的写入吞吐能力,按照每行 100Byte 估算,大约相当于 50W-200W 条/s 的写入速度。
1.1.5、数据分区与线程级并行
ClickHouse 将数据划分为多个 partition,每个 partition 再进一步划分为多个 index granularity(索引粒度),然后通过多个 CPU核心分别处理其中的一部分来实现并行数据处理。 在这种设计下,单条 Query 就能利用整机所有 CPU(吃CPU,是瓶颈)。极致的并行处理能力,极大的降低了查 询延时。
所以,ClickHouse 即使对于大量数据的查询也能够化整为零平行处理。但是有一个弊端 就是对于单条查询使用多 cpu,就不利于同时并发多条查询。所以对于高 qps (query per seconds)的查询业务, ClickHouse 并不是强项。
所以 CK 不适合做初始值的存储,它更适合对处理过的、字段特别多、数据量特别大的宽表;
1.1.6、查询性能
相比较其它 OLAP 数据库,CK 的单表查询几乎是最快的;而关联查询性能要差一点(因为 CK 的 join 底层就是右表加载到内存,也不管大小表,有点像旧版的 Hive(不过 Hive 是左边是小表进内存,右表是大表);所以一般我们要尽量避免 join,非要做 join 的话需要专门优化),所以我们说 CK 更适合对宽表进行处理,毕竟宽表都是 join 完的;
2、数据类型
2.1、整型
固定长度的整型,包括有符号整型或无符号整型。
整型范围(-2n-1~2n-1-1):
- Int8 - [-128 : 127](byte)
- Int16 - [-32768 : 32767](short)
- Int32 - [-2147483648 : 2147483647](int)
- Int64 - [-9223372036854775808 : 9223372036854775807](long)
无符号整型范围(0~2n-1):
- UInt8 - [0 : 255]
- UInt16 - [0 : 65535]
- UInt32 - [0 : 4294967295]
- UInt64 - [0 : 18446744073709551615]
使用场景: 个数、数量、也可以存储型 id。
2.2、浮点型
- Float32 - float
- Float64 -- double
建议尽可能以整数形式存储数据。例如,将固定精度的数字转换为整数值,如时间用毫秒为单位表示,因为浮点型进行计算时可能引起四舍五入的误差(所以企业不会用 double 去存和钱相关的数据)。
2.3、布尔型
ck 没有单独的类型来存储布尔值。可以使用 UInt8 类型,取值限制为 0 或 1。
2.4、Decimal 型
有符号的浮点数,可在加、减和乘法运算过程中保持精度。对于除法,最低有效数字会 被丢弃(不舍入)。
有三种声明:
- Decimal32(s),相当于 Decimal(9-s,s),有效位数为 1~9
- Decimal64(s),相当于 Decimal(18-s,s),有效位数为 1~18
- Decimal128(s),相当于 Decimal(38-s,s),有效位数为 1~38
s 表示小数位
使用场景: 一般金额字段、汇率、利率等字段为了保证小数点精度,都使用 Decimal 进行存储。
2.5、字符串
- String字符串可以任意长度的。它可以包含任意的字节集,包含空字节。
- **FixedString(N)**固定长度 N 的字符串,N 必须是严格的正自然数。当服务端读取长度小于 N 的字符串时候,通过在字符串末尾添加空字符来达到 N 字节长度。 当服务端读取长度大于 N 的 字符串时候,将返回错误消息。
与 String 相比,极少会使用 FixedString,因为使用起来不是很方便。
2.6、枚举类型
包括 Enum8 和 Enum16 类型。Enum 保存 'string'= integer 的对应关系。
- Enum8 用 'String'= Int8 对描述。
- Enum16 用 'String'= Int16 对描述
测试-创建表(只有 season 一个枚举类型字段的表):

插入并查询:

查询结果对应的枚举值(Int8):

使用场景 :对一些状态、类型的字段算是一种空间优化(毕竟只存了数字,不用存那么长的字符串),也算是一种数据约束。但是实 际使用中往往因为一些数据内容的变化增加一定的维护成本,甚至是数据丢失问题。所以谨 慎使用。
2.7、时间类型
之前我们学的 Hive 直接用 string 表示日期(尽管 Hive 有 Date 类型),但是在 ck 中不建议这么做,目前 ClickHouse 有三种时间类型:
- Date 接受年-月-日的字符串比如 '2019-12-16'
- Datetime 接受年-月-日 时:分:秒的字符串比如 '2019-12-16 20:50:10'
- Datetime64 接受年-月-日 时:分:秒.亚秒的字符串比如'2019-12-16 20:50:10.66' 日期类型,用两个字节存储,表示从 1970-01-01 (无符号) 到当前的日期值。
2.8、数组类型
Array(T):由 T 类型元素组成的数组。
T 可以是任意类型,包含数组类型。 但不推荐使用多维数组,ClickHouse 对多维数组的支持有限。例如,不能在 MergeTree 表中存储多维数组。

2.9、其它类型
ck 还支持特别多的类型:ClickHouse中文帮助文档
3、表引擎
表引擎(即表的类型)决定了:
- 数据的存储方式和位置,写到哪里以及从哪里读取数据(比如 /var/lib/clickhouse/metadata,/var/lib/clickhouse/data)
- 支持哪些查询以及如何支持。
- 并发数据访问。
- 索引的使用(如果存在)。
- 是否可以执行多线程请求。
- 数据复制参数。
表引擎的使用方式就是必须显式在创建表时定义该表使用的引擎,以及引擎使用的相关参数。
此外,需要注意表引擎在建表时都是大小写敏感的;
3.1、TinyLog
以列文件的形式保存在磁盘上,不支持索引,没有并发控制。适合小数据量(最多100w行),不适合生产情况;
3.2、Memory
内存引擎,数据以未压缩的原始形式直接保存在内存当中,服务器重启数据就会消失。 读写操作不会相互阻塞,不支持索引。简单查询下有非常非常高的性能表现(超过 10G/s)。
一般用到它的地方不多,除了用来测试,就是在需要非常高的性能,同时数据量又不太大(上限大概 1 亿行)的场景。
3.3、Merge Tree
适用于高负载任务的最通用和功能最强大的表引擎,支持索引和分区,地位相当于 MySQL 中的 InnoDB;
注意 :在 Merge Tree 表引擎中的主键会建索引,但是并没有唯一约束(也就是说,主键可以重复)
创建测试表(Merge 引擎):

插入测试数据:

3.3.1、分区
作用:表数据分区我们在 Hive 中就很熟悉了,主要是为了降低扫描的范围,优化查询速度;(在 Kafka 这种消息队列中分区主要是为了提高数据处理的并行度,让下游消费者可以快速处理)
Hive 和 CK 的分区实现都是是用目录来实现的,区别在于 Hive 是存在 hdfs,而 ck 是存在本地磁盘;
分区目录:Merge Tree 是以列文件 + 索引文件 + 表定义文件组成的,但是如果设定了分区那么这些文 件就会保存到不同的分区目录中。
并行:分区后,面对涉及跨分区的查询统计,ClickHouse 会以分区为单位并行处理。
这里,我更改了默认的数据存储路径(/var/lib/clickhouse):

这里的 metadata 目录下存放的是各个数据库下面的建表 sql:

data 目录下存储的是表数据:

目录命名规则 分区id_最小分区块编号_最大分区块编号_合并层级:
- 分区id
- 分区 id 由分区键决定,我们这里是 toYYYYMMDD(create_time),是日期类型;根据分区键的类型,可以分为:
- 未定义分区键:默认生成一个目录名为 all 的数据分区
- 整型:使用整型值作为分区id
- 日期:可以用日期字符串,ck会自动转为日期类;我们也可以自己转为日期类;Hive 中我们日期也一般都用字符串;但 ck 中,我们尽量自己手动转为日期类比较好一点;
- 其它类型:String、Float等类型,通过128位的hash算法取hash值作为分区 id
- 分区 id 由分区键决定,我们这里是 toYYYYMMDD(create_time),是日期类型;根据分区键的类型,可以分为:
- 最小分区块编号
- 自增类型,从 1 开始递增。每产生一个新目录分区就递增;
- 最大分区块编号
- 新建分区的最小分区块编号 = 最大分区块编号(分区合并的时候才会发生变化);
- 合并层级
- 被合并次数越多,层级值越大;
我们再看看具体的分区目录下面有什么?

- checksums.txt:校验文件
- columns.txt:存储了列的信息(字段名和字段类型)
- data.bin:每一列的数据(旧版本的 ck 为每个列创建一个 .bin 文件 和 .mrk3 文件)
- data.mrk3:每一列的偏移量(旧版本的 ck 为每个列创建一个 .bin 文件 和 .mrk3 文件)
- default_compression_codec.txt:压缩信息
- minmax_ceate_time.idx:分区键的最大值和最小值(查询时可以用来加速查询)
- count.txt:当前表的总列数
这里的 count.txt 存储了当前表中的行数,所以 ck 可以 O(1)时间返回当前表的总行数;
这让我联想到了上一篇 SQL 优化博客中,我们知道,MySIM 的 select count(*) 的性能特别高就是因为它把行数也持久化到磁盘文件中了;而 InnoDB 并没有,所以它只能全表扫描;
数据写入与分区合并:任何一个批次的数据写入都会产生一个临时分区,不会纳入任何一个已有的分区。写入 后的某个时刻(大概 10-15 分钟后),ClickHouse 会自动执行合并操作(等不及也可以手动 通过 optimize 执行),把临时分区的数据,合并到已有分区中:
sql
optimize table xxx final;
上面,我们已经插入过一次数据了,也看到数据目录下产生了分区目录;我们再次插入相同的数据,看看会发生什么情况:
sql
insert into order_info 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');
执行成功后查看结果:

可以看到,按道理我们在创建表的时候已经指定了分区的逻辑,但是上面的查询结果中一个分区的数据并没有被放在一起展示(只有客户端CLI窗口可以看出来);

我们通过查看数据目录也可以发现,一个分区的数据并没有被放到一个目录下面。下面我们执行手动合并:
s
重新查看数据目录:

现在我们可以理解这个分区目录真正的含义了(拿分区1:20200601 举例):
- 最小分区块编号:min(1,3) = 1
- 最大分区块编号:max(1,3) = 3
- 合并层级:合并次数 = 1
所以合并后的分区目录就是 20200601_1_3_1,而合并前的两个目录会在一定时间后自动被清理;
上面我们合并将两个分区都合并了,那我们能不能只合并一个分区呢?比如只对上面的 20200602 分区进行合并:
sql
optimize table order_info partition '20200602' final;
3.3.2、primary key(可选)
ClickHouse 中的主键,和其他数据库不太一样,它只提供了数据的一级索引,但是却不是唯一约束。这就意味着是可以存在相同 primary key 的数据的。
主键的设定主要依据是查询语句中的 where 条件。
根据条件通过对主键进行某种形式的二分查找,能够定位到对应的 index granularity,避免了全表扫描。
index granularity: 直接翻译的话就是索引粒度,指在稀疏索引中两个相邻索引对应数据的间隔。ClickHouse 中的 MergeTree 默认间隔是 8192。官方不建议修改这个值,除非该列存在大量重复值,比如在一个分区中几万行才有一个不同数据。
稀疏索引:

对于上面的表,第一列是主键。按照之前 MySQL 的惯例,会给每个主键添加一个聚集索引。 但稀疏索引并不会这样,它会隔几行建立一个索引;
稀疏索引的好处就是可以用很少的索引数据,定位更多的数据,代价就是只能定位到索引粒度的第一行,然后再进行进行一点扫描
3.3.3、order by(必选)
order by 设定了分区内的数据按照哪些字段顺序进行有序保存。
order by 是 MergeTree 中唯一一个必填项(毕竟借助稀疏索引查询数据做二分搜索前提就是有序),甚至比 primary key 还重要,因为当用户不 设置主键的情况,很多处理会依照 order by 的字段进行处理(比如后面会讲的去重和汇总)。
要求 :主键必须是 order by 字段的前缀字段。 比如 order by 字段是 (id,sku_id) 那么主键必须是 id 或者(id,sku_id)
3.3.4、二级索引
二级索引也叫跳数索引,主要解决大量数据重复的问题,此时一级索引的粒度可能小于重复值,所以在查询数据时可能有大量匹配的索引区间,而二级索引的粒度更粗,它是在一级索引的基础上再进行一次索引:

目前在 ClickHouse 的官网上二级索引的功能在 v20.1.2.4 之前是被标注为实验性的,在这个版本之后默认是开启的。
创建测试表:
sql
create table order_info_2(
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);
其中 GRANULARITY N 是设定二级索引对于一级索引粒度的粒度。
插入数据:
sql
insert into order_info_2 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');
使用下面语句进行测试,可以看出二级索引能够为非主键字段的查询发挥作用:
sql
clickhouse-client --send_logs_level=trace <<< 'select
* from t_order_mt2 where total_amount > toDecimal32(900., 2)';

在分区目录下,我们可以看到跳数索引(二级索引):

3.3.5、TTL
也就是数据的存活时间(Time To Live),MergeTree 提供了可以管理数据表或者列的生命周期的功能。
1)列级别 TTL
创建测试表:
注意 :TTL 中参与计算的字段不能是主键! (比如下面我们使用的 create_time 就不是主键)
sql
create table order_info_3(
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);
写入数据:
sql
insert into t_order_mt3 values
(106,'sku_001',1000.00,'2024-07-16 10:52:55'),
(107,'sku_002',2000.00,'2020-07-16 10:52:59'),
(110,'sku_003',600.00,'2020-07-16 10:53:30');
注意 :数据的TTL淘汰是在主键合并阶段执行的,如果数据迟迟没有进行主键合并,那过期的数据就无法淘汰。
查询结果:

当我们在创建表之后发现忘记指定 TTL 时,也可以通过修改语句来添加 TTL 值:
sql
ALTER TABLE order_info_3 MODIFY COLUMN total_amount Decimal32(16,2) TTL + INTERVAL 1 DAY;
2)表级 TTL
可以通过下面的雨具给表设置生命周期:
sql
alter table order_info_3 MODIFY TTL create_time + INTERVAL 10 SECOND;
显然,每行数据的 create_time 字段都不一样,所以删表的时间取决于 create_time 最大的行记录。
同样,涉及判断的字段必须是 Date 或者 Datetime 类型,推荐使用分区的日期字段。 能够使用的时间周期:
- SECOND
- MINUTE
- HOUR
- DAY
- WEEK
- MONTH
- QUARTER
- YEAR

3.4、ReplacingMergeTree
ReplacingMergeTree 是 MergeTree 的一个变种,它存储特性完全继承 MergeTree,只是多了一个去重的功能。 尽管 MergeTree 可以设置主键,但是 primary key 其实没有唯一约束的功能(也就是说主键可以重复,但它是根据 order by 的字段进行去重)。如果你想处理掉重复的数据,可以借助这个 ReplacingMergeTree。
注意:这因为这个引擎可以最终做到去重(合并分区后),所以可以保证最终一致性,当上游数据处理节点故障重启把部分数据重复插入到 ck 之后,ck 在一定时间会进行 optimize 合并分区并去重;而 SummingMergeTree 并不能保证数据的一致性,因为它可以接受重复数据,并对聚合字段(建表时指定的,否则所有非维度列的数值类型字段)的进行聚合;
1)去重时机
**数据的去重只会在合并的过程中出现。**合并会在未知的时间在后台进行,所以你无法预先作出计划。有一些数据可能仍未被处理。
2)去重范围
如果表经过了分区,去重只会在分区内部进行去重,不能执行跨分区的去重。 所以 ReplacingMergeTree 能力有限, ReplacingMergeTree 适用于在后台清除重复的数 据以节省空间,但是它不保证没有重复的数据出现。
创建测试表:
sql
-- 测试ReplacingMergeTree引擎
create table order_info_4(
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() 填入的参数为版本字段,重复数据保留版本字段值最大的。 如果不填版本字段或者版本相同,默认按照插入顺序保留最后一条。
插入数据:
sql
insert into order_info_4 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');
查询结果:

可以看到插入 6 条数据,保留了 4 条数据,也就是删除了两条数据:
- 如果 order by 字段相同则数据重复(注意:排序字段相同就算重复),比较 create_time,create_time 大的留下来,如果 create_time 相同,则保留后面插入的数据;
3.5、SummingMergeTree
对于不查询明细,只关心以维度进行汇总聚合结果的场景。如果只使用普通的 MergeTree 的话,无论是存储空间的开销,还是查询时临时聚合的开销都比较大。
ClickHouse 为了这种场景,提供了一种能够"预聚合"的引擎 SummingMergeTree;
- 分区内聚合
- 分区合并时才触发聚合
创建测试表:
sql
create table order_info_5(
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 );
插入数据:
sql
insert into order_info_5 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');
查询结果:

我们再次插入一条重复数据:

手动合并:

总结:
- 以 SummingMergeTree()中指定的列作为汇总数据列
- 可以填写多列必须数字列,如果不填,以所有非维度列(除了 order by 之外的字段)且为数字列的字段为汇总数据列
- 以 order by 的列为准,作为维度列
- 其他的列按插入顺序保留第一行
- 同分区才会聚合
- 只有在同一批次插入(新版本)或分片合并时才会进行聚合
正因为会数据聚合可能会有延迟,所以建议使用时仍然使用 sum 聚合函数返回结果;