介绍
数据集
- 我们将使用该样本数据集中的一个子集,共 887 万行 (事件) 。
- 未压缩时,数据包含 887 万个事件,大小约为 700 MB。存储到 ClickHouse 后会压缩至 200 MB。
- 在这个子集中,每一行包含三列,分别表示某个互联网用户 (
UserID列) 在特定时间 (EventTime列) 点击了某个 URL (URL列) 。
- “某个特定用户点击次数最多的 10 个 URL 是哪些?”
- “点击某个特定 URL 最频繁的前 10 位用户是谁?”
- “用户点击某个特定 URL 最集中的时间是什么时候 (例如一周中的哪几天) ?”
测试机器
全表扫描
hits 数据集的一个子集插入到该表中。
这里使用了 URL 表函数,从托管在 clickhouse.com 上的完整数据集中加载一个远程子集:
ClickHouse 索引设计
适用于海量数据规模的索引设计
B(+)-Tree 数据结构中查找一个条目的平均时间复杂度为 O(log n);更准确地说,log_b n = log_2 n / log_2 b,其中 b 是 B(+)-Tree 的分支因子,n 是已建立索引的行数。由于 b 通常在几百到几千之间,B(+)-Trees 的层级都很浅,因此定位记录只需要很少的磁盘寻道。对于 887 万行数据和 1000 的分支因子,平均只需 2.3 次磁盘寻道。不过,这种能力是有代价的:会带来额外的磁盘和内存开销,在向表中添加新行并向索引中写入条目时插入成本更高,有时还需要对 B-Tree 进行再平衡。
考虑到 B-Tree 索引带来的这些挑战,ClickHouse 中的表引擎采用了不同的方法。ClickHouse 的 MergeTree Engine Family 从设计和优化之初就是为了处理海量数据。这类表被设计为能够每秒接收数百万行插入,并存储极其庞大的数据量 (数百 PB) 。数据会按 parts 逐个快速写入表中,同时按照规则在后台对这些 parts 进行合并。在 ClickHouse 中,每个 part 都有自己的主索引。当 parts 被合并时,合并后 part 的主索引也会一并合并。对于 ClickHouse 所面向的超大规模场景,磁盘和内存效率至关重要。因此,part 的主索引并不会为每一行都建立索引,而是为每一组行 (称为“粒度”) 设置一个索引条目 (称为“标记”) ——这种技术称为稀疏索引。
之所以能够采用稀疏索引,是因为 ClickHouse 会按主键列顺序将一个 part 中的各行存储在磁盘上。与基于 B-Tree 的索引直接定位单行不同,稀疏主索引能够快速地 (通过对索引条目执行二分查找) 识别出可能匹配查询的行组。随后,这些定位到的、可能包含匹配行的行组 (粒度) 会被并行流式传输到 ClickHouse 引擎中,以找出匹配项。这种索引设计使主索引可以保持得很小 (它能够且必须完全装入主内存) ,同时仍能显著加快查询执行速度:尤其是对于数据分析场景中常见的范围查询。
下面将详细说明 ClickHouse 如何构建和使用其稀疏主索引。在本文后续部分,我们还会讨论一些最佳实践,介绍如何选择、移除以及排列用于构建索引的表列 (主键列) 。
带主键的表
DDL 语句详情
DDL 语句详情
为了简化本指南后续的讨论,并使图表和结果可复现,该 DDL 语句:
- 通过
ORDER BY子句为该表指定了一个复合排序键。 - 通过以下设置,显式控制主索引包含多少个索引条目:
index_granularity:显式设置为默认值 8192。这意味着主索引每 8192 行对应一个索引条目。例如,如果该表包含 16384 行,则索引将有两个索引条目。index_granularity_bytes:设置为 0,以禁用自适应索引粒度。自适应索引粒度意味着,当以下任一条件成立时,ClickHouse 会自动为一组 n 行创建一个索引条目:- 如果
n小于 8192,且这n行合并后的数据大小大于或等于 10 MB (即index_granularity_bytes的默认值) 。 - 如果
n行合并后的数据大小小于 10 MB,但n等于 8192。
- 如果
compress_primary_key:设置为 0,以禁用主索引压缩。这样我们稍后就可以视需要检查其内容。
接下来插入数据:
并对该表进行优化:
我们可以使用以下查询来获取该表的元数据:
- 该表的数据以wide format存储在磁盘上的某个特定目录中,这意味着该目录内表的每一列都对应一个数据文件 (以及一个标记文件) 。
- 该表有 887 万行。
- 所有行合计的未压缩数据大小为 733.28 MB。
- 所有行合计在磁盘上的压缩大小为 206.94 MB。
- 该表有一个主索引,包含 1083 个条目 (称为 ‘标记’) ,索引大小为 96.93 KB。
- 总计而言,该表的数据文件、标记文件和主索引文件在磁盘上共占用 207.07 MB。
数据在磁盘上按主键列排序存储
- 如果我们只指定排序键,那么主键会被隐式定义为与排序键相同。
- 为了提高内存使用效率,我们显式指定了一个主键,其中只包含查询中过滤条件使用的列。基于主键构建的主索引会完整加载到主内存中。
- 为了让本指南中的示意图保持一致,并尽可能提高压缩率,我们单独定义了一个包含表中所有列的排序键 (如果某一列中的相似数据彼此更接近,例如通过排序实现,那么这些数据通常会有更好的压缩效果) 。
- 如果两者都指定了,主键必须是排序键的前缀。
EventTime) 的词典序 (升序) 存储在磁盘上。
EventTime 列的值。- 在磁盘上的存储表示中,每个表列对应一个单独的数据文件 (*.bin),该列的所有值都以压缩格式存储在其中;并且
- 887 万行数据在磁盘上按主键列 (以及附加的排序键列) 的词典序升序存储,也就是说在这个例子中:
- 首先按
UserID, - 然后按
URL, - 最后按
EventTime:
- 首先按
UserID.bin、URL.bin 和 EventTime.bin 是磁盘上的数据文件,分别存储 UserID、URL 和 EventTime 列的值。
- 由于主键定义了磁盘上各行的词典序,因此一张表只能有一个主键。
- 我们从 0 开始对行编号,以便与 ClickHouse 内部的行编号方案保持一致,该方案也用于日志消息。
数据按粒度组织,以便并行处理数据
index_granularity (其值为默认的 8192) 。
按磁盘上的物理顺序,前 8192 行 (的列值) 在逻辑上属于粒度 0,接下来的 8192 行 (的列值) 属于粒度 1,以此类推。
- 最后一个粒度 (粒度 1082) “包含”的行数少于 8192 行。
- 我们在本指南开头的“DDL 语句详解”中提到过,我们禁用了自适应索引粒度 (这是为了简化本指南中的讨论,同时让图示和结果可以复现) 。 因此,示例表中的所有粒度 (最后一个除外) 大小都相同。
- 对于启用了自适应索引粒度的表 (默认情况下索引粒度是自适应的,参见 default) ,某些粒度的大小可能会因行数据大小不同而少于 8192 行。
-
我们将主键列 (
UserID、URL) 中的一些列值标记为橙色。 这些橙色标记的列值,就是每个粒度第一行的主键列值。 如下文所示,这些橙色标记的列值将成为表主索引中的条目。 - 我们从 0 开始对粒度编号,以与 ClickHouse 的内部编号方案保持一致,该方案也用于日志消息。
主索引中每个粒度对应一个条目
- 第一个索引条目 (下图中的“标记 0”) 存储的是上图中粒度 0 的第一行的键列值,
- 第二个索引条目 (下图中的“标记 1”) 存储的是上图中粒度 1 的第一行的键列值,以此类推。
- 对于启用了自适应索引粒度的表,主索引中还会额外存储一个最后的“final”标记,用于记录表最后一行的主键列值。但由于我们禁用了自适应索引粒度 (为了简化本指南中的讨论,并使图示和结果可复现) ,因此示例表的索引中不包含这个最终标记。
- 主索引文件会完整加载到主内存中。如果该文件大于可用的空闲内存,ClickHouse 将报错。
查看主索引的内容
查看主索引的内容
在自管理 ClickHouse 集群中,我们可以使用 file 表函数 来查看示例表主索引的内容。为此,首先需要将主索引文件复制到运行中集群某个节点的 user_files_path:
- 步骤 1:获取包含主索引文件的 part 路径
- 步骤 2:获取 user_files_path Linux 上的 默认 user_files_path 是
- 步骤 3:将主索引文件复制到 user_files_path
SELECT path FROM system.parts WHERE table = 'hits_UserID_URL' AND active = 1在测试机器上,该查询返回 /Users/tomschreiber/Clickhouse/store/85f/85f4ee68-6e28-4f08-98b1-7d8affa1d88c/all_1_9_4。/var/lib/clickhouse/user_files/在 Linux 上,你也可以检查它是否已被修改:$ grep user_files_path /etc/clickhouse-server/config.xml在测试机器上,该路径为 /Users/tomschreiber/Clickhouse/user_files/cp /Users/tomschreiber/Clickhouse/store/85f/85f4ee68-6e28-4f08-98b1-7d8affa1d88c/all_1_9_4/primary.idx /Users/tomschreiber/Clickhouse/user_files/primary-hits_UserID_URL.idx现在,我们就可以通过 SQL 查看主索引的内容:
- 获取条目数
- 获取前两个索引标记
- 获取最后一个索引标记
SELECT count( )<br/>FROM file('primary-hits_UserID_URL.idx', 'RowBinary', 'UserID UInt32, URL String');
返回 1083SELECT UserID, URL<br/>FROM file('primary-hits_UserID_URL.idx', 'RowBinary', 'UserID UInt32, URL String')<br/>LIMIT 0, 2;返回240923, http://showtopics.html%3...<br/> 4073710, http://mk.ru&pos=3_0SELECT UserID, URL FROM file('primary-hits_UserID_URL.idx', 'RowBinary', 'UserID UInt32, URL String')<br/>LIMIT 1082, 1;
返回
4292714039 │ http://sosyal-mansetleri...这与我们为示例表绘制的主索引内容示意图完全一致:
-
UserID 索引标记:
主索引中存储的
UserID值按升序排列。
因此,上图中的“标记 1”表示:粒度 1 以及其后所有粒度中的所有表行,其UserID值都保证大于或等于 4.073.710。
-
URL 索引标记:
主键列
UserID和URL的基数非常接近, 这意味着,一般来说,只有当某个键列的前一个键列在至少当前粒度内的所有表行中都保持相同取值时,该键列 (除第一列外) 的索引标记才能表示一个数据范围。
例如,由于上图中标记 0 和标记 1 的 UserID 值不同,ClickHouse 无法假定粒度 0 内所有表行的 URL 值都大于或等于'http://showtopics.html%3...'。但是,如果上图中标记 0 和标记 1 的 UserID 值相同 (这意味着粒度 0 内所有表行的 UserID 值都保持不变) ,那么 ClickHouse 就可以假定粒度 0 内所有表行的 URL 值都大于或等于'http://showtopics.html%3...'。 我们稍后会更详细地讨论这对查询执行性能的影响。
主索引用于筛选粒度
749927693 的行的粒度。这需要 19 个步骤,平均时间复杂度为 O(log2 n):
trace 日志详情
trace 日志详情
已定位到标记 176 (‘found left boundary mark’ 为包含边界,‘found right boundary mark’ 为排除边界) ,因此会将粒度 176 中的全部 8192 行 (该粒度从第 1.441.792 行开始——我们将在本指南后文看到) 读入 ClickHouse,以找出 UserID 列值为 749927693 的实际行。
UserID 列值为 749927693 的行。
如上所述,ClickHouse 使用其稀疏主索引快速 (通过二分查找) 选出可能包含与查询匹配行的粒度。 这是 ClickHouse 查询执行的第一阶段 (粒度选择) 。 在**第二阶段 (数据读取) **中,ClickHouse 会定位选中的粒度,以便将其中的所有行流式传输到 ClickHouse 引擎中,从而找出实际匹配查询的行。 我们将在下一节更详细地讨论第二阶段。
标记文件用于定位粒度
UserID 标记上执行二分查找,定位到了标记 176。因此,与之对应的粒度 176 可能包含 UserID 列值为 749.927.693 的行。
粒度选择详情
粒度选择详情
上图显示,标记 176 是第一个满足以下条件的索引条目:其对应粒度 176 的最小 UserID 值小于 749.927.693,而下一个标记 (标记 177) 对应的粒度 177 的最小 UserID 值大于该值。因此,只有标记 176 对应的粒度 176 才可能包含 UserID 列值为 749.927.693 的行。
UserID 列值为 749.927.693 的行,需要将属于该粒度的全部 8192 行流式传输到 ClickHouse 中。
为此,ClickHouse 需要知道粒度 176 的物理位置。
在 ClickHouse 中,该表所有粒度的物理位置都存储在标记文件中。与数据文件类似,每个表列都有一个标记文件。
下图展示了三个标记文件 UserID.mrk、URL.mrk 和 EventTime.mrk,它们存储了该表 UserID、URL 和 EventTime 列各个粒度的物理位置。
前文已经介绍过,主索引是一个扁平的未压缩数组文件 (primary.idx) ,其中包含从 0 开始编号的索引标记。
同样,标记文件也是一个扁平的未压缩数组文件 (*.mrk) ,其中包含从 0 开始编号的标记。
一旦 ClickHouse 识别并选出了某个粒度对应的索引标记,而该粒度可能包含与查询匹配的行,就可以在标记文件中按位置执行数组查找,从而获得该粒度的物理位置。
特定列的每个标记文件条目都会以偏移量的形式存储两个位置:
- 第一个偏移量 (上图中的“block_offset”) 用于定位块:即压缩列数据文件中包含所选粒度压缩版本的那个块。这个压缩块可能包含多个已压缩粒度。读取时,定位到的压缩文件块会先解压到主内存中。
- 第二个偏移量 (上图中的“granule_offset”) 来自标记文件,用于指出该粒度在未压缩块数据中的位置。
- 对于使用 wide format 且未启用 adaptive index granularity 的表,ClickHouse 会使用如上图所示的
.mrk标记文件,其中每个条目包含两个 8 字节长的地址。这些条目记录的是粒度的物理位置,而这些粒度的大小都相同。
-
对于使用 wide format 且启用了自适应索引粒度的表,ClickHouse 会使用
.mrk2标记文件。它与.mrk标记文件中的条目类似,但每个条目额外包含第三个值:当前条目所对应粒度的行数。 -
对于使用 compact format 的表,ClickHouse 使用
.mrk3标记文件。
EventTime,则不需要偏移信息。对于我们的示例查询,ClickHouse 只需要 UserID 数据文件 (UserID.bin) 中粒度 176 的两个物理位置偏移量,以及 URL 数据文件 (URL.bin) 中粒度 176 的两个物理位置偏移量。标记文件提供的这层间接寻址机制,避免了在主索引中直接存储 3 列共 1083 个粒度的所有物理位置条目,从而避免在主内存中保存不必要的 (且可能根本不会用到的) 数据。使用多个主索引
次级键列也可能 (不) 高效
我们使用以下查询来计算点击 URL “http://public_search” 次数最多的前 10 位用户:
通用排除搜索算法
- 一个查询,用于查找 URL 值为 “W3” 的行。
- 一个抽象化的 hits 表版本,其中 UserID 和 URL 采用简化后的值。
- 索引使用相同的复合主键 (UserID, URL)。这意味着行会先按 UserID 值排序,再按 URL 排序。
- 粒度大小为 2,即每个粒度包含两行。
- 索引标记 0 的 URL 值小于 W3,并且其紧随其后的索引标记的 URL 值也小于 W3,因此可以被排除,因为标记 0 和 1 具有相同的 UserID 值。请注意,这个排除前提保证了粒度 0 完全由 UserID 值为 U1 的行组成,因此 ClickHouse 可以推断粒度 0 中的最大 URL 值也小于 W3,并将该粒度排除。
- 索引标记 1 的 URL 值小于 (或等于) W3,并且其紧随其后的索引标记的 URL 值大于 (或等于) W3,因此会被选中,因为这意味着粒度 1 可能包含 URL 为 W3 的行。
- 索引标记 2 和 3 的 URL 值大于 W3,因此可以被排除,因为主索引的索引标记存储的是每个粒度首个表行的键列值,而表行在磁盘上是按键列值排序的,所以粒度 2 和 3 不可能包含 URL 值为 W3 的行。
关于数据跳过索引的说明
ALTER TABLE 语句中的 GRANULARITY 4 子句) :
第一个索引条目 (上图中的“mark 0”) 存储的是表中前 4 个粒度对应的行的 URL 最小值和最大值。
第二个索引条目 (“mark 1”) 存储的是表中接下来 4 个粒度对应的行的 URL 最小值和最大值,依此类推。
(ClickHouse 还为该数据跳过索引创建了一个特殊的标记文件,用于定位与这些索引标记对应的粒度组。)
由于 UserID 和 URL 都具有类似的高基数,因此在执行按 URL 过滤的查询时,这个辅助数据跳过索引无法帮助排除可不选取的粒度。
查询要查找的特定 URL 值 (即“http://public_search”) 极有可能落在索引为每组粒度存储的最小值和最大值之间,因此 ClickHouse 不得不选取这些粒度组 (因为其中可能包含与查询匹配的行) 。
需要使用多个主索引
创建额外主索引的选项
- 创建一个具有不同主键的第二张表。
- 在现有表上创建一个 materialized view。
- 为现有表添加一个投影。
选项 1:辅助表
UserIDs 过滤的示例查询在这个新的附加表上运行时也不会很高效,因为 UserID 现在是该表主索引中的第二个键列,因此 ClickHouse 会使用 通用排除搜索 来选择粒度;而对于 UserID 和 URL 这种同样基数很高的列,这种方式效果并不理想。
打开详情框查看具体信息。
按 UserIDs 过滤的查询现在性能较差
按 UserIDs 过滤的查询现在性能较差
UserIDs 过滤的查询和按 URL 过滤的查询进行了优化:
选项 2:Materialized Views
- 与我们的原始表相比,我们在视图的主键中调整了键列的顺序
- materialized view 由一个隐式创建的表支撑,该表的行顺序和主索引基于给定的主键定义
- 这个隐式创建的表会显示在
SHOW TABLES查询结果中,其名称以.inner开头 - 也可以先为 materialized view 显式创建其支撑表,然后让该视图通过
TO [db].[table]子句 以该表为目标 - 我们使用
POPULATE关键字,以便立即将源表 hits_UserID_URL 中全部 887 万行填充到这个隐式创建的表中 - 如果新行被插入到源表 hits_UserID_URL 中,这些行也会自动插入到这个隐式创建的表中
- 实际上,这个隐式创建的表具有与我们显式创建的辅助表相同的行顺序和主索引:
选项 3:投影
- 该投影会创建一个隐藏表,其行顺序和主索引基于投影中给定的
ORDER BY子句 - 该隐藏表不会出现在
SHOW TABLES查询结果中 - 我们使用
MATERIALIZE关键字,以便立即将源表 hits_UserID_URL 中的全部 887 万行填充到这个隐藏表中 - 如果有新行插入源表 hits_UserID_URL,这些行也会自动插入隐藏表
- 查询在语法上始终针对源表 hits_UserID_URL,但如果隐藏表的行顺序和主索引能让查询执行得更高效,则会改用该隐藏表
- 请注意,投影并不会让使用 ORDER BY 的查询变得更高效,即使该 ORDER BY 与投影的 ORDER BY 语句一致也是如此 (参见 https://github.com/ClickHouse/ClickHouse/issues/47333)
- 实际上,这个隐式创建的隐藏表,与我们显式创建的辅助表具有相同的行顺序和主索引:
摘要
高效安排排序键列
- 查询中对次级键列进行过滤的效率,以及
- 表的数据文件的压缩率。
UserID 列) 对某个 URL (URL 列) 的访问是否被标记为机器人流量 (IsRobot 列) 。
我们将使用一个包含上述三列的复合主键,它可用于加速典型的网站分析查询,这类查询用于计算:
- 某个特定 URL 的流量中有多少 (百分比) 来自机器人,或者
- 我们有多大把握认定某个特定用户是 (或不是) 机器人 (该用户流量中有多大比例被视为机器人流量或非机器人流量)
clickhouse client 中运行此查询:
URL 列和 IsRobot 列之间。因此,在复合主键中,这些列的顺序非常重要:它既会影响基于这些列进行过滤的查询加速效果,也会影响表列数据文件能否达到最佳压缩率。
为了演示这一点,我们为机器人流量分析数据创建两个版本的表:
- 表
hits_URL_UserID_IsRobot,其复合主键为(URL, UserID, IsRobot),键列按基数从高到低排序 - 表
hits_IsRobot_UserID_URL,其复合主键为(IsRobot, UserID, URL),键列按基数从低到高排序
(URL, UserID, IsRobot) 创建表 hits_URL_UserID_IsRobot:
hits_IsRobot_UserID_URL,并使用复合主键 (IsRobot, UserID, URL):
在次级键列上进行高效过滤
UserID 列进行过滤;在该表中,我们按基数从高到低排列了键列 (URL, UserID, IsRobot):
(IsRobot, UserID, URL) 进行了排序:
数据文件的最佳压缩率
UserID 列的压缩率:
(IsRobot, UserID, URL) 的表,UserID 列的压缩率明显更高。
尽管两个表中存储的数据完全相同 (我们向两个表插入了相同的 887 万行数据) ,复合主键中键列的顺序会显著影响表中压缩后数据所需的磁盘空间,也就是表的列数据文件占用的空间:
- 在表
hits_URL_UserID_IsRobot中,复合主键为(URL, UserID, IsRobot),键列按基数降序排列,UserID.bin数据文件占用 11.24 MiB 磁盘空间 - 在表
hits_IsRobot_UserID_URL中,复合主键为(IsRobot, UserID, URL),键列按基数升序排列,UserID.bin数据文件仅占用 877.47 KiB 磁盘空间
cl 值排序,而 cl 值相同的行再按 ch 值排序。由于第一个键列 cl 的基数较低,因此很可能存在多行具有相同的 cl 值。正因如此,ch 值也很可能呈现有序状态 (局部有序——即对于那些 cl 值相同的行而言) 。
如果某一列中的相似数据彼此相邻,例如通过排序实现,那么这些数据通常会有更好的压缩效果。
一般来说,压缩算法会受益于数据的连续长度 (看到的相似数据越多,通常越有利于压缩)
以及局部性 (数据越相似,压缩率就越高) 。
与上图相对,下图示意了当键列按基数降序排列时,主键对应的行在磁盘上的顺序:
现在,表中的行会先按其 ch 值排序,而 ch 值相同的行再按其 cl 值排序。
但由于第一个键列 ch 的基数很高,出现 ch 值相同的行的可能性很低。因此,cl 值也不太可能是有序的 (局部来看——即在 ch 值相同的那些行中) 。
因此,cl 值很可能是随机排列的,相应地,其局部性和压缩率通常都会比较差。
总结
高效识别单行
一个具体示例
- 内容发生变化时各行的插入顺序 (例如在文本区域中输入文本时产生的击键) ,以及
- 使用
PRIMARY KEY (hash)时,这些已插入行的数据在磁盘上的排列顺序:
hash 列被用作主键列,
- 可以非常快速地检索特定行,但
- 表中的行 (即其列数据) 会按哈希值 (唯一且随机) 升序存储在磁盘上。因此,content 列的值也会以随机顺序存储,缺乏数据局部性,从而导致content 列数据文件的压缩率不理想。
- 如上所述,内容的一个哈希值,对不同数据会产生不同的值;以及
- 一个在数据仅发生细微变化时不会改变的局部敏感哈希 (指纹) 。
- 内容发生变化时各行的插入顺序 (例如在文本区域中输入文本时产生的击键) ,以及
- 使用复合
PRIMARY KEY (fingerprint, hash)时,这些已插入行的数据在磁盘上的排列顺序:
fingerprint 排序;对于 fingerprint 值相同的行,再由其 hash 值决定最终顺序。
由于仅有细微差异的数据会得到相同的指纹值,相似的数据如今会在磁盘上的 content 列中彼此相邻存储。这对 content 列的压缩率非常有利,因为压缩算法通常能从数据局部性中受益 (数据越相似,压缩率通常越高) 。
这种折中在于:为了最优地利用由复合 PRIMARY KEY (fingerprint, hash) 产生的主索引,检索特定行时需要使用两个字段 (fingerprint 和 hash) 。