跳转到主要内容

介绍

在本指南中,我们将深入探讨 ClickHouse 的索引。我们将详细说明并讨论: 你也可以选择在自己的机器上亲自执行本指南中提供的所有 ClickHouse SQL 语句和查询。 有关 ClickHouse 的安装和入门说明,请参阅快速入门
本指南重点介绍 ClickHouse 稀疏主索引。有关 ClickHouse 的次级数据跳过索引,请参阅教程

数据集

在本指南中,我们将使用一份经过匿名化处理的网站流量样本数据集。
  • 我们将使用该样本数据集中的一个子集,共 887 万行 (事件) 。
  • 未压缩时,数据包含 887 万个事件,大小约为 700 MB。存储到 ClickHouse 后会压缩至 200 MB。
  • 在这个子集中,每一行包含三列,分别表示某个互联网用户 (UserID 列) 在特定时间 (EventTime 列) 点击了某个 URL (URL 列) 。
仅凭这三列,我们就可以构造一些典型的网站分析查询,例如:
  • “某个特定用户点击次数最多的 10 个 URL 是哪些?”
  • “点击某个特定 URL 最频繁的前 10 位用户是谁?”
  • “用户点击某个特定 URL 最集中的时间是什么时候 (例如一周中的哪几天) ?”

测试机器

本文档中给出的所有运行时数据,均基于在一台配备 Apple M1 Pro 芯片和 16GB RAM 的 MacBook Pro 上本地运行 ClickHouse 22.2.1 时获得的结果。

全表扫描

为了说明在没有主键的数据集上,查询是如何执行的,我们通过执行以下 SQL DDL 语句创建一个表 (使用 MergeTree 表引擎) :
接下来,使用下面的 SQL insert 语句将 hits 数据集的一个子集插入到该表中。 这里使用了 URL 表函数,从托管在 clickhouse.com 上的完整数据集中加载一个远程子集:
返回结果如下:
ClickHouse client 的输出结果显示,上面的语句已向表中插入 887 万行。 最后,为了便于本指南后续的讨论,并使图表和结果能够复现,我们使用 FINAL 关键字对该表进行优化
一般来说,将数据加载到表中后,通常既不需要,也不建议立即对表进行优化。至于为什么这个示例需要这样做,稍后就会明白。
现在我们来执行第一个网站分析查询。下面将计算 UserID 为 749927693 的互联网用户点击次数最多的前 10 个 URL:
响应如下:
ClickHouse 客户端的结果输出表明,ClickHouse 执行了全表扫描!表中的 887 万行数据全部被逐行读入 ClickHouse。这样显然不具备可扩展性。 要让这一过程变得 (大幅) 更高效、 (显著) 更快,我们需要使用具有合适主键的表。这样,ClickHouse 就会自动 (基于主键列) 创建稀疏主索引,从而显著加快示例查询的执行速度。

ClickHouse 索引设计

适用于海量数据规模的索引设计

在传统的关系型数据库管理系统中,主索引会为表中的每一行保存一个条目。对于我们的数据集,这意味着主索引将包含 887 万个条目。这样的索引能够快速定位特定行,因此查找查询和点更新的效率都很高。在 B(+)-Tree 数据结构中查找一个条目的平均时间复杂度为 O(log n);更准确地说,log_b n = log_2 n / log_2 b,其中 bB(+)-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 如何构建和使用其稀疏主索引。在本文后续部分,我们还会讨论一些最佳实践,介绍如何选择、移除以及排列用于构建索引的表列 (主键列) 。

带主键的表

创建一个表,使用 UserID 和 URL 作为复合主键的键列:

为了简化本指南后续的讨论,并使图表和结果可复现,该 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,以禁用主索引压缩。这样我们稍后就可以视需要检查其内容。

上述 DDL 语句中的主键会基于指定的两个键列创建主索引。
接下来插入数据:
返回结果如下:

并对该表进行优化:

我们可以使用以下查询来获取该表的元数据:
返回结果如下:
ClickHouse 客户端的输出显示:
  • 该表的数据以wide format存储在磁盘上的某个特定目录中,这意味着该目录内表的每一列都对应一个数据文件 (以及一个标记文件) 。
  • 该表有 887 万行。
  • 所有行合计的未压缩数据大小为 733.28 MB。
  • 所有行合计在磁盘上的压缩大小为 206.94 MB。
  • 该表有一个主索引,包含 1083 个条目 (称为 ‘标记’) ,索引大小为 96.93 KB。
  • 总计而言,该表的数据文件、标记文件和主索引文件在磁盘上共占用 207.07 MB。

数据在磁盘上按主键列排序存储

