ClickHouseNotes

第 07 章:MergeTree

zjc 于 2026-01-07 发布

这是《ClickHouse 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 MergeTree 是 ClickHouse 最重要的存储引擎。理解 part、分区、排序键、稀疏索引、后台合并和 TTL,就理解了 ClickHouse 高性能分析的基础。

7.1 存储结构

一张 MergeTree 表的逻辑结构:

table
  partition 202608
    part all_1_1_0
      primary.idx        稀疏主键索引
      event_date.bin      列数据
      event_type.bin
      user_id.bin
      amount.bin
      count.txt           元信息

每个 part 内数据按 ORDER BY 排序,列文件独立保存,并带有压缩块和校验信息。

7.2 建表示例

CREATE TABLE analytics.events_local
(
    event_date Date,
    event_time DateTime,
    event_id String,
    user_id UInt64,
    event_type LowCardinality(String),
    city_id UInt32,
    channel LowCardinality(String),
    amount Decimal64(2),
    is_deleted UInt8
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, event_type, city_id, user_id)
TTL event_date + INTERVAL 13 MONTH
SETTINGS index_granularity = 8192;

各元素职责:

元素 职责
PARTITION BY 生命周期管理与粗粒度剪枝
ORDER BY 物理排序、稀疏索引、局部性
TTL 自动过期数据或列
index_granularity 稀疏索引粒度
SETTINGS 引擎行为细节

7.3 part 生命周期

INSERT 生成宽 part
        |
        v
后台 merge 合并同分区 part
        |
        v
生成更大 part,旧 part 标记 inactive
        |
        v
超过保留期后物理删除

查看 part:

SELECT
    partition,
    name,
    active,
    rows,
    bytes_on_disk,
    min_block_number,
    max_block_number,
    level
FROM system.parts
WHERE table = 'events_local' AND active
ORDER BY partition, name;

强制合并:

OPTIMIZE TABLE analytics.events_local PARTITION 202608;

生产上不要频繁执行全表 FINAL 合并。强制合并会占用大量 CPU、IO 和内存。

7.4 分区设计

常用分区:

PARTITION BY toYYYYMM(event_date)

大表也可以按天:

PARTITION BY toDate(event_date)

分区不是越细越好。过多分区会带来:

  1. 更多目录和元数据;
  2. 后台合并调度压力;
  3. 查询计划开销;
  4. 小文件问题。

建议:

场景 分区
保留 1 年,TB 级事件 按月或按周
保留 7 到 30 天,每天量大 按天
小维表或测试表 通常不分区
明确生命周期管理 优先时间分区

7.5 排序键设计

ORDER BY 决定每个 part 内数据的物理顺序,也决定稀疏索引能否跳过无关 granule。

设计原则:

  1. 低基数字段放前面;
  2. 常用等值或范围过滤字段尽量靠前;
  3. 避免过高基数字段占据前缀;
  4. 与核心报表维度一致;
  5. 不追求覆盖所有查询,用投影补齐不同排序。

示例:

ORDER BY (event_date, event_type, city_id, user_id)

查询有效:

SELECT count()
FROM analytics.events_local
WHERE event_date = '2026-08-25'
  AND event_type = 'pay'
  AND city_id = 1;

查询较弱:

SELECT count()
FROM analytics.events_local
WHERE user_id = 1001;

第二个查询跳过了排序键前缀,需要扫描更多 granule。针对高频用户查询可以额外建投影或物化汇总表。

7.6 稀疏索引

ClickHouse 不是每行一条 B+Tree 索引,而是每隔 index_granularity 行记录一次排序键标记:

granule 0: min_key = (08-01, view, 1, 10)
granule 1: min_key = (08-01, view, 1, 5000)
granule 2: min_key = (08-01, view, 2, 20)

查询时通过 primary.idx 判断哪些 granule 可能包含数据,再读取列块。

查看 granule 信息:

SELECT
    table,
    primary_key,
    index_granularity
FROM system.tables
WHERE database = 'analytics';

index_granularity 一般保持默认。盲目调小会让索引和元数据膨胀,盲目调大可能降低过滤精度。

7.7 跳数索引

跳数索引是二级数据结构,用于跳过不满足条件的 granule:

ALTER TABLE analytics.events_local
ADD INDEX idx_user_bloom user_id TYPE bloom_filter GRANULARITY 3;

ALTER TABLE analytics.events_local
ADD INDEX idx_amount_minmax amount TYPE minmax GRANULARITY 1;

ALTER TABLE analytics.events_local MATERIALIZE INDEX idx_user_bloom;

常见类型:

类型 适合
minmax 数值和时间范围
set(N) 少量离散值
bloom_filter 等值查询,高基数可选
tokenbf_v1 token 匹配
ngrambf_v1 子串匹配

跳数索引有构建成本和存储成本,也会降低写入速度。只给高频过滤条件添加。

7.8 TTL

行 TTL:

ALTER TABLE analytics.events_local
MODIFY TTL event_date + INTERVAL 13 MONTH;

列 TTL:

ALTER TABLE analytics.events_local
MODIFY COLUMN
    remark String TTL event_date + INTERVAL 1 MONTH;

查看 TTL 信息:

SELECT database, table, engine_full
FROM system.tables
WHERE table = 'events_local';

TTL 删除通常由后台任务执行,不是精确到秒的实时删除。关键保留期要配合监控和审计确认。

7.9 合并策略与设置

常用设置:

设置 说明
index_granularity 稀疏索引粒度
min_bytes_for_wide_part 宽 part 和紧凑 part 阈值
merge_max_block_size 合并处理块大小
max_parts_in_total part 总数保护
ttl_only_drop_parts 按整 part 删除 TTL 数据

按整分区过期可提高删除效率:

ALTER TABLE analytics.events_local
MODIFY SETTING ttl_only_drop_parts = 1;

适用条件:TTL 边界尽量与分区边界对齐。否则效果有限。

7.10 常见故障

Too many parts

SELECT table, count() AS parts, sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY table
ORDER BY parts DESC;

处理:

  1. 降低写入频率;
  2. 增大批次;
  3. 减少分区数量;
  4. 检查合并线程和磁盘 IO;
  5. 扩容或降低写入流量。

查询扫描量过大

SELECT query, read_rows, read_bytes, query_duration_ms
FROM system.query_log
WHERE type = 'QueryFinish'
ORDER BY read_bytes DESC
LIMIT 20;

优先检查分区条件、排序键、投影和预聚合。

part 损坏

查看异常:

SELECT database, table, name, active, reason
FROM system.parts
WHERE not active
ORDER BY modification_time DESC;

处理方式与是否有副本有关。副本表优先从健康副本恢复;单机表需要使用备份恢复,不要盲目删除目录。

本章小结

MergeTree 通过按列存储、part 合并、分区剪枝、排序键稀疏索引和跳数索引,把海量扫描和聚合做成高吞吐流水线。设计时要先确定数据生命周期和主要查询模式,再决定分区、排序键和索引。频繁小写入、过细分区和滥用强制合并都是常见反模式。

思考题

  1. part、partition、granule 分别是什么?
  2. 分区和排序键是否都能减少扫描?
  3. 排序键前缀为什么重要?
  4. 跳数索引有哪些代价?
  5. 什么时候使用 ttl_only_drop_parts