ClickHouse学习笔记(三):MergeTree分区排序与稀疏索引

第二篇的建表语句里有三行特别重要:

1
2
3
ENGINE = MergeTree
PARTITION BY toYYYYMM(paid_at)
ORDER BY (city, paid_at, order_id)

刚开始很容易把它们都理解成“建索引”。但它们分别决定表引擎、数据分区和排序方式,解决的问题并不相同。

这一篇沿着写入一批数据,再读取其中一部分的过程,理解 MergeTree 为什么这样设计。

系列导航:

  1. 列式存储与 OLAP
  2. Docker 与 SQL 入门实战
  3. MergeTree、分区与索引
  4. 物化视图与聚合状态
  5. 查询优化与常见问题

本篇沿用第二篇的 ClickHouse 25.8 单节点环境。SQL 在已连接的客户端执行,使用 clickhouse_learning 数据库;八条支付记录保持不变。


一、Part:每批写入之后发生了什么?

可以先用下面的图理解 MergeTree 的普通写入流程:

1
2
3
4
5
6
7
8
9
一批输入记录
↓
按分区键拆分
↓
各部分按排序键排序
↓
形成新的数据 part
↓
后台选择同一分区中的 part 进行合并

Part 是表中的数据片段,Partition 是逻辑上的数据分区。 一个分区可以包含多个 part,一次写入也可能涉及多个分区。官方存储说明介绍了 part 的排序和合并方式。

1.1 为什么不能把一次 INSERT 等同于一个 Part?

假设一批记录跨越 9 月和 10 月,而分区键是 toYYYYMM(paid_at):

1
2
3
同一批输入
├── 202609 的记录 → 9 月的数据 part
└── 202610 的记录 → 10 月的数据 part

另外,输入还可能被拆成多个数据块。实际形成多少 part,要结合输入方式、块大小、分区数量和写入设置观察。

1.2 后台合并解决什么问题?

很多小 part 会增加文件、元数据和查询调度成本。后台合并把多个片段整理为较大的片段,也可能改善压缩和后续读取。

可以类比成不断收到小批账单,再逐步整理成较大的有序账本。

但普通 MergeTree 合并时不会因为两行的 order_id 相同就删除其中一行。存储合并不等于业务去重。


二、Partition:主要用于组织和管理数据

我们的分区表达式是:

1
PARTITION BY toYYYYMM(paid_at)

2026 年 10 月的支付属于 202610,11 月属于 202611。

2.1 它能帮我们做什么?

  • 按时间范围组织数据。
  • 在查询条件允许时排除不相关的分区。
  • 按分区进行历史数据管理、迁移或清理。

不同分区的 part 不会互相合并。官方分区指南强调了分区粒度与管理成本的关系。

这里没有“所有表都必须按月分区”的规定。数据量很小,可以不分区;保留期较短、每天数据量很大时,也可能按天组织。选择前要考虑每个分区会有多少数据和多少 part。

2.2 为什么不按每个订单 ID 分区?

如果一亿个订单产生接近一亿个分区,管理和文件开销会非常大,每个分区的数据又太少,也不利于合并。

分区键不应该用来模仿逐行索引。 根据订单 ID 查数据,需要考虑排序键、其他索引机制或另外的访问模型。


三、ORDER BY 与 PRIMARY KEY:相近,但分工不同

第二篇使用:

1
ORDER BY (city, paid_at, order_id)

它描述的是各个 part 内部的字典序:先按城市,再按支付时间,最后按订单 ID 排序。

3.1 为什么城市放在前面?

这是针对“按城市筛选,再查询一段时间”的实验负载做的选择:

1
2
3
4
5
SELECT sum(amount)
FROM clickhouse_learning.order_payments
WHERE city = '上海'
AND paid_at >= toDateTime('2026-10-01 00:00:00', 'Asia/Shanghai')
AND paid_at < toDateTime('2026-10-02 00:00:00', 'Asia/Shanghai');

上海的记录在每个 part 中相邻,上海内部又按时间排列,更容易确定候选范围。

如果主要查询“全部城市最近五分钟的支付”,时间放在更靠前的位置可能更合适。排序键要从高频查询推导,不能只按字段类型或某个固定口诀排列。 官方 Schema 设计指南提供了按访问模式选键的思路。

3.2 不写 PRIMARY KEY 会怎样?

在 MergeTree 中,如果没有单独指定 PRIMARY KEY,就使用排序键作为主键索引的键。

也可以让主键只取排序键的前缀。下面是一个替代建表设计片段,不是修改现有表的命令:

1
2
3
4
ENGINE = MergeTree
PARTITION BY toYYYYMM(paid_at)
PRIMARY KEY (city, paid_at)
ORDER BY (city, paid_at, order_id)

这种情况下,数据仍按三个字段排序,但主键索引只记录前两个字段。主键表达式必须是排序键表达式的前缀,见官方主键说明。

3.3 ClickHouse 的主键保证唯一吗?

不保证。 普通 MergeTree 允许相同主键值出现多次,主键主要服务于数据访问。