我们在上面创建的表具有:
  • 一个复合主键 (UserID, URL),以及
  • 一个复合排序键 (UserID, URL, EventTime)
  • 如果我们只指定排序键,那么主键会被隐式定义为与排序键相同。
  • 为了提高内存使用效率,我们显式指定了一个主键,其中只包含查询中过滤条件使用的列。基于主键构建的主索引会完整加载到主内存中。
  • 为了让本指南中的示意图保持一致,并尽可能提高压缩率,我们单独定义了一个包含表中所有列的排序键 (如果某一列中的相似数据彼此更接近,例如通过排序实现,那么这些数据通常会有更好的压缩效果) 。
  • 如果两者都指定了,主键必须是排序键的前缀。
插入的行会按照主键列 (以及排序键中的附加列 EventTime) 的词典序 (升序) 存储在磁盘上。
ClickHouse 允许插入多行主键列值相同的数据。在这种情况下 (见下图中的第 1 行和第 2 行) ,最终顺序由指定的排序键决定,因此取决于 EventTime 列的值。
ClickHouse 是一种列式数据库管理系统。如下图所示:
  • 在磁盘上的存储表示中,每个表列对应一个单独的数据文件 (*.bin),该列的所有值都以压缩格式存储在其中;并且
  • 887 万行数据在磁盘上按主键列 (以及附加的排序键列) 的词典序升序存储,也就是说在这个例子中:
    • 首先按 UserID
    • 然后按 URL
    • 最后按 EventTime
UserID.binURL.binEventTime.bin 是磁盘上的数据文件,分别存储 UserIDURLEventTime 列的值。
  • 由于主键定义了磁盘上各行的词典序,因此一张表只能有一个主键。
  • 我们从 0 开始对行编号,以便与 ClickHouse 内部的行编号方案保持一致,该方案也用于日志消息。

数据按粒度组织,以便并行处理数据

为了进行数据处理,表中的列值在逻辑上会被划分为多个粒度。 粒度是流式传输到 ClickHouse 中进行数据处理的最小不可分割数据集。 这意味着 ClickHouse 不会读取单独的行,而始终是以流式、并行的方式读取整组 (即一个粒度) 的行。
列值并不是物理存储在粒度中的:粒度只是为了查询处理而对列值进行的一种逻辑组织方式。
下图展示了我们表中 887 万行数据 (的列值) 如何被组织为 1083 个粒度,这是因为该表的 DDL 语句中包含设置 index_granularity (其值为默认的 8192) 。 按磁盘上的物理顺序,前 8192 行 (的列值) 在逻辑上属于粒度 0,接下来的 8192 行 (的列值) 属于粒度 1,以此类推。
  • 最后一个粒度 (粒度 1082) “包含”的行数少于 8192 行。
  • 我们在本指南开头的“DDL 语句详解”中提到过,我们禁用了自适应索引粒度 (这是为了简化本指南中的讨论,同时让图示和结果可以复现) 。 因此,示例表中的所有粒度 (最后一个除外) 大小都相同。
  • 对于启用了自适应索引粒度的表 (默认情况下索引粒度是自适应的,参见 default) ,某些粒度的大小可能会因行数据大小不同而少于 8192 行。
  • 我们将主键列 (UserIDURL) 中的一些列值标记为橙色。 这些橙色标记的列值,就是每个粒度第一行的主键列值。 如下文所示,这些橙色标记的列值将成为表主索引中的条目。
  • 我们从 0 开始对粒度编号,以与 ClickHouse 的内部编号方案保持一致,该方案也用于日志消息。

主索引中每个粒度对应一个条目

主索引是根据上图所示的粒度创建的。该索引是一个未压缩的一维数组文件 (primary.idx) ,其中包含所谓的数值索引标记,编号从 0 开始。 下图显示,索引会为每个粒度的第一行存储主键列的值 (即上图中以橙色标出的值) 。 换句话说:主索引存储的是表中每隔 8192 行的主键列值 (基于由主键列定义的物理行顺序) 。 例如
  • 第一个索引条目 (下图中的“标记 0”) 存储的是上图中粒度 0 的第一行的键列值,
  • 第二个索引条目 (下图中的“标记 1”) 存储的是上图中粒度 1 的第一行的键列值,以此类推。
对于这个包含 887 万行和 1083 个粒度的表,该索引总共有 1083 个条目:
  • 对于启用了自适应索引粒度的表,主索引中还会额外存储一个最后的“final”标记,用于记录表最后一行的主键列值。但由于我们禁用了自适应索引粒度 (为了简化本指南中的讨论,并使图示和结果可复现) ,因此示例表的索引中不包含这个最终标记。
  • 主索引文件会完整加载到主内存中。如果该文件大于可用的空闲内存,ClickHouse 将报错。

