clickhouse、doris都是开源的比较热门的olap数据库。本次分享主要基于选品引擎自建时选型clickhouse,好奇驱使下想研究一下二者区别差异点。文章内容可能比较浅显,如果大家感兴趣可以后续分多次继续分享。
Doris 简介
架构
doris | ck 多主结构 |
|
|
FE节点
-
类似mysql server层,接收和返回客户端的请求。
-
负责存储和管理所有的元数据,包括表结构信息、用户信息,集群节点状态以及数据分布情况等。
-
查询计划(类似mysql的解释器 + 优化器 对查询语句进行解析、分析、改写优化等)生成。
-
FE 还会对 BE 发起心跳检测,并定期进行集群的负载均衡操作。Doris 也支持多 FE 实例部署,通过选主机制实现高可用。
BE节点
-
doris的存储引擎,负责数据存储与管理,以及查询计划的执行
-
数据负载均衡与副本管理
数据存储
数据分布
-
表结构定义是可以设置分区,如果未制定分区默认会生成一个all分区进行写入也就是不分区(与ck类似)。如果建表是不分桶,默认是不会分桶。
-
tablet是实际的物理存储单元。一张表可以被分为多个分区。每个分区数据又会根据分区键进行hash取余的方式进行分桶。(DISTRIBUTED BY HASH(site) BUCKETS 20)
-
一个表的tablet分布在创建时就已经被fe确定,fe会调用rpc接口去相应的be节点生成tablet。tablet的服务没有主从的区分。数据根据负载均衡会选择一个副本写入,其余副本会同步数据写入,每个副本都包含完整的数据,可以提供服务。
-
通过WAL保证一致性,然后将日志发送到与这个tablet相关be节点,然后写入。如果失败会涉及到重试,多次重试失败,fe会标记其为损坏节点。这时会挑选一个正常的节点去承接这个副本,然后复制这个副本的所有数据。
-
rowset类似ck每次写入会创建一个分区目录例如rowsetm_m后续然后会定期合并成rowsetm_n。会有参数控制rowset大小,到一定程度会拆分成两个
文件结构
-
Data Region: 用于存储各个列的数据信息,每个column的data数据是按照page为单位分块存储的,每个page的大小一般是64kb(即:Data Page数据块大小一般是64kb)
-
Index Region: Doris中将各个列的index数据统一存储在index region,这里的数据会按照列粒度进行加载。index索引类型包括:前缀索引(Short Key Index)、Ordinal 索引、ZoneMap索引、Bitmap 索引和Bloom filter索引。
-
Footer 包含了每一列的源信息,包括字段类型、数据长度、编码类型、索引的源信息等。
数据模型
在 Doris 中,数据以表(Table)的形式进行逻辑上的描述。 一张表包括行(Row)和列(Column)。Row 即用户的一行数据。Column 用于描述一行数据中不同的字段。
Column 可以分为两大类:Key 和 Value。从业务角度看,Key 和 Value 可以分别对应维度列和指标列。Doris的key列是建表语句中指定的列,建表语句中的关键字'unique key'或'aggregate key'或'duplicate key'后面的列就是 Key 列,除了 Key 列剩下的就是 Value 列。
Duplicate
CREATE TABLE IF NOT EXISTS example_db.example_tbl_duplicate
(
`timestamp` DATETIME NOT NULL COMMENT "日志时间",
`type` INT NOT NULL COMMENT "日志类型",
`error_code` INT COMMENT "错误码",
`error_msg` VARCHAR(1024) COMMENT "错误详细信息",
`op_id` BIGINT COMMENT "负责人id",
`op_time` DATETIME COMMENT "处理时间"
)
DUPLICATE KEY(`timestamp`, `type`, `error_code`)
DISTRIBUTED BY HASH(`type`) BUCKETS 1
PROPERTIES (
"replication_allocation" = "tag.location.default: 1"
);
语句中的key更贴切的来说只是排序键适用于没有聚合需求、且没有主键唯一性冲突的原始存储。
Aggregate
CREATE TABLE IF NOT EXISTS example_db.example_tbl_agg1
(
`user_id` LARGEINT NOT NULL COMMENT "用户id",
`date` DATE NOT NULL COMMENT "数据灌入日期时间",
`city` VARCHAR(20) COMMENT "用户所在城市",
`age` SMALLINT COMMENT "用户年龄",
`sex` TINYINT COMMENT "用户性别",
`last_visit_date` DATETIME REPLACE DEFAULT "1970-01-01 00:00:00" COMMENT "用户最后一次访问时间",
`cost` BIGINT SUM DEFAULT "0" COMMENT "用户总消费",
`max_dwell_time` INT MAX DEFAULT "0" COMMENT "用户最大停留时间",
`min_dwell_time` INT MIN DEFAULT "99999" COMMENT "用户最小停留时间"
)
AGGREGATE KEY(`user_id`, `date`, `city`, `age`, `sex`)
DISTRIBUTED BY HASH(`user_id`) BUCKETS 1
PROPERTIES (
"replication_allocation" = "tag.location.default: 1"
);
表中的列按照是否设置了按照是否设置了聚合类型分为key列和value列。Aggregate会自动将key列相同的数据,在value列上按照聚合函数类型进行聚合。
SUM:求和,多行的 Value 进行累加。 REPLACE:替代,下一批数据中的 Value 会替换之前导入过的行中的 Value。 MAX:保留最大值。 MIN:保留最小值。 REPLACE_IF_NOT_NULL:非空值替换。和 REPLACE 的区别在于对于null值,不做替换。 HLL_UNION:HLL 类型的列的聚合方式,通过 HyperLogLog 算法聚合。 BITMAP_UNION:BIMTAP 类型的列的聚合方式,进行位图的并集聚合。
CREATE TABLE IF NOT EXISTS example_db.example_tbl_unique
(
`user_id` LARGEINT NOT NULL COMMENT "用户id",
`username` VARCHAR(50) NOT NULL COMMENT "用户昵称",
`city` VARCHAR(20) COMMENT "用户所在城市",
`age` SMALLINT COMMENT "用户年龄",
`sex` TINYINT COMMENT "用户性别",
`phone` LARGEINT COMMENT "用户电话",
`address` VARCHAR(500) COMMENT "用户地址",
`register_time` DATETIME COMMENT "用户注册时间"
)
UNIQUE KEY(`user_id`, `username`)
DISTRIBUTED BY HASH(`user_id`) BUCKETS 1
PROPERTIES (
"replication_allocation" = "tag.location.default: 1"
);
-
当用户有数据更新需求时,可以选择使用Unique数据模型。Unique模型能够保证Key的唯一性,当用户更新一条数据时,新写入的数据会覆盖具有相同key的旧数据。
-
与Aggregate模型Replace聚合效果一样
-
数据合并实现
-
读时合并(merge-on-read)。在读时合并实现中,用户在进行数据写入时不会触发任何数据去重相关的操作,所有数据去重的操作都在查询或者compaction时进行。因此,读时合并的写入性能较好,查询性能较差,同时内存消耗也较高。
-
写时合并(merge-on-write)。在1.2版本中,引入了写时合并实现,该实现会在数据写入阶段完成所有数据去重的工作,因此能够提供非常好的查询性能。
-
-
数据更新语义
-
Unique模型默认的更新语意为整行
UPSERT
,即UPDATE OR INSERT,该行数据的key如果存在,则进行更新,如果不存在,则进行新数据插入。在整行UPSERT
语意下,即使用户使用insert into指定部分列进行写入,Doris也会在Planner中将未提供的列使用NULL值或者默认值进行填充 -
部分列更新。如果用户希望更新部分字段,需要使用写时合并实现,并通过特定的参数来开启部分列更新的支持。
-
ClickHouse
架构
-
多主架构,集群中任意一个节点都可以提供服务
-
使用zk进行分布式协调(比如副本之间数据同步)架构复杂度、维护成本提高
MergeTree 引擎
存储结构
数据是以分区维度进行组织。包含了索引文件、标记文件、以及数据文件等。
数据分区
每个分区包含了这几个文件
checksums.txt | 校验文件,使用二进制格式存储。它保存 了余下各类文件(primary.idx、count.txt等)的size大小及size的哈 希值,用于快速校验文件的完整性和正确性 |
columns.txt | 列信息文件,使用明文格式存储。用于保存 此数据分区下的列字段信息 |
count.txt | 计数文件,使用明文格式存储。用于记录当前 数据分区目录下数据的总行数 |
primary.idx | 一级索引文件,使用二进制格式存储。用于 存放稀疏索引,一张MergeTree表只能声明一次一级索引(通过ORDER BY或者PRIMARY KEY) |
column.bin | 数据文件,使用压缩格式存储,默认为LZ4 压缩格式,用于存储某一列的数据 |
column.mrk | 列字段标记文件,使用二进制格式存储。标 记文件中保存了.bin文件中数据的偏移量信息 |
column.mrk2 | 如果使用了自适应大小的索引间隔,则标 记文件会以.mrk2命名 |
partition.dat | partition.dat中保存分区表达式最终生成的值 |
minmax_[Column].idx | minmax索引用于记录当前分区下分区字段对应原始数据的最小和最大值 |
skp_idx_[Column].idx | 二级索引文件 |
skp_idx_[Column].mrk | 二级索引标记标记 |
索引
ClickHouse中索引均是采用稀疏索引存储的。我们平时使用mysql使用的是稠密索引一个索引标记对应一条记录
一级索引
索引粒度
-
数据以index_granularity的粒度(默认8192)被标记成多个小的区间,其中每个区间最多索引粒度数值那么多行数据。MergeTree使用MarkRange表示一个具体的区间,并通过start和end表示其具体的范围
生成规则
每隔索引粒度行,取主键id生成一个索引值。下图是索引粒度为8192且主键为counterID。
多列主键
二级索引
又称为跳数索引。不同品种的跳数索引都。包含一个重要的参数即见过多少个索引粒度生成一个索引。相当于一级索引更稀疏的一个索引。
数据存储
-
列示存储,各列都会有对应bin、mrk文件
-
数据以压缩块为单位存储。每个压缩数据块的体积按照其压缩前的数据字节大小都被严格控制在64KB~1MB,其上下限分别由min_compress_block_size(默认65536)与 max_compress_block_size(默认1048576)参数指定。而一个压缩数据块最终的大 小则和一个间隔(index_granularity)内数据的实际大小相关。
-
单个批次数据size<64KB :如果单个批次数据小于64KB,则继续获取下一 批数据,直至累积到size>=64KB时,生成下一个压缩数据块。
-
单个批次数据64KB<=size<=1MB :如果单个批次数据大小恰好在64KB与1MB 之间,则直接生成下一个压缩数据块。
-
单个批次数据size>1MB :如果单个批次数据直接超过1MB,则首先按照1MB 大小截断并生成下一个压缩数据块。剩余数据继续依照上述规则执行。此时,会出现 一个批次数据生成多个压缩数据块的情况。
-
-
数据读取操作都是按照块维度进行,不需要读取整个文件。
clickhouse不支持高qps高频写原因:
-
首先列式数据库在写入时就会涉及到操作多个文件,性能不如行式存储数据库
-
其次clickhouse使用类似LSM树结构,每次写入都会生成一个分区目录,如果高频写会产生大量目录。一般情况下后台会定期合并,但这个过程需要读取、重写和删除数据,会消耗额外的 IO 和 CPU 资源。
-
为减少存储空间和提高查询性能,ClickHouse 对数据进行了压缩。但是压缩过程会消耗额外的 CPU 资源,这会降低写入性能。
Doris VS Click
功能 | Clichouse | Doris |
存储空间 | PB以上 | 10PB以上 |
读写QPS | 百级 | 千级(需要看场景) |
读写延迟 | 与查询场景有关 | 与查询场景有关 |
存储结构 | table | table |
并发 | 较低 | 较高 |
Join查询性能 | 较低 | 较高 |
事务 | 不支持事务 | 提供导入事务支持 |
聚合 | 实时查询,灵活 | 支持实时,也支持预聚合(Rollup),但数据生产成本较高 |
并行计算 | 支持Vectorized与SIMD | Vectorized |
运维成本 | 较高 (分布式依赖zk,维护成本高) | 较低 (不依赖外部系统,自动故障恢复,运维简单) |
导入方式 | 只支持kafka和hive导入 | 支持kafka、hive和sdk导入,flink-sink |
动态schema支持 | clickhouse的map类型会展开为多隐式列存储,这样使得 | 需要自已实现扩列操作 |
适合场景 | 离线场景 | 实时场景,join查询和实时聚合下沉 |
优点 | 向量化SQL引擎,单表查询性能强悍; 可以基于明细数据提供灵活查询 | 支持各类分布式join,支持复杂场景需求; 兼容MySQL协议和标准SQL; 支持 Online Schema Change; 支持高并发查询场景; 支持物化视图; 支持更新; 支持基于时间分区,冷热数据分离; |
缺点 | SQL语法不标准; 分布式Join支持弱; 并发能力差; 横向扩展能力有限,运维复杂。 | 部分SQL语法不支持; 对批处理支持有限; |
QPS/并发差异原因
CK | Doris |
ClickHouse 将数据划分为多个 partition,每个 partition 再进一步划分为多个 index granularity(索引粒度),然后通过多个 CPU 核心分别处理其中的一部分来实现并行数据处理。分区后,面对涉及跨分区的查询统计,CH 会以分区为单位并行处理。在这种设计下,单条 Query 就能利用整机所有 CPU。极致的并行处理能力,极大的降低了查询延时。 所以,ClickHouse 即使对于大量数据的查询也能够化整为零平行处理。但是有一个弊端就是对于单条查询使用多 cpu,就不利于同时并发多条查询。所以对于高 qps 的查询业务, ClickHouse 并不是强项 。 | 自己理解:doris是集群部署数据集中在多个节点之中,扫描数据是资源分布到集群的所有节点之中,所以支持的qps会更高 |
JOIN性能差异原因
分布式join实现方式
-
Broadcast Join:如果 JOIN 操作中的一边数据较小,可以将其复制到所有查询节点,然后在各个查询节点上进行本地 JOIN 操作。这种方式称为 Broadcast Join,它减少了网络中的数据传输,适用于小表和大表之间的 JOIN 操作
-
Shuffle Join:当进行 Hash Join 时候,可以通过 Join 列计算对应的 Hash 值,并进行 Hash 分桶。但它只能支持 Hash Join,因为它是根据 Join 的条件也去做计算分桶的。
-
Bucket Shuffle Join:假如两张表需要做 Join,并且 Join 列是左表的分桶列,那么左表的数据其实可以不用去移动右表通过左表的数据分桶发送数据就可以完成 Join 的计算
-
Colocat:它与 Bucket Shuffle Join 相似,相当于在数据导入的时候,join表的分桶都落在相同的节点上,根据预设的Join 列的场景已经做好了数据的 Shuffle(比如使用join的字段分别当作分桶键,这样相同的数据会落在同一节点)。那么实际查询的时候就可以直接进行 Join 计算而不需要考虑数据的Shuffle 问题了。
Doris Join
上述四种join方式均实现了。能够自动根据条件选择join的执行方式。在Shuffle时还可以通过Runtime Filter Join 进行优化进一步过滤掉数据,可以将右表的join列的hash计算结果当作条件去过滤左表数据。
当前 Doris 支持三种类型 RuntimeFilter
-
一种是 IN,很好理解,将一个 hashset 下推到数据扫描节点。
-
第二种就是 BloomFilter,就是利用哈希表的数据构造一个 BloomFilter,然后把这个 BloomFilter 下推到查询数据的扫描节点。
-
最后一种就是 MinMax,就是个 Range 范围,通过右表数据确定 Range 范围之后,下推给数据扫描节点。
ClickHouse Join
ClickHouse集群并未实现完整意义上的Shuffle JOIN,实现了类Broadcast JOIN,通过事先完成数据重分布,能够实现Colocate JOIN。
普通JOIN实现
无GLOBAL关键字的JOIN的实现:
-
initiator 将SQL S中左表分布式表替换为对应的本地表,形成S'
-
initiator 将a.中的S'分发到集群每个节点
-
集群节点执行S',并将结果汇总到initiator 节点
-
initiator 节点将结果返回给客户端
SELECT a_.i, a_.s, b_.t FROM a_all as a_ JOIN b_all AS b_ ON a_.i = b_.i
的执行过程如下
如果右表为分布式表,则集群中每个节点会去执行分布式查询。这里就会存在一个非常严重的读放大现象。假设集群有N个节点,右表查询会在集群中执行N*N次。
可以看出,ClickHouse 普通分布式JOIN查询是一个简单版的Shuffle JOIN的实现,或者说是一个不完整的实现。不完整的地方在于,并未按JOIN KEY去Shuffle数据,而是每个节点全量拉去右表数据。这里实际上是存在着优化空间的。
在生产环境中,查询放大对查询性能的影响是不可忽略的。
GLOBAL JOIN实现
GLOBAL JOIN实现如下:
-
若右表为子查询,则initiator完成子查询计算
-
initiator 将右表数据发送给集群其他节点
-
集群节点将左表本地表与右表数据进行JOIN计算
-
集群其他节点将结果发回给initiator节点
-
initiator 将结果汇总,发给客户端
GLOBAL JOIN 可以看作一个不完整的Broadcast JOIN实现。如果JOIN的右表数据量较大,就会占用大量网络带宽,导致查询性能降低。
SELECT a_.i, a_.s, b_.t FROM a_all as a_ GLOBAL JOIN b_all AS b_ ON a_.i = b_.i
SELECT a_.i, a_.s, b_.t FROM a_all as a_ GLOBAL JOIN b_all AS b_ ON a_.i = b_.i
的执行过程如下
可以看出,GLOBAL JOIN 将右表的查询在initiator节点上完成后,通过网络发送到其他节点,避免其他节点重复计算,从而避免查询放大。
如何选型
-
涉及到多个表join doris > clickhouse
-
涉及到事务性导入 doris > click
-
其余场景目前想不出,可能还需要具体问题具体分析。