OLAP(四):ClickHouse

引言

什么是ClickHouse? | ClickHouse文档

ClickHouse是近年来备受关注的开源列式数据库,主要用于数据分析(OLAP)领域。目前国内各个大厂纷纷跟进大规模使用:

  • 今日头条内部用ClickHouse来做用户行为分析,内部一共几千个ClickHouse节点,单集群最大1200节点,总数据量几十PB,日增原始数据300TB左右。
  • 腾讯内部用ClickHouse做游戏数据分析,并且为之建立了一整套监控运维体系。
  • 携程内部从18年7月份开始接入试用,目前80%的业务都跑在ClickHouse上。每天数据增量十多亿,近百万次查询请求。
  • 快手内部也在使用ClickHouse,存储总量大约10PB, 每天新增200TB, 90%查询小于3S。
  • 阿里内部专门孵化了相应的云数据库ClickHouse,并且在包括手机淘宝流量分析在内的众多业务被广泛使用。

        在开源的短短几年时间内,ClickHouse就俘获了诸多大厂的“芳心”,并且在Github上的活跃度超越了众多老牌的经典开源项目,如Presto、Druid、Impala、Geenplum等;其受欢迎程度和社区火热程度可见一斑。

相比HBase和Redis这类NoSQL数据库,ClickHouse使用关系模型描述数据并提供了传统数据库的概念 ( 数据库、表、视图和函数等 )。与此同时,ClickHouse完全使用SQL作为查询语言 ( 支持GROUP BY、ORDER BY、JOIN、IN等大部分标准SQL ),这使得它平易近人,容易理解和学习。

关系模型相比文档和键值对等其他模型,拥有更好的描述能力,也能够更加清晰地表述实体间的关系。更重要的是,在OLAP领域,已有的大量数据建模工作都是基于关系模型展开的 ( 星型模型、雪花模型乃至宽表模型 )。ClickHouse使用了关系模型,所以将构建在传统关系型数据库或数据仓库之上的系统迁移到ClickHouse的成本会变得更低,可以直接沿用之前的经验成果。

--建表语句
create table t_order_mt(
id UInt32,
sku_id String,
total_amount Decimal(16,2),
--total_amount Decimal(16,2) TTL create_time+interval 10 SECOND, --到期后,指定的字段数据归 0
create_time Datetime
) engine =MergeTree
--engine =ReplacingMergeTree(create_time)
--填入的参数为版本字段,重复数据保留版本字段值最大的。如果不填版本字段,默认按照插入顺序保留最后一条。
--继承 MergeTree,只是多了一个去重的功能,数据的去重只会在合并的过程中出现
partition by toYYYYMMDD(create_time)  --可选
primary key (id)                      --可选
order by (id,sku_id);                 --必选

--表级 TTL
alter table t_order_mt3 MODIFY TTL create_time + INTERVAL 10 SECOND;

--插入数据
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')

--删除操作
alter table t_order_smt delete where sku_id ='sku_001';

--修改操作
alter table t_order_smt update total_amount=toDecimal32(2000.00,2) where id =102;

--新增字段
alter table tableName add column newcolname String after col1;

--修改字段类型
alter table tableName modify column newcolname String;

--删除字段 
alter table tableName drop column newcolname;

-- 导出数据
clickhouse-client --query "select * from t_order_mt where create_time='2020-06-01
12:00:00'" --format CSVWithNames> /opt/module/data/rs1.csv

        可以发现在执行了update,delete操作之后数据目录会生成文件mutation_5.txt,mutation_6.txt。
此外还有在同名的
目录下在末尾增加了_5 ,_6的后缀。
可以看到mutation_5.txt和mutation_6.txt 是日志文件,完整的记录了update和delete操作语句和时间。
mutation_id:生成对应的日志文件用于记录相关的信息。
数据删除的过程是以数据表的每个分区目录为单位,将所有目录重写为新的目录,在目录的命名规则是在原有的名称上加上 block_numbers.number
数据的在重写的过程中会讲所需要删除的数据去掉。旧的数据并不会立即删除,而是被标记为非激活状态(active =0),
等到MergeTree引擎的下一次合并动作触发的时候,
这些非活动目录才会被真正的从物理上删除。
 

ClickHouse的组件架构

下图是一个典型的ClickHouse集群部署结构图,符合经典的share-nothing架构。

一文读懂备受大厂青睐的ClickHouse高性能列存核心原理

