暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

[译] ClickHouse 版本 25.1特性详解

原创 摸鱼王者 2025-03-19
28

又过了一个月,这意味着是时候再次发布新版本了!

ClickHouse 25.1 版包含 15 项新功能 🦃 36 项性能优化 ⛸️ 77 个错误修复 🏕️

在此版本中,我们使用两级哈希图加速了并行哈希连接算法,在表级别引入了 MinMax 索引,改进了合并表,添加了自动增量功能等等!

新贡献者
特别欢迎 25.1 中

更快的超级哈希连接

撰稿人:Nikita Taranov

从24.11版本以来,硬件CPU连接一直是默认的连接策略,也是ClickHouse中最快的内存CPU表连接算法。但是,正如我们所承诺的那样,我们在每个版本中都通过命令的底层优化进一步提升连接性能。

在 24.7 版本中,我们改进了负载轴承连接的哈希表分配。从 24.12 版本开始,ClickHouse 可以自动确定连接查询中哪个表应该用于负载轴承连接的构建阶段(构建阶段)。在 25.1 版本中,我们进一步加快了探测阶段的算法(探测阶段)。

为了理解这一改进,我们首先简单解释一下构建阶段和探测阶段之前的工作原理。下图展示了 ClickHouse 中的某个节点连接的前面(机制点击放大):

在算法的构建阶段,右表中的数据被拆分并由处理线程线程处理,个线程执行一个哈希表实例。通过设置max_threads控制,在我们的示例中为 4。每个处理线程运行一个循环:

从右表加载下一个未处理的行块。
将实例哈希函数(图中的蓝色)评估每一行的连接键,然后对结果取线程数的模块来确定目标哈希表实例。
将索引插入哈希函数(上述的)评估连接键,并使用结果作为键将行数据插入到选定的哈希表实例中。
重复步骤1。
在算法的探测阶段中,左表中的数据被拆分并由处理线程处理个线程(同样,由设置max_threads控制)。每个处理线程运行一个循环:

从左表中加载下一个未处理的行块。
将构建阶段中使用的相同的实例哈希函数(换成的蓝色)查找每一行的连接键,然后对结果获取线程数的模数来创建哈希表实例。
将构建阶段中使用相同的存储表实例中执行查找的相同的存储表函数(上述的黄色)来判断连接键,并使用结果在选定的存储表实例中执行查找。
如果查找成功且连接键值匹配,则返回连接后的行。
重复步骤1。
上述描述的任务硬盘连接的构建阶段通过填充多个硬盘表来加快处理速度,从而比依赖于单个硬盘表的非任务硬盘连接更快。

由于读写表对于读取不是线程安全的,因此非允许读写表在单个线程上高效执行所有插入,这可能会成为连接查询中更大表的瓶颈。但是,读写表对于读写是线程安全的,非读写表中的线程从单个读写表中读取。

相比之下,任务负载连接的负载构建阶段会在上述中必须引入探测阶段,因为首先分割左表中的输入块将其路由到适当的负载表实例。

为了解决这个问题,探测阶段现在使用单个共享共享表,所有处理线程都可以同时访问,就像非一个共享共享连接一样。这消除了输入块分割的需要,减少了开销并提高了效率。

下图展示了ClickHouse中枢硬盘连接的改进机制(点击放大):

构建阶段仍然以并发方式执行。但是,当max_threads设置为此时,该过程现在使用两级哈希表实例,而不是资源填充它们的 256 个存储桶由处理线程负载填充,但不重叠:

对于哈希表实例#0,线程仅填充存储、存储桶#,……
对于哈希表实例#1,线程仅填充存储桶#1、存储桶#((N、,……
对于哈希表实例 #2,线程仅填充存储桶 #2、存储桶 #、存储桶 #,……
对于哈希表实例 #3,线程仅填充存储桶 #3、存储桶 #、存储桶 #,……
以此类推……
为了实现这一点,每个处理线程都运行一个循环:

从右表加载下一个未处理的行块。
将插入哈希函数(图中的黄色)应用于每行的连接键,然后对结果取模 256 以确定目标存储桶编号。
将步骤 2 中的目标存储桶数量对线程数取模,以确定目标两级哈希表实例。
以步骤 1 的结果作为键值对将该行数据插入到选定的两级哈希表实例的选定桶号中。
重复步骤 1。
在构建阶段填充两级哈希表实例的存储桶 而不发生重叠,可以高效地(恒定时间)将这些 个实例合并为单个共享两级哈希表。这是高效的,因为合并只需将所有已填充的存储桶放入新的两级哈希表容器中,而无需跨存储桶合并条目。

