ClickHouse系列(二):MergeTree 家族详解

张开发
2026/4/20 18:38:50 15 分钟阅读

分享文章

ClickHouse系列(二):MergeTree 家族详解
定位表引擎方法论。MergeTree 是 ClickHouse 的灵魂选错引擎意味着要么查询慢、要么数据错、要么存储爆炸。一、MergeTree 的数据组织模型1.1 Part数据的物理单元每次INSERT都会在磁盘上生成一个新的Part数据分片。一个 Part 是一个目录内部按列存储。表目录结构 /var/lib/clickhouse/data/default/events/ ├── 202401_1_1_0/ ← Part 1 │ ├── event_date.bin ← event_date 列的数据 │ ├── event_date.mrk2 ← event_date 列的 Mark 文件索引定位用 │ ├── user_id.bin │ ├── user_id.mrk2 │ ├── event_type.bin │ ├── event_type.mrk2 │ ├── primary.idx ← 主键稀疏索引 │ ├── count.txt ← 行数 │ └── checksums.txt ← 校验和 ├── 202401_2_2_0/ ← Part 2 └── 202401_1_2_1/ ← Part 1 和 Part 2 合并后的结果关键认知每次 INSERT 都产生一个新 Part不会修改已有 Part。这就是 ClickHouse 写入快的根本原因——纯追加写无随机 I/O。1.2 Granule数据的逻辑单元每个 Part 内部按index_granularity默认 8192 行划分为多个Granule。稀疏索引的每个 Mark 指向一个 Granule 的起始位置。一个 Part 内部结构 ┌─────────────────────────────────────────┐ │ Granule 0: 第 0-8191 行 ← Mark 0 │ │ Granule 1: 第 8192-16383 行 ← Mark 1 │ │ Granule 2: 第 16384-24575 行← Mark 2 │ │ ... │ └─────────────────────────────────────────┘ 查询时通过 primary.idx 二分查找定位到需要读取的 Granule 范围二、Merge 的本质写时换读2.1 为什么需要 Merge频繁 INSERT 会产生大量小 Part。如果不合并查询时需要打开大量文件I/O 开销大稀疏索引分散在多个 Part 中无法高效过滤对于 Replacing/Summing 等引擎语义无法保证ClickHouse 的后台线程会持续将小 Part 合并为大 Part这就是Merge过程。Merge 过程示意 时间线 → INSERT → [Part_1] INSERT → [Part_2] INSERT → [Part_3] ↓ 后台 Merge [Part_1_2_3] 合并后的大 Part INSERT → [Part_4] INSERT → [Part_5] ↓ 后台 Merge [Part_1_2_3_4_5]2.2 写时换读的权衡这是一个经典的LSM-Tree 思想维度说明写入极快——直接追加新 Part无需修改已有数据读取需要合并多个 Part 的结果Merge on Read后台Merge 线程持续工作逐步减少 Part 数量实际影响如果你每秒执行一次INSERT INTO ... VALUES (...)而不是批量写入会产生海量小 PartMerge 跟不上查询性能急剧下降。-- 错误写法逐行插入INSERTINTOeventsVALUES(now(),1,click);INSERTINTOeventsVALUES(now(),2,view);-- 每次产生一个 Part灾难性的-- 正确写法批量插入至少数千行一批INSERTINTOeventsVALUES(now(),1,click),(now(),2,view),...-- 数千到数万行生产建议单次 INSERT 至少包含1000-10000 行或使用 Buffer 表 / 异步写入中间件。三、各引擎的设计初衷3.1 MergeTree——基础款适合大多数场景CREATETABLElogs(timestampDateTime,service String,levelString,message String)ENGINEMergeTree()PARTITIONBYtoYYYYMM(timestamp)ORDERBY(service,timestamp);特点数据只追加不去重、不聚合Merge 时只做物理合并不改变数据内容适合日志、事件流等只写不改的场景3.2 ReplacingMergeTree——去重但别指望实时CREATETABLEuser_profiles(user_id UInt64,name String,email String,updated_atDateTime)ENGINEReplacingMergeTree(updated_at)ORDERBYuser_id;设计初衷对于相同ORDER BY键的行Merge 时只保留updated_at最大的那一行。Merge 前 Part_1: (user_id1, nameAlice_v1, updated_at10:00) Part_2: (user_id1, nameAlice_v2, updated_at11:00) Merge 后 (user_id1, nameAlice_v2, updated_at11:00) ← 只保留最新版本关键陷阱去重只在 Merge 时发生不是实时的。查询时如果 Part 还没合并你会看到重复行。-- 查询时手动去重推荐做法SELECTuser_id,argMax(name,updated_at)ASnameFROMuser_profilesGROUPBYuser_id;-- 或使用 FINAL 关键字性能较差小表可用SELECT*FROMuser_profiles FINAL;3.3 SummingMergeTree——预聚合求和CREATETABLEdaily_metrics(dateDate,service String,requests UInt64,errorsUInt64,latency_sum Float64)ENGINESummingMergeTree((requests,errors,latency_sum))ORDERBY(date,service);设计初衷对于相同ORDER BY键的行Merge 时自动对指定的数值列求和。Merge 前 Part_1: (date01-01, serviceapi, requests100, errors5) Part_2: (date01-01, serviceapi, requests200, errors3) Merge 后 (date01-01, serviceapi, requests300, errors8) ← 自动求和适合场景按固定维度的计数/求和指标如每日 PV/UV 统计、服务调用量汇总。同样的陷阱求和只在 Merge 时发生。查询时务必加SUM()-- 正确写法查询时仍然要 GROUP BY SUMSELECTdate,service,sum(requests),sum(errors)FROMdaily_metricsGROUPBYdate,service;3.4 AggregatingMergeTree——通用预聚合CREATETABLEagg_metrics(dateDate,service String,uv AggregateFunction(uniq,UInt64),p99_latency AggregateFunction(quantile(0.99),Float64))ENGINEAggregatingMergeTree()ORDERBY(date,service);设计初衷支持任意聚合函数的预聚合不仅限于 SUM。写入时必须使用-State后缀函数查询时使用-Merge后缀函数-- 写入通常配合物化视图INSERTINTOagg_metricsSELECTtoDate(timestamp)ASdate,service,uniqState(user_id)ASuv,quantileState(0.99)(latency)ASp99_latencyFROMraw_eventsGROUPBYdate,service;-- 查询SELECTdate,service,uniqMerge(uv)ASuv,quantileMerge(0.99)(p99_latency)ASp99FROMagg_metricsGROUPBYdate,service;四、为什么 AggregatingMergeTree 不会自动帮你聚合这是新手最常见的困惑。直觉上既然叫聚合引擎写入原始数据应该自动聚合才对。但实际上AggregatingMergeTree 只负责 Merge 时合并中间状态它不知道你的原始数据长什么样。你必须在写入时就把数据转换为聚合中间状态通过-State函数。通常的做法是原始数据写入 MergeTree通过物化视图自动转换后写入 AggregatingMergeTree。-- 完整的物化视图模式-- 1. 原始表CREATETABLEraw_events(timestampDateTime,service String,user_id UInt64,latency Float64)ENGINEMergeTree()ORDERBY(service,timestamp);-- 2. 聚合目标表CREATETABLEagg_metrics(dateDate,service String,uv AggregateFunction(uniq,UInt64),p99 AggregateFunction(quantile(0.99),Float64))ENGINEAggregatingMergeTree()ORDERBY(date,service);-- 3. 物化视图自动触发CREATEMATERIALIZEDVIEWmv_aggTOagg_metricsASSELECTtoDate(timestamp)ASdate,service,uniqState(user_id)ASuv,quantileState(0.99)(latency)ASp99FROMraw_eventsGROUPBYdate,service;-- 写入原始表物化视图自动处理INSERTINTOraw_eventsVALUES(now(),api,12345,0.15);五、Trace / Metrics / 账单场景的引擎映射业务场景数据特征推荐引擎理由链路追踪Trace写入后不变按 TraceID 查询MergeTree纯追加无需去重或聚合应用日志海量追加全文检索 聚合MergeTree配合tokenbf_v1索引做模糊搜索实时指标Metrics固定维度 数值指标需要预聚合SummingMergeTree或AggregatingMergeTree减少存储加速查询用户画像同一用户多次更新只保留最新ReplacingMergeTree按 user_id 去重账单/计费精确到分的费用不能丢不能重MergeTree 外部去重账单数据不能依赖引擎的异步去重UV/PV 统计需要uniq等复杂聚合AggregatingMergeTree 物化视图预计算 HyperLogLog 状态账单场景特别说明账单数据对准确性要求极高。虽然 ReplacingMergeTree 可以去重但它的去重是异步且不保证时效的。生产中推荐-- 方案MergeTree 写入侧幂等CREATETABLEbilling(bill_id String,user_id UInt64,amountDecimal(18,2),created_atDateTime)ENGINEMergeTree()ORDERBY(user_id,bill_id);-- 查询时用 bill_id 去重SELECTuser_id,sum(amount)FROM(SELECTDISTINCTON(bill_id)*FROMbillingORDERBYbill_id,created_atDESC)GROUPBYuser_id;总结引擎选择决策树你的数据需要去重吗 ├── 不需要 → 需要预聚合吗 │ ├── 不需要 → MergeTree ✓ │ └── 需要 → 只有 SUM/COUNT │ ├── 是 → SummingMergeTree ✓ │ └── 否uniq/quantile 等→ AggregatingMergeTree 物化视图 ✓ └── 需要 → 对去重时效性要求高吗 ├── 不高最终一致即可→ ReplacingMergeTree ✓ └── 很高实时精确→ MergeTree 查询时去重 / 外部去重 ✓记住一个原则引擎的 Merge 行为是异步的、不可预期的。任何依赖 Merge 才能保证正确性的逻辑都必须在查询层做兜底。下一篇我们将深入 ClickHouse 的排序键与索引设计讲清楚ORDER BY和PRIMARY KEY的真正含义。

更多文章