整个集群分为多个shard(分片),不同shard之间数据彼此隔离;在一个shard内部,可配置一个或多个replica(副本),互为副本的2个replica之间通过专有复制协议保持最终一致性。

ClickHouse根据表引擎将表分为本地表和分布式表,两种表在建表时都需要在所有节点上分别建立。其中本地表只负责当前所在server上的写入、查询请求;而分布式表则会按照特定规则,将写入请求和查询请求进行拆解,分发给所有server,并且最终汇总请求结果。

多主架构(对等架构)

HDFS、Spark、HBase和Elasticsearch这类分布式系统,都采用了Master-Slave主从架构,由一个管控节点作为Leader统筹全局。而ClickHouse则采用Multi-Master多主架构,集群中的每个节点角色对等,客户端访问任意一个节点都能得到相同的效果。这种多主的架构有许多优势,例如对等的角色使系统架构变得更加简单,不用再区分主控节点、数据节点和计算节点,集群中的所有节点功能相同。所以它天然规避了单点故障的问题,非常适合用于多数据中心、异地多活的场景。

一、ClickHouse存储层

        ClickHouse从OLAP场景需求出发,定制开发了一套全新的高效列式存储引擎,并且实现了数据列式存储、有序存储、主键索引、稀疏索引、数据Sharding、数据Partitioning、TTL、主备复制等丰富功能。以上功能共同为ClickHouse极速的分析性能奠定了基础。

一、列式存储

与行存将每一行的数据连续存储不同,列存将每一列的数据连续存储

相比于行式存储,列式存储在分析场景下有着许多优良的特性。

1)分析场景中往往需要读大量行但是少数几个列。在行存模式下,数据按行连续存储,所有列的数据都存储在一个block中,不参与计算的列在IO时也要全部读出,读取操作被严重放大。而列存模式下,只需要读取参与计算的列即可,极大的减低了IO cost,加速了查询。

2)同一列中的数据属于同一类型,压缩效果显著。列存往往有着高达十倍甚至更高的压缩比,节省了大量的存储空间,降低了存储成本。

3)更高的压缩比意味着更小的data size,从磁盘中读取相应数据耗时更短。

4)自由的压缩算法选择不同列的数据具有不同的数据类型,适用的压缩算法也就不尽相同。可以针对不同列类型,选择最合适的压缩算法。

5)高压缩比,意味着同等大小的内存能够存放更多数据,系统cache效果更好。

官方数据显示,通过使用列存,在某些分析场景下,能够获得100倍甚至更高的加速效应。

二、数据有序存储

ClickHouse支持在建表时,指定将数据按照某些列进行sort by。

排序后,保证了相同sort key的数据在磁盘上连续存储,且有序摆放。在进行等值、范围查询时,where条件命中的数据都紧密存储在一个或若干个连续的Block中,而不是分散的存储在任意多个Block, 大幅减少需要IO的block数量。另外,连续IO也能够充分利用操作系统page cache的预取能力,减少page fault。

三、主键索引

ClickHouse支持主键索引,它将每列数据按照index granularity(默认8192行)进行划分,每个index granularity的开头第一行被称为一个mark行。主键索引存储该mark行对应的primary key的值。

        对于where条件中含有primary key的查询,通过对主键索引进行二分查找,能够直接定位到对应的index granularity,避免了全表扫描从而加速查询。

但是值得注意的是:ClickHouse的主键索引与MySQL等数据库不同,它并不用于去重,即便primary key相同的行,也可以同时存在于数据库中。要想实现去重效果,需要结合具体的表引擎ReplacingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree实现,我们会在未来的文章系列中再进行详细解读。

四、稀疏索引

        ClickHouse支持对任意列创建任意数量的稀疏索引。其中被索引的value可以是任意的合法SQL Expression,并不仅仅局限于对column value本身进行索引。之所以叫稀疏索引,是因为它本质上是对一个完整index granularity(默认8192行)的统计信息,并不会具体记录每一行在文件中的位置。目前支持的稀疏索引类型包括:

  • minmax: 以index granularity为单位,存储指定表达式计算后的min、max值;在等值和范围查询中能够帮助快速跳过不满足要求的块,减少IO。
  • set(max_rows):以index granularity为单位,存储指定表达式的distinct value集合,用于快速判断等值查询是否命中该块,减少IO。
  • ngrambf_v1(n, size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed):将string进行ngram分词后,构建bloom filter,能够优化等值、like、in等查询条件。
  • tokenbf_v1(size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed): 与ngrambf_v1类似,区别是不使用ngram进行分词,而是通过标点符号进行词语分割。
  • bloom_filter([false_positive]):对指定列构建bloom filter,用于加速等值、like、in等查询条件的执行。