在自管理 ClickHouse 集群中,我们可以使用 file 表函数 来查看示例表主索引的内容。为此,首先需要将主索引文件复制到运行中集群某个节点的 user_files_path

  • 步骤 1:获取包含主索引文件的 part 路径
  • 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
  • 步骤 2:获取 user_files_path
  • Linux 上的 默认 user_files_path/var/lib/clickhouse/user_files/
    在 Linux 上,你也可以检查它是否已被修改:$ grep user_files_path /etc/clickhouse-server/config.xml在测试机器上,该路径为 /Users/tomschreiber/Clickhouse/user_files/
  • 步骤 3:将主索引文件复制到 user_files_path
  • 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'); 返回 1083
  • 获取前两个索引标记
  • SELECT 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_0
  • 获取最后一个索引标记
  • SELECT 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。
正如我们稍后会看到的,当查询对主键第一列进行过滤时,这种全局有序性使 ClickHouse 能够对第一键列的索引标记使用二分查找算法
  • URL 索引标记: 主键列 UserIDURL 的基数非常接近, 这意味着,一般来说,只有当某个键列的前一个键列在至少当前粒度内的所有表行中都保持相同取值时,该键列 (除第一列外) 的索引标记才能表示一个数据范围。
    例如,由于上图中标记 0 和标记 1 的 UserID 值不同,ClickHouse 无法假定粒度 0 内所有表行的 URL 值都大于或等于 'http://showtopics.html%3...'。但是,如果上图中标记 0 和标记 1 的 UserID 值相同 (这意味着粒度 0 内所有表行的 UserID 值都保持不变) ,那么 ClickHouse 就可以假定粒度 0 内所有表行的 URL 值都大于或等于 'http://showtopics.html%3...'
    我们稍后会更详细地讨论这对查询执行性能的影响。

主索引用于筛选粒度

现在,我们可以借助主索引来执行查询。 下面计算 UserID 749927693 点击次数最多的 10 个 URL。
返回结果如下:
ClickHouse 客户端的输出现在显示,ClickHouse 仅流入了 8190 行数据,而不是执行全表扫描。 如果启用了 trace 日志,那么 ClickHouse 服务器日志文件会显示,ClickHouse 在 1083 个 UserID 索引标记上执行了 二分查找,以识别可能包含 UserID 列值为 749927693 的行的粒度。这需要 19 个步骤,平均时间复杂度为 O(log2 n)
我们可以从上面的 trace 日志中看到,在现有的 1083 个标记中,只有 1 个标记满足该查询。

已定位到标记 176 (‘found left boundary mark’ 为包含边界,‘found right boundary mark’ 为排除边界) ,因此会将粒度 176 中的全部 8192 行 (该粒度从第 1.441.792 行开始——我们将在本指南后文看到) 读入 ClickHouse,以找出 UserID 列值为 749927693 的实际行。

我们也可以在示例查询中使用 EXPLAIN 子句 来复现这一点:
响应如下:
客户端输出表明,在 1083 个粒度中,有 1 个被选中,因为它可能包含 UserID 列值为 749927693 的行。
结论当查询对复合键中的首个键列进行过滤时,ClickHouse 会在该键列的索引标记上执行二分查找算法。

如上所述,ClickHouse 使用其稀疏主索引快速 (通过二分查找) 选出可能包含与查询匹配行的粒度。 这是 ClickHouse 查询执行的第一阶段 (粒度选择) 在**第二阶段 (数据读取) **中,ClickHouse 会定位选中的粒度,以便将其中的所有行流式传输到 ClickHouse 引擎中,从而找出实际匹配查询的行。 我们将在下一节更详细地讨论第二阶段。

标记文件用于定位粒度

下图展示了该表主索引文件的一部分。 如上所述,通过在索引的 1083 个 UserID 标记上执行二分查找,定位到了标记 176。因此,与之对应的粒度 176 可能包含 UserID 列值为 749.927.693 的行。

上图显示,标记 176 是第一个满足以下条件的索引条目:其对应粒度 176 的最小 UserID 值小于 749.927.693,而下一个标记 (标记 177) 对应的粒度 177 的最小 UserID 值大于该值。因此,只有标记 176 对应的粒度 176 才可能包含 UserID 列值为 749.927.693 的行。

为了确认 (或排除) 粒度 176 中是否存在 UserID 列值为 749.927.693 的行,需要将属于该粒度的全部 8192 行流式传输到 ClickHouse 中。 为此,ClickHouse 需要知道粒度 176 的物理位置。 在 ClickHouse 中,该表所有粒度的物理位置都存储在标记文件中。与数据文件类似,每个表列都有一个标记文件。 下图展示了三个标记文件 UserID.mrkURL.mrkEventTime.mrk,它们存储了该表 UserIDURLEventTime 列各个粒度的物理位置。 前文已经介绍过,主索引是一个扁平的未压缩数组文件 (primary.idx) ,其中包含从 0 开始编号的索引标记。 同样,标记文件也是一个扁平的未压缩数组文件 (*.mrk) ,其中包含从 0 开始编号的标记。 一旦 ClickHouse 识别并选出了某个粒度对应的索引标记,而该粒度可能包含与查询匹配的行,就可以在标记文件中按位置执行数组查找,从而获得该粒度的物理位置。 特定列的每个标记文件条目都会以偏移量的形式存储两个位置:
  • 第一个偏移量 (上图中的“block_offset”) 用于定位:即压缩列数据文件中包含所选粒度压缩版本的那个块。这个压缩块可能包含多个已压缩粒度。读取时,定位到的压缩文件块会先解压到主内存中。
  • 第二个偏移量 (上图中的“granule_offset”) 来自标记文件,用于指出该粒度在未压缩块数据中的位置。
