
本文字数:10990;估计阅读时间:28分钟
作者:ClickHouse Team
本文在公众号【ClickHouseInc】首发

又一个月过去了,这意味着又一个新版本发布了!
ClickHouse 26.3 包含 27 项新功能 🌷 40 项性能优化 🐇 以及 202 个错误修复 🐝
在此版本中,异步插入(async inserts)默认开启,并支持 ANTI、SEMI 和 FULL 类型的 JOIN 重新排序,引入了物化 CTE (Common Table Expression) 等诸多新特性!
新贡献者
热烈欢迎所有参与 ClickHouse 26.3 版本的新贡献者!ClickHouse 社区的蓬勃发展令人感到荣幸,我们衷心感谢每一份贡献,正是它们让 ClickHouse 如此受欢迎。
以下是新贡献者名单:
Alex Soffronow-Pagonidis, Alexey Smirnov, Amy Chen, Andrii Beskomornyi, Artem Brustovetskii, Artem Kytkin, Caio Ishizaka Costa, Cursor Agent, Daniel Q, Den Kalantaevskii, Desel72, Enric Calabuig, Finn, Fisnik Kastrati, François Martin, Herman Schaaf, JIaQi Tang, Maksim Kozlov, Nazarii Piontko, NeedmeFordev, Onyx2406, Riyane El Qoqui, Semen Checherinda, Vasily Chekalkin, Victor Zhou, Vikash, Yash, lioshik, martinfrancois, mcalfin, paf91, spider-yamet, tanner-bruce, vyalamar, wangzhibo
提示:如果您对我们如何生成此列表感到好奇,请点击 这里(https://gist.github.com/gingerwizard/5a9a87a39ba93b422d8640d811e269e9)。
https://youtu.be/_bY0ucNB1lQ
物化 CTE
贡献者:Dmitry Novik
26.3 版本引入了 MATERIALIZED 子句,这意味着 CTE(WITH 子句中的子查询)将只被评估一次,并存储在临时表中。
让我们结合 英国房产价格数据集 看看如何使用物化 CTE。以下查询将返回最昂贵的房产,同时显示该郡当年及历史所有售出房产的平均价格。
WITH county_year_avg AS MATERIALIZED
(
SELECT county, toYear(date) AS year, avg(price) AS avg_price
FROM uk_price_paid3
GROUP BY county,year
)
SELECT p.price, p.addr1, p.town,
p.county,
toYear(p.date) AS year,
round(cya.avg_price) AS countyYear,
round(ca.avg_price) AS countyAllTime
FROM uk_price_paid3 AS p
INNER JOIN county_year_avg AS cya
ON (p.county = cya.county) AND (toYear(p.date) = cya.year)
INNER JOIN
(
SELECT county, avg(avg_price) AS avg_price
FROM county_year_avg
GROUP BY county
) AS ca ON p.county = ca.county
ORDER BY p.price DESC
LIMIT 10;
只有在配置了以下设置时,CTE 才能被物化:
SET enable_materialized_cte=1;
运行此查询的结果如下:
┌─────price─┬─street────────────┬─p.county───────┬─year─┬─ctyYear─┬─ctyAllTime─┐
│ 900000000 │ VICTORIA ROAD │ KENT │ 2021 │ 457070 │ 251980 │
│ 594300000 │ BAKER STREET │ GREATER LONDON │ 2017 │ 797029 │ 466002 │
│ 569200000 │ STANHOPE ROW │ GREATER LONDON │ 2018 │ 821394 │ 466002 │
│ 542540820 │ FORTESS ROAD │ GREATER LONDON │ 2019 │ 837867 │ 466002 │
│ 523000000 │ NINE ELMS LANE │ GREATER LONDON │ 2021 │ 800579 │ 466002 │
│ 494400000 │ NEWMARKET LANE │ WEST YORKSHIRE │ 2019 │ 244610 │ 154516 │
│ 494400000 │ NEWMARKET LANE │ WEST YORKSHIRE │ 2019 │ 244610 │ 154516 │
│ 480000000 │ SUTHERLAND AVENUE │ WEST MIDLANDS │ 2022 │ 343339 │ 170087 │
│ 480000000 │ COOPER STREET │ WEST MIDLANDS │ 2022 │ 343339 │ 170087 │
│ 480000000 │ SUTHERLAND AVENUE │ WEST MIDLANDS │ 2022 │ 343339 │ 170087 │
└───────────┴───────────────────┴────────────────┴──────┴─────────┴────────────┘
当 CTE 未物化时的运行时间如下:
10 rows in set. Elapsed: 2.590 sec. Processed 91.36 million rows, 892.55 MB (35.27 million rows/s., 344.56 MB/s.)
Peak memory usage: 1.50 GiB.
10 rows in set. Elapsed: 2.707 sec. Processed 91.36 million rows, 892.55 MB (33.75 million rows/s., 329.71 MB/s.)
Peak memory usage: 1.50 GiB.
10 rows in set. Elapsed: 2.636 sec. Processed 91.36 million rows, 892.55 MB (34.66 million rows/s., 338.59 MB/s.)
Peak memory usage: 1.50 GiB.
当 CTE 被物化时:
10 rows in set. Elapsed: 1.243 sec. Processed 60.91 million rows, 679.63 MB (49.02 million rows/s., 546.98 MB/s.)
Peak memory usage: 87.40 MiB.
10 rows in set. Elapsed: 1.219 sec. Processed 60.91 million rows, 679.63 MB (49.98 million rows/s., 557.68 MB/s.)
Peak memory usage: 88.97 MiB.
10 rows in set. Elapsed: 1.229 sec. Processed 60.91 million rows, 679.63 MB (49.58 million rows/s., 553.17 MB/s.)
Peak memory usage: 87.43 MiB.
物化版本的速度提升了两倍多。由于该数据集相对较小,仅包含 3000 万条记录,因此在更大规模的数据集上,我们将会看到更显著的性能提升。
美化 EXPLAIN
Kirill Kopnev 贡献
26.3 版本在使用 EXPLAIN 子句时还引入了新设置:
• pretty=1 - 树形缩进输出。
• compact=1 - 精简表达式步骤。
如果我们在上一节的查询前添加以下内容:
EXPLAIN indexes=1, pretty=1, compact=1
我们将看到未物化 CTE 的以下输出:

以及物化后的以下输出:

自然排序
Nazarii Piontko 贡献
naturalSortKey 函数提供了更符合人类习惯的排序方式。
例如,如果我们要查询 ClickHouse 何时添加了地理空间函数,可以编写以下查询:
SELECT introduced_in, count()
FROM system.functions
WHERE categories LIKE '%Geo%'
GROUP BY ALL
ORDER BY introduced_in;
┌─introduced_in─┬─count()─┐
│ 1.1.0 │ 5 │
│ 20.1.0 │ 10 │
│ 20.3.0 │ 6 │
│ 20.4.0 │ 1 │
│ 21.11.0 │ 4 │
│ 21.4.0 │ 24 │
│ 21.9.0 │ 11 │
│ 22.1.0 │ 3 │
│ 22.2.0 │ 5 │
│ 22.6.0 │ 15 │
│ 25.10.0 │ 4 │
│ 25.11.0 │ 6 │
│ 25.12.0 │ 1 │
│ 25.6.0 │ 2 │
│ 25.7.0 │ 2 │
└───────────────┴─────────┘
在正常排序中,21.11.0 会优先于 21.4.0 和 21.9.0,这并非我们所期望的结果。我们可以使用这个新函数,按照预期顺序对数据进行排序:
SELECT introduced_in, count()
FROM system.functions
WHERE categories LIKE '%Geo%'
GROUP BY ALL
ORDER BY naturalSortKey(introduced_in);
┌─introduced_in─┬─count()─┐
│ 1.1.0 │ 5 │
│ 20.1.0 │ 10 │
│ 20.3.0 │ 6 │
│ 20.4.0 │ 1 │
│ 21.4.0 │ 24 │
│ 21.9.0 │ 11 │
│ 21.11.0 │ 4 │
│ 22.1.0 │ 3 │
│ 22.2.0 │ 5 │
│ 22.6.0 │ 15 │
│ 25.6.0 │ 2 │
│ 25.7.0 │ 2 │
│ 25.10.0 │ 4 │
│ 25.11.0 │ 6 │
│ 25.12.0 │ 1 │
└───────────────┴─────────┘
JSONExtract 函数支持 JSON 类型
Fisnik Kastrati 贡献
在 ClickHouse 26.3 之前,JSONExtract 函数 只能用于从 JSON 字符串中提取字段,如下例所示:
WITH '{"ClickHouse":{"version":"26.3"}}' AS s
SELECT s, toTypeName(s), JSONExtractString(s, 'ClickHouse', 'version');
┌─s─────────────────────────────────┬─toTypeName(s)─┬─JSONExtractS⋯ 'version')─┐
│ {"ClickHouse":{"version":"26.3"}} │ String │ 26.3 │
└───────────────────────────────────┴───────────────┴──────────────────────────┘
如果尝试使用此函数从 JSON 类型中提取字段,将会遇到以下异常:
WITH '{"ClickHouse":{"version":"26.3"}}'::JSON AS s
SELECT s, toTypeName(s), JSONExtractString(s, 'ClickHouse', 'version');
Received exception:
Code: 43. DB::Exception: The first argument of function JSONExtractString should be a string containing JSON, illegal type: JSON: In scope WITH CAST('{"ClickHouse":{"version":"26.3"}}', 'JSON') AS s SELECT JSONExtractString(s, 'ClickHouse', 'version'). (ILLEGAL_TYPE_OF_ARGUMENT)
如果我们在 26.3 中运行相同的查询,它将返回以下输出:
┌─s─────────────────────────────────┬─toTypeName(s)─┬─JSONExtractS⋯ 'version')─┐
│ {"ClickHouse":{"version":"26.3"}} │ JSON │ 26.3 │
└───────────────────────────────────┴───────────────┴──────────────────────────┘
TTL DELETE 的垂直合并
murphy-4o 贡献
在 ClickHouse 中,每次 INSERT 操作都会创建一个新的数据分块 (data part),这些分块会根据表的排序键进行排序。为了保持插入操作的高速,额外的数据处理会被推迟到后台分块合并过程中执行。
这些合并操作持续进行,将较小的分块合并为较大的分块。在此过程中,ClickHouse 不仅优化数据布局,以支持数据跳读功能,还执行各种维护工作,例如行替换、行删除、行更新或数据预聚合等。
为了高效地执行合并操作,ClickHouse 会根据表的宽度、行数和数据大小等因素,自动选择以下两种合并算法之一:
1. 水平合并 (Horizontal merge)
• 逐块地 同时读取并合并 所有列
• 将合并后的数据写回磁盘
2. 垂直合并 (Vertical merge)
• 优先读取并合并 仅包含排序键的列
• 暂时记录剩余列的最终行顺序
• 然后 逐列 处理并写入剩余列
为了更清晰地了解它们之间的差异,我们将探讨每种合并策略在实践中是如何运作的。
水平合并 (Horizontal merge):简单且 CPU 效率高
水平合并 (Horizontal merging) 的实现非常直接。由于所有数据分块都已根据相同的键进行排序,ClickHouse 会执行一次线性的合并遍历,这类似于归并排序:
• 分块按顺序读取
• 行在读取过程中进行即时比较
• 写入一个新的合并分块
下面的动画通过一个以 (town, street) 为排序键的表的示例数据分块来演示这一过程:
动画展示了水平合并(horizontal merge)过程的三个步骤:

