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 | 变长字符串 | 两个数组:偏移量 + 数据 |
ColumnArray | Array 列 | 嵌套偏移量 + 嵌套列 |
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 | 表达式计算 | 是否出现多余计算 |
FilterTransform | WHERE 过滤 | 谓词是否下沉到存储层 |
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_usage | 10GB | 抛出 MEMORY_LIMIT_EXCEEDED |
max_memory_usage_for_user | 无 | 该用户所有查询共享上限 |
max_memory_usage_for_all_queries | 无 | 服务器全局上限 |
max_bytes_before_external_group_by | 0(关闭) | 超过则聚合溢写临时文件 |
max_bytes_before_external_sort | 0(关闭) | 超过则排序溢写临时文件 |
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 收益巨大 |
| EXPLAIN | SYNTAX 看改写、PIPELINE 看算子、indexes=1 看裁剪 |
| 优化规则 | 谓词下推与主键裁剪是两大性能杠杆 |
| 并行 | max_threads 与 Part 数匹配,必要时用 parallel_replicas |
| 内存 | max_memory_usage + 外部聚合双保险 |
| 日志 | system.query_log + ProfileEvents 是性能分析主战场 |
ClickHouse 的查询引擎是一台「列式向量化管道机」:数据以 Block 为颗粒流过各算子,优化器负责让每个算子尽量少干活。掌握 EXPLAIN、query_log 与 ProfileEvents 三件套,任何慢查询都能定位到「读太多」还是「算太重」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。