惰性物化
本文介绍了惰性物化的工作原理,以及它在 ClickHouse 更广泛的 I/O 优化栈中的作用。 文中给出了一个实际示例,演示惰性物化如何提升查询性能。
惰性物化在 ClickHouse 25.4 版本中引入,并默认启用。
概览
多年来,ClickHouse 引入了一系列分层优化来大幅减少 I/O。 这些技术构成了其高速与高效的基础:
| Optimization | Description |
|---|---|
| Columnar storage | 允许跳过查询不需要的整列数据,并通过将相似值分组来实现高压缩率,从而在数据加载过程中将 I/O 最小化。 |
| Sparse primary indexes | secondary data-skipping indexes | projections | 通过识别哪些 granules(行块)可能匹配_已建立索引的列_上的过滤条件来裁剪无关数据。这些技术在 granule 级别工作,可以单独使用,也可以组合使用。 |
| PREWHERE | 同样会检查_未建立索引_列上的过滤条件,以尽早跳过那些本来会被加载然后丢弃的数据。它可以独立工作,也可以在索引选出的 granule 基础上进一步细化,通过跳过那些不满足_所有_列过滤条件的行,来补充 granule 级别的裁剪。 |
| Query condition cache | 通过记住上一次哪些 granule 匹配了全部过滤条件来加速重复查询。即使查询结构发生变化,ClickHouse 也可以跳过读取和过滤那些之前未匹配的 granule。 |
尽管上述 I/O 优化可以显著减少读取的数据量,但它们仍然默认:所有通过 WHERE 子句筛选的行,其所有列都必须在执行排序、聚合或 LIMIT 等操作之前被加载。
但如果有些列要到后续步骤才会用到,或者部分数据虽然通过了 WHERE 子句筛选,但实际上根本不会被用到,又该怎么办?
这就是 lazy materialization 发挥作用的地方。它是一个正交的增强功能,使 I/O 优化体系更加完整:
- 索引与
PREWHERE结合,确保只处理在WHERE子句中匹配列过滤条件的行。 - Lazy materialization 在此基础上,通过将列读取推迟到查询执行计划实际需要它们时才进行。
即便在过滤之后,也只会立刻加载下一步操作(例如排序)所需的列。
其他列会被延后读取,并且由于
LIMIT的存在,通常只会被部分读取,仅需足以产生最终结果的数据即可。 这使得 lazy materialization 对 Top N 查询尤其强大,因为最终结果可能只需要来自某些(通常很大)列的少量行。
一个完整示例
我们强烈推荐阅读博文 "ClickHouse gets lazier (and faster): Introducing lazy materialization",以深入了解惰性物化。下面的示例取自前述博文,并在此复现,用于演示在启用惰性物化后,如何将一个 ClickHouse 查询的执行时间从 219 秒减少到仅 139 毫秒(加速 1576×)。
为了从索引和 PREWHERE 中获益,查询需要包含过滤条件:对主键列的过滤用于索引,对任意列的过滤用于 PREWHERE。
惰性物化可以很自然地叠加在这些机制之上,但与前面提到的其他优化不同的是,它同样可以加速完全没有列过滤条件的查询。
考虑下列示例查询:在不考虑日期、产品、评分或验证状态的前提下,它查找获得最多 helpful votes 的 Amazon 评论,并返回排名前 3 条及其标题、摘要和完整文本。
首先在禁用惰性物化的情况下(使用 query_plan_optimize_lazy_materialization),并在文件系统缓存尚未预热(冷缓存)时运行该查询:
接下来,在同样是冷文件系统缓存的情况下再次运行该查询,不过这一次启用了惰性物化:
通常你不需要显式将 query_plan_optimize_lazy_materialization = true 设为 true,就能获得延迟物化带来的收益。
该特性默认已启用。
比较关闭和开启延迟物化时的性能差异:
| 指标 | 关闭延迟物化 | 启用延迟物化 | 改进效果 |
|---|---|---|---|
| 耗时 | 219.071 sec | 0.139 sec | ~快 1576× |
| 读取数据量 | 71.38 GB | 1.81 GB | ~减少 40× |
| 峰值内存 | 1.11 GiB | 3.80 MiB | ~减少 300× |
如何在查询执行计划中确认惰性物化
你可以通过使用 EXPLAIN 子句检查查询的逻辑执行计划,来确认上一条查询是否使用了惰性物化:
你可以自下而上阅读算子计划,会发现 ClickHouse 会将对这三个体积较大的 String 列的读取推迟到排序和 LIMIT 之后才执行。