本文字数:12318;估计阅读时间:31 分钟
Meetup活动
ClickHouse Shanghai User Group第2届 Meetup 开启报名,详见文末海报!

又到了每月发布新版本的时间!
发布概要
ClickHouse 25.1 版本正式发布,本次更新带来了15项全新功能🦃、36项性能优化⛸️、77个bug修复🏕️
在本次发布中,我们优化了并行哈希连接算法,引入了二级哈希表进行加速;在表级别新增了 MinMax 索引;改进了 Merge 表;增加了自增功能……还有更多精彩更新等你探索!
热烈欢迎 25.1 版本的新贡献者!ClickHouse 社区的成长令人欣喜,我们始终感激每一位贡献者,正是你们的努力让 ClickHouse 变得更加出色和受欢迎。
以下是本次版本的新贡献者名单:
Artem Yurov, Gamezardashvili George, Garrett Thomas, Ivan Nesterov, Jesse Grodman, Jony Mohajan, Juan A. Pedreira, Julian Meyers, Kai Zhu, Manish Gill, Michael Anastasakis, Olli Draese, Pete Hampton, RinChanNOWWW, Sameer Tamsekar, Sante Allegrini, Sergey, Vladimir Zhirov, Yutong Xiao, heymind, jonymohajanGmail, mkalfon, ollidraese
贡献者:Nikita Taranov
自 24.11 版本以来,并行哈希连接已成为 ClickHouse 的默认连接策略,并且它本身已经是最快的内存哈希表连接算法。但我们从未停止优化,每个版本都会通过底层优化进一步提升连接性能。
在 24.7 版本,我们优化了并行哈希连接的哈希表分配;从 24.12 版本开始,ClickHouse 能够自动判断连接查询中哪个表应用于并行哈希连接的构建阶段。而在 25.1 版本,我们进一步加快了探测阶段的执行速度。
并行哈希连接的执行流程
为了更好地理解这项优化,我们先回顾一下并行哈希连接的构建和探测阶段。下图展示了 ClickHouse 之前的并行哈希连接机制(点击放大):
在 ① 构建阶段,右表的数据被拆分,并由 N 个线程并行处理,分别填充 N 个哈希表实例。N 由 max_threads 控制,本例中设为 4。每个线程执行如下循环:
1. 读取右表的下一批数据块。
2. 对每行的连接键应用 实例哈希函数(图中蓝色),取模线程数,确定目标哈希表实例。
3. 再对连接键应用 插入哈希函数(图中黄色),计算哈希表的存储键,并 ② 插入 该哈希表实例。
4. 继续处理下一批数据块。
在 ③ 探测阶段,左表数据也会被拆分,并由 N 个线程并行处理(同样受 max_threads 控制)。每个线程执行以下循环:
1. 读取左表的下一批数据块。
2. 对连接键应用与构建阶段相同的 实例哈希函数(图中蓝色),取模线程数,确定要查找的哈希表实例。
3. 计算哈希值,在选定的哈希表实例中执行 ④ 查找。
4. 如果查找成功且连接键匹配,则 ⑤ 返回 连接后的结果。
5. 继续处理下一批数据块。
并行哈希连接的构建阶段能够同时填充多个哈希表,从而比传统的非并行哈希连接更快。
传统的非并行哈希连接由于哈希表不支持并发写入,因此只能在单线程中完成插入操作,处理大表时容易成为瓶颈。而在读取阶段,哈希表支持并发查询,因此即便是非并行哈希连接,也能在探测阶段实现高效的并行读取。
相较之下,并行哈希连接的并发构建阶段会在探测阶段带来额外的开销。因为左表的输入数据块需要先拆分,再路由到相应的哈希表实例。
为此,我们对探测阶段进行了优化,使其采用 单个共享哈希表,所有处理线程可以同时访问,就像非并行哈希连接一样。这一改进消除了数据块拆分的需求,减少了额外开销,提高了整体效率。
并行哈希连接优化后的执行流程
下图展示了 ClickHouse 并行哈希连接的优化机制(点击放大):
① 构建阶段 仍然是并发执行的。但是,当 max_threads 设置为 N 时,算法不再并行填充 N 个独立的哈希表实例,而是采用 N 个两级哈希表实例。这些哈希表共有 256 个桶,并由 N 个处理线程并发填充,但填充方式确保不同线程不会操作相同的桶:
哈希表实例 #0 负责填充 桶 #0、桶 #N、桶 #(N * 2),…
哈希表实例 #1 负责填充 桶 #1、桶 #N + 1、桶 #(N * 2 + 1),…
哈希表实例 #2 负责填充 桶 #2、桶 #N + 2、桶 #(N * 2 + 2),…
哈希表实例 #3 负责填充 桶 #3、桶 #N + 3、桶 #(N * 2 + 3),…
依此类推…
实现这一点的关键在于,每个处理线程执行如下循环:
1. 读取右表的下一批数据块。
2. 计算 插入哈希函数(图中黄色),对 256 取模,确定目标桶编号。
3. 再对桶编号对线程数取模,以确定 目标两级哈希表实例。
4. 使用插入哈希函数的计算结果作为键,将行数据 ② 插入 到选定哈希表实例的对应桶中。
5. 继续处理下一批数据。
这种无重叠的桶填充方式使得 ③ 合并 N 个哈希表实例到一个共享的两级哈希表 变得 极为高效。合并操作仅需将已填充的桶直接移动到新的哈希表容器,无需对桶内的条目进行额外处理,因此该操作具有 常数时间复杂度。
在 ④ 探测阶段,所有 N 个处理线程可以 并行 访问这个共享的两级哈希表,与非并行哈希连接的工作方式相同。每个线程执行以下循环:
1. 读取左表的下一批数据块。
2. 计算 插入哈希函数(图中黄色),对 256 取模,确定要查找的桶编号。
3. 在选定的桶中执行 ⑤ 查找。
4. 如果查找成功且连接键匹配,则 ⑥ 返回 连接后的行。
5. 继续处理下一批数据。
值得一提的是,与之前的实现相比,现在 构建和探测阶段共用同一个哈希函数,而不再使用两个不同的哈希函数。此外,两级哈希表的设计仅引入了轻量级的取模运算,对性能影响极小。
并行哈希连接的性能提升
为了展示 新并行哈希连接 的性能提升,我们在 AWS EC2 m6i.8xlarge 实例上运行了一组测试。该实例配置为 32 vCPU 和 128 GiB RAM。
我们在 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()──┐
1. │ 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.521 秒缩短至 0.330 秒,提升约 36.66%。
我们还在相同的机器上使用 TPC-H 数据集(规模因子 100)进行了测试。该数据集模拟了批发供应商的数据仓库,所有表的创建和数据加载均按照官方文档进行。
一个典型的查询在 ClickHouse 24.12 上执行,涉及 lineitem 和 orders 表的连接。以下是热运行结果(取三次运行中最快的一次):
SELECT
count(),
version()
FROM lineitem AS li
INNER JOIN orders AS o ON li.l_partkey = o.o_orderkey
SETTINGS join_algorithm = 'parallel_hash';
┌───count()─┬─version()──┐
1. │ 150007337 │ 24.12.1.27 │
└───────────┴────────────┘
1 row in set. Elapsed: 4.104 sec. Processed 750.04 million rows, 3.00 GB (182.77 million rows/s., 731.10 MB/s.)
Peak memory usage: 16.42 GiB.
然后在 ClickHouse 25.1 版本上运行相同的查询:
SELECT
count(),
version()
FROM lineitem AS li
INNER JOIN orders AS o ON li.l_partkey = o.o_orderkey
SETTINGS join_algorithm = 'parallel_hash';
┌───count()─┬─version()─┐
1. │ 150007337 │ 25.1.3.23 │
└───────────┴───────────┘
1 row in set. Elapsed: 3.056 sec. Processed 750.04 million rows, 3.00 GB (245.42 million rows/s., 981.67 MB/s.)
Peak memory usage: 16.19 GiB.
查询时间从 4.104 秒缩短至 3.056 秒,提升约 25.54%。
未来的版本将带来更多 Join 连接性能优化,敬请期待!
贡献者:Smita Kulkarni
MinMax 索引存储了每个数据块的最小值和最大值,可用于加速查询。如果数据大致有序,该索引将非常有效;但对于完全随机的数据,效果可能不佳。
在ClickHouse 25.1 之前,MinMax 索引需要手动为每个列单独指定。但 25.1 版本引入了 add_minmax_index_for_numeric_columns 选项,使 MinMax 索引可自动应用于所有数值列。
我们以 StackOverflow 数据集(包含 5000 万条问题、回答、标签等数据)为例,来学习如何使用这一特性。首先,我们创建一个 stackoverflow 数据库:
CREATE DATABASE stackoverflow;
以下是未启用 MinMax 索引 的 CREATE TABLE 语句:
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 语句:
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;
在第一张表中,主键 (Primary Key) 默认与排序键 (Sorting Key) 相同。在第二张表中,我们仍然使用相同的主键,但 额外添加 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 │
└────────────────────────────────────┴────────┴─────────────┘
查询结果显示 granularity 值为 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 的问题:
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 │
└──────────┴───────────┴──────────────┘
MinMax 索引的查询效果
在我的笔记本电脑上,无论查询哪张表,该查询的运行时间均约为 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 │
└─────────────────────────────────────────────┘
查询结果显示,主索引 (Primary Index) 将需要扫描的粒度数 (Granules) 从 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 │
└─────────────────────────────────────────────────────┘
这张表的粒度数与 posts 表略有不同,但主索引依然有效,将扫描的粒度数 从 7682 降至 3206。
不过,ViewCount 字段的 MinMax 索引作用不大,仅从 3206 降至 3192。相比之下,CommentCount 字段的 MinMax 索引更加高效,将扫描的粒度数从 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
为了提高可读性,ClickHouse 现在在 pretty 格式 下 自动缩短列名。 例如,以下查询用于计算 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] │
└───────────────────────────┴──────────────────────────┘
贡献者:Alexey Milovidov
ClickHouse 新增了 generateSerialID 函数,用于生成分布式自增 ID(存储在 Keeper 中)。 该函数支持并行和分布式环境,并且批量处理使其执行速度更快。
它接受一个名称参数,并可以这样使用:
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
ClickHouse 的 Merge 表引擎允许将多个表整合为一个逻辑表,同时,merge 表函数 也能实现相同的效果。
在25.1 版本之前,merge 表函数默认使用第一个表的结构,除非手动指定其他结构。从25.1 版本开始,列的数据类型将标准化为通用类型,或者转换为 Variant 类型,确保合并后的表可以兼容不同的数据结构。
我们通过创建两个表来看看这个特性是如何工作的:
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,兼容了 players 表中的 String 类型 和 players_new 表中的 Array(String) 类型。
此外,我们也可以使用 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 类型,确保了不同表结构的兼容性。
好消息:ClickHouse Shanghai User Group第2届 Meetup 已经开放报名了,将于2025年03月01日在上海 阿里巴巴徐汇滨江园区X区3层X7-301龙门书院 举行,扫码免费报名
注册ClickHouse中国社区大使,领取认证考试券

ClickHouse社区大使计划正式启动,首批过审贡献者享原厂认证考试券!
试用阿里云 ClickHouse企业版
轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G

征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com