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 (...) 而不是批量写入,会产生海量小 Part,Merge 跟不上,查询性能急剧下降。

sql 复制代码
-- 错误写法:逐行插入
INSERT INTO events VALUES (now(), 1, 'click');
INSERT INTO events VALUES (now(), 2, 'view');
-- 每次产生一个 Part,灾难性的

-- 正确写法:批量插入(至少数千行一批)
INSERT INTO events VALUES
    (now(), 1, 'click'),
    (now(), 2, 'view'),
    ... -- 数千到数万行

生产建议:单次 INSERT 至少包含 1000-10000 行,或使用 Buffer 表 / 异步写入中间件。


三、各引擎的设计初衷

3.1 MergeTree------基础款,适合大多数场景

sql 复制代码
CREATE TABLE logs (
    timestamp DateTime,
    service String,
    level String,
    message String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (service, timestamp);

特点:

  • 数据只追加,不去重、不聚合
  • Merge 时只做物理合并,不改变数据内容
  • 适合日志、事件流等只写不改的场景

3.2 ReplacingMergeTree------去重,但别指望实时

sql 复制代码
CREATE TABLE user_profiles (
    user_id UInt64,
    name String,
    email String,
    updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;

设计初衷:对于相同 ORDER BY 键的行,Merge 时只保留 updated_at 最大的那一行。

复制代码
Merge 前:
Part_1: (user_id=1, name="Alice_v1", updated_at=10:00)
Part_2: (user_id=1, name="Alice_v2", updated_at=11:00)

Merge 后:
(user_id=1, name="Alice_v2", updated_at=11:00)  ← 只保留最新版本

关键陷阱:去重只在 Merge 时发生,不是实时的。查询时如果 Part 还没合并,你会看到重复行。

sql 复制代码
-- 查询时手动去重(推荐做法)
SELECT user_id, argMax(name, updated_at) AS name
FROM user_profiles
GROUP BY user_id;

-- 或使用 FINAL 关键字(性能较差,小表可用)
SELECT * FROM user_profiles FINAL;

3.3 SummingMergeTree------预聚合求和

sql 复制代码
CREATE TABLE daily_metrics (
    date Date,
    service String,
    requests UInt64,
    errors UInt64,
    latency_sum Float64
) ENGINE = SummingMergeTree((requests, errors, latency_sum))
ORDER BY (date, service);

设计初衷:对于相同 ORDER BY 键的行,Merge 时自动对指定的数值列求和。

复制代码
Merge 前:
Part_1: (date=01-01, service="api", requests=100, errors=5)
Part_2: (date=01-01, service="api", requests=200, errors=3)

Merge 后:
(date=01-01, service="api", requests=300, errors=8)  ← 自动求和

适合场景:按固定维度的计数/求和指标,如每日 PV/UV 统计、服务调用量汇总。

同样的陷阱:求和只在 Merge 时发生。查询时务必加 SUM()

sql 复制代码
-- 正确写法:查询时仍然要 GROUP BY + SUM
SELECT date, service, sum(requests), sum(errors)
FROM daily_metrics
GROUP BY date, service;

3.4 AggregatingMergeTree------通用预聚合

sql 复制代码
CREATE TABLE agg_metrics (
    date Date,
    service String,
    uv AggregateFunction(uniq, UInt64),
    p99_latency AggregateFunction(quantile(0.99), Float64)
) ENGINE = AggregatingMergeTree()
ORDER BY (date, service);

设计初衷:支持任意聚合函数的预聚合,不仅限于 SUM。

写入时必须使用 -State 后缀函数,查询时使用 -Merge 后缀函数:

sql 复制代码
-- 写入(通常配合物化视图)
INSERT INTO agg_metrics
SELECT
    toDate(timestamp) AS date,
    service,
    uniqState(user_id) AS uv,
    quantileState(0.99)(latency) AS p99_latency
FROM raw_events
GROUP BY date, service;

-- 查询
SELECT
    date,
    service,
    uniqMerge(uv) AS uv,
    quantileMerge(0.99)(p99_latency) AS p99
FROM agg_metrics
GROUP BY date, service;

四、为什么 AggregatingMergeTree 不会自动帮你聚合

这是新手最常见的困惑。直觉上,既然叫"聚合引擎",写入原始数据应该自动聚合才对。但实际上:

  1. AggregatingMergeTree 只负责 Merge 时合并中间状态,它不知道你的原始数据长什么样。
  2. 你必须在写入时就把数据转换为聚合中间状态 (通过 -State 函数)。
  3. 通常的做法是:原始数据写入 MergeTree,通过物化视图自动转换后写入 AggregatingMergeTree
sql 复制代码
-- 完整的物化视图模式
-- 1. 原始表
CREATE TABLE raw_events (
    timestamp DateTime,
    service String,
    user_id UInt64,
    latency Float64
) ENGINE = MergeTree()
ORDER BY (service, timestamp);

-- 2. 聚合目标表
CREATE TABLE agg_metrics (
    date Date,
    service String,
    uv AggregateFunction(uniq, UInt64),
    p99 AggregateFunction(quantile(0.99), Float64)
) ENGINE = AggregatingMergeTree()
ORDER BY (date, service);

-- 3. 物化视图(自动触发)
CREATE MATERIALIZED VIEW mv_agg TO agg_metrics AS
SELECT
    toDate(timestamp) AS date,
    service,
    uniqState(user_id) AS uv,
    quantileState(0.99)(latency) AS p99
FROM raw_events
GROUP BY date, service;

-- 写入原始表,物化视图自动处理
INSERT INTO raw_events VALUES (now(), 'api', 12345, 0.15);

五、Trace / Metrics / 账单场景的引擎映射

业务场景 数据特征 推荐引擎 理由
链路追踪(Trace) 写入后不变,按 TraceID 查询 MergeTree 纯追加,无需去重或聚合
应用日志 海量追加,全文检索 + 聚合 MergeTree 配合 tokenbf_v1 索引做模糊搜索
实时指标(Metrics) 固定维度 + 数值指标,需要预聚合 SummingMergeTreeAggregatingMergeTree 减少存储,加速查询
用户画像 同一用户多次更新,只保留最新 ReplacingMergeTree 按 user_id 去重
账单/计费 精确到分的费用,不能丢不能重 MergeTree + 外部去重 账单数据不能依赖引擎的异步去重
UV/PV 统计 需要 uniq 等复杂聚合 AggregatingMergeTree + 物化视图 预计算 HyperLogLog 状态

账单场景特别说明

账单数据对准确性要求极高。虽然 ReplacingMergeTree 可以去重,但它的去重是异步且不保证时效的。生产中推荐:

sql 复制代码
-- 方案:MergeTree + 写入侧幂等
CREATE TABLE billing (
    bill_id String,
    user_id UInt64,
    amount Decimal(18, 2),
    created_at DateTime
) ENGINE = MergeTree()
ORDER BY (user_id, bill_id);

-- 查询时用 bill_id 去重
SELECT user_id, sum(amount)
FROM (
    SELECT DISTINCT ON (bill_id) *
    FROM billing
    ORDER BY bill_id, created_at DESC
)
GROUP BY user_id;

总结:引擎选择决策树

复制代码
你的数据需要去重吗?
├── 不需要 → 需要预聚合吗?
│   ├── 不需要 → MergeTree ✓
│   └── 需要 → 只有 SUM/COUNT?
│       ├── 是 → SummingMergeTree ✓
│       └── 否(uniq/quantile 等)→ AggregatingMergeTree + 物化视图 ✓
└── 需要 → 对去重时效性要求高吗?
    ├── 不高(最终一致即可)→ ReplacingMergeTree ✓
    └── 很高(实时精确)→ MergeTree + 查询时去重 / 外部去重 ✓

记住一个原则:引擎的 Merge 行为是异步的、不可预期的 。任何依赖 Merge 才能保证正确性的逻辑,都必须在查询层做兜底。下一篇我们将深入 ClickHouse 的排序键与索引设计,讲清楚 ORDER BYPRIMARY KEY 的真正含义。

相关推荐
lifallen2 小时前
Flink Agent:RunnerContext 注入与装配演进分析
java·大数据·人工智能·语言模型·flink
Yana.nice2 小时前
MySQL 三大日志(redo log、undo log、binlog)的区别和作用
数据库·mysql
QDYOKR1682 小时前
一文了解什么是OKR
大数据·人工智能·笔记·钉钉·企业微信
XDHCOM2 小时前
MySQL CASE WHEN语句应用实例:如何实现条件查询与数据转换?
数据库·mysql
Jul1en_2 小时前
【Redis】常用命令及定时器实现思想
数据库·redis·缓存
杰克尼2 小时前
redis(day02-短信登录)
数据库·redis·缓存
Elastic 中国社区官方博客2 小时前
Elasticsearch:运用 JINA 来实现多模态搜索的 RAG
大数据·人工智能·elasticsearch·搜索引擎·ai·全文检索·jina
知识分享小能手2 小时前
MongoDB入门学习教程,从入门到精通,MongoDB的分片管理(17)
数据库·学习·mongodb
木下~learning2 小时前
MySQL 从入门到精通:安装、终端操作、远程连接与 C 语言 API 全教程
c语言·数据库·mysql