Skip to content

필사 모드: ClickHouse 的 Lazy Materialization — LIMIT 10 的小技巧是如何长成 FINAL 和 JOIN 的

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 如何解读「同一条 SQL 快了 1,576 倍」

2025 年 4 月,ClickHouse 官方博客上出现了这样一句话。查询语句一行都没改,219 秒变成了 0.139 秒 — 1,576 倍。看到这种数字,通常会有两种反应。要么说一声「哇」就翻篇,要么去拆解它到底是在什么条件下出来的。本文走的是后一条路。

这个数字的主角是 lazy materialization(延迟物化)。它在 25.4 中首次引入时,是一个闸门非常窄的优化 — 只对 LIMIT 10 及以下的查询生效。但从 2025 年底到 2026 年上半年,这道闸门明显地被拓宽了 — 25.12 重做了执行模型,把它提高到 LIMIT 10,000;26.2 扩展到 UNION ALL 的全部分支;26.4 扩展到 ReplacingMergeTree 的 FINAL;到了 26.6(2026 年 6 月 25 日发布),又扩展到 JOIN 之后的 selective LIMIT。这是一个小技巧逐渐长成贯穿整个查询规划器的原则的过程,其中被回滚的 PR 和修复 bug 的痕迹也都原样留在了历史里。

如果你对 ClickHouse 的 MergeTree 结构或向量化执行还不熟悉,不妨先看看ClickHouse 内部结构深度剖析实时 OLAP 优化篇。本文建立在这些内容之上,只讨论 2026 年各版本中的变化。

Lazy Materialization 到底是什么

官方文档把这个功能描述为 ClickHouse I/O 优化栈的最后一层。从下往上叠加,是这样的。

  1. 列式存储。 查询不需要的列,压根就不读。
  2. 稀疏主键索引、跳数索引、投影。 用已建索引列上的过滤条件,整块跳过 granule(默认 8,192 行的块)。
  3. PREWHERE。 对没有索引的列,也按代价从便宜到贵依次评估过滤条件,后面的列只读存活下来的那些行。
  4. 查询条件缓存。 在重复查询中,记住上一次没匹配上的 granule,直接跳过。

到目前为止有一个共同的前提假设 — 只要行通过了过滤,SELECT 里的列,不管是要排序还是要聚合,都得先整个读进来。lazy materialization 打破了这个假设。它先只读排序所需要的列,等到排序和 LIMIT 结束、最终的行已经确定之后,才把剩下的 SELECT 列取回来 —— 而且只取这些行的。如果是 ORDER BY 小列 LIMIT 3 这样的 Top-N 查询,SELECT 里又搭上了一个 50GiB 的文本列,那这个文本列就只需要读 3 行的量。

是否触发了这个优化,可以直接在执行计划里确认。

EXPLAIN actions = 1
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3;
Lazily read columns: review_headline, review_body, product_title
  Limit
    Sorting
      ReadFromMergeTree

只要看到 Lazily read columns 这一行,就说明生效了。文档也明确指出,这种按行粒度的延迟读取之所以可能,本身就是列式存储带来的好处 —— 在行式数据库里,反正整行都会一起被读出来,这套逻辑根本不成立。

厂商基准测试的条件 — 那 3 分钟的真面目其实是磁盘

现在来看看那 1,576 倍是在什么条件下测出来的。下面的数字全部是 ClickHouse 在自家博客上公开的厂商自测数据,测试环境也在博客里写明了:AWS m6i.8xlarge(32 vCPU,128GiB RAM),配了一块 1TiB gp3 SSD 且保持默认设置 — 3,000 IOPS,最大吞吐量 125MiB/s — 每次运行前都清空了 OS 页缓存(cold cache)。数据是 Amazon 评论 150.96M 行,未压缩 70.47GiB,ZSTD(1) 压缩后为 30.05GiB。

对一个带过滤条件的查询(日期、类别、验证购买、星级过滤 + ORDER BY helpful_votes DESC LIMIT 3)逐层叠加优化,得到的阶梯是这样的。

阶段执行时间处理数据峰值内存
全表扫描(索引、PREWHERE、lazy 全部关闭)219.508秒72.13GB953.25MiB
+ 主键索引95.865秒27.67GB629.00MiB
+ PREWHERE61.148秒16.28GB583.30MiB
+ lazy materialization0.181秒807.55MB3.88MiB