① 合并数据块
系统从多个数据分块中按块读取数据,并根据排序键(sorting key)通过一次线性合并操作在内存中完成合并。为简化起见,动画中展示的是完整的数据分块,而非逐块处理。
② 将数据块写入新的数据分块
合并后的数据被写入到一个新的数据分块中。同样,为简化起见,动画中将其展示为一个步骤。
③ 使旧数据分块失效
合并完成后,原始数据分块将被标记为失效并最终移除。
对于宽表(例如包含 100 列以上),这种方法可能会占用大量内存。
由于合并操作是基于行块(row blocks)进行的,ClickHouse 必须将整个宽行数据块加载到内存中。表越宽,这种操作的开销就越大。
为了解决这个问题,ClickHouse 采用了一种替代的合并策略。
垂直合并:针对宽表的内存优化
垂直合并(Vertical merging)通过按列独立处理数据来减少内存使用。
下面的动画展示了一个以 (town, street) 为排序键的表的垂直合并过程。为简化起见,动画中仅展示了一个额外的 price 列;其他列以相同方式处理。

动画展示了垂直合并过程的五个步骤:
① 首先合并排序键列
系统从多个数据分块中按块读取数据,并根据排序键通过一次线性合并操作在内存中完成合并。为简化起见,动画中展示的是完整的列数据,而非多个列数据块。
② 记录行顺序并写入排序键列
计算出的最终行顺序被临时存储,合并后的排序键列被写入到新的数据分块中。
③ 按照记录的行顺序合并下一列
剩余的列会逐一处理。对于每一列,数据会从所有数据分块中按块读取,并根据先前记录的最终行顺序进行合并。动画中将其展示为一个步骤,且仅针对一列。
正是这种逐列(column-by-column)和逐块(block-by-block)处理的组合,使得垂直合并(vertical merge)具有内存效率。
④ 将列数据添加到新的数据分块
每列处理完成后,合并后的列数据会追加到新的数据分块中。
⑤ 使旧数据分块失效
一旦所有列处理完毕,原始数据块将被标记为非活跃并最终移除。
这种合并策略对于宽表(wide tables)效率更高,因为一次性加载所有列会消耗大量内存。
在实践中,ClickHouse 仅在预计能带来益处时才使用垂直合并 (vertical merges)。
ClickHouse 何时使用垂直合并?
在默认设置下,当待合并的数据块总行数达到 131,072 行或包含至少 11 个非主键列时,垂直合并即被激活。
换句话说,对于预期内存节省能够抵消额外管理开销的合并操作,ClickHouse 会自动切换到内存效率更高的垂直合并算法。
在 TTL (Time To Live) 驱动的工作负载中,情况通常如此,因为大量数据会随时间累积,并通常存储在宽表(wide tables)中。
宽表的高效 TTL 删除
在 ClickHouse 中,您可以定义 TTL 规则,以便在一定时期后 自动删除表数据。
这对于随时间自然失效的数据尤其有用,例如日志 (logs)、事件 (events)、遥测流 (telemetry streams) 或滚动分析数据集 (rolling analytics datasets)。
这些工作负载通常会随着时间累积大量数据。在现代可观测性 (observability) 用例中,这些数据通常以 宽事件 的形式存储,其中 每行包含大量属性。
因此,基于 TTL 的删除操作通常在大型宽表上执行,这使得合并操作变得内存密集。
如前所述,TTL 删除操作在后台合并期间执行。尽管它不合并数据块,但会读取单个数据块,根据 TTL 规则对其进行过滤,然后重写。
从 26.3 版本开始,TTL DELETE 操作现在可以利用垂直合并(vertical merges),从而减少这些操作过程中的内存消耗。
这一行为由新的 MergeTree 设置 vertical_merge_optimize_ttl_delete 控制(该设置默认启用)。
异步写入默认开启
贡献者:Sema Checherinda
如上文“TTL DELETE 的垂直合并”一节所述,ClickHouse 通过写入独立的数据分片(data parts),并在后台进行合并,从而实现高插入吞吐量(high insert throughput)。
在短时间内创建和合并大量小型数据分片会消耗大量资源,因此为获得最佳性能,插入操作应进行批量处理。
批量处理可以在客户端进行,或者您也可以使用 ClickHouse 中的异步写入(asynchronous inserts)功能。
异步写入将数据批量处理从客户端转移到服务器端:来自插入查询的数据会首先被写入一个缓冲区,随后在达到超时时间、累积数据量或插入次数等条件触发下一次缓冲区刷新时,数据才会被写入存储。