五、数据Sharding,分片集群

        副本虽然能够提高数据的可用性,降低丢失风险,但是每台服务器实际上必须容纳全量
数据,对数据的横向扩容没有解决。要解决数据水平切分的问题,需要引入分片的概念。通过分片把一份完整的数据进行切分,不同的分片分布到不同的节点上,再通过 Distributed 表引擎把数据拼接起来一同使用。

        ClickHouse支持单机模式,也支持分布式集群模式。在分布式模式下,ClickHouse会将数据分为多个分片,并且分布到不同节点上。不同的分片策略在应对不同的SQL Pattern时,各有优势。ClickHouse提供了丰富的sharding策略,让业务可以根据实际需求选用。

  • 1) random随机分片:写入数据会被随机分发到分布式集群中的某个节点上。
  • 2) constant固定分片:写入数据会被分发到固定一个节点上。
  • 3)column value分片:按照某一列的值进行hash分片。
  • 4)自定义表达式分片:指定任意合法表达式,根据表达式被计算后的值进行hash分片。

数据分片,让ClickHouse可以充分利用整个集群的大规模并行计算能力,快速返回查询结果。

更重要的是,多样化的分片功能,为业务优化打开了想象空间。比如在hash sharding的情况下,JOIN计算能够避免数据shuffle,直接在本地进行local join; 支持自定义sharding,可以为不同业务和SQL Pattern定制最适合的分片策略;利用自定义sharding功能,通过设置合理的sharding expression可以解决分片间数据倾斜问题等。

另外,sharding机制使得ClickHouse可以横向线性拓展,构建大规模分布式集群,从而具备处理海量数据的能力。

集群写入流程(3 分片 2 副本共 6 个节点):

 集群读取流程(3 分片 2 副本共 6 个节点):

hadoop202创建 Distribute 分布式表 

create table st_order_mt_all on cluster gmall_cluster
(
id UInt32,
sku_id String,
total_amount Decimal(16,2),
create_time Datetime
)engine = Distributed(gmall_cluster,default, st_order_mt,hiveHash(sku_id));

--参数含义
Distributed(集群名称,库名,本地表名,分片键)
分片键必须是整型数字,所以用 hiveHash 函数转换,也可以 rand()

insert into st_order_mt_all values
(201,'sku_001',1000.00,'2020-06-01 12:00:00') ,
(202,'sku_002',2000.00,'2020-06-01 12:00:00'),
(203,'sku_004',2500.00,'2020-06-01 12:00:00'),
(204,'sku_002',2000.00,'2020-06-01 12:00:00'),
(205,'sku_003',600.00,'2020-06-02 12:00:00');

--通过查询分布式表和本地表观察输出结果
SELECT * FROM st_order_mt_all;

st_order_mt_all 

 hadoop202: st_order_mt

hadoop203:st_order_mt

 

hadoop204:st_order_mt

六、数据Partitioning分区

        ClickHouse支持PARTITION BY子句,在建表时可以指定按照任意合法表达式进行数据分区操作,比如通过toYYYYMM()将数据按月进行分区、toMonday()将数据按照周几进行分区、对Enum类型的列直接每种取值作为一个分区等。

数据Partition在ClickHouse中主要有两方面应用:

  • 在partition key上进行分区裁剪,只查询必要的数据。灵活的partition expression设置,使得可以根据SQL Pattern进行分区设置,最大化的贴合业务特点。
  • 对partition进行TTL管理,淘汰过期的分区数据。

七、主备同步,副本写入流程

        ClickHouse通过主备复制提供了高可用能力,主备架构下支持无缝升级等运维操作。而且相比于其他系统它的实现有着自己的特色:

  • 1)默认配置下,任何副本都处于active模式,可以对外提供查询服务;
  • 2)可以任意配置副本个数,副本数量可以从0个到任意多个;
  • 3)不同shard可以配置不提供副本个数,用于解决单个shard的查询热点问题;

 副本只能同步数据,不能同步表结构,所以我们需要在每台机器上自己手动建表