在去掉全部过滤条件的纯 Top-N 查询上,219.071 秒变成了 0.139 秒。处理数据从 71.38GB 降到 1.81GB,峰值内存从 1.11GiB 降到 3.80MiB。这就是那 1,576 倍的出处。

要理解这个数字,有一处算术值得先算一遍。把无过滤条件查询要读的四个列的压缩大小、按博客里公布的表格加起来,大约是 26.8GiB(review_body 21.60GiB + product_title 3.53GiB + review_headline 1.58GiB + helpful_votes 72.11MiB)。用这个数字除以磁盘的吞吐量上限 125MiB/s,得到大约 219 秒 —— 和实测的 219.071 秒几乎一致。也就是说,基线的 3 分 39 秒并不是 ClickHouse 慢,而是在一块被限速的磁盘上读取 27GiB 所需要的物理时间。博客自己也在磁盘规格旁边配了一个蜗牛表情符号。因为 lazy materialization 的收益是「未读取的字节数 ÷ 磁盘速度」,所以这个配置恰好也是这项收益看起来最大的配置。

也要说清楚哪些内容没有公开。这篇博客里没有 hot cache(数据已经在页缓存里)状态下的对比数字 —— 全程都是 cold cache。也没有在更快的 NVMe 或本地 SSD 上的倍数。而且这条查询本身,是对这项优化最有利的形态 —— 按压缩字节算,SELECT 目标里大约 99.7% 属于被延迟的列(即上面三个文本列)。博客自己也诚实地写明了这一点 —— 根据数据集和查询形态的不同,索引或 PREWHERE 有可能带来更大的收益。如果你的查询只 SELECT 几个窄列,那压根就没有可以延迟的字节,倍数也会完全不同。

为什么一开始只到 LIMIT 10 — 闸门从 10 → 100 → 10,000 的历史

在最初上线时(25.4,2025 年 4 月 22 日),闸门保守得令人惊讶。query_plan_max_limit_for_lazy_materialization 默认值是 10 —— LIMIT 一旦超过 10,这项优化就完全不会触发。原因,25.12 发布说明事后解释得很清楚。最初的实现是在 Top-N 的行确定之后,逐行(row by row)去取剩下的列。当 LIMIT 较大时,这种 lookup 就变成了零散的随机读取,延迟读取带来的收益会被这部分开销吃掉。

厂商自己的演示把这一点量化了出来。在网站分析数据集(hits,100M 行,同样是 m6i.8xlarge + gp3)上跑 SELECT * FROM hits ORDER BY EventTime LIMIT 100000,把闸门关掉(= 0)之后 —— 25.11 的逐行方式会变成大约一千万次单独的 lookup(104 个非排序列 × 100,000 行)。3 次运行下来是 34.2 秒到 38.7 秒。同一条查询如果把 lazy 完全关掉(改成 eager,全部提前读取),则是 7.0 秒到 7.9 秒。也就是说,在这个点上,旧版 lazy 方式比 eager 慢了将近 5 倍 —— 不过它处理的数据要少得多,1.20GB 对 56.83GB,峰值内存也是约 1GiB 对 74–78GiB,差距悬殊。只看时间的话,闸门为什么设在 10,一目了然。

