先说结论

ClickHouse 优化不能只盯某个参数。先确认业务规模和慢在哪里,再从建模、写入、查询、后台任务和集群资源这五层往下找。讲清楚一次优化,至少要交代:

  1. 原始业务与数据规模。
  2. 观察到的瓶颈和量化指标。
  3. 使用什么证据定位根因。
  4. 实施了哪些改动,为什么有效。
  5. 优化后的数据以及副作用。

可以按“建模、写入、查询、后台任务、集群治理”五层展开。

一、建表与数据模型优化

1. 优化分区键

原先按天分区,在多年历史数据和多表场景下形成大量分区。若业务主要按月清理和查询,可调整为按月分区:

PARTITION BY toYYYYMM(event_date)

分区首先用于生命周期管理和粗粒度裁剪。不能使用用户 ID 等高基数字段制造海量分区,也不能把分区当作传统二级索引。

2. 优化排序键

假设查询几乎都携带租户和时间范围:

ORDER BY (tenant_id, event_date, event_type, user_id)

把稳定的高频过滤条件放在前部,使同租户、相近日期的数据聚集,减少读取 Granule 数量。排序键不是字段越多越好,过长会增加索引与排序成本。

优化前后通过 EXPLAIN indexes = 1read_rowsread_bytes 验证裁剪效果,而不是只比较一次耗时。

3. 选择紧凑数据类型

  • 能用 UInt32 不使用 String 保存数值 ID。
  • 日期使用 Date/DateTime64,不保存格式化字符串。
  • 低基数重复字符串使用 LowCardinality
  • 根据数值变化选择 Delta、DoubleDelta、Gorilla 等 Codec。
  • 不需要可空语义时避免滥用 Nullable

类型优化能同时减少磁盘、网络、解压和聚合内存,但必须先验证取值范围,避免未来溢出。

4. 面向查询适度宽表化

原本每次报表都关联事实表和多个小维表,延迟和内存不稳定。可以在数据写入阶段补齐稳定维度字段,或用 Dictionary 加速 ID 到属性映射。

宽表适合历史快照语义;Dictionary 适合需要读取较新维度值的场景。二者对数据一致性的语义不同,必须与产品确认。

二、写入优化

1. 从单条写改为批量写

单条 INSERT 会不断创建小 Part,后台 Merge 跟不上时出现 Too many parts。生产者应在可接受延迟内按条数或字节聚合:

每批 5,000~50,000 行,或达到目标字节数后写入

具体批量不能照抄,应结合单行大小、写入延迟、网络和服务端指标压测。

2. 控制分区跨度

一次写入跨越很多分区会为每个分区创建 Part。消息消费端可以按日期或分区键聚合批次,减少单批触及的分区数量。

3. 使用异步插入

客户端难以批量时,可以评估异步插入,由服务端缓冲小写入并合并。但要明确确认语义、内存限制、失败可见性和客户端是否等待刷入。

4. 处理重复写入

消息重试会导致数据重复。可以携带业务唯一 ID 与版本,使用 ReplacingMergeTree,或在聚合口径中按唯一键去重。

ReplacingMergeTree 去重依赖后台 Merge,不能保证查询瞬间唯一。使用 FINAL 会增加查询成本,应把幂等设计前移并评估业务容忍度。

三、查询优化

1. 禁止 SELECT *

列式数据库的优势来自只读需要的列。列表和报表应明确选择字段,大字符串和 JSON 列尤其不能无条件读取。

2. 增加有效过滤

强制或引导查询携带时间、租户等排序键前缀,限制最大时间跨度。开放式分析平台可以在网关层设置扫描量、执行时间和结果行数限制。

3. 优化高基数聚合

对用户 ID、设备 ID 做精确去重可能消耗大量内存。允许误差的报表可使用近似去重函数,并在业务上明确误差范围。精确财务数据不能直接替换为近似结果。

4. 物化视图预聚合

分钟、小时、天级指标被频繁重复查询时,在写入阶段预聚合:

原始明细表 → 物化视图 → 小时聚合表 → 报表查询

这能把扫描亿级明细变为扫描少量聚合状态。需要处理历史回填、迟到数据、重复事件和聚合口径版本。

5. 避免滥用 FINAL

FINAL 在查询时完成额外合并或去重,可能显著增加 CPU 和内存。可通过上游幂等、版本过滤、预聚合或定期优化减少依赖。