七、高吞吐写入能力LSM Tree

        ClickHouse采用类LSM Tree的结构,数据写入后定期在后台Compaction。通过类LSM tree的结构,ClickHouse在数据导入时全部是顺序append写,写入后数据段不可更改,在后台compaction时也是多个段merge sort后顺序写回磁盘。顺序写的特性,充分利用了磁盘的吞吐能力,即便在HDD上也有着优异的写入性能。

官方公开benchmark测试显示能够达到50MB-200MB/s的写入吞吐能力,按照每行100Byte估算,大约相当于50W-200W条/s的写入速度。

ClickHouse采用列式作为单机存储,并且采用了类LSM tree的结构来进行组织与合并。一张MergeTree本地表,从磁盘文件构成如下图所示。

本地表的数据被划分为多个Data PART,每个Data PART对应一个磁盘目录。Data PART在落盘后,就是immutable的,不再变化。ClickHouse后台会调度MergerThread将多个小的Data PART不断合并起来,形成更大的Data PART,从而获得更高的压缩率、更快的查询速度。当每次向本地表中进行一次insert请求时,就会产生一个新的Data PART,也即新增一个目录。如果insert的batch size太小,且insert频率很高,可能会导致目录数过多进而耗尽inode,也会降低后台数据合并的性能,这也是为什么ClickHouse推荐使用大batch进行写入且每秒不超过1次的原因。

        在Data PART内部存储着各个列的数据,由于采用了列存格式,所以不同列使用完全独立的物理文件。每个列至少有2个文件构成,分别是.bin 和 .mrk文件其中.bin是数据文件,保存着实际的data;而.mrk是元数据文件,保存着数据的metadata。此外,ClickHouse还支持primary index、skip index等索引机制,所以也可能存在着对应的pk.idx,skip_idx.idx文件

数据写入

        在数据写入过程中,数据被按照index_granularity切分为多个颗粒(granularity),默认值为8192行对应一个颗粒。多个颗粒在内存buffer中积攒到了一定大小(由参数min_compress_block_size控制,默认64KB),会触发数据的压缩、落盘等操作,形成一个block。每个颗粒会对应一个mark,该mark主要存储着2项信息:1)当前block在压缩后的物理文件中的offset,2)当前granularity在解压后block中的offset。所以Block是ClickHouse与磁盘进行IO交互、压缩/解压缩的最小单位,而granularity是ClickHouse在内存中进行数据扫描的最小单位。

        如果有ORDER BY key或Primary key,则ClickHouse在Block数据落盘前,会将数据按照ORDER BY key进行排序。主键索引pk.idx中存储着每个mark对应的第一行数据,也即在每个颗粒中各个列的最小值。

当存在其他类型的稀疏索引时,会额外增加一个<col>_<type>.idx文件,用来记录对应颗粒的统计信息。比如:

  • minmax会记录各个颗粒的最小、最大值;
  • set会记录各个颗粒中的distinct值;
  • bloomfilter会使用近似算法记录对应颗粒中,某个值是否存在;

一文读懂备受大厂青睐的ClickHouse高性能列存核心原理

数据查询        

        在查找时,如果query包含主键索引条件,则首先在pk.idx中进行二分查找,找到符合条件的颗粒mark,并从mark文件中获取block offset、granularity offset等元数据信息,进而将数据从磁盘读入内存进行查找操作。类似的,如果条件命中skip index,则借助于index中的minmax、set等信息,定位出符合条件的颗粒mark,进而执行IO操作。借助于mark文件,ClickHouse在定位出符合条件的颗粒之后,可以将颗粒平均分派给多个线程进行并行处理,最大化利用磁盘的IO吞吐和CPU的多核处理能力。

 ClickHouse写入链路

ClickHouse提供2种写入方法,1)写本地表;2)写分布式表。

写本地表方式,需要业务层感知底层所有server的IP,并且自行处理数据的分片操作。由于每个节点都可以分别直接写入,这种方式使得集群的整体写入能力与节点数完全成正比,提供了非常高的吞吐能力和定制灵活性。但是相对而言,也增加了业务层的依赖,引入了更多复杂性,尤其是节点failover容错处理、扩缩容数据re-balance、写入和查询需要分别使用不同表引擎等都要在业务上自行处理。

而写分布式表则相对简单,业务层只需要将数据写入单一endpoint及单一一张分布式表即可,不需要感知底层server拓扑结构等实现细节。写分布式表也有很好的性能表现,在不需要极高写入吞吐能力的业务场景中,建议直接写入分布式表降低业务复杂度。

