查询优化器与执行引擎深入

深入 ClickHouse 查询引擎:向量化执行与列式内存布局、数据块(Block)流式管道、EXPLAIN 执行计划、优化器规则(谓词下推/主键裁剪/常量折叠)、多线程并行执行、内存限制与外部聚合,以及基于 system.query_log 与 ProfileEvents 的性能分析实战。

1. 向量化执行原理

ClickHouse 性能的秘密不在 SQL 优化器,而在执行引擎:它把每一列数据看作向量,对整列执行同一运算,配合 SIMD 指令一次处理 128/256 位数据,实现一个 CPU 周期内处理多行。

1.1 从行式到向量式

-- 这条查询在行式数据库中逐行执行:
-- 对每一行:读取 x -> 做乘法 -> 过滤 -> 累加
-- 在 ClickHouse 中则是:
SELECT sum(x * 2) FROM numbers(10_000_000);
维度行式执行向量化执行
数据处理单位一行一整列(Block)
CPU 指令分支预测密集SIMD 批量运算
缓存利用按行取字段,缓存命中差列连续存放,顺序扫描
典型提升基线10~100 倍

关键机制:火山模型(Volcano)的迭代器被替换为批处理管道——每个算子一次消费一个 Block、生产一个 Block,函数调用次数从「每行」降到「每 Block」。

1.2 批处理与 SIMD

-- 用一个简单聚合观察向量化效果
SELECT count(), sum(number) FROM numbers(1_000_000_000);
  • numbers() 表函数按 Block 流式生成数字列;
  • sum() 对整列向量做 SIMD 归约;
  • 全程几乎零内存分配(列缓冲区复用)。

向量化的前提是列式存储:同类型数据连续摆放,CPU 才能批量读入寄存器。这也是为什么 ClickHouse 的 JOIN 会把右表构建成内存哈希表——哈希探测同样按列向量化。

2. 列式内存布局与数据块

2.1 Block 与列类型

执行引擎的最小传输单元是 Block = {列名, 列数据, 数据类型}。列数据由具体的列容器承载:

列容器用途内存布局
ColumnVector<UInt64>定长数值列连续数组,可直接 SIMD
ColumnString变长字符串两个数组:偏移量 + 数据
ColumnArrayArray 列嵌套偏移量 + 嵌套列
ColumnNullable可空列数据列 + Null 掩码列
ColumnLowCardinality低基数列字典编码:字典 + 索引

2.2 LowCardinality 的内存红利

低基数列(如 event_type)用字典编码后,内存中只存「字典值 + 整数索引」,聚合时对整数索引运算,快一个数量级:

CREATE TABLE events_lc (
    event_time DateTime,
    event_type LowCardinality(String),
    user_id UInt64
) ENGINE = MergeTree()
ORDER BY event_time;

2.3 Block 大小与吞吐

-- 查看当前单 Block 的行数上限
SELECT name, value FROM system.settings WHERE name = 'max_block_size';
-- 默认 65409 行/块

max_block_size 是向量化的「批大小」:太小则函数调用开销大,太大则缓存与内存压力上升。65,409 行的默认值是经验折中。

3. 执行计划 EXPLAIN

3.1 EXPLAIN 的四种形态

-- 1. 逻辑执行计划(默认)
EXPLAIN SELECT count() FROM events WHERE event_type = 'view';

-- 2. 语法树重写(优化器改写后的 SQL)
EXPLAIN SYNTAX SELECT count() FROM events WHERE event_type = 'view';

-- 3. 物理执行管道(线程 + 算子)
EXPLAIN PIPELINE SELECT count() FROM events WHERE event_type = 'view';

-- 4. 估算行数
EXPLAIN ESTIMATE SELECT count() FROM events WHERE event_type = 'view';

3.2 读懂执行计划

EXPLAIN PIPELINE graph = 1
SELECT event_type, count() FROM events GROUP BY event_type;

典型输出结构:

计划元素含义关注点
ExpressionTransform表达式计算是否出现多余计算
FilterTransformWHERE 过滤谓词是否下沉到存储层
AggregatingTransform聚合是否走 optimize_aggregation_in_order
ParallelReadingFromParts并行读 Part读线程数与 Part 数匹配
SettingQuotaAndLimits内存/配额检查限流点

3.3 索引命中的验证

EXPLAIN indexes = 1
SELECT count() FROM events
WHERE event_time >= '2024-06-01' AND event_time < '2024-06-02';
-- SelectedParts: N, SelectedGranules: M, SelectedMarks: K