自我们首次撰文介绍异步写入功能以来,我们对其进行了进一步的完善和优化。
例如,自 24.2 版本起,异步写入采用了自适应算法(adaptive algorithm),能够根据写入频率自动调整缓冲区刷新超时时间。
在 26.1 版本中,我们为结合物化视图(materialized views)的异步写入引入了一种一致的去重机制(consistent deduplication mechanism)。
现在,从 26.3 LTS (长期支持版本) 开始,异步写入功能已默认启用。
ClickHouse 自动批量处理小型插入,减少了频繁写入生成的数据分片数量,且大多数用户无需进行配置更改。
JOIN 重新排序:支持 ANTI, SEMI, FULL 类型
Hechem Selmi 贡献
“你们什么时候会停止优化 JOIN 性能?” 我们永远不会停止!
不仅仅是 asynchronous inserts 取得了长足进步,JOIN reordering 在最近几个月也取得了显著提升(顺带一提,就在上个月我们 改进 了 RIGHT OUTER 和 FULL OUTER JOIN 的性能)。
JOIN 重新排序:基础知识
简要回顾一下,当多个表进行 JOIN 时,JOIN 的顺序并不会影响结果的正确性,但却能显著影响性能。这是因为不同的 JOIN 顺序可能产生截然不同的中间数据量。ClickHouse 默认的 hash-based join algorithms 会从每个 JOIN 操作的一侧构建内存结构,因此选择一个能保持构建输入量较小的 JOIN 顺序,对于实现快速高效的执行至关重要。
ClickHouse 中 JOIN 重新排序的演进之路
ClickHouse 的 JOIN 重新排序功能在近期版本中取得了显著演进:
• 局部自动 JOIN 重新排序 功能首先引入,针对两个 JOIN 表,它使优化器能够将两个表中较小的表移动到右侧(构建侧),从而减少了构建哈希表所需的工作量。( 24.12 )
• 紧随其后的是 全局自动 JOIN 重新排序 ,该功能能够高效优化涉及数十个表的复杂 JOIN 图,并 支持最常见的 JOIN 类型 (inner, outer, cross, semi, anti)。( 25.09 )
这带来了显著的改进,例如,在一个 TPC-H 示例查询中,实现了 1,450 倍的加速和 25 倍的内存使用量减少。
• 为了进一步优化决策过程,ClickHouse 引入了 自动列统计信息 (automatic column statistics) ,这有助于更准确地估算 JOIN 排序的成本。 ( 25.10 )
• 最后,针对 INNER JOIN,ClickHouse 添加了一个更强大的 JOIN 重排序算法 ( DPsize ),它能够探索更广阔的 JOIN 顺序空间,并常能生成更高效的执行计划。 ( 25.12 )
现已:ClickHouse 支持所有主要 JOIN 类型的重排序
ClickHouse 现已能够对所有主要 JOIN 类型进行重排序,包括 ANTI JOIN、SEMI JOIN 和 FULL JOIN。
优化器此前仅限于处理 INNER JOIN 和 LEFT/RIGHT JOIN,而现在它可以在所有主要 JOIN 类型中自动选择最高效的构建侧 (build side),从而生成更优的执行计划并降低内存消耗。
此功能依赖于为表启用统计信息。
Sharded Map
贡献者:Pavel Kruglov
本次发布不仅引入了 “Vertical merges for TTL DELETE”——这项对可观测性 (Observability) 工作负载特别有用的优化,还改进了 ClickHouse Map data type 的内部存储,从而加速了这些工作负载中常见的访问模式。
可观测性工作负载中的 Map
Observability 数据,例如 OpenTelemetry (OTEL) 事件,通常包含大量的 tags。这些 tags 是简单的键值对 (key-value pairs),为每个被记录的事件提供额外的上下文信息。
由于 tags 天然扁平,它们无法从支持深度嵌套结构的 JSON 等数据类型中受益。
相反,Map data type 作为键值对的集合,天然契合 tags 结构。
Map 当前存储方式
在 ClickHouse 内部,Map 实际上是 Array(Tuple(Key, Value)) 类型。下图展示了将两行数据插入到包含 Map 类型 tags 列的表中时,数据在磁盘上的存储方式。