以下阐述分布式表的写入实现原理。

ClickHouse使用Block作为数据处理的核心抽象,表示在内存中的多个列的数据,其中列的数据在内存中也采用列存格式进行存储。示意图如下:其中header部分包含block相关元信息,而id UInt8、name String、_date Date则是三个不同类型列的数据表示。

一文读懂备受大厂青睐的ClickHouse高性能列存核心原理

 在Block之上,封装了能够进行流式IO的stream接口,分别是IBlockInputStream、IBlockOutputStream,接口的不同对应实现不同功能。

当收到INSERT INTO请求时,ClickHouse会构造一个完整的stream pipeline,每一个stream实现相应的逻辑:

InputStreamFromASTInsertQuery        #将insert into请求封装为InputStream作为数据源
-> CountingBlockOutputStream         #统计写入block count
-> SquashingBlockOutputStream        #积攒写入block,直到达到特定内存阈值,提升写入吞吐
-> AddingDefaultBlockOutputStream    #用default值补全缺失列
-> CheckConstraintsBlockOutputStream #检查各种限制约束是否满足
-> PushingToViewsBlockOutputStream   #如有物化视图,则将数据写入到物化视图中
-> DistributedBlockOutputStream      #将block写入到分布式表中

注:*左右滑动阅览

在以上过程中,ClickHouse非常注重细节优化,处处为性能考虑。在SQL解析时,ClickHouse并不会一次性将完整的INSERT INTO table(cols) values(rows)解析完毕,而是先读取insert into table(cols)这些短小的头部信息来构建block结构,values部分的大量数据则采用流式解析,降低内存开销。在多个stream之间传递block时,实现了copy-on-write机制,尽最大可能减少内存拷贝。在内存中采用列存存储结构,为后续在磁盘上直接落盘为列存格式做好准备。


SquashingBlockOutputStream将客户端的若干小写,转化为大batch,提升写盘吞吐、降低写入放大、加速数据Compaction。

默认情况下,分布式表写入是异步转发的。
DistributedBlockOutputStream将Block按照建表DDL中指定的规则(如hash或random)切分为多个分片,每个分片对应本地的一个子目录,将对应数据落盘为子目录下的.bin文件,写入完成后就返回client成功。随后分布式表的后台线程,扫描这些文件夹并将.bin文件推送给相应的分片server。.bin文件的存储格式示意如下:

一文读懂备受大厂青睐的ClickHouse高性能列存核心原理

二、ClickHouse计算层

        ClickHouse在计算层做了非常细致的工作,竭尽所能榨干硬件能力,提升查询速度。它实现了单机多核并行、分布式计算、向量化执行与SIMD指令、代码生成等多种重要技术。

1、多核并行

        ClickHouse将数据划分为多个partition,每个partition再进一步划分为多个index granularity,然后通过多个CPU核心分别处理其中的一部分来实现并行数据处理

在这种设计下,单条Query就能利用整机所有CPU。极致的并行处理能力,极大的降低了查询延时。

2、分布式计算

        除了优秀的单机并行处理能力,ClickHouse还提供了可线性拓展的分布式计算能力。ClickHouse会自动将查询拆解为多个task下发到集群中,然后进行多机并行处理,最后把结果汇聚到一起。

在存在多副本的情况下,ClickHouse提供了多种query下发策略:

  • 随机下发:在多个replica中随机选择一个;
  • 最近hostname原则:选择与当前下发机器最相近的hostname节点,进行query下发。在特定的网络拓扑下,可以降低网络延时。而且能够确保query下发到固定的replica机器,充分利用系统cache。
  • in order:按照特定顺序逐个尝试下发,当前一个replica不可用时,顺延到下一个replica。
  • first or random:在In Order模式下,当第一个replica不可用时,所有workload都会积压到第二个Replica,导致负载不均衡。first or random解决了这个问题:当第一个replica不可用时,随机选择一个其他replica,从而保证其余replica间负载均衡。另外在跨region复制场景下,通过设置第一个replica为本region内的副本,可以显著降低网络延时。

