ClickHouse 基本概念
ClickHouse 是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。
OLAP
什么是 OLAP?
OLAP 名为联机分析,又可以称为多维分析,是由关系型数据库之父埃德加·科德(EdgarFrank Codd)于 1993 年提出的概念。顾名思义,它指的是通过多种不同的维度审视数据,进行深层次分析。
维度可以看作观察数据的一种视角,例如人类能看到的世界是三维的,它包含长、宽、高三个维度。直接一点理解,维度就好比是一张数据表的字段,而多维分析则是基于这些字段进行聚合查询。
如上图,多维分析包含以下几种操作:
- 下钻: 从高层次向低层次明细数据穿透,例如从省下钻到市。
- 上卷: 和下钻相反,从低层次向高层次汇聚,例如从市汇聚成省。
- 切片: 观察立方体的一层,将一个或多个维度设为单个固定值,然后观察剩余的维度,例如将商品维度固定为足球。
- 切块: 与切片类似,只是将单个固定值变成多个值。例如将商品维度固定成足球、篮球。
- 旋转: 旋转立方体的一面,如果要将数据映射到一张二维表,那么就要进行旋转,这就等同于行列置换。
OLAP 与 OLTP
OLTP(on-line transaction processing)翻译为联机事务处理, OLAP(On-Line Analytical Processing)翻译为联机分析处理。
-
从字面上来看 OLTP 是做事务处理,OLAP 是做分析处理。
-
从对数据库操作来看,OLTP 主要是对数据的增删改,OLAP 是对数据的查询。
-
因为 OLTP 所产生的业务数据分散在不同的业务系统中,而 OLAP 往往需要将不同的业务数据集中到一起进行统一综合的分析,这时候就需要根据业务分析需求做对应的数据清洗后存储在数据仓库中,然后由数据仓库来统一提供 OLAP 分析。
-
OLTP 是数据库的应用,OLAP 是数据仓库的应用
下面用一张图来简要对比。
列式存储
列式存储与行式存储
在传统的行式数据库系统中,处于同一行中的数据总是被物理的存储在一起,存储方式如下图:
在列式数据库系统中,来自不同列的值被单独存储,来自同一列的数据被存储在一起,数据按如下的顺序存储:
不同的数据存储方式适用不同的业务场景,而对于 OLAP 来说,列式存储是最适合的选择。
列式存储与 OLAP
为什么列式数据库更适合于 OLAP 场景呢?下面这两张图就可以给你答案
- 行式数据库
- 列式数据库
下面分别从两个 I/O 和 CPU 两个角度来分析为什么他们有如此之大的差别
- I/O
- 针对分析类查询,通常只需要读取表的一小部分列。在列式数据库中你可以只读取你需要的数据。
- 由于数据总是打包成批量读取的,所以压缩是非常容易的。同时数据按列分别存储这也更容易压缩。这进一步降低了 I/O 的体积。
- 由于 I/O 的降低,这将帮助更多的数据被系统缓存,进一步降低了数据传输的成本。
- CPU
- 由于执行一个查询需要处理大量的行,因此在整个向量上执行所有操作将比在每一行上执行所有操作更加高效。同时这将有助于实现一个几乎没有调用成本的查询引擎。如果你不这样做,使用任何一个机械硬盘,查询引擎都不可避免的停止 CPU 进行等待。所以,在数据按列存储并且按列执行是很有意义的。
列式存储与数据压缩
如果你想让查询变得更快,最简单且有效的方法是减少数据扫描范围和数据传输时的大小,而列式存储和数据压缩就可以帮助我们实现上述两点。
列式存储和数据压缩通常是伴生的。数据按列存储。而具体到每个列字段,数据也是独立存储的,每个列字段都拥有一个与之对应的 .bin 数据文件,相同类型的数据放在同一个文件中,对压缩更加友好。数据默认使用 LZ4 算法压缩,在 Yandex.Metrica 的生产环境中,数据总体的压缩比可以达到 8:1(未压缩前 17PB,压缩后 2PB)。
核心特点
完备的 DBMS 功能
ClickHouse 拥有完备的管理功能,所以它称得上是一个 DBMS(Database Management System,数据库管理系统),而不仅是一个数据库。作为一个 DBMS,它具备了一些基本功能,
如下所示。
- DDL(数据定义语言):可以动态地创建、修改或删除数据库、表和视图,而无须重启服务。
- DML(数据操作语言):可以动态查询、插入、修改或删除数据。
- 权限控制:可以按照用户粒度设置数据库或者表的操作权限,保障数据的安全性。
- 数据备份与恢复:提供了数据备份导出与导入恢复机制,满足生产环境的要求。
- 分布式管理:提供集群模式,能够自动管理多个数据库节点。
关系模型与 SQL 查询
相比 HBase 和 Redis 这类 NoSQL 数据库,ClickHouse 使用关系模型描述数据并提供了传统数据库的概念(数据库、表、视图和函数等)。与此同时,ClickHouse 完全使用 SQL 作为查询语言(支持 GROUP BY
、ORDER BY
、JOIN
、IN
等大部分标准 SQL),这使得它平易近人,容易理解和学习。
向量化表引擎
向量化执行,可以简单地看作从硬件的角度上消除程序中循环的优化。
为了实现向量化执行,需要利用 CPU 的 SIMD 指令。SIMD 的全称是 Single Instruction MultipleData,即用单条指令操作多条数据。现代计算机系统概念中,它是通过数据并行以提高性能的一种实现方式,它的原理是在 CPU 寄存器层面实现数据的并行操作。例如有 8 个 32 位整形数据都需要进行移位运行,则由一条对 32 位整形数据进行移位的指令重复执行 8 次完成。SIMD 引入了一组大容量的寄存器,一个寄存器包含 8 * 32 位,可以将这 8 个数据按次序同时放到一个寄存器。同时,CPU 新增了处理这种 8 * 32 位寄存器的指令,可以在一个指令周期内完成 8 个数据的位移运算。(本质就是将每次处理的数据从一条变为一批)
多样化的表引擎
与 MySQL 类似,ClickHouse 也将存储部分进行了抽象,把存储引擎作为一层独立的接口。ClickHouse 共拥有合并树、内存、文件、接口和其他 6 大类 20 多种表引擎。其中每一种表引擎都有着各自的特点,用户可以根据实际业务场景的要求,选择合适的表引擎使用。
多主架构
ClickHouse 则采用 Multi-Master多主架构,集群中的每个节点角色对等,客户端访问任意一个节点都能得到相同的效果。这种多主的架构有许多优势,例如对等的角色使系统架构变得更加简单,不用再区分主控节点、数据节点和计算节点,集群中的所有节点功能相同。所以它天然规避了单点故障的问题,非常适合用于多数据中心、异地多活的场景。
多线程与分布式
在各服务器之间,通过网络传输数据的成本是高昂的,所以相比移动数据,更为聪明的做法是预先将数据分布到各台服务器,将数据的计算查询直接下推到数据所在的服务器。ClickHouse 在数据存取方面,既支持分区(纵向扩展,利用多线程原理),也支持分片(横向扩展,利用分布式原理),可以说是将多线程和分布式的技术应用到了极致。
分片与分布式查询
数据分片是将数据进行横向切分,这是一种在面对海量数据的场景下,解决存储和查询瓶颈的有效手段,是一种分治思想的体现。ClickHouse 支持分片,而分片则依赖集群。每个集群由 1 到多个分片组成,而每个分片则对应了 ClickHouse 的 1 个服务节点。分片的数量上限取决于节点数量(1 个分片只能对应 1 个服务节点)。
ClickHouse 并不像其他分布式系统那样,拥有高度自动化的分片功能。ClickHouse 提供了**本地表(Local Table)与分布式表(Distributed Table)**的概念。一张本地表等同于一份数据的分片,而分布式表本身不存储任何数据,它是本地表的访问代理,其作用类似分库中间件。借助分布式表,能够代理访问多个数据分片,从而实现分布式查询。
应用场景
擅长的场景
- 绝大多数是读请求
- 数据以相当大的批次(> 1000 行)更新,而不是单行更新;或者根本没有更新。
- 已添加到数据库的数据不能修改。
- 对于读取,从数据库中提取相当多的行,但只提取列的一小部分。
- 宽表,即每个表包含着大量的列。
- 查询相对较少(通常每台服务器每秒查询数百次或更少)。
- 对于简单查询,允许延迟大约 50 毫秒。
- 列中的数据相对较小:数字和短字符串(例如,每个 URL 60 个字节)。
- 处理单个查询时需要高吞吐量(每台服务器每秒可达数十亿行)。
- 事务不是必须的。
- 对数据一致性要求低。
- 每个查询有一个大表。除了他以外,其他的都很小。
- 查询结果明显小于源数据。换句话说,数据经过过滤或聚合,因此结果适合于单个服务器的 RAM 中。
不擅长的场景
- OLTP 事务性操作(不支持事务,不支持真正的更新/删除)
- 不擅长根据主键按行粒度进行查询(如
select * from table where user_id in (xxx, xxx, xxx, ...)
) - 不擅长存储和查询 blob 或者大量文本类数据(按列存储)
- 不擅长执行有大量
join
的查询(Distributed 引擎局限) - 不支持高并发,官方建议 QPS <= 100
Clickhouse 为什么会这么快?
首先亮出官方的测试报告:Clickhouse 性能对比报告
所有用于对比的数据库都使用了相同配置的服务器,在单个节点的情况下,对一张拥有 133 个字段的数据表分别在 1000 万、1 亿和 10 亿这三种数据体量下执行基准测试,基准测试的范围涵盖 43 项 SQL 查询。
市面上有很多与 Clickhouse 采用了同样技术(如列式存储、向量化引擎等)的数据库,但为什么 ClickHouse 的性能能够将其他数据库远远甩在身后呢?这主要依赖于下面几个方面
- 着眼硬件,先想后做
- ClickHouse 会在内存中进行
GROUP BY
,并且使用 HashTable 装载数据。 - ClickHouse 非常在意 CPU L3 级别的缓存,因为一次 L3 的缓存失效会带来 70 ~ 100ns 的延迟。这意味着在单核CPU上,它会浪费 4000 万次/秒的运算;而在一个 32 线程的 CPU 上,则可能会浪费 5 亿次/秒的运算。
- ClickHouse 会在内存中进行
- 算法在前,抽象在后
- 对于常量,使用 Volnitsky 算法;
- 对于非常量,使用 CPU 的向量化执行 SIMD(用于文本转换、数据过滤、数据解压和 JSON 转换等),暴力优化;
- 正则匹配使用 re2 和 hyperscan 算法。性能是算法选择的首要考量指标。
- 勇于尝鲜,不行就换
- 除了字符串之外,其余的场景也与它类似,ClickHouse 会使用最合适、最快的算法。如果世面上出现了号称性能强大的新算法,ClickHouse 团队会立即将其纳入并进行验证。如果效果不错,就保留使用;如果性能不尽人意,就将其抛弃。
- 特定场景,特殊优化
- 针对同一个场景的不同状况,选择使用不同的实现方式,尽可能将性能最大化。
- 例如去重计数
uniqCombined
函数,会根据数据量的不同选择不同的算法:当数据量较小的时候,会选择 Array 保存;当数据量中等的时候,会选择 HashSet;而当数据量很大的时候,则使用 HyperLogLog 算法。 - 针对不同的场景,Clickhouse 提供了 MergeTree 引擎家族,如 MergeTree、ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree、CollapsingMergeTree和VersionedCollapsingMergeTree 等。
- 持续测试,持续改进
- 由于 Yandex 的天然优势,ClickHouse 经常会使用真实的数据进行测试,这一点很好地保证了测试场景的真实性。
- ClickHouse 差不多每个月都能发布一个版本,正因为拥有这样的发版频率,ClickHouse 才能够快速迭代、快速改进。
ClickHouse 的架构
目前 ClickHouse 公开的资料相对匮乏,比如在架构设计层面就很难找到完整的资料,甚至连一张整体的架构图都没有,根据官网提供的信息,我们能够得出一个大概的架构,如下图
-
Parser: Parser 分析器可以将一条 SQL 语句以递归下降的方法解析成 AST 语法树的形式。不同的 SQL 语句,会经由不同的 Parser 实现类解析。
-
Interpreter:Interpreter 解释器的作用就像 Service 服务层一样,起到串联整个查询过程的作用,它会根据解释器的类型,聚合它所需要的资源。首先它会解析AST对象;然后执行“业务逻辑”(例如分支判断、设置参数、调用接口等);最终返回
IBlock
对象,以线程的形式建立起一个查询执行管道。 -
Tables:Tables由
IStorage
接口表示。该接口的不同实现对应不同的表引擎。比如 StorageMergeTree、StorageMemory 等。这些类的实例就是表。- IStorage 接口定义了DDL(如 ALTER、RENAME、OPTIMIZE 和 DROP 等)、read 和 write 方法,它们分别负责数据的定义、查询与写入。在数据查询时,IStorage 负责根据 AST 查询语句的指示要求,返回指定列的原始数据。
- 后续对数据的进一步加工、计算和过滤,则会统一交由Interpreter解释器对象处理。对Table发起的一次操作通常都会经历这样的过程,接收AST查询语句,根据AST返回指定列的数据,之后再将数据交由Interpreter做进一步处理。
-
Block与Block Streams:ClickHouse 内部的数据操作是面向 Block 对象进行的,并且采用了流的形式。
- Block:虽然 Column 和 Filed 组成了数据的基本映射单元,但对应到实际操作,它们还缺少了一些必要的信息,比如数据的类型及列的名称。于是 ClickHouse 设计了 Block对象,Block 对象可以看作数据表的子集。Block 对象的本质是由数据对象、数据类型和列名称组成的三元组,即 Column、DataType 及列名称字符串。Column 提供了数据的读取能力,而DataType知道如何正反序列化,所以 Block 在这些对象的基础之上实现了进一步的抽象和封装,从而简化了整个使用的过程,仅通过Block对象就能完成一系列的数据操作。在具体的实现过程中,Block 并没有直接聚合Column和DataType对象,而是通过
ColumnWithTypeAndName
对象进行间接引用。 - Block Streams:Block Streams 用于处理数据。我们可以使用 Block Streams 从某个地方读取数据,执行数据转换,或将数据写到某个地方。
IBlockInputStream
具有read
方法,其能够在数据可用时获取下一个块。IBlockOutputStream
具有write
方法,其能够将块写到某处。
- Block:虽然 Column 和 Filed 组成了数据的基本映射单元,但对应到实际操作,它们还缺少了一些必要的信息,比如数据的类型及列的名称。于是 ClickHouse 设计了 Block对象,Block 对象可以看作数据表的子集。Block 对象的本质是由数据对象、数据类型和列名称组成的三元组,即 Column、DataType 及列名称字符串。Column 提供了数据的读取能力,而DataType知道如何正反序列化,所以 Block 在这些对象的基础之上实现了进一步的抽象和封装,从而简化了整个使用的过程,仅通过Block对象就能完成一系列的数据操作。在具体的实现过程中,Block 并没有直接聚合Column和DataType对象,而是通过
-
Functions:ClickHouse 主要提供两类函数——普通函数和聚合函数。
- Function:普通函数由
IFunction
接口定义,其是没有状态的,函数效果作用于每行数据之上。当然,在函数具体执行的过程中,并不会一行一行地运算,而是采用向量化的方式直接作用于一整列数据。 - AggregateFunction:聚合函数由
IAggregateFunction
接口定义,相比无状态的普通函数,聚合函数是有状态的,并且聚合函数的状态支持序列化与反序列化,所以能够在分布式节点之间进行传输,以实现增量计算。
- Function:普通函数由
-
DataType:数据的序列化和反序列化工作由 DataType 负责。根据不同的数据类型,
IDataType
接口会有不同的实现类。DataType 虽然会对数据进行正反序列化,但是它不会直接和内存或者磁盘做交互,而是转交给 Column 和 Filed 处理。 -
Column 与 Field:Column 和 Field 是 ClickHouse 数据最基础的映射单元。
- Column:内存中的一列数据由一个 Column 对象表示。Column 对象分为接口和实现两个部分,在
IColumn
接口对象中,定义了对数据进行各种关系运算的方法,例如插入数据的insertRangeFrom
和insertFrom
方法、用于分页的cut
,以及用于过滤的filter
方法等。而这些方法的具体实现对象则根据数据类型的不同,由相应的对象实现。 - Field:在大多数场合,ClickHouse 都会以整列的方式操作数据,但凡事也有例外。如果需要操作单个具体的数值(也就是单列中的一行数据),则需要使用 Field 对象,Field 对象代表一个单值。与 Column 对象的泛化设计思路不同,Field 对象使用了聚合的设计模式。在 Field 对象内部聚合了 Null、UInt64、String 和 Array 等 13 种数据类型及相应的处理逻辑。
- Column:内存中的一列数据由一个 Column 对象表示。Column 对象分为接口和实现两个部分,在