在 探测阶段,所有 个处理线程可以同时从这个共享的两级哈希表中读取数据,就像在非并行哈希连接中一样。每个处理线程运行一个循环:

从左表中加载下一个未处理的行块。
将构建阶段中使用的相同插入哈希函数(图中的黄色)应用于每行的连接键,然后对结果取模 256 以确定在共享两级哈希表中查找的存储桶编号。
在选定的存储桶中执行查找。
如果查找成功且连接键值匹配,则返回连接后的行。
重复步骤 1。
请注意,与之前的实现相比,现在在构建和探测阶段仅使用一个哈希函数,而不是两个。两级哈希表引入的间接性仅需要轻量级模数运算。

为了展示新的并行哈希连接的速度改进,我们首先在具有 32 个 vCPU 和 128 GiB RAM 的 AWS EC2 m6i.8xlarge 实例上运行综合测试。

我们在 ClickHouse 版本 24.12 上运行以下查询:

SELECT
    count(c),
    version()
FROM numbers_mt(100000000) AS a
INNER JOIN
(
    SELECT
        number,
        toString(number) AS c
    FROM numbers(2000000)
) AS b ON (a.number % 10000000) = b.number
SETTINGS join_algorithm = 'parallel_hash';
复制

输出结果如下:

┌─count(c)─┬─version()──┐
│ 20000000 │ 24.12.1.27 │
└──────────┴────────────┘
1 row in set. Elapsed: 0.521 sec. Processed 102.00 million rows, 816.00 MB (195.83 million rows/s., 1.57 GB/s.)
Peak memory usage: 259.52 MiB.
复制

在ClickHouse版本25.1上运行

SELECT
    count(c),
    version()
FROM numbers_mt(100000000) AS a
INNER JOIN
(
    SELECT
        number,
        toString(number) AS c
    FROM numbers(2000000)
) AS b ON (a.number % 10000000) = b.number
SETTINGS join_algorithm = 'parallel_hash';
复制
   ┌─count(c)─┬─version()─┐
1. │ 20000000 │ 25.1.3.23 │
   └──────────┴───────────┘

1 row in set. Elapsed: 0.330 sec. Processed 102.00 million rows, 816.00 MB (309.09 million rows/s., 2.47 GB/s.)
Peak memory usage: 284.96 MiB.
复制

0.330 秒比 0.521 秒快约36.66% 。

还在同一台机器上使用缩放因子为 100 的TPC-H 数据集测试了速度改进。根据官方文档创建和加载了用于建模批发供应商数据仓库的表。

典型查询使用 ClickHouse 24.12 连接lineitem和orders表。热运行结果如下所示,其中热运行是三次连续运行中最快的:

SELECT
    count(),
    version()
FROM lineitem AS li
INNER JOIN orders AS o ON li.l_orderkey = o.o_orderkey
SETTINGS join_algorithm = 'parallel_hash';
复制
   ┌───count()─┬─version()──┐
1. │ 600037902 │ 24.12.1.27 │
   └───────────┴────────────┘

1 row in set. Elapsed: 3.100 sec. Processed 750.04 million rows, 3.00 GB (241.97 million rows/s., 967.89 MB/s.)
Peak memory usage: 16.79 GiB.
复制

现在 ClickHouse 版本为 25.1:

SELECT
    count(),
    version()
FROM lineitem AS li
INNER JOIN orders AS o ON li.l_orderkey = o.o_orderkey
SETTINGS join_algorithm = 'parallel_hash';
复制
   ┌───count()─┬─version()─┐
1. │ 600037902 │ 25.1.3.23 │
   └───────────┴───────────┘

1 row in set. Elapsed: 2.112 sec. Processed 750.04 million rows, 3.00 GB (355.15 million rows/s., 1.42 GB/s.)
Peak memory usage: 16.19 GiB.
复制

2.112 秒比 3.100 秒快约31.87% 。

请继续关注下一版本以及之后版本的更多连接性能改进(您明白了)!

表级别的 MinMax 索引

撰稿人:Smita Kulkarni

MinMax 索引存储每个块的索引表达式的最小值和最大值。它对于数据稍微排序的列很有用 -如果数据完全随机,它将不起作用。

在 25.1 版本之前,我们需要为每一列单独指定这种索引类型。25.1 引入了将add_minmax_index_for_numeric_columns索引应用于所有数字列的设置。