随后,属于该已定位未压缩粒度的全部 8192 行都会流式传输到 ClickHouse 中做进一步处理。
  • 对于使用 wide format 且未启用 adaptive index granularity 的表,ClickHouse 会使用如上图所示的 .mrk 标记文件,其中每个条目包含两个 8 字节长的地址。这些条目记录的是粒度的物理位置,而这些粒度的大小都相同。
索引粒度默认是自适应的,参见 default;但在本示例表中,我们禁用了自适应索引粒度 (以简化本指南中的说明,并使图示和结果可复现) 。我们的表使用 wide format,是因为数据大小超过了 min_bytes_for_wide_part (对于自管理 cluster,其默认值为 10 MB) 。
  • 对于使用 wide format 且启用了自适应索引粒度的表,ClickHouse 会使用 .mrk2 标记文件。它与 .mrk 标记文件中的条目类似,但每个条目额外包含第三个值:当前条目所对应粒度的行数。
  • 对于使用 compact format 的表,ClickHouse 使用 .mrk3 标记文件。
为什么需要标记文件为什么主索引不直接包含与索引标记对应的粒度的物理位置?因为在 ClickHouse 面向的超大规模场景中,磁盘和内存利用效率至关重要。主索引文件必须能够放入主内存。对于我们的示例查询,ClickHouse 使用主索引后,选中了一个可能包含与查询匹配行的粒度。只有针对这一个粒度,ClickHouse 才需要知道其物理位置,以便流式读取相应的行并进行后续处理。此外,这些偏移信息只对 UserID 和 URL 列有用。对于查询中未使用的列,例如 EventTime,则不需要偏移信息。对于我们的示例查询,ClickHouse 只需要 UserID 数据文件 (UserID.bin) 中粒度 176 的两个物理位置偏移量,以及 URL 数据文件 (URL.bin) 中粒度 176 的两个物理位置偏移量。标记文件提供的这层间接寻址机制,避免了在主索引中直接存储 3 列共 1083 个粒度的所有物理位置条目,从而避免在主内存中保存不必要的 (且可能根本不会用到的) 数据。
下图和下方文字说明了在我们的示例查询中,ClickHouse 如何在 UserID.bin 数据文件中定位粒度 176。 我们在本指南前面已经讨论过,ClickHouse 选中了主索引标记 176,因此也选中了粒度 176,认为它可能包含与查询匹配的行。 现在,ClickHouse 使用索引中选定的标记编号 (176) ,在 UserID.mrk 标记文件中按位置进行数组查找,以获取用于定位粒度 176 的两个偏移量。 如图所示,第一个偏移量用于定位 UserID.bin 数据文件中的压缩文件块,而该文件块中包含了粒度 176 的压缩数据。 一旦定位到的文件块被解压到主内存中,就可以使用标记文件中的第二个偏移量,在未压缩数据中定位粒度 176。 为了执行我们的示例查询 (UserID 为 749.927.693 的互联网用户点击次数最多的前 10 个 URL) ,ClickHouse 需要同时在 UserID.bin 和 URL.bin 数据文件中定位粒度 176 (并流式读取其中的所有值) 。 上图展示了 ClickHouse 如何在 UserID.bin 数据文件中定位该粒度。 与此同时,ClickHouse 也会对 URL.bin 数据文件中的粒度 176 执行相同操作。两个对应的粒度彼此对齐,并被流式传入 ClickHouse 引擎进行后续处理,即对 UserID 为 749.927.693 的所有行按组聚合并统计 URL 值,最后按计数降序输出计数最高的 10 个 URL 分组。

使用多个主索引

次级键列也可能 (不) 高效

当查询按复合键中的某一列进行过滤,且该列是第一个键列时,ClickHouse 会在该键列的索引标记上运行二分查找算法 但如果查询按复合键中的某一列进行过滤,而该列并不是第一个键列,会发生什么呢?
这里讨论的是这样一种场景:查询明确不是按第一个键列过滤,而是按次级键列过滤。当查询同时按第一个键列以及其后的任意键列进行过滤时,ClickHouse 会在第一个键列的索引标记上运行二分查找。