如果 SelectedParts 远小于总 Part 数,说明分区裁剪生效;SelectedGranules 说明主键索引裁剪到位。

4. 优化器规则

4.1 核心规则一览

优化规则作用示例
谓词下推WHERE 下推到读数据阶段存储层过滤后再进管道
主键/分区裁剪跳过无关 granule 与分区范围条件命中稀疏索引
常量折叠编译期求值常量表达式WHERE x > 1 + 1 → x > 2
聚合优化空聚合/顺序聚合optimize_aggregation_in_order
子查询重写去关联、物化子查询IN (SELECT ...) 构建 Set
读顺序优化按排序键顺序读optimize_read_in_order
计数捷径count() 用元数据optimize_trivial_count_query

4.2 谓词下推与主键裁剪

-- 下面两个查询,ClickHouse 会尽量把过滤下推到读 Part 阶段
EXPLAIN PIPELINE SELECT count() FROM events WHERE user_id = 42;
EXPLAIN indexes = 1 SELECT count() FROM events
WHERE event_time BETWEEN '2024-06-01' AND '2024-06-30';

关键点:下推的深度取决于 ORDER BY 设计。过滤条件匹配排序键前缀时,存储层能精确跳过 granule;不匹配时只能做列级 minmax 裁剪(如果有跳过索引)。

4.3 用 optimize_* 设置控制规则

-- 开启/关闭具体优化,用于排查性能差异
SET optimize_aggregation_in_order = 1;
SET optimize_read_in_order = 1;
SET optimize_trivial_count_query = 1;

-- 对比开启前后的计划与耗时
EXPLAIN PIPELINE SELECT count() FROM events;

排查「查询突然变慢」的通用手法:逐个关闭 optimize_* 设置,找到起作用的那个,再决定是修表结构还是改 SQL。

5. 多线程并行执行

5.1 并行度模型

ClickHouse 把一张表的数据分成多个流(streams),每个流由独立线程处理:

  • max_threads:单查询最大线程数,默认 = CPU 核数;
  • max_insert_threads:写入端并行线程;
  • max_streams:读流的数量,通常与 Part 数或分区数相关。
-- 查看当前线程数
SELECT name, value FROM system.settings WHERE name = 'max_threads';

-- 会话级覆盖
SET max_threads = 16;

5.2 并行读 Part

-- 单表 30 个 Part,max_threads = 16 时,16 个线程各读一部分 Part
EXPLAIN PIPELINE
SELECT sum(value) FROM events WHERE event_time >= '2024-06-01';

并行度的理想关系:

条件瓶颈建议
Part 数 » 线程数合并后 Part 过少调大 max_threads 或拆分区
线程数 > Part 数线程空转合并 Part / 增大粒度
单线程 IO 瓶颈磁盘吞吐用 NVMe / 分布式并行副本
内存不足OOM见第 6 节内存限制

5.3 并行副本与集群并行

多副本集群下可以启用 parallel_replicas,把查询分片到多个副本并行执行:

SET parallel_replicas_count = 3;   -- 3 个副本并行
SELECT count() FROM events WHERE event_time >= today() - 1;

并行副本会放大对源数据的读取压力,适合聚合型大查询;高并发小查询不建议开启。

6. 内存限制

6.1 内存超限的三种保护

-- 单查询内存上限(默认 10GB)
SET max_memory_usage = 20_000_000_000;

-- 允许 GROUP BY / ORDER BY 溢写磁盘
SET max_bytes_before_external_group_by = 10_000_000_000;
SET max_bytes_before_external_sort = 10_000_000_000;
设置默认超限行为
max_memory_usage10GB抛出 MEMORY_LIMIT_EXCEEDED
max_memory_usage_for_user无该用户所有查询共享上限
max_memory_usage_for_all_queries无服务器全局上限
max_bytes_before_external_group_by0(关闭)超过则聚合溢写临时文件
max_bytes_before_external_sort0(关闭)超过则排序溢写临时文件

6.2 观察内存占用

-- 正在运行的查询内存
SELECT query_id, query, memory_usage, elapsed
FROM system.processes
WHERE query NOT ILIKE '%system.processes%';

-- 历史查询的峰值内存
SELECT query, memory_usage, read_rows, query_duration_ms
FROM system.query_log
ORDER BY event_time DESC
LIMIT 10;

