先说结论

ClickHouse 擅长扫描和聚合海量列式数据。优化重点不是让每条语句都“走索引”,而是尽量少读分区、少读数据块、少读列,并减少高基数聚合和不必要的数据交换。

建表阶段决定上限

PARTITION BY 用于数据生命周期管理和粗粒度裁剪,不应使用高基数字段制造海量小分区。ORDER BY 决定数据在磁盘上的排序方式,是查询跳过数据的关键,应优先放常用过滤条件,并兼顾基数和查询模式。

CREATE TABLE events
(
    event_date Date,
    tenant_id UInt32,
    event_type LowCardinality(String),
    user_id UInt64,
    payload String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, event_type);

分区键和排序键职责不同;排序键也不等同于传统数据库的唯一主键。

只读取需要的数据

  • 避免 SELECT *,列式存储读取无关列仍会产生 IO 和解压成本。
  • 尽早增加可裁剪的过滤条件。
  • 时间范围使用与字段类型一致的表达式,避免阻碍裁剪。
  • 大结果集不要直接返回客户端,先聚合或分页导出。

PREWHERE 可以先读取过滤列,命中后再读取其余列。ClickHouse 通常会自动优化,但理解它有助于分析实际读取量。

数据类型与编码

选择满足范围的最小数值类型;枚举值或重复字符串可以评估 LowCardinality;时间字段使用 DateDateTime,不要全部保存为字符串。合理类型能够降低磁盘占用、内存和 CPU 解码成本。

聚合与物化

重复执行的固定口径聚合可以通过物化视图在写入阶段预计算,把查询成本转移到写入。它适合稳定指标,不适合维度频繁变化的临时分析。使用前需要规划历史数据回填、幂等以及源表变更。

定位慢查询

通过 system.query_log 关注 read_rowsread_bytes、执行时间和内存使用量。若返回几百行却读取数十亿行,优先检查分区与排序键是否匹配查询。

EXPLAIN indexes = 1 可以观察分区、主键等裁剪效果。优化后要比较实际读取行数和字节数,而不仅是单次耗时,因为缓存和并发负载会干扰耗时。

常见反模式

  1. 每条数据单独插入,产生大量小 Part;应批量写入。
  2. 把高基数 UUID 直接设为首个排序键,却很少按它过滤。
  3. 使用海量小分区,把分区当作二级索引。
  4. 在大表之间无约束 JOIN;应过滤、预聚合并选择合理 JOIN 策略。
  5. 依赖 FINAL 修正所有查询,它可能带来显著额外开销。

常见问题

追问:为什么分区不能越细越好?

过多分区会增加元数据、小 Part 和文件系统管理成本。分区首先服务数据生命周期管理,查询加速更多依赖排序键与跳数索引。

追问:什么时候使用物化视图?

适合口径稳定、频繁重复的聚合,把计算移到写入阶段;维度频繁变化的临时分析不适合,且需设计历史回填与幂等。