表引擎即表的类型,决定了:
ByteHouse 云数仓版最常用的表引擎是 CnchMergeTree,除此之外也有其他特殊类型的表引擎包括 Hive外表、Kafka表等。本文重点分享 CnchMergeTree 表引擎的原理。
CNCHMergeTree 是最常用的表引擎,核心思想和LSM-Tree类似,数据按分区键(partition by)进行分区,然后排序键(order by)进行有序存储。主要有如下特点:
1. 逻辑分区
如果指定了分区键的话,数据会按分区键划分成了不同的逻辑数据集(逻辑分区,Partition)。
每一个逻辑分区可以存在零到多个数据片段(DataPart)。如果查询条件可以裁剪分区,通常可以加速查询。如果没有指定分区键,全部数据都在一个逻辑分区里。
2. 数据片段
数据片段里的数据按排序键排序。每个数据片段还会存在一个min/max索引,来加速分区选择。
3. 数据颗粒(Granule)
每个数据片段被逻辑的分割成颗粒(granule),默认的Granule为8192行(由表的index_granularity配置决定)。颗粒是 ByteHouse 中进行数据查询时的最小不可分割数据集。每个颗粒的第一行通过该行的主键值进行标记, ByteHouse 会为每个数据片段创建一个索引文件来存储这些标记。对于每列,无论它是否包含在主键当中,ByteHouse 都会存储类似标记。这些标记让您可以在列文件中直接找到数据。Granule作为ByteHouse 稀疏索引的索引目标,也是在内存中进行数据扫描的单位。
4. 后台 Merge
后台任务会定时对同一个分区的DataPart进行合并,并保持按排序键有序。后台的合并减少了 Part 的数目,以便更高效存储,并提升了查询性能。
CncnMergeTree 表引擎支持的建表语义如下:
CREATE TABLE [IF NOT EXISTS] [db.]table_name ( name1 [type1] [DEFAULT|ALIAS expr1] [compression_codec] [TTL expr1], name2 [type2] [DEFAULT|ALIAS expr2] [compression_codec] [TTL expr2], ... INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1, INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2, ) ENGINE = CnchMergeTree() ORDER BY expr [PARTITION BY expr] [CLUSTER BY (column, expression, ...) INTO value1 BUCKETS SPLIT_NUMBER value2 WITH_RANGE] [PRIMARY KEY expr] [UNIQUE KEY expr] [SAMPLE BY expr] [TTL expr] [SETTINGS name=value, ...]
分区键定义分区,分区是在一个表中通过指定的规则划分而成的逻辑数据集。可以按任意标准进行分区,如按日期。为了减少需要操作的数据,每个分区都是分开存储的。查询时,ByteHouse 尽量使用这些分区的最小子集。建表时候通过 PARTITION BY expr
子句指定。分区键可以是表中列的任意表达式。例如,指定按月分区,表达式为 toYYYYMM(date)
;或者按表达元组,如(toMonday(date), EventType)
等。
需要注意,表中分区表达式计算出的取值范围不能太大(推荐不超过一万),太多分区会占用比较大的内存以及带来比较多的 IO 和计算开销。
合理的设计分区键可以极大减少查询时需要扫描的数据量,一般考虑将查询中最常用的条件同时取值范围不超过一万的列设计为分区键(如日期等)
可以是一组列的元组或任意的表达式。 例如: ORDER BY (OrderID, Date)
。
如果不需要排序,可以使用 ORDER BY tuple()
,DataPart将按照数据插入的顺序存储。
默认情况不需要显式指定,ByteHouse 将使用排序键作为主键。当有特殊场景主键和排序键不一致时,主键必须为排序键的最左前缀。如排序键为(OrderID, Date),主键必须为OrderID,不能为Date。
ByteHouse 会在主键上建立以 Granule 为单位的稀疏索引,(与之对比,所谓稠密索引则是每一行都会建立索引信息)。
如果查询条件能匹配主键索引的最左前缀,通过主键索引可以快速过滤出可能需要读取的数据颗粒,相比扫描整个 DataPart,通常要高效很多。
另外需要注意,PRIMARY KEY不能保证唯一性,所以可以插入主键重复的数据行。
分区(PARTITION BY)和主键(PRIMARY KEY)是两种不同的加速数据查询的方式,定义的时候应当尽量错开使用不同的列来定义两者,来覆盖更多的查询场景。例如order by的第一个列一定不要重复放到partition by里。下面是如何选择主键的一些考虑:
主键(PRIMARY KEY)不能保证去重,如果有唯一键去重的需求,需要在建表时设置唯一键索引。设置唯一键之后,ByteHouse 提供 upsert 更新写语义,可以根据唯一键高效更新数据行,或者在upsert的时候通过设置虚拟列 _delete_flag_=1
,可以用来删除指定的 key。查询自动返回每个唯一键的最新值。详情可参考 使用示例。
唯一键可以是一组列的元组或任意的表达式,如UNIQUE KEY (product_id, sipHash64(city))
。
通过唯一键查询时会用上唯一键索引过滤数据加速查询,所以通常主键可以设置和唯一键不一样列,覆盖更多的查询条件。不过如果要使用部分列更新功能的话,是需要唯一键为排序键的最左前缀。
详情可参考 ByteHouse Unique 表最佳实践。
分桶常用于以下场景,具体请参考 应用案例。
注意
更改现有表以添加存储桶只会影响新分区,但不会影响现有分区。
用于抽样的表达式,该配置为可选项。
如果要用抽样表达式,主键中必须包含这个表达式。例如: SAMPLE BY intHash32(UserID) ORDER BY (CounterID, EventDate, intHash32(UserID))
。
指定行存储的持续时间并定义数据片段在硬盘和卷上的移动逻辑的规则列表,可选项。
表达式中必须存在至少一个 Date
或 DateTime
类型的列,比如:TTL date + INTERVAl 1 DAY
。
compression_codec字段可以用于配置编解码器,该配置为可选项,默认值为 LZ4。
ByteHouse支持通用目的编码和特定编码,通用编解码器更像默认编解码器(LZ4, ZTSD)及其修改版本。特定编解码器是为了利用数据的特定特征使压缩更有效而设计的。
举例参考:
CREATE TABLE codec_example ( date Date CODEC(Delta, ZSTD), ts DateTime CODEC(LZ4HC), float_value Float32 CODEC(NONE), double_value Float64 CODEC(LZ4HC(9)) ) ENGINE = CnchMergeTree PARTITION BY tuple() ORDER BY date
更多建表相关配置,例如 Unique 表,分桶表等,可以参考最佳实践中的对应文档。
CnchMergeTree 合并的核心价值在于零存整取:数据分不同批次导入表中,但可以通过合并减少文件数,并让数据顺序存储。使得 ByteHouse 能最大限度运用磁盘强大的顺序读能力,带来极优的查询性能。
但合并的问题也显而易见:如果后台的写入太过零碎(如每次只插入几百行,几十行),则带来非常多的 Part,Merge 任务会导致 CPU 开销、内存占用提升,带来查询任务的性能下降升值出错。此外,如果过多的小文件导致合并变慢,也会导致查询最新的数据时,Part 还没来得及合并,也会导致查询性能降低。
Select *
?由于 ByteHouse 为列式存储数据库,数据存放在不同的列存文件(.bin)中,这一设计是为了查询指定列时只需要读取有限的文件数,加速查询。
如果select *
,后台需要读取所有的.bin
列存文件,相当于放弃了列存带来的优势。
Insert Into
插入数据?一次Insert Into
会新建一个 part 文件夹,而不断调用Insert Into
则会带来很多 part,且每个 part 的数据量很小,后台需要长时间的合并才能减少 part 数量。带来的问题:
限制分区后,查询只会扫描有限的 part 目录,减少扫描数据量,可以大大加速查询。
目前 ByteHouse 仅支持可以转为日期的字段(int,string,data,datatime)来配置分区键。因为从业务视角上看,每天的数据量 / 每小时的数据量接近,日期字段分区可以带来每个分区的大小比较均衡,不会造成单个查询的延迟剧烈波动;
前文中可以看到在每个 block 内会按照排序索引进行排序,并且基于该字段建立了稀疏索引。查询条件中只要带有排序索引,MergeTree 引擎会通过索引中标记的行与数据的对应关系裁剪不必要读取的 granule,扫描行数降低,查询性能提升。
如果查询不带排序索引,则只能进行全文件的扫描,效率很低。