3、向量化执行与SIMD

        ClickHouse不仅将数据按列存储,而且按列进行计算。传统OLTP数据库通常采用按行计算,原因是事务处理中以点查为主,SQL计算量小,实现这些技术的收益不够明显。但是在分析场景下,单个SQL所涉及计算量可能极大,将每行作为一个基本单元进行处理会带来严重的性能损耗:

  • 1)对每一行数据都要调用相应的函数,函数调用开销占比高;
  • 2)存储层按列存储数据,在内存中也按列组织,但是计算层按行处理,无法充分利用CPU cache的预读能力,造成CPU Cache miss严重;
  • 3)按行处理,无法利用高效的SIMD指令;

        ClickHouse实现了向量执行引擎(Vectorized execution engine),对内存中的列式数据,一个batch调用一次SIMD指令(而非每一行调用一次),不仅减少了函数调用次数、降低了cache miss,而且可以充分发挥SIMD指令的并行能力,大幅缩短了计算耗时。向量执行引擎,通常能够带来数倍的性能提升。

4、动态代码生成Runtime Codegen

        在经典的数据库实现中,通常对表达式计算采用火山模型,也即将查询转换成一个个operator,比如HashJoin、Scan、IndexScan、Aggregation等。为了连接不同算子,operator之间采用统一的接口,比如open/next/close。在每个算子内部都实现了父类的这些虚函数,在分析场景中单条SQL要处理数据通常高达数亿行,虚函数的调用开销不再可以忽略不计。另外,在每个算子内部都要考虑多种变量,比如列类型、列的size、列的个数等,存在着大量的if-else分支判断导致CPU分支预测失效。

ClickHouse实现了Expression级别的runtime codegen,动态地根据当前SQL直接生成代码,然后编译执行。如下图例子所示,对于Expression直接生成代码,不仅消除了大量的虚函数调用(即图中多个function pointer的调用),而且由于在运行时表达式的参数类型、个数等都是已知的,也消除了不必要的if-else分支判断。

5、近似计算

        近似计算以损失一定结果精度为代价,极大地提升查询性能。在海量数据处理中,近似计算价值更加明显。

ClickHouse实现了多种近似计算功能:

  • 近似估算distinct values、中位数,分位数等多种聚合函数;
  • 建表DDL支持SAMPLE BY子句,支持对于数据进行抽样处理;

6、复杂数据类型支持

ClickHouse还提供了array、json、tuple、set等复合数据类型,支持业务schema的灵活变更。

哪些场景比较适合使用ClickHouse呢?

ClickHouse比较适合分析结构化的、干净的、不可变的流式数据,比如打点日志分析啦,行为分析啦。强烈建议将源源不断的流式数据和提前已经定义好的维度表组合起来,并塞到一个基于事实的大宽表中去( a single wide fact table,一张宽大的事实表,是不是很有亲切感?)。

ClickHouse比较适用于以下行业/场景:

  • 网页端和客户端产品的数据分析
  • 广告系统和实时竞价广告
  • 电信行业
  • 电商和金融行业
  • 信息安全
  • 实时监控和遥感测量
  • 时间序列
  • 商业智能
  • 在线游戏
  • 所有的互联网场景

ClickHouse不适合的场景:

  • 联机事物处理(上面提到过的,小黑板敲起来,mysql这种比较适合)
  • 键值对数据高效率访问请求(想到redis了嘛?)
  • 二进制数据或文件存储(想到mongDB了嘛?)
  • 过度标准化的数据(有很多人好奇了,开什么玩笑,数据按说应该是越标准化越好啊?这个问题应该这样理解,那些过于简单直接、没啥维度和灵活度需求的数据,咋能体现ClickHouse的优势呢,你用啥数据库都能做得很6啊)

Clickhouse为什么这么快?减少数据扫描范围

相信看过ClickHouse性能测试报告的同学都很震惊于他超高的OLAP查询性能。于是下一步开始搜索“ClickHouse性能为什么高”看到了例如:列存储、数据压缩、并行处理、向量化引擎 等等一些关键词,对于我们一般人来说,并没有解答心中的疑惑:ClickHouse性能为什么高? 于是想写几篇博文,用通俗、简单的实例和大家一起探讨一下这个问题,希望能通过博文和大家的探讨解答这个疑惑!
针对OLAP类的查询最简单的优化方式就是减少数据扫描范围,故而我们以此作为开篇。

问题:
有一个表(tab01)有10个int类型的字段(col1,col2,col3…col10),这个表中有十亿条数据。
{176, 35, 27, 82, 65, 75, 20, 25, 92, 35},
{322, 42, 33, 12, 82, 77, 65, 22, 98, 12},

为了简单假定该表没有主键和索引,在本篇中也不考虑任何的数据压缩。