容易混淆的含义 本系列中的实际含义
主键自动阻止订单重复写入 不提供这个唯一性约束
ORDER BY 决定所有查询的返回顺序 决定存储排序;输出顺序要在 SELECT 中指定
排序键只影响排序 也影响索引范围和压缩
更长的主键一定更好 会增加索引大小和维护成本,要评估收益

四、Granule 与稀疏索引:先排除范围,再读取记录

如果每一行都在主键索引里存一个条目,一亿行就需要大量索引项。

MergeTree 使用稀疏主键索引:按数据粒度记录代表性的键值,再借助 marks 定位列数据。

4.1 一个概念示意

为了画图,假设每个 granule 只有四行,实际不要把这个数字当作默认值:

1
2
3
4
5
6
7
8
9
10
Part 内已排序的记录:

G0:[北京 ...][北京 ...][北京 ...][北京 ...]
G1:[广州 ...][广州 ...][广州 ...][广州 ...]
G2:[上海 ...][上海 ...][上海 ...][上海 ...]

主键索引概念示意:
G0 起始键 → 北京 ...
G1 起始键 → 广州 ...
G2 起始键 → 上海 ...

查询上海时,索引帮助排除明显不可能包含上海的范围,然后读取候选 granule 的所需列,再执行行级过滤。

实际 granule 可能跨越城市边界,稀疏索引也可能读到一些额外记录,所以它不是“每次直接定位到唯一一行”。官方主索引指南解释了 granule、稀疏索引与 marks 的配合方式。

4.2 是不是每个 Granule 都有 8192 行?

index_granularity 常见默认值是 8192,但自适应粒度还受 index_granularity_bytes 等设置影响,实际粒度可能更小。

因此,更合适的记法是:

索引按数据范围工作,粒度受行数和字节大小等设置共同影响。

八条记录只是用来验证 SQL,无法展示大表中的粒度剪枝收益。不要在这张小表上测到几毫秒差异,就宣布一种排序键快了多少倍。


五、用系统表和 EXPLAIN 观察

查看当前有效 part:

1
2
3
4
5
6
7
8
9
10
11
SELECT
partition,
name,
rows,
marks,
formatReadableSize(bytes_on_disk) AS disk_size
FROM system.parts
WHERE database = 'clickhouse_learning'
AND table = 'order_payments'
AND active = 1
ORDER BY partition, name;

active = 1 过滤当前有效片段,避免把尚未清理的旧片段也当作当前数据。字段说明见system.parts 文档。

再查看查询计划:

1
2
3
4
5
6
EXPLAIN indexes = 1
SELECT sum(amount)
FROM clickhouse_learning.order_payments
WHERE city = '上海'
AND paid_at >= toDateTime('2026-10-01 00:00:00', 'Asia/Shanghai')
AND paid_at < toDateTime('2026-10-02 00:00:00', 'Asia/Shanghai');

重点关注计划中的分区、主键以及候选 parts、granules 数量,具体输出随版本和数据状态变化。EXPLAIN 文档列出了索引分析选项。

索引出现在计划里,不代表过滤效果一定显著。 如果表只包含一个很小的 granule,它可能仍需要读取整个候选范围。


六、ReplacingMergeTree:去重为什么不能只等后台?

支付事实表和订单状态表是两个模型。为了演示更新版本,另外创建一张状态表,不修改第二篇的支付数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE clickhouse_learning.order_latest_demo
(
order_id UInt64,
status String,
version UInt64
)
ENGINE = ReplacingMergeTree(version)
ORDER BY order_id;

INSERT INTO clickhouse_learning.order_latest_demo VALUES
(1001, 'CREATED', 1),
(1001, 'PAID', 2),
(1002, 'CREATED', 1);

ReplacingMergeTree(version) 根据整个排序键识别重复记录,合并时保留版本更高的一行,具体规则见官方引擎文档。

6.1 查询时如何得到当前版本?

1
2
3
SELECT order_id, status, version
FROM clickhouse_learning.order_latest_demo FINAL
ORDER BY order_id;

预期结果:

订单 ID 状态 版本
1001 PAID 2
1002 CREATED 1

FINAL 在查询时应用引擎的合并规则。它不会把结果永久写回表,也不是一个启动后台合并的命令。

不带 FINAL 查询时,可能仍然看到旧版本,也可能已经看不到;不能依赖后台合并何时发生来保证业务结果正确。

6.2 建模时最容易踩的两个坑

第一,把变化字段放入排序键。

如果写成 ORDER BY (order_id, status),CREATED 和 PAID 的键不同,就不是同一组待替换记录。

第二,同一实体跨越不同分区。

后台不跨分区合并,所以同一订单的版本应设计为落在稳定的分区中。查询是否跨分区应用 FINAL 还涉及具体设置,不能用查询行为弥补不明确的模型。官方更新建模指南说明了排序键和分区的注意事项。

版本号也需要由业务定义顺序,避免不同内容使用相同版本时无法按预期选择。

ReplacingMergeTree 适合表达可合并的版本状态,但不是即时唯一约束。 如果再把它与金额累加的物化视图直接连接起来,还会出现旧版本已经被统计的问题,下一篇继续解释。