核心答案

MergeTree 将每批写入保存为不可变数据片段,并在后台持续合并;数据按排序键组织,查询可以通过稀疏主键索引跳过大量无关数据。

写入过程

一次 INSERT 通常形成一个新的 data part。片段内部按 ORDER BY 指定的键排序,并保存列式数据、标记和索引信息。大量极小批次写入会产生过多 parts,增加合并压力。

分区与排序键

PARTITION BY 主要用于数据管理和分区裁剪,不应产生过多细粒度分区;ORDER BY 决定数据的物理排序,是查询性能设计的核心。

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

后台合并

后台线程把多个较小片段合并成更大片段。ReplacingMergeTree、SummingMergeTree 等变体会在合并阶段执行去重或聚合,但合并是异步的,查询不能假设数据已经立即完成最终合并。

设计注意点

  • 排序键优先放常用过滤列,但也要考虑基数和查询模式。
  • 避免大量小批量 INSERT。
  • 分区不是越细越好。
  • ClickHouse 更适合追加写和分析查询,不适合高频单行更新事务。

参考资料

核心考点清单

  • 每批写入形成不可变 Part,后台 Merge 将小 Part 合并成大 Part。
  • 分区键用于生命周期管理,排序键决定磁盘顺序和裁剪能力。
  • ClickHouse 主键是稀疏索引,不要求唯一,也不是 MySQL 式约束。
  • 写入应批量进行,过多小 Part 会让合并速度跟不上写入。
  • ReplacingMergeTree 的去重通常发生在合并时,不保证立即唯一。

Part 和稀疏索引

数据按排序键排序并分列保存,索引按固定粒度记录关键位置。查询先判断哪些 Granule 可能命中,再读取相关列。它牺牲逐行精确定位,换来更小的索引和高吞吐扫描。

高频追问与参考回答

追问 1:ORDER BY 和 PRIMARY KEY 有何区别?

ORDER BY 决定物理排序;主键表达式可以是其前缀以减小索引,但不提供唯一性约束。

追问 2:为什么会出现 Too many parts?

写入批次过小或分区过多,Part 产生速度超过后台合并速度。应增大批次、优化分区并检查磁盘与合并负载。

追问 3:ReplacingMergeTree 能实时去重吗?

不能。后台合并前多个版本可能同时存在,查询 FINAL 虽可处理但成本较高,应结合版本列和业务查询设计。

机制全景图

下面把「ClickHouse MergeTree 引擎原理是什么?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["批量写入数据块"]
    A --> B["排序并生成新 Part"]
    B --> C["记录列文件与稀疏索引"]
    C --> D["后台选择 Parts 合并"]
    D --> E["查询按分区和主键裁剪"]

完整链路:从输入到结果

沿着「批量写入数据块 → 排序并生成新 Part → 记录列文件与稀疏索引 → 后台选择 Parts 合并 → 查询按分区和主键裁剪」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 批量写入数据块

客户端批次先转换为 Block,批次过小会产生大量独立写入事务和元数据。

2. 排序并生成新 Part

每次 INSERT 把数据按 ORDER BY 排序并落成不可变 Part,写入不是原地修改旧文件。

3. 记录列文件与稀疏索引

各列独立存储并压缩,主键稀疏索引按 granule 保存标记,减少需要读取的数据范围。

4. 后台选择 Parts 合并

后台合并把多个小 Part 重写为更大 Part,同时执行 TTL、去重或聚合语义。

5. 查询按分区和主键裁剪

查询先做分区裁剪,再按主键和跳数索引筛 granule,最后只读所需列。

源码与实现定位

入口 阅读重点
system.parts Part 数、行数和级别
src/Storages/MergeTree 写 Part 与后台 merge

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

SELECT partition,count(),sum(rows) FROM system.parts WHERE active GROUP BY partition;

用每行/千行/万行批次持续写 30 分钟,对比 Part 生成与合并净速率。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「active parts」为主基线,记录值应满足「记录稳态基线」;同时保存 active parts 数、后台 merge 队列,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「system.parts」确认请求确实进入「Part 数、行数和级别」对应的实现,再沿「src/Storages/MergeTree」观察「写 Part 与后台 merge」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「小批写入制造 Part 爆炸」,并把单一变量逐级放大,直到「active parts」越过「持续增长」。随后再分别验证「ORDER BY 与查询过滤无关」和「高基数分区导致目录和元数据膨胀」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「客户端攒批或 async_insert」,确认它能控制影响范围;第二轮应用「降低分区粒度」,验证核心链路恢复;最后落实「为 merge 保留 CPU/I/O」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「active parts」回到「记录稳态基线」、「active parts P99」回到「同负载可复现」、「结果核对」回到「差异为 0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
active parts 记录稳态基线 持续增长 合并跟不上
active parts P99 同负载可复现 超过基线 2 倍 合并跟不上
结果核对 差异为 0 任意非零 停止切换并修复

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:实时埋点写入后查询延迟持续升高

每条事件单独 INSERT,单分区产生数万小 Part,后台合并追不上,查询需打开大量文件。客户端攒批、Kafka Engine 汇聚并限制分区粒度后,active parts 恢复稳定。

失败模式 首要证据 第一处置动作
小批写入制造 Part 爆炸 active parts 数 客户端攒批或 async_insert
ORDER BY 与查询过滤无关 后台 merge 队列 降低分区粒度
高基数分区导致目录和元数据膨胀 读 granule/行数 为 merge 保留 CPU/I/O

发布与回滚检查点

  • 发布前:确认「system.parts」对应实现和上述配置在目标版本仍然有效,并保存「active parts」基线。
  • 灰度中:同时观察 active parts 数、后台 merge 队列、读 granule/行数;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「客户端攒批或 async_insert」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「小批写入制造 Part 爆炸」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
MergeTree 通用明细事实表 列存、排序与后台合并能力完整 更新删除不是行存式原地操作
Log 系列表引擎 小型临时数据 结构简单 缺少主键和高级合并能力
外部表/湖存储 低频历史与跨系统共享 存算分离、成本低 延迟和功能依赖外部格式

选型至少带上 日增量、分区规模、查询并发、扫描行数和压缩比,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

MergeTree 的主键不保证唯一,核心价值是数据排序和稀疏裁剪;唯一或替换语义由具体家族引擎和查询时机决定。

工程落地遵循:以数据布局减少扫描,以批量写入减少小 Part,避免照搬行存思路。回答时直接引用「system.parts」、配置实验和事故数据,比复述固定模板更有说服力。