背景#

明细表 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 去重省一个数量级以上的内存。

参考资料#

相关文章#