1. ck 概述
ClickHouse
全称是 Click Stream,Data WareHouse
。根据名字可以分析为:在采集数据过程中,一次页面点击(click)会产生一个事件(event)。其逻辑就是,基于页面的点击事件流,面向数据仓库进行 OLAP 分析,ck 在研发之初就是应用与 OLAP
(OnLine Analytical Processing,即联机分析处理) 领域。研发团队是俄罗斯的Yandex公司,该公司是做搜索引擎的,类似于 谷歌、百度。
相较于 OLAP 领域其他产品,ck 没有走 Hadoop 的路线,而是更像传统的数据库,完全称的上是一个 DBMS
(Database Management System,数据库管理系统)。有:DDL、DML、权限管控、数据备份和恢复、分布式集群管理等继承功能。
在 OLAP 领域中 ck 的突出特点在于高性能,面对海量大数据,ck 有着传统 DBMS 没有的高存储量和查询性能。而对比 Hadoop 生态的产品,ck 又有着它们不可替代的实时性查询。下面不得不提到几个特点。
列式存储
我们常接触的 rdb 数据库(如:mysql、oracle等)都是行式存储,存储时每条数据记录就是一行数据,每行数据中包含列中的各个字段。
行模式存储适合 OLTP
(Online Transaction Processing)系统。因为数据基于行存储,所以数据的写入会更快。对按记录查询数据也更简单。
假设建一张存储博客的表,包含:文章标题、作者、点赞数、发布日期。如果我想统计总点赞数,行式存储该如何统计?我想应该是将所有行数据读入内存,然后汇总“点赞数”字段。而列存储,则只需将该列数据读入内存统计即可。
有人说可以给这个字段建普通索引(叶子节点只含该字段和主键),直接基于索引统计。但我总不能给所有字段都建索引吧,数据库维护压力多大。而且如果真的每个字段都创建索引了,不就成了变相的列存储了吗。
所以列式存储在 OLAP 领域比较流行,在统计分析时减少了数据扫描范围。
数据压缩
假设有两个字符串 abcdefghi 和 bcdefghi,现在对他们进行压缩,如下:
- 压缩前:abcdefghi_bcdefghi
- 压缩后:abcdefghi_(9,8)
可见压缩是针对重复部分进行编码转换的,如果(9,8)表示,如果从下划线开始向前移动9个字节,会匹配到8个字节重复项。当然,真实的压缩算法更复杂,但实质都一样。
数据中重复项越多,则压缩率越高;压缩率越高,则数据体量越小;数据体量越小,则数据在网络传输的越快,对网络带宽和磁盘IO压力也越小。
那什么样的数据重复性越高呢?当然是列存储的数据,它们有相同数据类型和现实语以,如果是枚举类型的语义,那基本都是重复的数据。
所以 ck 作为一款列式存储的数据库,压缩率发挥的最高。默认使用 LZ4算法压缩,在 Yandex 自己的产线数据库中,压缩比达到8:1。
2. MergeTree 概述
ck 其实有20多种表引擎,以应对 OLAP 中不同的场景,但最常见的就是 MergeTree 表引擎以及其家族系列(*MergeTree)。
2.1. MergeTree家族
MergeTree 作为家族中最基本的表引擎,提供了:主键、索引、数据分区、数据副本和数据采样等基本能力。而其他表引擎则在MergeTree的基础上各有所长,下面介绍几个:
1. ReplacingMergeTree 去重合并树
MergeTree支持主键,但主键主要用来缩小查询范围,且不具备唯一性约束,可以正常写入相同主键的数据。但在一些情况下,可能需要表中某列没有重复的数据。ReplacingMergeTree 就是在MergeTree的基础上加入了去重的功能,但它仅会在合并分区时,去删除重复的数据,写入相同数据时并不会引发异常。
但要注意的是,ReplacingMergeTree 是针对排序键的列去重,而不是主键。虽然通常情况下没有特意申明主键,主键默认就是排序键,但当申明后二者不一致,ReplacingMergeTree 是针对排序键的。
另外,在数据写入时,重复数据是都会被写入的,去重读操作是在合并时进行的。因为合并的操作不是实时(除非每次都optimize),所以可能会存在某段时间有重复数据。
因为分区表中,分区数据是分开存放的,所以数据去重只在对应的分区内执行,不同分区可能会存在重复数据。
2. SummingMergeTree 汇总合并树
假设有这样⼀种查询需求:终端⽤户只需要查询数据的汇总结果,不关⼼明细数据,并且数据的汇总条件是预先明确的(GROUP BY 条件明确,且不会随意改变)。
对于这样的查询场景,在ClickHouse中如何解决呢?如果还是使⽤ MergeTree 存储数据,那么就通过 GROUP BY 聚合查询,并利⽤ SUM聚合函数汇总结果。这种⽅案存在两个问题。
- 存在额外的存储开销:终端⽤户不会查询任何明细数据,只关⼼汇总结果,所以不应该⼀直保存所有的明细数据。
- 存在额外的查询开销:终端⽤户只关⼼汇总结果,虽然 MergeTree性能强⼤,但是每次查询都进⾏实时聚合计算也是⼀种性能消耗。
SummingMergeTree 就是为了应对这类查询场景⽽⽣的。顾名思义,它能够在合并分区的时候按照预先定义的条件聚合汇总数据,将同⼀分组下的多⾏数据汇总合并成⼀⾏,这样既减少了数据⾏,⼜降低了后续汇总查询的开销。
如果在定义引擎时指定了columns汇总列(⾮主键的数值类型字段),则SUM汇总这些列字段;如果未指定,则聚合所有⾮主键的数值类型字段。
同样和前面的一样,SummingMergeTree 的汇总操作也是在合并时进行的,所以也存在数据的准实时性。而且在分区表中,只会将同一分区内的数据汇总成一行。
3. ReplicatedMergeTree 副本合并树
这个大家就很熟悉了,所有只有由 Replicated* 开头的引擎,才支持复制副本模式,因此类似的引擎还有 ReplicatedReplacingMergeTree、ReplicatedSummingMergeTree 等。
2.2. MergeTree 合并操作
MergeTree 翻译过来叫 “合并树”,突出一个合并。在前面介绍 MergeTree 家族的其他引擎时,发现它们的特性也都是在合并时执行的,那么这里就介绍一下合并的操作。
MergeTree 的分区目录并不是在数据表被创建之后就存在的,而是在数据写入过程中被创建的。也就是说如果一张数据表没有任何数据,那么也不会有任何分区目录存在。MergeTree 的分区目录伴随着每一批数据的写入(一次 INSERT 语句),MergeTree 都会生成一批新的分区目录。即便不同批次写入的数据属于相同分区,也会生成不同的分区目录。
也就是说,任何一个批次的数据写入都会产生一个临时分区,不会纳入任何一个已有的分区。在之后的某个时刻(写入后的 10 ~ 15 分钟,也可以手动执行 optimize 查询语句),ClickHouse 会通过后台任务再将属于相同分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立即被删除,而是在之后的某个时刻通过后台任务被删除(默认 8 分钟)。
所以分区合并的操作,是伴随着每次数据写入后的某段时间的。虽然失去了实时性,但带来的好处在于:提升了写入和查询的性能。写入时小的目录直接写入,不执行分区合并、以及 ReplacingMergeTree、SummingMergeTree 等引擎的操作,提升写入并发量。小的分片目录太多,查询时会占用资源,合并后就优化了性能。
2.3. ES 的合并操作
其实这么干不只是 ck,还有es 以及其他大数据数据库,这里看看 es 的操作。
1. refresh
当我们往es写入数据时,数据是先写入 Memory Buffer,然后定时(默认每隔1S)将 Memory Buffer 中的数据写入一个新的 Segment 文件中,并进入 FileSystem cache(同时清空Memory Buffer),这个过程就是 refresh
;
每个Segment事实上是一些倒排索引的集合,只有经历了refresh操作之后,数据才能变成可搜索状态,这也是常说 es 的准实时原因。
不过 refresh 实现了近实时搜索,但 refresh 无法保障数据安全,我们仍然需要经常进行完整提交来确保能从失败中恢复。flush
就是一次完全提交的过程,一次完整的提交会将 segment 刷到磁盘,并写入一个包含所有段列表的提交点
。es 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片,保证数据的安全。而这个过程可以细分成下面的 merge
和 flush
。
2. merge
es 每次 refresh 一次都会生成一个新的 Segment 文件,这样下来 Segment 文件就会越来越多。那这样会导致什么问题呢?因为每个Segment都会占用文件句柄、内存、cpu资源,更重要的是,每个搜索请求都必须访问每一个 Segment,这就意味着存在的 Segment 越多,搜索请求就会变得越慢。
有一个后台进程专门负责 Segment 的合并,定期执行 merge
操作,将多个小 Segment 文件合并成一个 Segment,在合并时被标记为 deleted 的Doc(或被更新文档的旧版本)不会被写入新的 Segment中。
3. flush
合并完成后,将新的 Segment 文件 flush
写入磁盘;然后创建一个新的 commit point文件,标识所有新的 Segment 文件,并排除掉旧的 Segment 和已经被合并的小 Segment。然后打开新 Segment 文件用于搜索使用。
等所有的搜索请求都从小的 Segment 转到大 Segment 上以后,删除旧的 Segment 文件,这时候,索引里 Segment 数量就下降了
上述过程都不需要人工干涉,es 会自动在索引和搜索的过程中完成,合并的 Segment 可以是磁盘上已经 commit 过的 Segment,也可以是在内存中还未 commit 的Segment,合并的过程不会打断当前的索引和搜索功能。
3. MergeTree 结构
3.1. 建表
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],
...
INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) ENGINE = MergeTree()
ORDER BY expr
[PARTITION BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]
[SETTINGS name=value, ...]
PARTITON BY
:选填,表示分区键,用于指定表数据以何种标准进行分区。分区键既可以是单个字段,也可以通过元组的形式指定多个字段,同时也支持使用列表达式。如果不支持分区键,那么 ClickHouse 会生成一个名称为 all 的分区,合理地使用分区可以有效的减少查询时数据量。ORDER BY
:必填,表示排序键,用于指定在一个分区内,数据以何种标准进行排序。排序键既可以是单个字段,例如 ORDER BY CounterID,也可以是通过元组声明的多个字段,例如 ORDER BY (CounterID, EventDate)。如果是多个字段,那么会先按照第一个字段排序,如果第一个字段中有相同的值,那么再按照第二个字段排序,依次类推。总之在每个分区内,数据是按照分区键排好序的,但多个分区之间就没有这种关系了。PRIMARY KEY
:选填,表示主键,声明之后会依次按照主键字段生成一级索引,用于加速表查询。如果不指定,那么主键默认和排序键(ORDER BY)相同,所以通常直接使用 ORDER BY 代为指定主键,无须使用 PRIMARY KEY 声明。一般情况下,在每个分区内,数据与一级索引以相同的规则升序排列。和其它关系型数据库不同,MergeTree 允许主键有重复数据(也可以通过ReplacingMergeTree 实现去重)。SAMPLE KEY
:选填,抽样表达式。用于声明数据以何种标准进行采样,注意:如果声明了此配置项,那么主键的配置中也要声明同样的表达式。SETTINGS
:index_granularity
:它表示索引粒度,默认值8192。也就是说,MergeTreee 的索引在默认情况下,即每隔8192行数据才生成一条索引。该值通常不需要修改。index_granularity_bytes
:在 19.11 版本之前,ck只支持固定大小的索引间隔,即由 index_granularity 控制。在新版本后,它加入了自适应间隔大小的特性,正是由 index_granularity_bytes 参数控制,默认为10M,设置为0则不开启自适应功能。enable_mixed_granularity_parts
:设置是否开启自适应索引间隔功能,默认开启。merge_with_ttl_timeout
:从 19.6 版本开始,MergeTree 提供了数据 TTL的功能。即表或列数据,在TTL时间后会自动删除数据。storage_policy
:从 19.15版本开始,MergeTree 提供了多路径的存储策略。
这里着重讲一下 index_granularity
和 index_granularity_bytes
,当二者都启用时:
- 如果n行数据写入,n小于
index_granularity
(默认:8192),但这n行数据总量大于index_granularity_bytes
(默认:10M),则10M的数据作为一个索引粒度。 - 如果n行数据写入,n行数据总量小于
index_granularity_bytes
(默认:10M),但n达到index_granularity
(默认:8192),则index_granularity
行数据作为一个索引粒度。
3.2. 存储结构
MergeTree 表引擎中的数据时有物理存储的,数据会按照分区目录的形式保存到磁盘中,其完整的存储结构如下:
table_name
partition_1
checksums.txt
columns.txt
count.txt
primary.idx
[Column].bin
[Column].mrk
[Column].mrk2
partition.dat
minmax_[Column].idx
skp_idx_[Column].idx
skp_idx_[Column].mrk
partition_2
...
partition_3
...
可见,一张数据表的完整物理结构分为3个层级,依次为:数据表目录、分区目录、各分区下具体的数据文件。下面详细介绍一下:
partition
:分区目录,里面的各类数据文件(primary.idx
、data.mrk
、data.bin
等等)都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到同一个分区目录,而不同分区的数据永远不会被合并在一起;checksums.txt
:校验文件,使用二进制的格式进行存储,它保存了余下各类文件(primary.txt
、count.txt
等等)的 size 大小以及哈希值,用于快速校验文件的完整性和正确性;columns.txt
:列信息文件,使用明文格式存储,用于保存此分区下的列字段信息;count.txt
:计数文件,使用明文格式存储,用于记录当前分区下的数据总数,所以后续在查询数据总量的时候可以瞬间返回,因为已经提前记录好了;primary.idx
:一级索引文件,使用二进制格式存储,用于存储稀疏索引,一张 MergeTree 表只能声明一次一级索引(通过 ORDER BY 或 PRIMARY KEY)。借助稀疏索引,在查询数据时能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速度;data.bin
:数据文件,使用压缩格式存储,默认为 LZ4 格式,用于存储表的数据。在老版本中每一个列字段都有自己独立的 .bin 数据文件,并以列字段命名,但是在新版本中只有一个 data.bin,也就是合并在一起了;data.mrk
:标记文件,使用二进制格式存储,标记文件中保存了 data.bin 文件中数据的偏移量信息,并且标记文件与稀疏索引对齐,因此 MergeTree 通过标记文件建立了稀疏索引(primary.idx)与数据文件(data.bin)之间的映射关系。而在读取数据的时候,首先会通过稀疏索引(primary.idx)找到对应数据的偏移量信息(data.mrk),因为两者是对齐的,然后再根据偏移量信息直接从 data.bin 文件中读取数据;data.mrk3
:如果使用了自适应大小的索引间隔,则标记文件会以 data.mrk3 结尾,但它的工作原理和 data.mrk 文件是相同的;partition.dat
和minmax_[Column].idx
:如果使用了分区键,例如上面的 PARTITION BY toYYYYMM(JoinTime),则会额外生成 partition.dat 与 minmax_JoinTime.idx 索引文件,它们均使用二进制格式存储。partition.dat 用于保存当前分区下分区表达式最终生成的值,而 minmax_[Column].idx 则负责记录当前分区下分区字段对应原始数据的最小值和最大值。举个栗子,假设我们往上面的 user_activity_event 表中插入了 5 条数据,JoinTime 分别 2020-05-05、2020-05-15、2020-05-31、2020-05-03、2020-05-24,显然这 5 条都会进入到同一个分区,因为 toYYYMM 之后它们的结果是相同的,都是 2020-05,而 partition.dat 中存储的就是 2020-05,也就是分区表达式最终生成的值;同时还会有一个 minmax_JoinTime.idx 文件,里面存储的就是 2020-05-03 2020-05-31,也就是分区字段对应的原始数据的最小值和最大值;skp_idx_[IndexName].idx
和skp_idx_[IndexName].mrk3
:如果在建表语句中指定了二级索引,则会额外生成相应的二级索引文件与标记文件,它们同样使用二进制存储。二级索引在 ClickHouse 中又被称为跳数索引,目前拥有minmax
、set
、ngrambf_v1
和token_v1
四种类型,这些种类的跳数索引的目的和一级索引都相同,都是为了进一步减少数据的扫描范围,从而加速整个查询过程。
由此可以总结出:
- 不同分区下的物理结构,单独目录存放
- 一级索引,单独文件存放,索引文件(primary.idx)和标记文件(data.bin/data.mrk,用于在索引文件和数据文件之间建立关系)
- 每个二级索引,都有单独文件存放,索引文件(skp_idx_[IndexName].idx)和标记文件(skp_idx_[IndexName].mrk,用于在索引文件和数据文件之间建立关系)
- 老版本中,每个列字段都有单独的文件存储(列存储的由来)。但新版本,都合并在 data.bin 文件中
4. 分区
MergeTree 中,数据是以分区目录的方式组织的,每个分区独立分开存储。借助这个方式,在对 MergeTree 进行数据查询时,可以有效的跳过无用的数据文件。例如:当确定要查的数据在分区A时,就只需要载入分区A目录下的数据即可。
分区与分片
一定要区分开分区与分片:
- 分区:是针对本地数据的一种拆分,MergeTree 不能依靠分区,将一张表的数据分布到不同 ck 的节点上。
- 分片:是针对节点的一种拆分,依托副本,将数据分布到不同的 ck 节点上。
分区规则
MergeTree数据分区规则由分区ID决定,每个分区ID是由分区键的取值决定,分区ID的生成逻辑规则有:
- 不指定分区键,如果不使用PARTITION BY声明任何分区表达式,则分区ID命名为
all
- 整型:兼容UInt64,包括有符号整型和无符号整型
- 日期类型:分区键值属于日期类型,或能够转换为YYYYMMDD格式的整型,则按照YYYYMMDD进行格式化后的字符形式输出
- 其他类型:如果不是整型、日期类型,则通过128位Hash算法取Hash值作为分区ID值
分区目录的命名规则
分区目录命名公式:
`PartitionID_MinBlockNum_MaxBlockNum_Level`
- PartitionID:分区ID
- MinBlockNum/MaxBlockNum:最小数据块编号和最大数据块编号
- Level:合并的层级,及某个分区被合并过的次数,初始值为0
5. 索引
5.1. 一级索引(稀疏索引)
一级索引是主键索引,而且属于稀疏索引。
1. 稀疏索引与稠密索引
有稀疏索引就有稠密索引,它们的区别在于:
- 稠密索引:每一个索引就对应一条数据记录,就像书本的页码,都对应一页。如:mysql的索引。
- 稀疏索引:每一个索引对应一整段数据记录,就像书本的一章节,对应很多页。
稠密索引存储的数据量大,可以很快的定位到一行数据,但在大数据的场景下就不太合适了。
而稀疏索引的优势显而易见,仅需使用少量的索引标记就能记录大量数据的区间位置信息,且数据量越大优势越明显。由于稀疏索引占用空间小,所以 primary.idx
内的索引数据常驻内存,取用数据自然极快。
2. 索引粒度
前面讲过 index_granularity
这个参数,表示索引粒度。虽然新版本中,ck 提供了自适应大小的特性,但为了便于理解,仍然会使用固定的索引粒度(默认8192)进行讲解。
索引粒度就像一把标尺,依照刻度对数据进行标注,最终将数据标记成多个间隔的小段。数据以index_granularity
(这里假设为默认值8192) 的粒度被标记成多个小的区间,其中每个区间最多8192行数据。MergeTree 使用 MarkRange 表示一个具体的区间,并通过 start 和 end 表示其具体的范围。
index_granularity 的命名虽然取了“索引”二字,但它不单只作用于 primary.idx
索引文件。同时也会影响数据标记(data.mrk
/data.mrk3
)和数据文件(data.bin
)。因为仅一级索引自身是无法完成查询工作的,它需要借助数据标记才能定位数据,所以:
- 数据标记(
data.mrk
/data.mrk3
):一级索引和数据标记的间隔粒度是相同的(同为 index_granularity 行),彼此对齐。 - 数据文件(
data.bin
):数据文件也会依照 index_granularity 的间隔粒度生成压缩数据块。
3. 压缩数据块规则
聊到索引粒度影响数据文件压缩存储,就顺便讲一下压缩数据块规则。
每个压缩数据块的体积,其压缩前的数据字节大小,都被严格控制在 64KB ~ 1MB,其上下限分别由 min_compress_block_size
(默认65536)和 max_compress_block_size
(默认1048576)参数指定。
而在具体数据写入过程中,会依照索引粒度,按批次获取数据并处理(即单个批次8192条)。如果把一批数据未压缩大小设为size,则写入过程遵循如下规则:
- 单个批次数据 size < 64KB:则继续获取下一批次数据,直到累计到 size >= 64 KB,生成下一个压缩数据块。
- 单个批次数据 64KB <= size <= 1MB:则直接生成下一个压缩数据块。
- **单个批次数据 size > 1MB:则首先按照1MB大小截断,并生成下一个压缩数据块。剩余的数据继续依照上述规则执行。
4. 索引数据生成规则
索引值会依据声明的主键字段获取。如果使用 counterId 作为主键(ORDER BY counterId),则每间隔8192行数据就会取一次 counterId 的值作为索引值,索引数据最终会被写入 primary.idx 文件进行保存。
例如第0(81920)行 counterId 取值 57,第8192(81921)行 counterId 取值 1635,而第16384(8192*2)行 counterId 取值 3266,最终索引数据将会是5716353266.
5. 索引的查询过程
要想知道索引是如何工作的,需要先知道 MarkRange
是什么。MarkRange 是 ck 中用于定义标记区间的对象,MergeTree 按照 index_granularity
的间隔粒度将一段完整的数据划分成多个小的间隔数据段,其中的每个小的数据段都是一个 MarkRange。
MarkRange 与索引编号对应,使用 start 和 end 两个属性表示其区间范围。通过与 start 及 end 对应的索引编号的取值,即能够得到它所对应的数值区间。而数值区间表示了此MarkRange包含的数据范围。
举个例子:假如现在有一份测试数据,共192行记录。其中,主键ID为String类型,ID的取值从A000开始,后面依次为A001、A002……直至A192为止。MergeTree的索引粒度index_granularity=3。根据索引数据,MergeTree 会将此数据片段划分成 192/3=64 个小的 MarkRange,两个相邻 MarkRange 相距的步长为1。其中,所有MarkRange(整个数据片段)的最大数值区间为 [A000,+inf)
。
在引出了数值区间的概念之后,对于索引的查询过程就很好解释了。索引查询其实就是两个数值区间的交集判断。其中,一个区间是由基于主键的查询条件转换而来的条件区间;而另一个区间是刚才所讲述的与MarkRange对应的数值区间。
整个索引查询过程可以大致分为3个步骤。
- 生成查询条件区间:首先,将查询条件转换为条件区间。即便是单个值的查询条件,也会被转换成区间的形式。
递归交集判断:以递归的形式,依次对MarkRange的数值区间与条件区间做交集判断。从最大的区间[A000,+inf)开始:
- 如果不存在交集,则直接通过剪枝算法优化此整段MarkRange。
- 如果存在交集,且MarkRange步长大于8(end-start),则将此区间进一步拆分成8个子区间(由merge_tree_coarse_index_granularity指定,默认值为8),并重复此规则,继续做递归交集判断。
- 如果存在交集,且MarkRange不可再分解(步长小于8),则记录MarkRange并返回。
- 合并MarkRange区间:将最终匹配的MarkRange聚在一起,合并它们的范围
5.2. 二级索引(跳数索引)
MergeTree 除了一级索引外,还支持二级索引,二级索引又称跳数索引,是由数据的聚合信息构建而成。根据索引的类型不同,其聚合信息的内容也不同。目的和一级索引一样,帮助减少数据的扫描范围。
5.2.1. 创建索引
在 ck 20.1.2.4版本之前,二级索引是实验性的,需要手动开启,开启方式为:
set allow_experimental_data_skipping_indices=1;
但在20.1.2.4之后的版本中,allow_experimental_data_skipping_indices
参数已经被删除,二级索引默认开启。
跳数索引可以在创建表时添加:
create table ck_test.test_skip_index
(
id UInt32,
org_id String,
user_id Int32,
name String,
l_date Date,
index idx_org_id org_id TYPE set(100) GRANULARITY 2
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(l_date)
PRIMARY KEY(id)
ORDER BY id;
也可以在建完表之后添加:
ALTER TABLE ck_test.test_skip_index ADD INDEX idx_user_id user_id TYPE set(100) GRANULARITY 2;
跳数索引只应用于新插入的数据,所以仅仅添加索引不会影响已经有的数据,要使已经有的数据也生效,需要重新执行下面的命令:
ALTER TABLE ck_test.test_skip_index MATERIALIZE INDEX idx_user_id;
跳数索引中,不仅可以为某个列常见索引,也能为某个表达式创建索引
ALTER TABLE ck_test.test_skip_index ADD INDEX idx_calculate (id*user_id) TYPE minmax GRANULARITY 2;
这一类索引可以提高特定查询的性能,就比如刚好要对 id*user_id 作为条件进行查询:
select * from ck_test.test_skip_index where id*user_id=10;
同一级索引一样,创建了跳数索引之后,会额外生成相应的索引与标记文件(skp_idx_[Column].idx
、skp_idx_[Column].mrk
)。
5.2.2. 索引类型
1. minmax
minmax 是最简单的一种。它存储特定列(或表达式)的最小值和最大值,在查询执行过程中,ck 可以在不扫描列的情况下快速检查列值是否超出范围,并跳过不满足最大最小值的颗粒块。当列的值随排序顺序缓慢变化时,它的效果最好。
2. set
这种轻量级索引类型接受单个参数 max_size
,这种索引会将指定颗粒中的所有不同值存储起来,如果不同值数量超过了max_size,该索引就不生效。
ck 在使用 where 条件查询时,如果遇到了 set 类型的跳数索引,则会检查 where 条件中的值是否在 set 集合中,如果不在就跳过这些颗粒。
set 类型的索引很适合那种有大量重复值的列,比如枚举列(省市等)。
很多人刚开始听说布隆过滤器应该是在 redis 中:
- 一个数据如果在布隆过滤器中返回不存在,那么这个数一定不存在;
- 如果一个数在布隆过滤器中返回存在,那么有一定的可能这个数据不存在。
布隆过滤器可以很快地过滤掉那些一定不存在的数据。ck 提供了三种布隆过滤器过滤方法。
3. 布隆过滤器: bloom_filter
ALTER TABLE ck_test.test_skip_index ADD INDEX idx_user_bf user_id TYPE bloom_filter(0.25) GRANULARITY 2;
基本的bloom_filter接受一个可选参数,该参数表示在0到1之间允许的“假阳性”率(如果未指定,则使用.025)。
4. 布隆过滤器: tokenbf_v1
ALTER TABLE ck_test.test_skip_index ADD INDEX idx_name_token_bf name TYPE tokenbf_v1(256, 2, 0) GRANULARITY 2;
相比基本的 bloom_filter 更加专业,需要三个参数,用来优化布隆过滤器
size_of_bloom_filter_in_bytes
:布隆过滤器大小,字节为单位。(因为压缩得好,可以指定比较大的值,如 256 或 512)。number_of_hash_functions
:布隆过滤器中使用的哈希函数的个数。random_seed
:哈希函数的随机种子。
tokenbf_v1 主要用于字符串的过滤,比如:This is a candidate for a “full text” search将被分割为This | is | a | candidate | for | full | text | search 存储,因此更适合 LIKE、EQUALS、in 等操作。
5. 布隆过滤器: ngrambf_v1
ALTER TABLE ck_test.test_skip_index ADD INDEX idx_name_ngram_bf name TYPE ngrambf_v1(4, 256, 2, 0) GRANULARITY 2;
该索引的功能与tokenbf_v1相同。在Bloom filter设置之前需要一个额外的参数,即要索引的ngram的大小,一个ngram是长度为n的任何字符串。
A short string会被分割为A sho, shor, hort, ort s, or st, r str, stri, trin, ring,这个索引对于文本搜索也很有用。
5.2.3. granularity 与 index_granularity 关系
跳数索引中都会涉及到 granularity 参数,刚接触很容易将 granularity
与 index_granularity
搞混淆。granularity 定义了一行跳数索引能够跳过多少个 index_granularity 区间的数据。
跳数索引的数据生成规则是:
- 首先,按照 index_granularity 粒度间隔将数据划分成 n 段,总共有 [0,n-1] 个区间(n=total_rows/index_granularity)。
- 接着,根据索引定义时声明的表达式,从0区间开始,依次按照 index_granularity 粒度从数据中获取聚合信息,每次向前移动1步(n+1),聚合信息逐步累加。
- 最后,当移动 granularity 次区间时,汇总并生成一行跳数索引数据。
以 minmax 索引为例,它的聚合信息是在一个 index_granularity 区间内数据的最小和最大值。假设 index_granularity=8192 且 granularity=3,则数据会按照 index_granularity 划分成 n 等份。MergeTree 从第0段分区开始,依次获取聚合信息。当获取到第3个分区时(granularity=3),则汇总并生成第一行 minmax 索引。
5.2.4. 查询过程
跳数索引的核心是跳过那些一定不会被命中的数据,从而只在少量的颗粒中进行查询,提升查询速度。
假设给 user_id 字段创建了 set 类型的跳数索引,查询时匹配 user_id='Kerry'
,当第1个索引块中匹配到了Kerry字段,而第2个索引块中没有匹配到。那么就将第2个索引块跳过去。
use_skip_indexes
use_skip_indexes
可以设置为 0 或者 1,默认是 1,如果想要在某一次查询中不使用跳数索引,则可以在查询时将该参数设置为 0。
SELECT * FROM ck_test.test_skip_index SETTINGS use_skip_indexes=0;