需要完成如下查询:

SELECT avg(col1),avg(col2) FROM tab01 WHERE col3 > 500;

方案一:行存储

如果该表以行存储的方式存储为一个文件:tab01.dat , 每条数据40字节,那么整个文件大小为40GB,

在这个方案中,我们需要从头到尾读取文件所有数据,也就是需要从磁盘读取40GB的内容。显然行存储方案对于处理这类问题来说并不高效。

方案二:列存储

如果我们将该表的数据以列存储,即:将所有数据存储为10个文件,col1.dat 存储col1列所有的10亿条数据,依此类推,我们得到了10个数据文件,每个数据文件存储十亿个数据,大小为4GB。
那么完成上面的查询,只需要读取col1.dat 、 col2.dat 、 col3.dat 三个文件,总共12GB。 读磁盘的大小仅为方案一的30%。 现在我们就通过减少数据扫描范围达到了提升性能的目的。这也是列存储广泛应用在OLAP场景中的原因。

列存储压缩

        默认使用LZ4算法压缩,在Yandex.Metrica的生产环境中,数据总体的压缩比可以达到8:1 ( 未压缩前17PB,压缩后2PB )。列式存储除了降低IO和存储的压力之外,还为向量化执行做好了铺垫。

按列存储相比按行存储的另一个优势是对数据压缩的友好性。同样可以用一个示例简单说明压缩的本质是什么。假设有两个字符串abcdefghi和bcdefghi,现在对它们进行压缩,如下所示:

压缩前:abcdefghi_bcdefghi压缩后:abcdefghi_(9,8)
可以看到,压缩的本质是按照一定步长对数据进行匹配扫描,当发现重复部分的时候就进行编码转换。例如上述示例中的 (9,8),表示如果从下划线开始向前移动9个字节,会匹配到8个字节长度的重复项,即这里的bcdefghi。

方案三: 将数据分块

借助分布式表,能够代理访问多个数据分片,从而实现分布式查询。

在列存储的基础上,如果按照8192条数据进行分块,比如:col1.dat 的前 8192条数据是数据块1,接下来8192条数据是数据块2。所有数据文件都是如此,数据的逻辑组织如下所示:

 数据列分块示意图
执行过程:以数据块为单位依次读取col3列的所有数据块,取得数据块中所有符合条件(col3 > 500)的数据下标。

如果在该数据块中有符合条件的数据,则从col1.dat和col2.dat中读取对应位置的数据块,并取出对应位置的数据。
如果在该数据块中没有符合条件的数据,则不需要读取col1.dat和col2.dat中对应位置的数据块。
在这个优化中,我们只需要读取col3.dat全部数据和部分或全部col1.dat和col2.dat的数据,数据扫描范围相比于上一个方案进一步缩小。 这也就是ClickHouse中PreWhere所作的优化。

方案四: 数据块索引

一般的OLAP可能需要存储千亿甚至万亿级的数据,普通一条数据一个索引的方式已经不适应于这么大的数据量。此时,对数据列上以数据块为单位建立索引是比较适合的方案。例如:对上例中列col3以数据块建立索引每条索引存储指定数据块中的最大值、最小值,十亿条数据的列仅需要不到13万条索引。

数据块索引
执行过程:首先读取col3数据块索引,判断该列存储的数据是否满足条件[27,87] 与 (500,) 是否有交集。

如果有交集则继续上一个方案的步骤。
如果没有交集则不需要读取任何数据块。
在这个优化中,需要读取col3.idx所有的数据,以及col1, col2, col3 的部分数据块。与上一个方案相比,扫描到的数据量进一步缩小。

一说到Clickhouse,大家都知道它非常快,那么CH为什么这么快,是因为使用了向量化、列式数据库还是其它。下面我们一起来探索它的奥秘。(《ClickHouse原理解析与应用实践》总结)

1、硬件方面

        ClickHouse会在内存中进行GROUP BY,并且使用HashTable装载数据。与此同时,他们非常在意CPU L3级别的缓存,因为一次L3的缓存失效会带来70~100ns的延迟。这意味着在单核CPU上,它会浪费4000万次/秒的运算;而在一个32线程的CPU上,则可能会浪费5亿次/秒的运算。所以别小看这些细节,一点一滴地将它们累加起来,数据是非常可观的。正因为注意了这些细节,所以ClickHouse在基准查询中能做到1.75亿次/秒的数据扫描性能。

