本文字数:10534;估计阅读时间:27 分钟
作者:ClickHouse Team
本文在公众号【ClickHouseInc】首发
又一个月过去,ClickHouse 迎来了全新的版本更新!
发布概要
本次ClickHouse 25.6 发布包含 25 项新功能 🌸 32 项性能优化 🦋 以及 105 个缺陷修复 🐝。
在这一版本中,ClickHouse 实现了在同一个查询中对所有子查询和表引用使用单一一致性快照(snapshot)、支持按多个投影(projection)过滤、对 Parquet 文件中 JSON 的支持,等等。
新贡献者
在此特别欢迎 25.6 版本的新贡献者!ClickHouse 社区的不断壮大令人感到振奋,我们非常感谢大家为 ClickHouse 流行度持续增长做出的贡献。
以下是新贡献者名单:
Alexander Grueneberg, Delyan Kratunov, Denis K, Engel Danila, George Larionov, Hans Krutzer, Himanshu Pandey, Joel Höner, Kenny Sun, Krishna Mannem, Lucas Pelecq, Lucas Ricoy, Nihal Z., Nikolay Govorov, Philip Dubé, Q3Master, Roy Kim, San Tran, Sav, Shakhaev Kyamran, Tanner Bruce, abashkeev, clickhouse-gh[bot], fhw12345, haowenfeng, haoyangqian, mekpro, nihalzp, roykim98, sdairs, sinfillo
SELECT 的单一快照(snapshot)
由 Amos Bird 贡献
ClickHouse 确保 SELECT 查询总是在一致性的快照上运行。这意味着,在整个查询执行期间,即使并发地插入新的行,或更新、删除现有的行,查询看到的数据也始终保持一致。
这一能力的核心来自表数据分片(data parts)的不可变特性[https://clickhouse.com/docs/parts]:
-
插入操作总是创建新的分片,现有数据从不被原地修改。
-
删除和更新会通过 mutation(mutation)或分片合并,最终生成包含更改的新分片。
依靠这种不可变性,SELECT 查询就可以安全地基于查询启动时的分片快照执行。
下面这张动画演示了这一快照机制:
① 当 ClickHouse 收到针对表 t 的 SELECT 查询 Q1 时,会在查询计划与索引分析阶段确定相关的数据分片。
② 这些分片会被标记为当前查询的引用对象。
在 Q1 执行期间,以下变化可以安全地发生且不会影响结果:
③ 一个分片被删除
④ 一个新的分片被插入
⑤ 一个分片被变更(mutate)
由于引用机制的存在,Q1 会持续从原始快照中读取数据,在整个执行过程中保证一致性。
更重要的是,所有被查询引用的分片在查询结束前都不会被删除。一旦 Q1 执行完成,引用会被释放,旧分片也就可以安全清理。
问题:子查询快照不一致
在 ClickHouse 25.6 版本之前,系统会为每一个子查询或表引用分别生成快照,这种行为会带来数据一致性风险:
① 当 ClickHouse 收到一个包含子查询 SQ1 和 SQ2 的 SELECT 查询时,
② 它会先为 SQ1 确定需要使用的数据分片,然后再单独为 SQ2(④)确定分片。
③ 如果此过程中发生了数据分片的插入、删除或变更,
④ 那么两个子查询很可能读取到不一致的版本。
解决方案:整条查询使用单一快照
从 25.6 版本起,ClickHouse 会在查询执行开始时为整个查询(即便包含多个子查询或公共表表达式 CTE)创建一个统一、一致的快照。这让即使最复杂的 SELECT 查询也能保证正确的结果。
这个行为由配置项 enable_shared_storage_snapshot_in_query 控制:
SET enable_shared_storage_snapshot_in_query = 1;
启用后,所有子查询会共享同一张表的相同快照:
在版本发布演示中,你可以看到这一新旧机制的对比效果,Alexey 在讲解时还提供了一个真实的案例(点击可跳转至正确时间戳的视频)。
[视频链接:https://www.youtube.com/live/_9IBOM3pR4U?t=779s]
功能改进:按多个投影(projection)过滤
由 Amos Bird 贡献
ClickHouse 为大规模实时分析提供了多种加速手段,其中一个非常强大的功能就是投影(projection)。它是一种由系统自动维护并隐藏的结构化表,用来优化查询效率。
投影可以采用与基础表不同的排序规则(因此主索引也不同),甚至可以提前对数据进行预聚合。在执行查询时,ClickHouse 会自动判断并选择最优的执行路径,无论是从基础表还是从投影读取,以尽可能减少扫描数据的开销。
下面通过一个简单示例来说明:一个基础表和它的两个投影:
CREATE TABLE page_views
(
id UInt64,
event_date Date,
user_id UInt32,
url String,
region String,
PROJECTION region_proj
(
SELECT * ORDER BY region
),
PROJECTION user_id_proj
(
SELECT * ORDER BY user_id
)
)
ENGINE = MergeTree
ORDER BY (event_date, id);
下图展示了基础表及其两个投影:
① 基础表 page_views 用于追踪网站访问记录,按照(event_date, id)排序,这样它的主索引就能让基于这两列的过滤查询高效执行。
② region_proj 投影按 region 列排序,主索引能快速响应按地域过滤的查询。
③ 同样,user_id_proj 投影按 user_id 排序,从而优化基于用户 ID 的查询性能。
之前的两个主要限制(现已改进)
在过去,投影(projection)主要存在两个限制:
-
每个投影都会完整复制基础表的全部数据,从而增加存储负担。
-
每次查询只能利用一个投影,在面对包含多个过滤条件的查询时优化能力受限。
更智能的存储方案:_part_offset
从 25.5 版本开始,ClickHouse 在投影中支持一个名为 _part_offset(_part_offset)的虚拟列,使得投影可以采用更加节省存储空间的方式。
现在有两种方式来定义投影:
-
存储完整列(原有方式):投影包含全部原始数据,当查询过滤条件和投影的排序匹配时,能实现最快的性能。
-
只存储排序键 + _part_offset:这种投影更像一个索引结构。ClickHouse 利用投影的主索引定位到符合条件的行,但实际数据仍然从基础表中读取,从而降低存储开销,只是查询时 I/O 会稍微增多。
此外,你也可以混合使用这两种方式,把部分列直接存储在投影里,而其他列通过 _part_offset 间接访问。
下面的插图展示了第二种(类似索引)的做法:
① 基础表中现在会显示一个虚拟列 _part_offset
② region_proj 和 ③ user_id_proj 投影只存储各自的排序键以及 _part_offset,引用基础表的行,从而显著减少数据重复。
在一个查询中利用多个投影
从 25.6 版本开始,基于 25.5 中引入的 _part_offset 功能,ClickHouse 现在支持在一个查询中利用多个投影来优化包含多个过滤条件的场景。
需要注意的是,ClickHouse 实际上依旧只会从一个投影(或基础表)中读取数据,但会使用其他投影的主索引来在读取前做分片剪枝(pruning)。
对于那些涉及多列过滤条件、并且每列可能对应不同投影的复杂查询,这种机制特别有用。
目前,这项机制只能在分片(parts)级别做剪枝,还不支持更细粒度的 granule(granule)级别。
为了演示这个特性,我们定义了一个包含使用 _part_offset 列的投影的表,并插入 5 行示例数据,与上面图示相符。
CREATE TABLE page_views
(
id UInt64,
event_date Date,
user_id UInt32,
url String,
region String,
PROJECTION region_proj
(
SELECT _part_offset ORDER BY region
),
PROJECTION user_id_proj
(
SELECT _part_offset ORDER BY user_id
)
)
ENGINE = MergeTree
ORDER BY (event_date, id);
SETTINGS
index_granularity = 1, -- one row per granule
max_bytes_to_merge_at_max_space_in_pool = 1; -- disable merge
INSERT INTO page_views VALUES (
1, '2025-07-01', 101, 'https://example.com/page1', 'europe');
INSERT INTO page_views VALUES (
2, '2025-07-01', 102, 'https://example.com/page2', 'us_west');
INSERT INTO page_views VALUES (
3, '2025-07-02', 106, 'https://example.com/page3', 'us_west');
INSERT INTO page_views VALUES (
4, '2025-07-02', 107, 'https://example.com/page4', 'us_west');
INSERT INTO page_views VALUES (
5, '2025-07-03', 104, 'https://example.com/page5', 'asia');
提示:为了演示效果,此表启用了自定义设置,比如每个 granule 仅一行并禁用分片合并,但这并不建议在生产环境中采用。
这样会生成:
-
5 个独立的分片(每插入一行产生一个分片)
-
每行在基础表和所有投影中都有一个主索引条目
-
每个分片仅包含一条记录
接下来执行一个同时按 region 和 user_id 过滤的查询(如下)。由于基础表的复合主索引(event_date, id)在这里并不适合,ClickHouse 会利用:
-
region_proj 对 region 进行分片剪枝
-
user_id_proj 进一步按 user_id 做剪枝
可以通过 SQL 命令 EXPLAIN projections = 1 观察这个行为,它会展示 ClickHouse 如何选择并使用投影。
EXPLAIN projections=1
SELECT * FROM page_views WHERE region = 'us_west' AND user_id = 107;
Expression ((Project names + Projection))
Expression
① ReadFromMergeTree (default.page_views)
Projections:
② Name: region_proj
Description: Projection has been analyzed and is used for part-level filtering
Condition: (region in ['us_west', 'us_west'])
Search Algorithm: binary search
Parts: 3
Marks: 3
Ranges: 3
Rows: 3
Filtered Parts: 2
③ Name: user_id_proj
Description: Projection has been analyzed and is used for part-level filtering
Condition: (user_id in [107, 107])
Search Algorithm: binary search
Parts: 1
Marks: 1
Ranges: 1
Rows: 1
Filtered Parts: 2
EXPLAIN 的输出结果揭示了自上而下的查询执行计划:
① 计划从基础表 page_views 读取
② 使用 region_proj 找出 region = 'us_west' 的 3 个分片,并剪枝掉 2 个
③ 再通过 user_id_proj 找出 user_id = 107 的 1 个分片,并剪枝剩下 3 个中的 2 个
最终只需要从基础表读取 5 个分片中的 1 个。
通过结合多个投影的索引分析,ClickHouse 在保持较低存储占用的同时,显著减少了扫描数据的量并提升了查询性能。
在 Parquet 中使用 JSON
能改进,由 Nihal Z. Miaji 贡献
Parquet 并不原生支持 JSON 数据类型,而是通过一种逻辑 JSON 类型,将实际数据以字符串形式存储,并添加注解来实现。就像官方文档所描述[https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#json]:
它会对 BYTE_ARRAY 基本类型进行注释,将 BYTE_ARRAY 视作符合 JSON 规范的 UTF-8 编码字符串来解释。
下面演示一下如何将 ClickHouse 的 JSON 类型写入 Parquet 文件:
SELECT '{"database": "ClickHouse", "is_it_good": true}'::JSON AS data
INTO OUTFILE 'test.parquet';
我们可以在解析这个文件时,使用 ParquetMetadata 作为输入格式:
SELECT *
FROM file('test.parquet', ParquetMetadata)
FORMAT PrettyJSONEachRow;
{
"num_columns": "1",
"num_rows": "1",
"num_row_groups": "1",
"format_version": "2",
"metadata_size": "232",
"total_uncompressed_size": "174",
"total_compressed_size": "206",
"columns": [
{
"name": "data",
"path": "data",
"max_definition_level": "0",
"max_repetition_level": "0",
"physical_type": "BYTE_ARRAY",
"logical_type": "JSON",
"compression": "ZSTD",
"total_uncompressed_size": "174",
"total_compressed_size": "206",
"space_saved": "-18.39%",
"encodings": [
"PLAIN",
"RLE_DICTIONARY"
]
}
],
"row_groups": [
{
"file_offset": "4",
"num_columns": "1",
"num_rows": "1",
"total_uncompressed_size": "174",
"total_compressed_size": "206",
"columns": [
{
"name": "data",
"path": "data",
"total_compressed_size": "206",
"total_uncompressed_size": "174",
"have_statistics": true,
"statistics": {
"num_values": "1",
"null_count": null,
"distinct_count": null,
"min": "{\"database\":\"ClickHouse\",\"is_it_good\":true}",
"max": "{\"database\":\"ClickHouse\",\"is_it_good\":true}"
},
"bloom_filter_bytes": "47"
}
]
}
]
}
在 columns 中,我们可以看到逻辑类型被标记为 JSON,物理类型则是 BYTE_ARRAY,这也符合预期。
在 ClickHouse 25.5 及之前的版本中,读取时会把这种逻辑 JSON 类型当作 String 类型返回。
select *, * APPLY(toTypeName)
FROM file('test.parquet');
┌─data────────────────────────────────────────┬─toTypeName(data)─┐
│ {"database":"ClickHouse","is_it_good":true} │ Nullable(String) │
└─────────────────────────────────────────────┴──────────────────┘
而从 ClickHouse 25.6 开始,读取时则会自动转换为 JSON 数据类型。
┌─data────────────────────────────────────────┬─toTypeName(data)─┐
│ {"database":"ClickHouse","is_it_good":true} │ JSON │
└─────────────────────────────────────────────┴──────────────────┘
Time 与 Time64 数据类型
功能改进,由 Yarik Briukhovetskyi 贡献
为了更好地兼容其他 SQL 数据库管理系统(DBMS),ClickHouse 新增了 Time 和 Time64 两种数据类型,用于存储时间信息。
Time 类型可以精确到秒,范围是 [-999:59, 999:59](分钟:秒),每个值占用 32 位;而 Time64 则可以精确到亚秒级,范围是 [-999:59.999999999, 999:59.99999999],每个值占用 64 位。
目前该特性处于实验阶段,因此需要先设置 enable_time_time64_type 配置参数才能启用:
SET enable_time_time64_type=1;
接着我们可以将 now() 的输出转换成 Time 类型,以提取当前时间:
SELECT now()::Time;
┌─CAST(now(), 'Time')─┐
│ 13:38:25 │
└─────────────────────┘
或者用于记录运行指标:
CREATE TABLE runningTimes(
time Time64(3)
)
ORDER BY time;
INSERT INTO runningTimes VALUES ('00:07:45.143') ('00:08:02.001') ('00:07:42.001');
如果需要计算平均时间,目前暂时还不支持直接在 Time 类型上执行(但该功能正在进行中[https://github.com/ClickHouse/ClickHouse/issues/82267])。不过可以先将时间值转换成 UInt32,做完聚合后再转回原来的格式:
select avg(toUInt32(time))::Time AS avg
FROM runningTimes;
┌──────avg─┐
│ 00:07:49 │
└──────────┘
新系统表:codecs 与 iceberg_history
功能改进,由 Jimmy Aguilar Mena 和 Smita Kulkarni 贡献
ClickHouse 本次还新增了两个系统表:
-
system.codecs:用于查看 ClickHouse 中可用的压缩与加密编解码器文档
-
iceberg_history:记录 Apache Iceberg 表所有可用快照的信息
system.codecs:了解压缩和加密编解码器
首先,我们来看一下 system.codecs 表:
DESCRIBE system.codecs;
┌─name───────────────────┬─type───┐
│ name │ String │
│ method_byte │ UInt8 │
│ is_compression │ UInt8 │
│ is_generic_compression │ UInt8 │
│ is_encryption │ UInt8 │
│ is_timeseries_codec │ UInt8 │
│ is_experimental │ UInt8 │
│ description │ String │
└────────────────────────┴────────┘
可以通过以下查询,返回部分编解码器的名称与描述:
SELECT name, description
FROM system.codecs
LIMIT 3
FORMAT Vertical;
Row 1:
──────
name: GCD
description: Preprocessor. Greatest common divisor compression; divides values by a common divisor; effective for divisible integer sequences.
Row 2:
──────
name: AES_128_GCM_SIV
description: Encrypts and decrypts blocks with AES-128 in GCM-SIV mode (RFC-8452).
Row 3:
──────
name: FPC
description: High Throughput Compression of Double-Precision Floating-Point Data
system.iceberg_history:探索 Apache Iceberg 表快照
接着,对于 Iceberg 用户来说,system.iceberg_history 表的结构如下所示:
DESCRIBE TABLE system.iceberg_history
┌─name────────────────┬─type────────────────────┐
│ database_name │ String │
│ table_name │ String │
│ made_current_at │ Nullable(DateTime64(6)) │
│ snapshot_id │ UInt64 │
│ parent_id │ UInt64 │
│ is_current_ancestor │ UInt8 │
└─────────────────────┴─────────────────────────┘
然后就可以通过基于 made_current_at 或 snapshot_id 的查询,来实现基于快照的时间回溯功能。
Bloom filter 索引优化
由 Delyan Kratunov 贡献
仅仅一行的修改,就可能挽救了 OpenAI 的集群,也让几位工程师松了一口气。
在 GPT-4o 图像生成功能发布期间,当全球用户正忙着把宠物、头像等各种图片生成宫崎骏风格时,OpenAI 的可观测性(observability)系统遭遇了巨大的流量冲击。日志量在一夜之间暴增 50%,CPU 使用率也直线上升。
造成 CPU 消耗居高不下的根本原因,是 ClickHouse 中 Bloom filter 索引实现里的一个代价昂贵的除法操作,它会在每次插入元素时被触发。OpenAI 工程师 Delyan Kratunov 发现这个问题,并用快速的乘法和位移运算替换了原本的除法。
结果呢?只改动几乎一行代码,就把 CPU 使用率降低了 40%,避免了一次危机,成功保住集群的稳定运行。而现在,多亏 Delyan,这项优化也随着 25.6 版本惠及整个社区。
你可以在 OpenAI 的用户故事中看到完整细节[https://clickhouse.com/blog/why-openai-uses-clickhouse-for-petabyte-scale-observability],了解他们为何会在超大规模可观测性场景下选择 ClickHouse。
再次感谢 Delyan 和 OpenAI 团队将这项修复贡献给上游!🌸
彩蛋:使用 chdig 深入探索 ClickHouse
由 Azat Khuzhin 贡献
最后再介绍一个实用工具:现在每次安装 ClickHouse 时,都会自带一个全新的命令行监控与诊断工具 —— chdig。[https://github.com/azat/chdig]
它可以像其他 ClickHouse 工具一样启动,例如 clickhouse-chdig、clickhouse chdig,或者直接运行 chdig。
chdig 是一个类似 top 的 TUI 界面工具,专门针对 ClickHouse 打造,能够实时查看查询和服务器的状态。
它的一些亮点功能包括:
-
全面的 Flamegraph
可以查看 CPU、内存和实时 flamegraph,帮助快速定位查询瓶颈和内部行为,并且内置交互式 speedscope(一个可视化分析工具)支持。 -
一目了然的查询可视化
快速切换查看查询处理器、执行管道、日志、系统表、查询计划,甚至可以一键终止异常查询。 -
集群支持与历史日志查看
不仅支持一次性监控整个集群,还可以利用 system.log 表中轮转的历史日志进行回溯分析。
想更直观地了解它的效果,可以观看 Alexey 在发布会上的演示(点击可直接跳转到对应的时间戳):
[视频链接:https://www.youtube.com/live/_9IBOM3pR4U?t=2233s]