我们使用以下查询来计算点击 URL “http://public&#95;search” 次数最多的前 10 位用户:
响应如下:
客户端输出表明,尽管 URL 列是复合主键的一部分,ClickHouse 仍几乎执行了全表扫描!ClickHouse 从该表的 887 万行中读取了 881 万行。 如果启用了 trace_logging,那么 ClickHouse server 日志文件会显示,ClickHouse 对 1083 个 URL 索引标记使用了通用排除搜索,以识别那些可能包含 URL 列值为 “http://public&#95;search” 的行的粒度:
我们可以从上面的样本 trace 日志中看到,在 1083 个粒度中,有 1076 个 (通过标记) 被选为可能包含 URL 匹配值的行。 因此,为了找出实际包含 URL 值 “http://public&#95;search” 的行,881 万行被流式传入 ClickHouse 引擎 (通过 10 个流并行处理) 。 不过,正如我们稍后将看到的,在选中的 1076 个粒度中,实际上只有 39 个粒度包含匹配的行。 虽然基于复合主键 (UserID, URL) 的主索引对于加速按特定 UserID 值过滤行的查询非常有用,但对于按特定 URL 值过滤行的查询,该索引并没有提供明显帮助。 原因在于,URL 列不是第一个键列,因此 ClickHouse 在 URL 列的索引标记上使用的是通用排除搜索算法 (而不是二分查找) ,并且该算法的有效性取决于 URL 列与其前一个键列 UserID 之间的基数差异。 为了说明这一点,我们先介绍一下通用排除搜索的工作原理。

通用排除搜索算法

下面说明了当通过次级列选择粒度,且前一个键列具有较低或较高基数时,ClickHouse 通用排除搜索算法是如何工作的。 作为这两种情况的示例,我们假设:
  • 一个查询,用于查找 URL 值为 “W3” 的行。
  • 一个抽象化的 hits 表版本,其中 UserID 和 URL 采用简化后的值。
  • 索引使用相同的复合主键 (UserID, URL)。这意味着行会先按 UserID 值排序,再按 URL 排序。
  • 粒度大小为 2,即每个粒度包含两行。
在下图中,我们用橙色标出了每个粒度首行的键列值。 前一个键列具有较低基数 假设 UserID 的基数较低。在这种情况下,相同的 UserID 值很可能会分布在多个表行、粒度以及相应的索引标记中。对于 UserID 相同的索引标记,其 URL 值会按升序排列 (因为表行先按 UserID、再按 URL 排序) 。这就可以实现如下所述的高效过滤: 对于上图中抽象样本数据的粒度选择过程,有三种不同场景:
  1. 索引标记 0 的 URL 值小于 W3,并且其紧随其后的索引标记的 URL 值也小于 W3,因此可以被排除,因为标记 0 和 1 具有相同的 UserID 值。请注意,这个排除前提保证了粒度 0 完全由 UserID 值为 U1 的行组成,因此 ClickHouse 可以推断粒度 0 中的最大 URL 值也小于 W3,并将该粒度排除。
  2. 索引标记 1 的 URL 值小于 (或等于) W3,并且其紧随其后的索引标记的 URL 值大于 (或等于) W3,因此会被选中,因为这意味着粒度 1 可能包含 URL 为 W3 的行。
  3. 索引标记 2 和 3 的 URL 值大于 W3,因此可以被排除,因为主索引的索引标记存储的是每个粒度首个表行的键列值,而表行在磁盘上是按键列值排序的,所以粒度 2 和 3 不可能包含 URL 值为 W3 的行。
前一个键列具有较高基数 当 UserID 具有较高基数时,相同的 UserID 值不太可能分布在多个表行和粒度中。这意味着索引标记中的 URL 值并不是单调递增的: 正如我们在上图中看到的,所有显示出的 URL 值小于 W3 的标记都会被选中,以将其关联粒度中的行流式传输到 ClickHouse engine 中。 这是因为,虽然图中的所有索引标记都属于上文所述的场景 1,但它们不满足前面提到的排除前提,即 紧随其后的索引标记与当前标记具有相同的 UserID 值,因此不能被排除。 例如,考虑索引标记 0:其 URL 值小于 W3,并且其紧随其后的索引标记的 URL 值也小于 W3。它不能被排除,因为紧随其后的索引标记 1 与当前标记 0 的 UserID 值相同。 这最终使 ClickHouse 无法对粒度 0 中的最大 URL 值作出推断。相反,它只能假设粒度 0 可能包含 URL 值为 W3 的行,因此不得不选择标记 0。 标记 1、2 和 3 也是同样的情况。
结论当查询按某个属于复合键但不是第一个键列的列进行过滤时,ClickHouse 使用的不是二分查找算法,而是通用排除搜索算法;当前置键列的基数较低时,这种算法的效果最好。
在我们的样本数据集中,两个键列 (UserID、URL) 的基数都较高且相近。正如前文所述,当 URL 列的前置键列具有较高或相近的基数时,通用排除搜索算法并不太有效。

关于数据跳过索引的说明

