ClickHouse 的查询路径和 FINAL 的代价
目录
背景#
明细表 events 按结算日分区,单日从一千多万行到三亿多行不等。两亿行那套部署一个日分区约 30 GiB,按全表落盘字节除以行数算,每行落盘 120 到 150 字节。表的键是在 Aiven 上现有集群的 system.tables 里查到的;列和跳数索引出自一份较早的建表语句(34 列、8 个跳数索引),现表有没有变还没核。下面列出和查询有关的部分:
CREATE TABLE events
(
id String,
-- ……共 34 列,金额是五个 Decimal(20, 8),维度列是租户、币种、来源等
tenant_id UInt32,
currency_id UInt32,
start_ms UInt64,
settle_ms UInt64,
create_time DateTime DEFAULT now(),
INDEX settle_ms_minmax_idx settle_ms TYPE minmax GRANULARITY 12,
INDEX start_ms_minmax_idx start_ms TYPE minmax GRANULARITY 12,
INDEX tenant_id_bf_idx tenant_id TYPE bloom_filter GRANULARITY 4,
INDEX currency_id_bf_idx currency_id TYPE bloom_filter GRANULARITY 4,
INDEX src_id_bf_idx src_id TYPE bloom_filter GRANULARITY 4,
INDEX create_time_minmax_idx create_time TYPE minmax GRANULARITY 12,
INDEX id_bf_idx id TYPE bloom_filter GRANULARITY 12,
INDEX batch_src_ref_bf_idx (batch_ref, src_ref) TYPE bloom_filter GRANULARITY 12
)
ENGINE = ReplicatedMergeTree
PARTITION BY toYYYYMMDD(toDateTime(settle_ms / 1000))
ORDER BY (settle_ms, id, rev)
PRIMARY KEY (settle_ms, id)
SETTINGS index_granularity = 8192;
从排序键和跳数索引看,查询大致有三类:按结算时间范围取一段明细或做对账;按 id 查单笔;按租户、币种、来源筛。ClickHouse 处理它们的思路一样:尽量不读不需要的数据。要跳过数据,有三层可用,跳完之后只读用到的列。这篇按这个顺序过一遍,最后单独看明细换成 ReplacingMergeTree 之后,读的时候要付的 FINAL 代价。
文中「实验 NN」指 clickhouse-lab 里的同号脚本,跑在 25.3.14.14 的三副本实验台上;现有集群是 25.3.14.1。part、分区、merge 这些概念在写入路径那篇。
按分区和 part 的最值整块跳过#
最粗的一层是分区。查询条件里带了分区键,或者直接写 _partition_id = '20260310'(_partition_id 是虚拟列,值就是分区 ID),不相关的日分区整个跳过。每个 part 目录里还存着分区键所用列的最小值和最大值(minmax_*.idx),条件里只有 settle_ms 的范围时,范围对不上的 part 也整个跳过,别的日分区照样裁得掉。
对账时按小时数多余行数,用的是分区和主键这两层:
SELECT count() AS rows, uniqExact(id, settle_ms, rev) AS keys
FROM events
WHERE _partition_id = '20260310'
AND settle_ms >= {hour_start_ms}
AND settle_ms < {hour_start_ms} + 3600000;
_partition_id 把日分区写死;settle_ms 是排序键的第一列,主键索引靠它在这一天里跳 granule,去掉它就要把整个日分区扫一遍。用 EXPLAIN indexes = 1 看这条查询,分区、part 最值和主键各裁掉了多少,会逐项列出来(EXPLAIN)。
稀疏主键按 granule 跳#
ClickHouse 读数据的最小单位是 granule,默认 8192 行一个,行很宽时按 index_granularity_bytes(10 MiB)切得更小(MergeTree)。主键索引是稀疏的:每个 granule 只记第一行的键,整份索引能放进内存。查询时 ClickHouse 扫一遍这些索引条目,挑出可能含有目标行的 granule,只读这些(Primary indexes)。
lab 里有一个直观的例子(实验 14)。那张表一毫秒一行、共一千万行,开头 10 万行又原样重投了 5 次;为了让重复留着、不被合并掉,merge 是停的,表里有 14 个 part。按排序键查 50 秒的数据,5 万行加上 5 份重投,约 30 万行,落在其中 6 个 part 里,每个 part 读 7 个 granule,一共 42 个,也就是 42 × 8192 = 344064 行。一千万多行里只读了三十四万行。
主键可以只取排序键的前缀,这张表的 PRIMARY KEY (settle_ms, id) 就比 ORDER BY (settle_ms, id, rev) 少一列,rev 只影响同一个 (settle_ms, id) 内部的排序(MergeTree)。
按结算时间范围查,用的正是索引的第一列,效果最好。只按 id 查就不一样了:id 是第二列,ClickHouse 对非首列的条件用的是一种排除式搜索,它在前一列基数低时才有效,前一列每行都不同时几乎排除不掉 granule(A practical introduction to primary indexes 的「Generic exclusion search algorithm」一节)。settle_ms 是毫秒时间戳,基数很高,所以按 id 单查靠的是下一层的跳数索引,或者调用方同时带上结算时间范围。
跳数索引再筛一遍#
跳数索引给每 N 个 granule 记一条摘要,GRANULARITY 4 就是每 4 × 8192 = 32768 行一条(Data skipping indexes)。minmax 记这一段的最小值和最大值,适合大致有序的列;bloom_filter 判断某个值在不在这一段里,有一定的误判率(默认 0.025)。
这张表的 8 个索引分两类。settle_ms、start_ms、create_time 上的 minmax,对和排序键 settle_ms 大致同向的时间列有用:start_ms 比结算时间早几秒到几分钟,create_time 晚几秒到几分钟,人工补发的除外。id、tenant_id、currency_id、src_id、(batch_ref, src_ref) 上的 bloom_filter,管的是「这个值在不在」。
跳数索引有效的前提是值不要散在每一段里。文档的说法是,一个值在索引块里哪怕只出现一次,整块都得读进来,索引的开销也就白花了;而且索引在写入时、在用不上它的查询上都有成本。按这条原理推,id 每行不同,一个块里只含少数几个 id,bloom filter 能排除掉绝大部分块;tenant_id、currency_id 这类维度只有几十到几千个取值,分布偏斜,大租户、主流币种几乎在每一段里都出现,按它们筛很难跳过什么。上面对 id 和维度列的判断是按文档的原理推的,现在那张明细表上每个索引实际跳过了多少还没测过。
一条查询跳得好不好,返回明细行的查询(比如按 id 查单笔)可以看 system.query_log 里 read_rows 和 result_rows 的比值:读了几百万行只返回几行,说明前面三层基本没起作用。聚合查询只返回几行,这个比值没有意义,要拿 read_rows 和条件范围本身有多少行比。具体是哪一层没起作用,用 EXPLAIN indexes = 1 看。
只读用到的列,元数据能回答的不读数据#
跳完 granule 之后,ClickHouse 只读查询用到的那几列,每列一次读一整个 granule,解压后成块计算;查询计划按最大线程数展开成几条互相独立的执行通道并行跑(架构概览)。34 列的宽表,按时间范围求金额合计,只碰 settle_ms 和金额那一列。
有的查询连数据都不用读。不带条件的 count() 直接读每个 part 里记的行数,lab 里一千万行的表 read_rows 是 1(实验 14)。这个快捷路径在带上 FINAL 之后就没了,下一节会看到。
去重之后的明细要带 FINAL 读#
这条链路上的写入是至少一次,重复行会落进明细表。去重方案选的是把明细表换成 ReplicatedReplacingMergeTree,排序键相同的行在合并时只留一行(去重方案那篇,现有集群上还没换)。合并什么时候发生不由查询方决定,还没合并到的重复,普通查询照样读得到。要读到折叠之后的结果,查询要带 FINAL:ClickHouse 在返回结果之前把数据现合一遍,合并时该做的转换在查询时做掉;也可以在用户的 profile 里设 final = 1,自动给查询里所有适用的表加上(FINAL modifier)。
文档对代价的说法是,合并时做的事要挪到查询时在内存里做,需要额外的计算和内存。实验 14 把这笔额外开销拆开测了:两张 ReplacingMergeTree 表各一千万行,停掉 merge,再各写进 5 次重投、每次 10 万行逐字节相同的数据。物理行数是 1050 万,带 FINAL 读出来正好 1000 万。一张表的重投都落在最前面 10 万个键上,和现有集群上只重投最近刚写进来的数据同向;另一张表的重投撒在全部键上,每个 part 都和别的 part 处处重叠。
下面的数都来自一千万行的窄表(每行未压缩约 38 字节)、一台 M2 Pro。绝对耗时带不到现有集群;倍数关系能不能带到单日两三亿行、每行未压缩约 300 字节(按落盘字节和压缩比估的)的宽表上,还要在那个量级上再测。
FINAL 贵不贵看三件事:查询的写法和重叠范围决定它花多少,拿什么当对照决定它算不算贵。
查询的写法#
| 查询 | 不带 FINAL | 带 FINAL |
|---|---|---|
count() |
1 ms,读 1 行 | 12 ms,读 1080 万行 |
| 按排序键查一个范围 | 4 ms,读 34.4 万行 | 9 ms,读 49.2 万行 |
count() 的倍数最大,因为不带 FINAL 时走的是元数据快捷路径。走排序键的范围查询只碰被选中的那些 granule,带上 FINAL 耗时是不带的 2.2 倍,读的行数多四成。
重叠有多广#
| 表的状态 | count() FINAL 的中位耗时 |
|---|---|
| 重叠集中在前 10 万个键,14 个 part | 16 ms |
| 再堆 15 个同样集中的小 part,29 个 part | 24 ms |
| 重叠铺满全部键,14 个 part | 50 ms |
FINAL 只归并互相重叠的键区间,不重叠的部分直接读(25.3 上 split_parts_ranges_into_intersecting_and_non_intersecting_final 默认是 1)。所以它的代价跟着「落在重叠区间里的行」走,光看 part 数不够:part 多了一倍、重叠集中的那张表,仍然比 part 少、重叠铺满的便宜一半以上。合并成 1 个 part 之后没有任何重叠,两张表的 count() FINAL 都降到几毫秒,FINAL 退化成普通读取。
和手写 GROUP BY、LIMIT 1 BY 去重比#
| 表 | 去重方式 | 耗时 | 内存 |
|---|---|---|---|
| 重叠集中 | FINAL |
27 ms | 17.13 MiB |
| 重叠集中 | 手写 GROUP BY |
169 ms | 1.01 GiB |
| 重叠集中 | 手写 LIMIT 1 BY |
1287 ms | 1.15 GiB |
| 重叠铺满 | FINAL |
47 ms | 88.29 MiB |
| 重叠铺满 | 手写 GROUP BY |
127 ms | 1.14 GiB |
手写 GROUP BY 去重的内存是 FINAL 的 13 到 60 倍,耗时是 3 到 6 倍。耗时这一项每轮波动大,几轮实跑下来在 2 倍到 51 倍之间,内存的差距一直在一个数量级以上。原因在于每个 part 本来就按排序键有序,FINAL 做的是流式归并;手写去重丢掉了这个前提,要在内存里重新攒一遍哈希表或者重新排序。
「FINAL 太贵别用」这句话要分清跟谁比。跟不去重比它确实贵,跟自己在查询里去重比它便宜得多。按时间排序、只重投最近写入的数据的表,重叠天然集中在一小段键上,正好落在便宜的那一头。
官方的去重策略页对 FINAL 的说法更保守:数据量小时用它没问题,数据量大时它「probably not the best option」,建议在查询里用业务逻辑和聚合函数处理(Deduplication strategies)。实验 14 测的「手写 GROUP BY 去重」就是这一类做法,在 25.3、一千万行上它比 FINAL 贵;到两三亿行的宽表上谁更省,还要在那个量级上再测一次。
反复扫大范围的报表,改读增量聚合好的报表表,不必每次对明细带 FINAL 现算(用物化视图做交易报表)。
小结#
- 查交易明细时,ClickHouse 依次按分区、part 的最值、稀疏主键、跳数索引跳过数据,最后只读用到的列。
- 主键索引对排序键的第一列最有效。这张表以毫秒时间戳
settle_ms开头,按时间范围查最省;只按id查要靠 bloom filter,或者带上时间范围。 - 跳数索引只在值不散布在每一段里时有效,
id这种每行不同的列适合,低基数、分布偏斜的维度列不一定跳得掉,现有集群上还没测过。 ReplacingMergeTree的折叠只在合并时发生,读明细要带FINAL或设final = 1。lab 上它的代价跟着重叠范围走,比手写GROUP BY去重省一个数量级以上的内存。
参考资料#
- Primary indexes、A practical introduction to primary indexes in ClickHouse:稀疏索引和非首列的排除式搜索
- Data skipping indexes:
GRANULARITY、minmax、bloom_filter,以及索引在什么情况下跳不掉数据 - MergeTree:granule 大小、主键取排序键前缀
- FINAL modifier:
FINAL做了什么、final设置 - Deduplication strategies:官方对
FINAL在大数据量上的保留意见 - EXPLAIN:
indexes = 1 - clickhouse-lab 实验 14:
FINAL代价的脚本,输出在results/14-replacing-final-cost.log
相关文章#
- ClickHouse 交易明细系统的整体架构:系列总览和阅读顺序
- ClickHouse 的写入路径
- ClickHouse 明细表的去重方案:为什么换成
ReplacingMergeTree - 用物化视图做 ClickHouse 交易报表