ClickHouse
参考资料:
ClickHouse
官方文档- 《
ClickHouse
数据目录详解》 - 《
ClickHouse
源码阅读 —— SQL的前世今生》 - 《
ClickHouse MergeTree
详细解析》 - 《向量化与编译执行浅析》
- 《一条指令在CPU中的执行流程》
(一)数据库管理系统分类
OLTP(一般是行式数据库管理系统)
OLTP
(联机事务处理):传统关系型数据库的主要应用,用于数据修订、状态改变等日常事务处理,如银行交易。
OLAP(一般是列式数据库管理系统)
OLAP
(联机分析处理):数据仓库系统的主要应用,支持复杂的分析操作,侧重决策支持,并且提供直观易懂的查询结果。
列式数据库总是将同一列的数据存储在一起,不同列的数据总是分开存储。
数据库是按行来进行I/O
的。
- 100列的数据中仅需要用到5列,使用
OLTP
(行式)需要读取每行的100列; - 但是,在
OLAP
(列式)中列转为行存储了,仅仅需要读取5行,减少了20倍的I/O
消耗!
OLAP场景的关键特征
- 大多数请求是读请求
- 数据总是以相当大的批(> 1000 rows)写入
- 不修改已添加的数据
如:业务线的数据(一般超过1000行)打包,以一条
Kafka
消息发送给数仓,不支持更新已有数据,如果需要修改一般是删除后新增。
- 宽表:每个表包含大量的列
- 列中数据相对较小:数字/短字符串
- 每次查询都从数据库读取大量行,但同时又只需要少量的列
- 较少的查询(通常每台服务器每秒数百个查询或者更少)
- 对于简单查询,允许延迟大约50ms
- 处理单个查询时需要高吞吐量(即:单位时间内处理的数据量要大,但是允许存在延迟),一般每个服务器每秒高达数十亿行
- 对数据一致性的要求较低
- 查询结果明显小于源数据,换句话说,数据被过滤或聚合后能够被盛放在单台服务器的内存中
常见应用:报表系统
- 事务不是必须的
(二)ClickHouse特性(为什么这么快?)
ClickHouse
为什么这么快?
- CPU:按列存储、按向量处理、并行处理
- I/O:定长数据、压缩、顺序I/O
- 内存:索引、列式存储可以加载更多热数据到内存
1.真正的列式DBMS:支持定长数值类型
我们首先从我们熟悉的MySQL
中的char
和varchar
对比说起:
char(100)
是定长存储,哪怕实际只存入3个字节也会占用100个字节,不足的部分会用空格进行补全;varchar(100)
是变长存储,如果实际只存入3个字节,会占用3+1个字节,额外的一个字节用于记录实际长度。
题外话:
varchar(100)
存入3个字节只是实际占用了4个字节,分配内存的时候仍然是分配100个字节,相当于实际内存和虚拟内存的区别。
也就是说char
占用的实际存储空间一般比varchar
多,使用varchar
可以可以优化存储。但是char
的检索速度比varchar
要快。
遍历
char
时,由于是定长,所以每次只需要一次I/O
; 遍历varchar
时,则每次需要两次I/O
(一次读数据长度,一次读实际数据)
在一个真正的列式数据库管理系统中,除了数据本身外不应该存在其他额外的数据。这意味着为了避免在值旁边存储它们的长度,必须支持定长数值类型。
列式存储方式可以装在更多热数据在内存中,响应速度更快(单服务器简单查询峰值可以达到2TB/s,每秒亿级到十亿行数据)。
2.数据压缩
支持两种无损数据压缩算法:
LZ4
:压缩速度更快,但是压缩比不高
原理:将
abcde_bcdefgh
压缩成abcde_(5,4)fgh
,(5,4)表示向前5个字符、有4个重复字符。
ZStandard
(简称ZSTD
):压缩比高,压缩速度没有LZ4
快
3.数据存储在磁盘
部分列式数据库管理系统只能在内存中工作,会带来更多设备预算。而ClickHouse
数据存储在磁盘中。
4.向量(列的一部分)引擎
为了高效的使用CPU,数据不仅仅按列存储,同时还按向量(列的一部分)进行处理,这样可以更加高效地使用CPU。
当前数据库引擎两种主流优化方式,分别是:编译执行、向量化执行 这两种优化方式,都是为了充分适配当代CPU多核、高并发的特性
【CPU构成】
CPU是计算机的运算核心和控制核心,所以它总体由两部分组成:运算器 + 控制器
。
运算器(Core)
- 寄存器:存储位数据的存储器,是CPU取数最快的存储。
- 算术逻辑运算单元(
ALU
,Arithmetic Logic Unit):计算对位进行操作的函数的组合逻辑。
控制器(CU
,Control Unit)
- 指令寄存器(
IR
,Instruction Register):用来保存当前正在执行的一条指令。 - 程序计数器(
PC
,Program Counter):用于确定下一条指令地址。 - ……
【CPU指令执行过程】
CPU指令执行可以分为多个阶段:取指令、译码、取数、运算……
- 取指令(
IF
, Instruction Fetch):当执行一条指令时,首先把该指令从内存读取到数据寄存器中,然后再传送至指令寄存器(IR
)。PC
指向下一条指令。 - 指令译码(
ID
, Instruction Decoder):对取回的指令进行拆分和解释,判断指令类型和获取操作数的方法。 - 指令执行(
EX
, Execute):通过ALU
执行指令。 - 访存取数(
MEM
, Memory):根据指令地址码,得到操作数在内存中的位置,并从内存中读取操作数用于运算。 - 结果回写(
WB
, Write Back):将运算结果写回寄存器(如果下一步运算还需要用到)或者内存。执行结果无误,则从PC
中获取下一条指令继续执行。
【当代CPU特性】
相信看完这些特性后,你能更好地理解为什么
Java 8
会引入流操作。
超标量流水线与乱序执行
- 流水线:一个控制单元(
CU
)可以同时执行多条指令,只是出于不同的执行阶段,比如A指令取指令时,B指令正在进行指令译码。 - 超标量:CPU中有多个
CU
,因此会有多套这样的流水线并发执行。 - 乱序执行:CPU中会维护一个乱序窗口,无数据依赖的指令就会被取来乱序执行。
为了提高超标量流水线的吞吐,要做到:
- 并发指令越多越好,指令中没有依赖,就可以在多个流水线中并发执行。
- 程序分支尽可能少,这样不需要等到上一条命令执行完,就可以执行下一条命令,流水线不会断。
注意: ①这里只是说
IF
、ID
、EX
可以提前执行,MEM
阶段如果依赖上一条命令的结果还是需要等待,但是因为完成了前置步骤,总体时间降低了。 ②如果涉及到分支跳转,这时候指令有多种情况,CPU不知道该取哪条指令,这时候就需要分支预测(如下)。
分支预测
- 当执行一个跳转指令时,在得到跳转的目的地址之前,不知道该从哪取下一条指令,流水线就只能空缺等待。
- 为了提高这种情况下的流水线效率,CPU引入了一组寄存器,用来专门记录最近几次某个地址的跳转指令的目的地址。这样,当再一次执行到这个跳转指令时,就直接从上次保存的目的地址出取指令,放入流水线。等到真正获取到目的地址的时候,再看如果取错了,则推翻当前流水线中的指令,取真正的指令执行。
多级存储与数据预取
- CPU取数速度:寄存器 >
L1
/L2
/L3
CPU高速缓存 >> 内存 - 数据预取:CPU会主动将后续的内存块预先加载到
L1
/L2
/L3
CPU高速缓存。
SIMD
(单指令流多数据流,Single Instruction Multiple Data)
这里用一个形象的例子比喻。小胡经营了一家果汁店,虽然店里的鲜榨苹果汁深受大家喜爱,但客户总是抱怨制作果汁的速度太慢。小胡的店里只有一台榨汁机,每次他都会从篮子里拿出一个苹果,放到榨汁机内等待出汁。如果有8个客户,每个客户都点了一杯苹果汁,那么小胡需要重复循环8次上述的榨汁流程,才能榨出8杯苹果汁。如果制作一杯果汁需要5分钟,那么全部制作完毕则需要40分钟。为了提升果汁的制作速度,小胡想出了一个办法。他将榨汁机的数量从1台增加到了8台,这么一来,他就可以从篮子里一次性拿出8个苹果,分别放入8台榨汁机同时榨汁。
- 对于计算密集型程序来说,可能经常会需要对大量不同的数据进行同样的运算。
SIMD
引入之前,执行流程为同样的指令重复执行,每次取一条数据进行运算。例如有8个32位整形数据都需要进行移位运行,则由一条对32位整形数据进行移位的指令重复执行8次完成。SIMD
引入了一组大容量的寄存器,一个寄存器包含8x32位,可以将这8个数据按次序同时放到一个寄存器。同时,CPU新增了处理这种8x32位寄存器的指令,可以在一个指令周期内完成8个数据的位移运算。
【查询执行模型】
火山模型
SQL查询在数据库中经过解析,会生成一颗查询树(下图左),查询树的每个节点都是代数运算符(Operator
)。火山模型把Operator
看作迭代器,每个迭代器包含一个next()
方法。
一般
next()
方法分为3步(递归):①调用子节点Operator.next()
获取一行数据(tuple
);②对tuple
进行Operator
特殊处理,如filter
;③返回处理后的tuple
。
优点:逻辑清晰(查询执行时会由查询树自顶向下调用next()
接口,数据则自底向上被拉取处理,Pull-Based
),每个Operator
只关心自己的逻辑,耦合性低。
缺点:
- 数据以行为单位进行处理,不利于CPU cache发挥作用。
编译执行:直降将整个
pipeline
中的数据放入寄存器执行; 向量化执行:以块为单位提高了cache命中率
- 处理每一行都需要大量调用
next()
函数,开销大。
编译执行:通过从下到上推减少
next()
函数; 向量化执行:通过Vector
减少next()
函数
编译执行(Push-Based + pipeline + 寄存器
)
如何减少大量
next()
函数调用?
采用推送模型(Push-Based
),自底向上执行,执行逻辑从底层Operator
开始,处理完一个tuple
后就将其推给上层Operator
处理。
每个Operator
包含两个函数,一个produce()
负责产生对应的tuple
,然后调用上一个Operator
的consume()
函数将tuple
向上传递。
CPU的多级存储中,数据访问速度最快的是寄存器。
最理想的情况当然是数据一直存储在寄存器中,每个Opereator
直接处理寄存器中的数据,这样就由next()
的递归调用变成了以数据为中心的顺序执行。
但是一个
tuple
可能过大,不能存储在寄存器中(比如join
的时候),这时候就需要把数据从寄存器中取出,落入内存(称为Materialization
)。
编译执行将这种不得不Materialization
的Operator
称为Pipeline Breaker
,然后以Pipeline Breaker
为分隔,将查询树分为多个pipeline
(如上图右)。在同一个pipeline
中,数据可以一直保留在寄存器中(pipeline
就相当于一次for
循环,一次循环处理一个tuple
,tuple
在一次循环内是不离开寄存器的)。
向量化执行(Pull-Based + Vector
)
向量化执行是Pull-Based
的,与火山模型的不同点在于每个Operator
不是返回一个tuple
,而是返回一个Vector
(一批数据,比如1000 rows),同时Operator
需要定义一些专门处理Vector
的原语。
优点:
- 每次返回一个
Vector
而非一个tuple
,减少了next()
函数的调用。 - 以块为单位处理数据,提高了cache命中率。
- 多行并发处理,契合了CPU乱序执行和并发执行的特性。
- 可以通过
SIMD
同时处理多行数据。
5.多核心并行处理
ClickHouse
会使用服务器上一切可用的资源,从而以最自然的方式并行处理大型查询。- 由于
SIMD
不适合分支跳转较多的情况,ClickHouse
也使用了大量多线程技术来提速,以此和向量化形成互补。
6.多服务器分布式处理
ClickHouse
集群中数据可以存储在不同的shard
上,每一组shard
都由一组用于容错的replica
组成,查询可以并行地在所有shard
上进行。这些对用户来说是透明的。
- 集群(
cluster
):集群由多个shard
组成。 - 分片(
shard
):包含数据不同部分的服务器。一个shard
由一组replica
组成。
数据根据一定路由规则负载均衡到不同
shard
上; 要访问进群中的全部数据,必须访问所有分片。
- 副本(
replica
):存储复制数据的服务器,用于容错。
要访问一个
shard
上的所有数据,访问任一replica
即可,可以通过配置load_balancing
算法挑选副本。 如果和选中replica
服务器无法连接,会尝试短超时的重连;重连失败,会顺延到下一replica
;如果和所有replica
重连都失败,会采用相同方式再重复几次。
7.支持SQL
- 支持
group by
/order by
/in
/join
以及非相关子查询。
非相关子查询:内部查询独立于外部查询,内部查询仅执行一次,执行完毕后结果作为外部查询的条件使用。 如:
select * from score as a where a.cou_id=1 and a.score>(select avg(b.score) from score as b where b.cou_id=1);
- 不支持窗口函数和相关子查询。
相关子查询:外部查询每执行一次,内部查询也执行一次。 如:
select * from score as a where a.score>(select avg(b.score) from score as b where a.cou_id=b.cou_id);
8.主键排序
ClickHouse
支持在表中定义主键。为了使查询能够快速在主键中进行范围查找,数据总是以增量的方式有序存储在MergeTree
中。
并不要求主键唯一,所以你可以插入多条具有相同主键的行。
如果希望主键唯一,可以使用ReplacingMergeTree
(去重引擎,异步进行,会比较慢);如果希望立即看到去重后的效果,可以在表名后加FINAL
关键字。
- 数据可以持续不断地高效写入表中,并且写入过程不存在任何加锁行为。
ClickHouse
也支持多版本并发控制:create_version <= SELECT <= delete_version
,可以同时进行不冲突的读、写。
9.适合在线查询
在没有对数据做任何预处理的情况下以极低的延迟处理查询并将结果加载到用户的页面中。
如:报表系统。
10.支持近似计算
提供各种在允许牺牲数据精度的情况下对查询进行加速的方法:
- 用于近似计算的各类聚合函数:
distinct values
、medians
(中值)、quantiles
(分位数) - 基于数据的部分样本进行近似查询,这时仅会从磁盘检索少部分比例的数据
- 不使用全部的聚合条件,通过随机选择有限个数据聚合条件进行聚合。这在数据聚合条件满足某些分布条件下,在提供相当准确的聚合结果的同时降低了计算资源的使用
11.支持数据复制和数据完整性
ClickHouse
使用异步的多主复制技术:当数据被写入任何一个可用副本后,系统会在后台将数据分发给其他副本,以保证系统在不同副本上保持相同的数据。
基于
ZooKeeper
。
- 在大多数情况下
ClickHouse
能在故障后自动恢复,在一些少数的复杂情况下需要手动恢复
12.线性可扩展
- 不需要对数据库进行更改就可以直接扩容数据库
- 横向、纵向都具有很好的扩展性
13.限制
- 没有完整的事务支持(可以进行事务操作,只是没有
MySQL
那种批量删除修改的事务)
但是可以引入外部事务,由外部事务来判断数据是否可用,不可用时不允许下游查询。
- 缺少高频率,低延迟的修改或删除已存在数据的能力。仅能用于批量删除或修改数据
- 稀疏索引使得
ClickHouse
不适合通过其键检索单行的点查询
【稀疏索引 vs 稠密索引】
- 稠密索引:为数据库中的每一行都设置索引记录。精确查找速度快、占用空间大。
比如
MySQL
中的主键索引,哪怕不指定主键也会填充默认值。
- 稀疏索引:不会为数据库中的每一行都设置索引记录,而是通过索引定位到最近位置,然后顺序检索。精确查找速度慢(范围查找并不会慢)、占用空间小。
比如
MySQL
中的非主键索引,或者Redis
中zset
用到的的跳跃表。
(三)ClickHouse引擎
数据库引擎
Lazy
延时引擎:在最近一次访问expiration_time_in_seconds
的时间内,将表保存在内存中,仅适用于*Log
引擎表(针对这类表的访问间隔一般都较长)。
CREATE DATABASE testlazy ENGINE = Lazy(expiration_time_in_seconds);
MySQL
引擎:用于将远程的MySQL
服务器上的表映射到ClickHouse
中。- 数据库引擎:默认引擎,提供可配置的表引擎和所有支持的SQL语法。
表引擎
只介绍常见的表引擎,详细类型可以查看文档。
1.MergeTree(合并树)
*MergeTree
系列的引擎被设计用于插入极大量的数据到一张表中。
- 数据以数据片段(DATA PART)的形式、按主键排序快速写入;
- 同一个分区的数据片段会在插入后的10~15分钟按照一定的规则自动合并为一整个片段,这个过程后台异步进行,并不保证相同的主键都归入同一个数据片段;
分区是在建表时通过
PARTITION BY expr
子句指定的。分区 != 分片(shard)
分区方案不应该太过精细,否则会由于分区文件太多导致查询效果不佳。
- 支持数据副本,支持数据采样。
数据片段存储格式
通过min_bytes_for_wide_part
和min_rows_for_wide_part
参数控制,如果数据片段中字节数/行数少于对应值则以compact
格式存储,否则以wide
形式存储:
wide
:每一列都在文件系统中存储为单独的文件compact
:所有列都存储在同一个文件中。可以提高插入量少插入频率高情况下的效率
每个数据片段被逻辑分隔为颗粒(granules),每个颗粒包含整数个行,是ClickHouse
数据查询的最小不可分割数据集。每个颗粒的第一行通过该行的主键值进行标记,作为索引值方便查找。
由于数据片段合并时并不保证相同主键归入同一个片段,所以读取主键单个区间范围内的数据时,会额外读该区间左右两个颗粒的数据。
主键索引(primary key
)
- 如果主键是(A,B),现根据A排序,A相同时,根据B排序。
- 主键的选择应该要能够使你的查询略过很长的数据范围,比如很长范围内的A、B都是相同值,可以考虑加入C构成(A,B,C)索引。
MergeTree
只能有一个主索引,没有任何辅助索引,是一个存储在内存中的稀疏索引,速度非常快
数据目录
default.test_analysis
:数据库·表名20180424_20180424_1_6_1
:MergeTree
每次插入数据生成一个DATA PART
,多个数据块会不定时合并成一个大数据块;数据块中数据按照主键排序,相同主键并不保证合并到同一个数据块。checksums.txt
:校验和columns.txt
:记录了表中的所有列名(如created_at、page_id)column_name.mrk
:每个列都有一个mrk文件,存储的是数据块内稀疏索引所在行在数据文件中的偏移量column_name.bin
:每个列都有一个bin文件,存储了压缩后的真实数据(需要读取某一列数据时,需要将数据加载到内存中解压缩,才能进行后续数据处理;数据压缩和解压缩本身会带来额外性能损耗,所以需要控制数据库大小)primary.idx
:主键文件,常驻内存
primary.idx
记录了哪些行是索引项,column.mrk
记录了这些索引项在数据文件中的位置。
多设备冷热存储
MergeTree
系列表引擎可以将数据存储在多块设备上。这对某些可以潜在被划分为“冷”“热”的表来说是很有用的。近期数据被定期的查询但只需要很小的空间。相反,详尽的历史数据很少被用到。如果有多块磁盘可用,那么“热”的数据可以放置在快速的磁盘上(比如 NVMe 固态硬盘或内存),“冷”的数据可以放在相对较慢的磁盘上(比如机械硬盘)。- 数据片段是
MergeTree
引擎表的最小可移动单元。属于同一个数据片段的数据被存储在同一块磁盘上。数据片段会在后台自动的在磁盘间移动,也可以通过ALTER
查询来移动。
2.Log(日志)
- 数据存储在磁盘上
- 写入时数据追加在文件末位
- 不支持索引(
SELECT
效率低) - 非原子性(如果写操作过程中服务器异常关闭,表中会有脏数据)
3.Memory(内存表)
- 以未压缩的形式将数据存储在内存中,读写操作不会相互阻塞。
- 不支持索引,但是由于数据存储在内存中,所以查询效率也非常高。
- 一旦重启服务器,表中数据会清空,所以一般只用于测试。
4.Distributed(分布式)
- 分布式引擎相当于一个视图(
view
),它本身不存储数据,但是通过分布式引擎可以像使用本地服务器一样使用集群 —— 查询分布式表的时候ClickHouse
会自动去查询对应的每个本地表中的数据。
分布式表就相当于
MySQL
中的非聚集索引,本地表相当于MySQL
中的主键索引,每次查分布式表都需要进行"回表"操作。
- 读是自动并行的,如果远程服务器有索引会在读取时被使用。
- 远程服务器不止用于读取数据,还会对尽可能多数据做部分处理。
例如:对于
group by
查询,数据会先在远程服务器聚合,然后返回聚合函数的中间状态给查询请求的服务器,再在请求服务器上进一步汇总数据。![]()
- 参数:
Distributed(logs, default, hits[, sharding_key])
将从
logs
集群上的default
数据库、hits
表(,以sharding_key
进行数据分片)读取数据。
分布式查询
- 在一个集群中的一个或多个服务器上创建一个
Distributed
表。Distributed
表本身并不存储数据,它只为集群的多个节点上的所有本地表提供一个视图(view
)。 - 当从
Distributed
表中进行SELECT
时,它会重写该查询,根据负载平衡设置来选择远程节点,并将查询发送给节点。 Distributed
表请求远程服务器处理查询,直到可以合并来自不同服务器的中间结果的阶段。然后它接收中间结果并进行合并。- 分布式表会尝试将尽可能多的工作分配给远程服务器,并且不会通过网络发送太多的中间数据。
数据写入的两种方式
1. 直接写本地表(一般采用这种)
- 写入时可以由 客户端/负载均衡组件 控制数据分布,直接写入本地表。
- 这种方式有数据丢失、各节点之间数据不同步的风险,但是胜在灵活。
可以通过外部事务进行保证,并且动态调整写入节点和写入batch size。
2. 写分布式表
- 数据先写入分布式表下的节点临时目录,之后会尽快将
INSERT
语句发送到集群各个节点上执行,分布式表不存储实际数据。数据写入是异步的。 - 如果在
INSERT
到分布式表时服务器节点故障,插入数据可能会丢失,但是一旦提交到临时目录下了,数据就不会丢失了。
这种方法的缺点是:
- 可能因为分发策略造成各本地表数据不均衡
- 写放大问题:一个写分布式的表的操作最终会拆分成对多个节点本地表的
INSERT
。整个集群的写次数被放大后ZooKeeper
的压力会变大,同时负责拆分写入请求的、分布式表所在的节点负载也会升高。而且官方建议单机的写入控制在每秒一个写入请求,不建议高频次插入。
每次
INSERT
操作ClickHouse
都是写新的DATA PART
然后在后台把这些文件进行合并,如果非常高频的写入会导致后台合并线程处理跟不上,导致写入失败报too many parts
的错误。
5.View(视图)
- 创建方法:
CREATE VIEW
- 视图不能存储数据,但是可以存储对应的
SELECT
查询。从表中读取时,它会运行此查询,并删除所有不必要的列。
等价于
SELECT a,b,c FROM(SELECT ...)
6.MaterializedView(物化视图)
物化视图与视图的区别:
- 视图是虚拟表,不存储数据
- 物化视图存储对应
SELECT
查询转换的数据
物化视图可以用于预计算,加快实时查询效率(空间换时间)。
(四)常见问题
1.ClickHouse为什么这么快?
- CPU(并行、向量):(1)
ClickHouse
存在各种并行处理机制,全表扫描的速度也非常快(2)数据按列存储、按向量处理,可以高效利用CPU - I/O(压缩、顺序):(1)
ClickHouse
采用LZ4
/ZSTD
算法对定长数据类型数据进行了压缩,节省了磁盘空间,能有效的较少磁盘I/O,单次I/O可以处理的数据量更大,吞吐量更高(2)基本上都是顺序I/O - 内存:(1)只有需要处理的
column_name.bin
数据(而不是全部数据)才在内存中处理,这意味着通过I/O可以过滤掉很大一部分数据;(2)列式存储可以加载更多热数据到内存
2.ClickHouse vs Hadoop
同是列式存储,为什么要用ClickHouse
而非Hadoop
?
Hadoop
基于MapReduce
分布式排序设计,是分布式计算系统。MapReduce
以稳定性著称,但是过分强调容错和中间数据读写,延迟高,不适合在线查询,不适合作为web接口的后端服务。ClickHouse
的精髓是最大限度榨干CPU和内存,在内存中计算,速度快,可以作为web接口的后端服务。
3.SQL执行过程:ClickHouse vs MySQL
SQL执行流程都大同小异:
区别:
MySQL
单条SQL是单线程的,只能跑满一个核心,ClickHouse
并行处理,充分利用所有CPU。MySQL
的查询树采用的是火山模型,每次返回一条数据;而ClickHouse
通过向量化执行对查询树进行优化,每次返回一组列。