如图所示,Map 列在磁盘上存储为两个独立的数组:一个包含所有键,另一个包含对应的值。每个数组都配有一个偏移量文件,用于将条目重新关联到它们所属的表行。
局限性
在实际应用中,查询通常只访问 Map 中一小部分键。由于键和值是以没有索引的普通数组形式存储的,每次查找都必须扫描整个数组,从而导致不必要的数据读取。
解决方案
为了解决这一问题,新的存储格式通过将键分组到基于哈希 (hash-based) 的桶 (buckets) 中,从而将 Map 数据拆分为多个子数组。因此,访问单个键,例如 tags['status'],只需读取对应的桶,而无需读取整个列。
这种方式显著减少了常见查找模式所需处理的数据量。
无插入性能开销
重要的是,这项优化不会影响插入性能。新数据仍沿用现有格式写入,而分桶 (bucketed) 布局则在后续的后台合并 (background merges) 过程中应用。
下图简要描绘了向包含 Map 类型 tags 列的表进行两次插入的操作。

该图展示了三个步骤:
① 插入 → Level 0 数据部分 (默认格式)
每次插入都会使用标准的 Map 布局创建一个新的 数据部分:键 (Key) 和值 (Value) 作为两个平面数组存储,不进行任何分桶 (bucketing) 处理。
② 另一次插入 → 另一个 Level 0 数据部分
第二次插入会以相同格式生成另一个数据部分。在此阶段,所有 Map 数据仍以完整的键 (Key) 和值 (Value) 数组形式存储。
访问单个键需要扫描整个数组,但这通常不是问题,因为 Level 0 数据部分通常很小。
③ 后台合并 → 桶式 Map 存储
在进行下一次 后台合并 时,ClickHouse 会将 Map 数据重组,通过哈希算法把键分散到不同的桶中。每个桶都以更小的数组形式存储一部分键及其对应的值。
在访问 tags['status'] 这类键时,ClickHouse 会使用该键的哈希值来定位对应的桶(例如 bucket 3),并仅读取这些数组,从而显著减少了需要扫描的数据量。
性能影响
在实践中,这将使单键查找速度提升 2 到 49 倍,具体取决于 Map 的大小。
配置
此行为由新的 MergeTree 设置 map_serialization_version 控制,并需将其设置为 with_buckets。而 max_buckets_in_map 则用于指定数据最多被划分成多少个桶(默认为 32)。
其他设置可进一步控制具体的布局。根据上图所示的示例,桶式结构是通过以下配置实现的:
• map_serialization_version_for_zero_level_parts = 'basic'
• map_serialization_version = 'with_buckets'
• max_buckets_in_map = 3
• map_buckets_strategy = 'const'
• map_buckets_min_avg_size = 0.
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

2028

被折叠的 条评论
为什么被折叠?