6. 优化 JOIN

  • 大表 JOIN 前先过滤和预聚合。
  • 小维表可放右侧或使用 Dictionary。
  • 选择合适 JOIN 算法,并控制内存与溢出策略。
  • 高频固定关联考虑宽表化。

不要通过增加内存上限掩盖无边界大表 JOIN。

四、Merge 与存储优化

1. 监控 Part 数量

关注每表、每分区的 Active Part 数、平均 Part 大小和 Merge 队列。Part 持续增加说明写入粒度、分区设计或后台能力不匹配。

2. 避免频繁 OPTIMIZE FINAL

手动强制合并会消耗大量 IO 和 CPU,并可能与正常查询争抢资源。它不是日常清理命令,只应在明确场景、受控窗口和充分评估后执行。

3. TTL 与冷热分层

近期数据放高性能磁盘,历史数据迁移到低成本卷;到期数据通过 TTL 删除。TTL 执行依赖后台 Merge,需要预留额外磁盘和任务能力。

4. 控制 Mutation

批量 UPDATE/DELETE 会重写数据片段。项目中可以改为追加新版本、定期汇总,或把可变状态保留在 OLTP 数据库。必须执行 Mutation 时,应控制范围并监控队列。

五、集群与资源治理

1. 合理分片

分片键应让数据与负载均匀,并让高频查询尽量只访问少量分片。只按租户分片可能遇到超级租户热点,应评估组合哈希或单独隔离。

2. 副本分流

副本既用于容灾,也可承担读流量。要确保路由策略不会让所有重查询集中到一个节点,同时监控副本延迟和队列。

3. 限制并发与资源

为在线报表、内部分析和离线任务配置不同用户或 Profile,限制:

  • 最大执行时间。
  • 最大读取行数或字节数。
  • 单查询内存。
  • 查询并发。
  • 线程数。

让在线小查询与离线大查询分池,避免一个临时分析拖垮整个集群。

4. 查询缓存与结果复用

固定报表可以在应用层或数据库能力范围内缓存结果。缓存 Key 必须包含租户、权限、时间范围和口径版本,避免错误共享数据。

六、如何定位优化方向

通过 system.query_log 聚合 SQL 指纹,重点看:

  • 查询次数与 P95/P99。
  • read_rowsread_bytes 与结果行数。
  • 峰值内存和线程数。
  • ProfileEvents 中的 IO、网络与 CPU 指标。
  • 异常、取消和超时次数。

通过 system.parts 查看 Part 数量和大小,通过 Merge 与 Mutation 系统表观察后台积压。性能问题必须区分查询读放大、写入小 Part、后台合并还是节点资源瓶颈。

看一个优化案例

项目每天写入约 20 亿条埋点,原查询按租户和近 7 天过滤,但排序键只有事件时间,单次报表读取数百 GB。我们重建表,将租户和日期放到排序键前部,写入由逐条改为每批约 2 万行,并建立小时级物化聚合表。通过 query_log 验证,核心报表读取字节降低约 95%,P99 从十几秒下降到 2 秒以内。副作用是写入链路和回填流程更复杂,因此增加了聚合校验与双写切换。

案例里的数字只是说明分析方法。实际判断时要用自己系统的真实数据,重点看定位和验证过程,不要追求夸张的提升比例。

常见问题

追问 1:排序键字段是不是越多越好?

不是。前部字段才决定主要数据裁剪,过长排序键增加索引、排序与存储成本。应围绕最稳定的高频过滤组合设计。

追问 2:物化视图为什么可能数据不一致?

重复写入、迟到事件、历史回填顺序、口径变更和目标表异常都可能造成偏差。需要幂等键、校验任务和可重建流程。

追问 3:为什么不直接调大 max_memory_usage?

它只推迟 OOM,还会让单查询吞噬更多集群资源。应先减少扫描、聚合基数和 JOIN 数据量,再设置与并发容量匹配的上限。

追问 4:如何判断排序键是否有效?

使用 EXPLAIN 查看裁剪范围,并比较 query_log 中读取行数、字节数与总行数。仅凭 SQL 包含排序键字段不能证明裁剪有效。

追问 5:ClickHouse 写入越大批越好吗?

不是。批次过小产生 Part,过大会增加客户端内存、失败重试成本和写入延迟。应按行大小、延迟目标和服务端负载压测平衡。