在 25.12(2025 年 12 月 18 日)中,这套执行模型被彻底重做了(PR #90309,发布说明里称之为「join-style execution model」)。不再是逐行 lookup,而是先为 Top-N 的行构建一组标识符,再像 join 一样批量到基表里查询 —— 由此获得和普通 join 一样的向量化、并行执行收益。同一条演示查询降到了 0.513–0.524 秒(按厂商的说法,相比旧方式提升约 75 倍,相比 eager 约 14 倍 —— 不过这个演示是取 3 次重复运行中最快的一次,缓存状态没有说明),闸门的默认值也因此被提高到了 10,000。设置历史文件里完整保留了这条轨迹 —— 25.4 中以 10 引入,25.11 提高到 100(「More optimal」),25.12 提高到 10,000(「Increase the limit after performance improvement」)。

为什么停在 10,000,发布说明里也写了原因。被延迟物化的列,最终还是需要按 Top-N 行的顺序重新排序,而当 LIMIT 变得非常大时,这部分成本又会重新变得显眼。这道闸门本身就是对权衡的一种自白 —— 这项优化不是免费的,它是一场在 N 较小时才会赢的赌注。

还有一点:从 25.8 起,lazy materialization 只在启用了新版 analyzer 时才生效(#83791)。analyzer 成为默认值已经有一段时间了,但如果你的环境为了兼容旧版本而把 enable_analyzer = 0,那本文的这一切都不适用于你。

2026 年:同一个想法,走进新的查询形态

到这里为止都是前史,2026 年的各个版本,做的是把「把读取推迟到排序、LIMIT 之后」这同一个想法,移植到新的查询形态上。以下内容全部是从 changelog 和已合并的 PR 原文中核实过的。

26.2(2026 年 2 月 26 日)— UNION ALL 的全部分支。 在此之前,lazy materialization 只对 UNION ALL 的第一个分支生效。从 #96832 开始,它对全部分支都生效 —— 如果一条查询把来自不同 MergeTree 表的、带排序和 LIMIT 的读取合并起来,那每个分支都能拿到延迟读取带来的 I/O 节省。顺带一提,这一条并没有出现在公开的 changelog 里,它落在 26.2 里这个事实,是根据 PR 中留下的合并记录(26.2.1.706)判断的。与其说是新增功能,不如说是补上了实现里的一个洞,但对于把时间序列拆成多张表、再用 UNION ALL 拼起来这种常见模式来说,是实实在在的差别。

26.4(2026 年 4 月 30 日)— ReplacingMergeTree 的 FINAL。 #101647 把 lazy materialization 移植到了 FINAL 读取上。机制在该设置的说明里有概括 —— 在选择性较强的条件下,构建一组主键,再用它重新做一次索引分析。不过有一点要留意:changelog 里把它列为性能改进,但按 master 分支 Settings.cpp 的现状,query_plan_optimize_lazy_final 目前默认仍是 false,也就是需要手动开启。而且随之而来还有三个护栏设置 —— 如果主键集合超过一千万行或 256MB,就回退到普通 FINAL(max_rows_for_lazy_finalmax_bytes_for_lazy_final);如果索引分析没能过滤掉至少一半的 mark,同样会回退(min_filtered_ratio_for_lazy_final,默认 0.5)。护栏的数量本身,就是信任程度的一种标示。实际上,26.5 的 changelog 里就记录了两条针对 lazy FINAL 路径的修复 —— 一条是管道扩展过程中的逻辑错误(#103230),另一条是在 PREWHERE 组合下发布版本会因 segfault 而导致服务器 abort(#104177)。如果 FINAL 是你 ReplacingMergeTree 工作负载的瓶颈,打开它做个 A/B 测试是值得的,但默认关闭这件事,理解成「是有原因的」才对。

26.6(2026 年 6 月 25 日)— JOIN 之后的 selective LIMIT。 作为这次发布的 JOIN 优化组合之一:当 JOIN 之后紧跟着一个选择性较强的 LIMIT、TopN,或者另一个 JOIN 时,左表的 payload 列(非连接键的列)不会立刻被物化,而是以 selector/replication 索引的形式携带下去(#106566)。等到行数减少之后才物化,因此在连接结果大部分都会被 LIMIT 丢弃的查询里,复制成本会降低。闸门有两个 —— 左侧 payload 列不少于 3 个(query_plan_min_columns_for_join_lazy_indexing,0 表示关闭),LIMIT 不超过 1,000(query_plan_max_limit_for_join_lazy_indexing,0 表示不限)。以远比主功能的 10,000 更窄的闸门开局,和当初引入时的模式如出一辙。

26.6 的这项功能有一个小插曲。它最初以 #98883 的形式在 2026 年 5 月 26 日合并,但三天后 CI 就抓到了一个 Bad cast from type DB::ColumnReplicated to DB::ColumnString 的逻辑错误(#106095),于是被回滚,之后又在 6 月 19 日随着一个「在排序转换阶段物化该列」的修复重新引入。整个过程都发生在 26.5 和 26.6 两个发布版本之间,破损的版本从未出现在稳定版里,但这段插曲很好地展示了这一系列优化共通的结构性难点 —— 被延迟的列表示,必须在管道的每一个角落都装得像一个真正的列。

同一时期,「lazy」这个词本身也在向代码库的其他角落蔓延,这一点也很有意思。26.3 中出现了一个实验性功能,把 JSON 列的类型提示变更处理成元数据操作而不重写数据(allow_experimental_json_lazy_type_hints#97412);26.6 中则出现了另一个实验性功能,不再把文本索引的 posting list 一次性展开成 Roaring Bitmap,而是基于游标按需解码(allow_experimental_text_index_lazy_apply#100035)。两者都还是实验性的,但方向是一致的 —— 把事情拖到最后一刻,其中相当一部分就再也不需要做了。

哪里不适用,需要注意什么

总结一下,截至 2026 年年中,一条查询要触发这项优化,必须满足以下条件。

  • Top-N 形态 —— 必须是 ORDER BY ... LIMIT N 这一类查询,且 N 不超过 10,000(默认闸门)。JOIN 变体要求 N 不超过 1,000,且 payload 列不少于 3 个。FINAL 变体必须手动开启。
  • 必须启用新版 analyzer(默认已启用)。
  • 如果计划中出现了 arrayJoin,就不会生效 —— 这是 26.5 中特意排除的情况(#101644)。这是一个 LIMIT 可能得不到遵守的正确性问题,被排除是出于正确性而非性能考虑。

结构上没有收益的情形,也一并写清楚。如果 SELECT 的列都很窄(和排序列的大小差不多),就没有可以延迟的字节。会把结果全部消费掉的查询(以聚合结束的查询,或者没有 LIMIT 的导出类查询)从一开始就不是这项优化的目标。而且,在工作集全部驻留在页缓存里的 hot 环境下,收益幅度厂商从未公开过 —— 也就是说,不能把 cold cache 下的 1,576 倍直接换算成你自己仪表盘延迟的改善幅度。测量必须用你自己的数据来做。好在,做 A/B 只需要一个会话级设置。

-- 关掉后对比
SELECT ... SETTINGS query_plan_optimize_lazy_materialization = false;
-- 在超出闸门的 LIMIT 上强制开启 (默认闸门是 10000)
SELECT ... SETTINGS query_plan_max_limit_for_lazy_materialization = 0;

最后要说一说 bug 的历史轨迹。这个功能自推出以来,几乎每个版本都有相关修复落地 —— 读取 Variant 列(25.8,#84400)、与外部排序重叠时的 CORRUPTED_DATA(25.8,#84738)、与投影重叠时的 AMBIGUOUS_COLUMN_NAME(25.6,#80251)、ALTER 新增列的旧 part 处理(25.12,#91142),甚至 26.6 里也有一处修复,是排序列指错导致 TYPE_MISMATCH 的情况(#107060)。这不是要吓唬你 —— 而是想说明,一个默认开启的优化出的 bug,是你从未主动打开过的代码路径上的 bug。这正是升级 ClickHouse 时应该去读发布说明里带「lazy」字样那几行的原因,也是当查询莫名其妙挂掉或变慢时,query_plan_optimize_lazy_materialization = false 是一个有效的首选隔离手段的原因。

结语

把 lazy materialization 过去一年多的轨迹压缩成一句话,是这样的 —— 25.4 中作为 LIMIT 10 的保守小技巧诞生(PR 本身早在 2023 年 10 月就开了,搁置了一年半),25.12 把执行模型改造成 join 风格,把闸门拓宽了 1,000 倍,进入 2026 年后又通过 UNION ALL(26.2)、FINAL(26.4,选择性开启)、JOIN(26.6)不断扩大适用范围。这是那种一行查询都不用改,光靠升级就能变快的改进,实用价值很明确。

与此同时,本文所确认的边界也同样清晰。这个头条倍数,是厂商在 cold cache、被限速的磁盘、以及被延迟的列占据了大部分字节的查询形态下自测出来的数字 —— 而且连厂商自己都同时公开了另一个数字:当 LIMIT 变大时,旧方式比 eager 慢了 5 倍。闸门、回退机制和默认关闭的选择性开启,都不是装饰,而是对这项优化成本结构的诚实记录。

把这个和当下其他分析型数据库正在打磨的层面放在一起看也很有意思 —— DuckDB 在替换它的客户端-服务器协议,Elasticsearch 在替换向量索引的默认值的同时,ClickHouse 则一直在深挖「读取到底能推迟多久」这一个问题,一次拓宽一种查询形态。至于下一个版本里这些闸门又会怎么动 —— 光是盯着 SettingsChangesHistory.cpp 就足以持续追踪这个功能的成熟度。

参考资料

현재 단락 (1/63)

2025 年 4 月,ClickHouse 官方博客上出现了这样一句话。查询语句一行都没改,219 秒变成了 0.139 秒 — 1,576 倍。看到这种数字,通常会有两种反应。要么说一声「哇」就翻篇...

작성 글자: 0원문 글자: 10,165작성 단락: 0/63