6.3 内存友好写法

  • 聚合里尽早 WHERE 过滤,减少状态表大小;
  • 高基数 GROUP BY 用外部聚合溢写而不是无限内存;
  • JOIN 右表构建哈希表占内存,小表放右边;
  • 用 LIMIT 截断排序,避免 ORDER BY 全量排序。
-- 反例:全量排序到内存
SELECT * FROM events ORDER BY value DESC;

-- 正例:只取 Top 100,配合 limit 优化
SELECT * FROM events ORDER BY value DESC LIMIT 100;

7. 查询日志与 profile

7.1 system.query_log

查询日志是性能分析的主入口,每个查询一行完整记录:

SELECT
    query_id,
    query_start_time,
    query_duration_ms,
    read_rows,
    read_bytes,
    result_rows,
    memory_usage,
    threads,
    exception_code,
    ProfileEvents['SelectedBytes'] AS selected_bytes
FROM system.query_log
WHERE query_start_time >= now() - INTERVAL 1 HOUR
ORDER BY query_duration_ms DESC
LIMIT 20;
字段含义慢查询判据
read_rows扫描行数远大于结果行数说明过滤不足
read_bytes扫描字节数反映列裁剪是否生效
memory_usage峰值内存接近上限说明有溢写风险
threads实际线程数低于 max_threads 说明并行不足
ProfileEvents执行指标字典见 7.2

7.2 ProfileEvents 关键指标

SELECT
    ProfileEvents['RealTimeMicroseconds'] / 1000000 AS real_s,
    ProfileEvents['UserTimeMicroseconds'] / 1000000 AS cpu_s,
    ProfileEvents['SelectedRows'] AS sel_rows,
    ProfileEvents['SelectedBytes'] AS sel_bytes,
    ProfileEvents['ContextLock'] AS ctx_lock
FROM system.query_log
WHERE query_id = '你的查询ID';
ProfileEvent含义优化提示
SelectedRows / SelectedBytes实际扫描量对比 read_rows,看索引裁剪效果
UserTimeMicroseconds用户态 CPU高则算子重,考虑预聚合
ContextLock锁等待高则并发冲突,错峰执行
CompressedReadBufferBlocks解压块数高则数据量大,建议分区裁剪

7.3 线程级追踪

-- 打开 trace_log 采样后,可定位耗时算子
SELECT arrayJoin(trace), count() FROM system.trace_log
WHERE query_id = '你的查询ID'
GROUP BY 1 ORDER BY 2 DESC LIMIT 10;

8. 性能分析实战与总结

8.1 一套完整的慢查询排查流程

# 1. 找到慢查询 ID
clickhouse-client --query "SELECT query_id, query FROM system.query_log
WHERE query_duration_ms > 1000 ORDER BY query_start_time DESC LIMIT 5"
-- 2. 看执行计划与索引命中
EXPLAIN indexes = 1 SELECT ...;      -- 确认裁剪
EXPLAIN PIPELINE SELECT ...;         -- 确认并行
-- 3. 看指标
SELECT read_rows, read_bytes, memory_usage, threads
FROM system.query_log WHERE query_id = '...';
-- 4. 对照 profile 决定优化方向
现象根因手段
read_rows 巨大索引未命中改 ORDER BY / 加跳过索引
read_bytes 大但 rows 小列未裁剪只 SELECT 需要的列
memory_usage 顶上限聚合状态过大外部聚合 / 降低基数
threads 未打满Part 太少拆分区 / 并行副本
cpu 高但 IO 低计算重预聚合 / 物化视图

8.2 结论速查表

主题核心要点
向量化按列批处理 + SIMD,Block 是执行单元
内存布局列容器决定运算速度,LowCardinality 收益巨大
EXPLAINSYNTAX 看改写、PIPELINE 看算子、indexes=1 看裁剪
优化规则谓词下推与主键裁剪是两大性能杠杆
并行max_threads 与 Part 数匹配,必要时用 parallel_replicas
内存max_memory_usage + 外部聚合双保险
日志system.query_log + ProfileEvents 是性能分析主战场

ClickHouse 的查询引擎是一台「列式向量化管道机」:数据以 Block 为颗粒流过各算子,优化器负责让每个算子尽量少干活。掌握 EXPLAIN、query_log 与 ProfileEvents 三件套,任何慢查询都能定位到「读太多」还是「算太重」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 查询缓存与预热:缓存策略、热点治理与查询加速
  2. 时序分析最佳实践:时间序列建模、降采样与异常检测 SQL
  3. 字典与维度表 JOIN:Dictionaries、dictGet 与星型模型优化