由于 UserID 和 URL 都具有较高且相近的基数,我们的按 URL 过滤的查询即使在具有复合主键 (UserID, URL) 的表的 URL 列上创建二级数据跳过索引,收益也不会太大。 例如,下面这两条语句会在我们表的 URL 列上创建并填充一个 minmax 数据跳过索引:
ClickHouse 现在又创建了一个额外索引,用于为每组 4 个连续的粒度存储 URL 的最小值和最大值 (请注意上文 ALTER TABLE 语句中的 GRANULARITY 4 子句) : 第一个索引条目 (上图中的“mark 0”) 存储的是表中前 4 个粒度对应的行的 URL 最小值和最大值。 第二个索引条目 (“mark 1”) 存储的是表中接下来 4 个粒度对应的行的 URL 最小值和最大值,依此类推。 (ClickHouse 还为该数据跳过索引创建了一个特殊的标记文件,用于定位与这些索引标记对应的粒度组。) 由于 UserID 和 URL 都具有类似的高基数,因此在执行按 URL 过滤的查询时,这个辅助数据跳过索引无法帮助排除可不选取的粒度。 查询要查找的特定 URL 值 (即“http://public&#95;search”) 极有可能落在索引为每组粒度存储的最小值和最大值之间,因此 ClickHouse 不得不选取这些粒度组 (因为其中可能包含与查询匹配的行) 。

需要使用多个主索引

因此,如果想显著加快按特定 URL 过滤行的示例查询,就需要使用针对该查询优化的主索引。 此外,如果还想保持按特定 UserID 过滤行的示例查询的良好性能,就需要使用多个主索引。 下面将介绍实现这一点的方法。

创建额外主索引的选项

如果我们想同时显著加快两个样本查询——一个按特定 UserID 过滤行,另一个按特定 URL 过滤行——就需要通过以下三种方式之一使用多个主索引:
  • 创建一个具有不同主键的第二张表
  • 在现有表上创建一个 materialized view
  • 为现有表添加一个投影
这三种方式本质上都会将样本数据复制到另一张附加表中,以便重新组织表的主索引和行排序顺序。 不过,这三种方式在这个附加表对用户的透明程度上有所不同,尤其体现在查询和 insert 语句的路由方面。 创建具有不同主键的第二张表时,必须显式将查询发送到最适合该查询的表版本,并且还必须将新数据显式插入到两张表中,以保持两张表同步: 使用 materialized view 时,附加表会被隐式创建,并且数据会在两张表之间自动保持同步: 投影是透明度最高的选项,因为除了会自动让隐式创建的 (且隐藏的) 附加表与数据变更保持同步之外,ClickHouse 还会自动为查询选择最高效的表版本: 下面我们将通过真实示例,更详细地讨论这三种创建和使用多个主索引的方式。

选项 1:辅助表

我们将新建一个附加表,并在主键中调整键列的顺序 (相对于原始表) :
将原始中的 887 万行全部插入到附加表中:
返回结果如下:
最后,对该表执行优化:
由于我们调整了主键中各列的顺序,现在插入的行会以不同的词典序存储在磁盘上 (相较于我们的原始表) ,因此该表的 1083 个粒度所包含的值也与之前不同: 这就是得到的主键: 现在,它可以用来显著加快示例查询的执行速度。该查询会过滤 URL 列,以计算最常点击 URL “http://public&#95;search” 的前 10 位用户:
响应如下:
现在,ClickHouse 不再几乎进行整表扫描,而是能够更高效地执行该查询。 原始表的主索引中,UserID 是第一主键列,URL 是第二主键列。ClickHouse 为执行该查询,对索引标记使用了 通用排除搜索,但由于 UserID 和 URL 的基数都很高,效果并不理想。 而当 URL 成为主索引中的第一列后,ClickHouse 现在会对索引标记执行 binary search。 ClickHouse server 日志文件中相应的 trace 日志也证实了这一点:
ClickHouse 仅选择了 39 个索引标记,而使用 通用排除搜索 时则会选择 1076 个。 请注意,这个附加表经过了优化,可加快我们按 URL 过滤的示例查询的执行。 与该查询在我们的原始表上表现出的较差性能类似,我们UserIDs 过滤的示例查询在这个新的附加表上运行时也不会很高效,因为 UserID 现在是该表主索引中的第二个键列,因此 ClickHouse 会使用 通用排除搜索 来选择粒度;而对于 UserID 和 URL 这种同样基数很高的列,这种方式效果并不理想。 打开详情框查看具体信息。

响应如下:
服务器日志:

现在我们有两个表,分别针对加快按 UserIDs 过滤的查询和按 URL 过滤的查询进行了优化:

选项 2:Materialized Views

基于现有表创建一个 materialized view
响应如下:
  • 与我们的原始表相比,我们在视图的主键中调整了键列的顺序
  • materialized view 由一个隐式创建的表支撑,该表的行顺序和主索引基于给定的主键定义
  • 这个隐式创建的表会显示在 SHOW TABLES 查询结果中,其名称以 .inner 开头
  • 也可以先为 materialized view 显式创建其支撑表,然后让该视图通过 TO [db].[table] 子句 以该表为目标
  • 我们使用 POPULATE 关键字,以便立即将源表 hits_UserID_URL 中全部 887 万行填充到这个隐式创建的表中
  • 如果新行被插入到源表 hits_UserID_URL 中,这些行也会自动插入到这个隐式创建的表中
  • 实际上,这个隐式创建的表具有与我们显式创建的辅助表相同的行顺序和主索引:
ClickHouse 会将该隐式创建表的列数据文件 (.bin) 、标记文件 (.mrk2) 以及主索引 (primary.idx) 存储在 ClickHouse server 数据目录中的一个特殊文件夹内:
现在,可利用支撑该 materialized view 的隐式创建表 (及其主索引) ,显著加快我们这个按 URL 列过滤的示例查询的执行速度:
响应如下:
因为实际上,为 materialized view 提供底层支撑而隐式创建的表 (及其主索引) 与我们显式创建的辅助表完全相同,所以该查询的执行方式实际上与使用显式创建的表时相同。 ClickHouse server 日志文件中相应的 trace 日志证实,ClickHouse 正在对索引标记执行二分查找:

选项 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)
  • 实际上,这个隐式创建的隐藏表,与我们显式创建的辅助表具有相同的行顺序和主索引:
