定位:表引擎方法论。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 不会自动帮你聚合
这是新手最常见的困惑。直觉上,既然叫"聚合引擎",写入原始数据应该自动聚合才对。但实际上:
- AggregatingMergeTree 只负责 Merge 时合并中间状态,它不知道你的原始数据长什么样。
- 你必须在写入时就把数据转换为聚合中间状态 (通过
-State函数)。 - 通常的做法是:原始数据写入 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) | 固定维度 + 数值指标,需要预聚合 | SummingMergeTree 或 AggregatingMergeTree |
减少存储,加速查询 |
| 用户画像 | 同一用户多次更新,只保留最新 | 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 BY 和 PRIMARY KEY 的真正含义。