让我们学习如何将此设置与StackOverflow 数据集一起使用,该数据集包含超过 5000 万个问题、答案、标签等。我们将创建一个名为的数据库stackoverflow:

CREATE DATABASE stackoverflow;
复制

未应用 MinMax 索引的建表语句如下所示:

CREATE TABLE stackoverflow.posts
(
    `Id` Int32 CODEC(Delta(4), ZSTD(1)),
    `PostTypeId` Enum8('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
    `AcceptedAnswerId` UInt32,
    `CreationDate` DateTime64(3, 'UTC'),
    `Score` Int32,
    `ViewCount` UInt32 CODEC(Delta(4), ZSTD(1)),
    `Body` String,
    `OwnerUserId` Int32,
    `OwnerDisplayName` String,
    `LastEditorUserId` Int32,
    `LastEditorDisplayName` String,
    `LastEditDate` DateTime64(3, 'UTC') CODEC(Delta(8), ZSTD(1)),
    `LastActivityDate` DateTime64(3, 'UTC'),
    `Title` String,
    `Tags` String,
    `AnswerCount` UInt16 CODEC(Delta(2), ZSTD(1)),
    `CommentCount` UInt8,
    `FavoriteCount` UInt8,
    `ContentLicense` LowCardinality(String),
    `ParentId` String,
    `CommunityOwnedDate` DateTime64(3, 'UTC'),
    `ClosedDate` DateTime64(3, 'UTC')
)
ENGINE = MergeTree
ORDER BY (PostTypeId, toDate(CreationDate), CreationDate);
复制

现在将 MinMax 索引应用于所有列。

CREATE TABLE stackoverflow.posts_min_max
(
   `Id` Int32 CODEC(Delta(4), ZSTD(1)),
   `PostTypeId` Enum8('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
   `AcceptedAnswerId` UInt32,
   `CreationDate` DateTime64(3, 'UTC'),
   `Score` Int32,
   `ViewCount` UInt32 CODEC(Delta(4), ZSTD(1)),
   `Body` String,
   `OwnerUserId` Int32,
   `OwnerDisplayName` String,
   `LastEditorUserId` Int32,
   `LastEditorDisplayName` String,
   `LastEditDate` DateTime64(3, 'UTC') CODEC(Delta(8), ZSTD(1)),
   `LastActivityDate` DateTime64(3, 'UTC'),
   `Title` String,
   `Tags` String,
   `AnswerCount` UInt16 CODEC(Delta(2), ZSTD(1)),
   `CommentCount` UInt8,
   `FavoriteCount` UInt8,
   `ContentLicense` LowCardinality(String),
   `ParentId` String,
   `CommunityOwnedDate` DateTime64(3, 'UTC'),
   `ClosedDate` DateTime64(3, 'UTC')
)
ENGINE = MergeTree
PRIMARY KEY (PostTypeId, toDate(CreationDate), CreationDate)
ORDER BY (PostTypeId, toDate(CreationDate), CreationDate, CommentCount)
SETTINGS add_minmax_index_for_numeric_columns=1;
复制

在第一个表中,主键与排序键相同(未提供时,主键默认为排序键)。我们将在此表中使用相同的主键,但我们已将其添加CommentCount到排序键以使 MinMax 索引更有效。

我们可以编写更有效的查询,对CommentCount与和进行过滤FavoriteCount,AnswerCount并与相关CommentCount。

我们可以通过运行以下查询来检查是否已在所有数字字段上创建 MinMax 索引:

SELECT name, type, granularity
FROM system.data_skipping_indices
WHERE (database = 'stackoverflow') AND (`table` = 'posts_min_max');
复制

┌─name───────────────────────────────┬─type───┬─granularity─┐
│ auto_minmax_index_Id               │ minmax │           1 │
│ auto_minmax_index_AcceptedAnswerId │ minmax │           1 │
│ auto_minmax_index_Score            │ minmax │           1 │
│ auto_minmax_index_ViewCount        │ minmax │           1 │
│ auto_minmax_index_OwnerUserId      │ minmax │           1 │
│ auto_minmax_index_LastEditorUserId │ minmax │           1 │
│ auto_minmax_index_AnswerCount      │ minmax │           1 │
│ auto_minmax_index_CommentCount     │ minmax │           1 │
│ auto_minmax_index_FavoriteCount    │ minmax │           1 │
└────────────────────────────────────┴────────┴─────────────┘
复制

粒度值1告诉我们 ClickHouse 正在为每个粒度的每一列创建一个 MinMax 索引。

现在是时候将数据插入到两个表中了,首先posts:

INSERT INTO stackoverflow.posts 
SELECT * 
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet');
复制

然后我们将数据从posts读入posts_min_max:

INSERT INTO stackoverflow.posts_min_max
SELECT *
FROM stackoverflow.posts;
复制

完成后,让我们针对每个表编写一个查询。此查询返回具有超过 50 条评论和超过 10,000 次浏览的问题:

SELECT Id, ViewCount, CommentCount
FROM stackoverflow.posts
WHERE PostTypeId = 'Question'
AND CommentCount > 50 AND ViewCount > 10000;
复制
SELECT Id, ViewCount, CommentCount
FROM stackoverflow.posts_min_max
WHERE PostTypeId = 'Question'
AND CommentCount > 50 AND ViewCount > 10000;
复制

运行该查询的结果如下所示:

┌───────Id─┬─ViewCount─┬─CommentCount─┐
│ 44796613 │     40560 │           61 │
│  3538156 │     89863 │           57 │
│ 33762339 │     12104 │           55 │
│  5797014 │     82433 │           55 │
│ 37629745 │     43433 │           89 │
│ 16209819 │     12343 │           54 │
│ 57726401 │     23950 │           51 │
│ 24203940 │     11403 │           56 │
│ 43343231 │     32926 │           51 │
│ 48729384 │     26346 │           56 │
└──────────┴───────────┴──────────────┘
复制

在我的笔记本电脑上,这个查询在两个表上运行大约需要 20 毫秒。MinMax 索引几乎没有什么区别,因为我们处理的是小数据。我们可以通过查看查询计划来了解执行每个查询时发生的情况。我们可以在每个查询前面加上前缀来做到这一点EXPLAIN indexes=1。对于posts表:

┌─explain─────────────────────────────────────┐
│ Expression ((Project names + Projection))   │
│   Expression                                │
│     ReadFromMergeTree (stackoverflow.posts) │
│     Indexes:                                │
│       PrimaryKey                            │
│         Keys:                               │
│           PostTypeId                        │
│         Condition: (PostTypeId in [1, 1])   │
│         Parts: 3/4                          │
│         Granules: 3046/7552                 │
└─────────────────────────────────────────────┘
复制

输出显示主索引将要扫描的颗粒数从 减少7552到3046。

现在,让我们看一下该表的查询计划posts_min_max:

┌─explain─────────────────────────────────────────────┐
│ Expression ((Project names + Projection))           │
│   Expression                                        │
│     ReadFromMergeTree (stackoverflow.posts_min_max) │
│     Indexes:                                        │
│       PrimaryKey                                    │
│         Keys:                                       │
│           PostTypeId                                │
│         Condition: (PostTypeId in [1, 1])           │
│         Parts: 2/9                                  │
│         Granules: 3206/7682                         │
│       Skip                                          │
│         Name: auto_minmax_index_ViewCount           │
│         Description: minmax GRANULARITY 1           │
│         Parts: 2/2                                  │
│         Granules: 3192/3206                         │
│       Skip                                          │
│         Name: auto_minmax_index_CommentCount        │
│         Description: minmax GRANULARITY 1           │
│         Parts: 2/2                                  │
│         Granules: 82/3192                           │
└─────────────────────────────────────────────────────┘
复制

该表的颗粒数与另一表略有不同,但主索引将我们从 降至3206的颗粒数7682。上的 MinMax 索引ViewCount不会过滤掉很多颗粒,只会将我们3192从降至3206。上的 MinMax 索引CommentCount更有效,将我们从 的3192颗粒数减少到82。

写入二进制格式前先询问

撰稿人:Alexey Milovidov
ClickHouse 现在会在执行此操作之前检查您是否确实要将二进制格式写入终端。例如,以下查询posts以 Parquet 格式写入表中的所有记录:

SELECT *
FROM stackoverflow.posts
FORMAT Parquet;
复制

当我们运行此查询时,我们将看到以下输出:

The requested output format `Parquet` is binary and could produce side-effects when output directly into the terminal.
If you want to output it into a file, use the "INTO OUTFILE" modifier in the query or redirect the output of the shell command.
Do you want to output it anyway? [y/N]
复制

我可能不想将价值 5000 万条 Parquet 记录写入我的终端,所以我会按N。查询将运行,但不会输出任何内容。

缩短列名称

撰稿人:Alexey Milovidov
另一个不错的可用性功能是使用漂亮格式时自动缩短列名。考虑以下我编写的查询,用于计算 StackOverflow 数据集中列的分位数:

SELECT
    quantiles(0.5, 0.9, 0.99)(ViewCount),
    quantiles(0.5, 0.9, 0.99)(CommentCount)
FROM stackoverflow.posts;
复制

两列的名称均缩写为:

┌─quantiles(0.⋯)(ViewCount)─┬─quantiles(0.⋯mmentCount)─┐
│ [0,1559,22827.5500000001] │ [1,4,11]                 │
└───────────────────────────┴──────────────────────────┘

复制

自动递增

供稿:丹尼拉·普佐夫 / 阿列克谢·米洛维多夫
该generateSerialID函数实现了命名分布式计数器(存储在 Keeper 中),可用于表自动递增。此新函数速度快(由于批处理)并且对于并行和分布式操作而言是安全的。

该函数接受一个name参数,可以像这样用作函数:

select number, generateSerialID('MyCounter')
FROM numbers(10);
复制
┌─number─┬─generateSeri⋯MyCounter')─┐
│      0 │                        0 │
│      1 │                        1 │
│      2 │                        2 │
│      3 │                        3 │
│      4 │                        4 │
│      5 │                        5 │
│      6 │                        6 │
│      7 │                        7 │
│      8 │                        8 │
│      9 │                        9 │
└────────┴──────────────────────────┘
复制

如果我们重新运行查询,值将从 10 继续:

┌─number─┬─generateSeri⋯MyCounter')─┐
│      0 │                       10 │
│      1 │                       11 │
│      2 │                       12 │
│      3 │                       13 │
│      4 │                       14 │
│      5 │                       15 │
│      6 │                       16 │
│      7 │                       17 │
│      8 │                       18 │
│      9 │                       19 │
└────────┴──────────────────────────┘
复制

我们还可以在表模式中使用此函数:

CREATE TABLE test
(
  id UInt64 DEFAULT generateSerialID('MyCounter'),
  data String
)
ORDER BY id;
复制

让我们获取一些数据:

INSERT INTO test (data) 
VALUES ('Hello'), ('World');
复制

然后查询表:

SELECT *
FROM test;
┌─id─┬─data──┐
│ 20 │ Hello │
│ 21 │ World │
└────┴───────┘
复制

更好地合并表格

撰稿人:Alexey Milovidov
合并表引擎可以将多个表合并为一个表。此外,此功能可通过merge表函数访问。

在版本 25.1 之前,除非明确指定其他结构,否则该函数默认采用第一个表的结构。从版本 25.1 开始,列被标准化为通用或变体数据类型。

让我们通过创建几个表来看一下它是如何工作的:

CREATE TABLE players (
  name String, 
  team String
)
ORDER BY name;
CREATE TABLE players_new (
  name String,
  team Array(String)
)
ORDER BY name;
复制

我们将插入一些数据:

INSERT INTO players VALUES ('Player1', 'Team1');
INSERT INTO players_new VALUES ('Player2', ['Team2', 'Team3']);
复制

接下来,让我们使用merge表函数查询两个表:

SELECT *, * APPLY(toTypeName)
FROM merge('players*')
FORMAT Vertical;
复制
Row 1:
──────
name:             Player1
team:             Team1
toTypeName(name): String
toTypeName(team): Variant(Array(String), String)

Row 2:
──────
name:             Player2
team:             ['Team2','Team3']
toTypeName(name): String
toTypeName(team): Variant(Array(String), String)

2 rows in set. Elapsed: 0.001 sec.
复制

我们可以看到该team列具有 Variant 类型,它结合了String来自players表的数据类型和Array(String)来自players_new表的数据类型。

我们也可以使用表引擎做类似的事情Merge:

CREATE TABLE players_merged
ENGINE = Merge(currentDatabase(), 'players*');

复制

如果我们描述新表:

DESCRIBE TABLE players_merged
SETTINGS describe_compact_output = 1;

复制
┌─name─┬─type───────────────────────────┐
│ name │ String                         │
│ team │ Variant(Array(String), String) │
└──────┴────────────────────────────────┘
复制

我们可以再次看到该team列现在是 Variant 类型。

原文地址:https://clickhouse.com/blog/clickhouse-release-25-01

最后修改时间:2025-03-19 12:26:29
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论