ClickHouse 会将隐藏表的列数据文件 (.bin)、标记文件 (.mrk2) 和主索引 (primary.idx) 存储在一个特殊文件夹中 (如下图中橙色标出的部分) ,该文件夹与源表的数据文件、标记文件和主索引文件位于同一级目录下:
投影创建的隐藏表 (及其主索引) 现在可以 (隐式地) 用于显著加快按 URL 列过滤的示例查询的执行。请注意,从语法上看,该查询针对的仍然是投影的源表。
响应如下:
由于 projection 创建的隐藏表 (及其主索引) 实际上与我们显式创建的辅助表完全一致,因此,该查询的实际执行方式与使用显式创建的表时相同。 ClickHouse server 的日志文件中对应的 trace 日志证实,ClickHouse 正在对索引标记执行二分查找:

摘要

我们的复合主键为 (UserID, URL) 的表的主索引,对于加速按 UserID 过滤的查询非常有用。但尽管 URL 列也是复合主键的一部分,该索引对加速按 URL 过滤的查询并没有明显帮助。 反过来也一样: 我们的复合主键为 (URL, UserID) 的表的主索引能够加速按 URL 过滤的查询,但对按 UserID 过滤的查询帮助不大。 由于主键列 UserID 和 URL 的基数都较高且相近,按第二个键列过滤的查询不会从索引中包含第二个键列这一点获得太多收益 因此,将第二个键列从主索引中移除是合理的 (这样可以减少索引的内存占用) ,并改为使用多个主索引 不过,如果复合主键中的键列在基数上差异很大,那么对于查询来说,按基数升序排列主键列会更有利。 键列之间的基数差异越大,这些列在键中的顺序就越重要。我们将在下一节中演示这一点。

高效安排排序键列

在复合主键中,键列的顺序会显著影响以下两方面:
  • 查询中对次级键列进行过滤的效率,以及
  • 表的数据文件的压缩率。
为了说明这一点,我们将使用网站流量样本数据集的一个版本, 其中每一行都包含三列,用于指示某个互联网“用户” (UserID 列) 对某个 URL (URL 列) 的访问是否被标记为机器人流量 (IsRobot 列) 。 我们将使用一个包含上述三列的复合主键,它可用于加速典型的网站分析查询,这类查询用于计算:
  • 某个特定 URL 的流量中有多少 (百分比) 来自机器人,或者
  • 我们有多大把握认定某个特定用户是 (或不是) 机器人 (该用户流量中有多大比例被视为机器人流量或非机器人流量)
我们使用以下查询来计算这三列的基数,这三列将作为复合主键中的键列 (请注意,我们使用 URL 表函数 对 TSV 数据进行临时查询,而无需创建本地表) 。请在 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
并向其中插入 887 万行数据:
响应如下:
接下来,创建表 hits_IsRobot_UserID_URL,并使用复合主键 (IsRobot, UserID, URL)
并使用与填充前一个表相同的 887 万行数据来填充该表:
响应如下:

在次级键列上进行高效过滤