2、算法方面

        在ClickHouse的底层实现中,经常会面对一些重复的场景,例如字符串子串查询、数组排序、使用HashTable等。如何才能实现性能的最大化呢?算法的选择是重中之重。以字符串为例,有一本专门讲解字符串搜索的书,名为'Handbook of Exact String Matching Algorithms',列举了35种常见的字符串搜索算法。各位猜一猜ClickHouse使用了其中的哪一种?答案是一种都没有。这是为什么呢?因为性能不够快。在字符串搜索方面,针对不同的场景,ClickHouse最终选择了这些算法

  • 对于常量,使用Volnitsky算法;
  • 对于非常量,使用CPU的向量化执行SIMD,暴力优化;
  • 正则匹配使用re2和hyperscan算法。

性能是算法选择的首要考量指标。

3、应景优化,特定场景,特殊优化

CH会在不同的场景使用不同的算法。例如,在去重函数uniqCombined中,会根据数据量选择不同的算法:数据量比较少的时候,会选择使用Array来保存;数据量中等的时候,使用HashSet;数据量很大的时候,会使用HyperLogLog算法。

针对同一个场景的不同状况,选择使用不同的实现方式,尽可能将性能最大化。关于这一点,其实在前面介绍字符串查询时,针对不同场景选择不同算法的思路就有体现了。类似的例子还有很多,例如去重计数uniqCombined函数,会根据数据量的不同选择不同的算法:

  • 当数据量较小的时候,会选择Array保存;
  • 当数据量中等的时候,会选择HashSet;
  • 而当数据量很大的时候,则使用HyperLogLog算法。

对于数据结构比较清晰的场景,会通过代码生成技术实现循环展开,以减少循环次数。接着就是大家熟知的大杀器—向量化执行了。SIMD被广泛地应用于文本转换、数据过滤、数据解压和JSON转换等场景。相较于单纯地使用CPU,利用寄存器暴力优化也算是一种降维打击了。

5. 多样化的表引擎

也许因为Yandex.Metrica的最初架构是基于MySQL实现的,所以在ClickHouse的设计中,能够察觉到一些MySQL的影子,表引擎的设计就是其中之一。与MySQL类似,ClickHouse也将存储部分进行了抽象,把存储引擎作为一层独立的接口。截至本书完稿时,ClickHouse共拥有合并树、内存、文件、接口和其他6大类20多种表引擎。其中每一种表引擎都有着各自的特点,用户可以根据实际业务场景的要求,选择合适的表引擎使用。

将表引擎独立设计的好处是显而易见的,通过特定的表引擎支撑特定的场景,十分灵活。对于简单的场景,可直接使用简单的引擎降低成本,而复杂的场景也有合适的选择。

4、向量化、多线程与分布式

坊间有句玩笑,即'能用钱解决的问题,千万别花时间'。而业界也有种调侃如出一辙,即'能升级硬件解决的问题,千万别优化程序'。有时候,你千辛万苦优化程序逻辑带来的性能提升,还不如直接升级硬件来得简单直接。

 距离CPU越远,则数据的访问速度越慢。从寄存器中访问数据的速度,是从内存访问数据速度的300倍,是从磁盘中访问数据速度的3000万倍。所以利用CPU向量化执行的特性,对于程序的性能提升意义非凡。

SIMD的全称是Single Instruction Multiple Data,即用单条指令操作多条数据。现代计算机系统概念中,它是通过数据并行以提高性能的一种实现方式 ( 其他的还有指令级并行和线程级并行 ),它的原理是在CPU寄存器层面实现数据的并行操作。,CH使用向量化执行。SIMD被广泛地应用于文本转换、数据过滤、数据解压和JSON转换等场景。相对于单纯使用CPU,利用寄存器暴力优化也算是一种降维打击

5、持续测试和持续改进

如果只是单纯地在上述细节上下功夫,还不足以构建出如此强大的ClickHouse,还需要拥有一个能够持续验证、持续改进的机制。由于Yandex的天然优势,ClickHouse经常会使用真实的数据进行测试,这一点很好地保证了测试场景的真实性。与此同时,ClickHouse也是我见过的发版速度最快的开源软件了,差不多每个月都能发布一个版本。没有一个可靠的持续集成环境,这一点是做不到的。正因为拥有这样的发版频率,ClickHouse才能够快速迭代、快速改进。
 

猜你喜欢

转载自blog.csdn.net/qq_22473611/article/details/119920741