当查询按复合键中的至少一列进行过滤,且该列是第一个键列时,ClickHouse 会在该键列的索引标记上运行二分查找算法 当查询 (仅) 按复合键中的某一列进行过滤,但该列不是第一个键列时,ClickHouse 会在该键列的索引标记上使用通用排除搜索算法 对于第二种情况,复合主键中各键列的顺序会显著影响 通用排除搜索算法 的效果。 下面这个查询按表中的 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 磁盘空间
表的列数据在磁盘上具有良好的压缩率,不仅可以节省磁盘空间,还能让需要读取该列数据的查询 (尤其是分析类查询) 执行得更快,因为将该列数据从磁盘移动到主内存 (操作系统的文件缓存) 所需的 I/O 更少。 下面我们来说明,为什么按基数升序排列主键列有利于提升表中各列的压缩率。 下图示意了当键列按基数升序排列时,主键对应的行在磁盘上的顺序: 我们前面已经讨论过,表的行数据在磁盘上会按主键列的顺序存储 在上图中,表中的行 (即它们在磁盘上的列值) 首先按 cl 值排序,而 cl 值相同的行再按 ch 值排序。由于第一个键列 cl 的基数较低,因此很可能存在多行具有相同的 cl 值。正因如此,ch 值也很可能呈现有序状态 (局部有序——即对于那些 cl 值相同的行而言) 。 如果某一列中的相似数据彼此相邻,例如通过排序实现,那么这些数据通常会有更好的压缩效果。 一般来说,压缩算法会受益于数据的连续长度 (看到的相似数据越多,通常越有利于压缩) 以及局部性 (数据越相似,压缩率就越高) 。 与上图相对,下图示意了当键列按基数降序排列时,主键对应的行在磁盘上的顺序: 现在,表中的行会先按其 ch 值排序,而 ch 值相同的行再按其 cl 值排序。 但由于第一个键列 ch 的基数很高,出现 ch 值相同的行的可能性很低。因此,cl 值也不太可能是有序的 (局部来看——即在 ch 值相同的那些行中) 。 因此,cl 值很可能是随机排列的,相应地,其局部性和压缩率通常都会比较差。

总结

无论是为了在查询中高效过滤二级键列,还是为了提高表列数据文件的压缩率,按基数从低到高排列主键中的各列都更有利。

高效识别单行

虽然总的来说,这并不是 ClickHouse 的最佳适用场景, 但有时构建在 ClickHouse 之上的应用确实需要识别 ClickHouse 表中的单行。 一个直观的解决方案是使用一个 UUID 列,为每一行分配唯一值,并将该列用作主键列,以便快速检索行。 为了实现最快的检索,UUID 列需要作为第一个键列 正如我们前面所讨论的,由于 ClickHouse 表的行数据在磁盘上按主键列顺序存储,因此在主键或复合主键中,将基数很高的列 (例如 UUID 列) 放在基数较低的列之前,会损害其他表列的压缩率 兼顾最快检索速度和最佳数据压缩效果的一种折中方案,是使用复合主键,并将 UUID 作为最后一个键列,放在基数较低的键列之后;这些列可用于确保表中某些列获得良好的压缩率。

一个具体示例

一个具体示例是明文粘贴服务 https://pastila.nl。该服务由 Alexey Milovidov 开发,并曾在博客中介绍 文本区域每发生一次变化,数据都会自动保存到 ClickHouse 表中的一行 (每次变更对应一行) 。 识别并检索粘贴内容的某个特定版本的一种方法,是将内容的哈希值用作包含该内容的表行 UUID。 下图展示了:
  • 内容发生变化时各行的插入顺序 (例如在文本区域中输入文本时产生的击键) ,以及
  • 使用 PRIMARY KEY (hash) 时,这些已插入行的数据在磁盘上的排列顺序:
由于 hash 列被用作主键列,
  • 可以非常快速地检索特定行,但
  • 表中的行 (即其列数据) 会按哈希值 (唯一且随机) 升序存储在磁盘上。因此,content 列的值也会以随机顺序存储,缺乏数据局部性,从而导致content 列数据文件的压缩率不理想
为了在仍能快速检索特定行的同时,显著提升 content 列的压缩率,pastila.nl 使用两个哈希值 (以及一个复合主键) 来标识特定行:
  • 如上所述,内容的一个哈希值,对不同数据会产生不同的值;以及
  • 一个在数据仅发生细微变化时不会改变的局部敏感哈希 (指纹)
下图展示了:
  • 内容发生变化时各行的插入顺序 (例如在文本区域中输入文本时产生的击键) ,以及
  • 使用复合 PRIMARY KEY (fingerprint, hash) 时,这些已插入行的数据在磁盘上的排列顺序:
现在,磁盘上的行会先按 fingerprint 排序;对于 fingerprint 值相同的行,再由其 hash 值决定最终顺序。 由于仅有细微差异的数据会得到相同的指纹值,相似的数据如今会在磁盘上的 content 列中彼此相邻存储。这对 content 列的压缩率非常有利,因为压缩算法通常能从数据局部性中受益 (数据越相似,压缩率通常越高) 。 这种折中在于:为了最优地利用由复合 PRIMARY KEY (fingerprint, hash) 产生的主索引,检索特定行时需要使用两个字段 (fingerprinthash) 。
最后修改于 2026年6月12日