ClickHouse 的写入路径
目录
背景#
这个系列里的交易明细从 Kafka 经 ClickHouse 官方的 Kafka Connect sink 写进一套 1 shard × 3 replicas 的集群。sink 每次 poll 按 Kafka 分区各发一条 INSERT,一条往往只有十几到几十行。这套系统按 region 分成几套独立部署,数据量最大的两套一天分别约两亿行和三亿多行:两亿行那套三个副本合计每秒新建约 124 个写入块,每块十几到二十行;三亿多行那套每秒约 40 个块,每块约 90 行。
ClickHouse 不原地改数据。每次写入生成一个新的 part,写完就不再动,后台再把小 part 合并成大 part。后面几篇反复用到这条规则:重复为什么会直接落库,去重为什么要等合并,改一行数据为什么要给整个 part 出一个新版本,修数据为什么按分区整体替换。这篇按一条 INSERT 的顺序把写入路径过一遍。
版本以 25.3 为准,Aiven 上的现有集群是 25.3.14.1。文中「实验 NN」指 clickhouse-lab 里 experiments/ 下的同号脚本,输出在 results/,跑在一套 25.3.14.14 的三副本实验台上。
一条 INSERT 怎么变成 part#
服务端收到一批行,先按分区键把行拆开,每个分区单独成一块;每块按排序键排序、拆成列、逐列压缩,写进一个新目录,这个目录就是 part,稀疏主键索引也写在里面(Parts)。复制表在提交前还要去 Keeper(存复制元数据的协调服务,Aiven 在每个节点上同机跑一个 ZooKeeper)领块号、查重、记一条复制日志,这几步放在后面复制和块级去重两节。
lab 上新写的一个 Wide part,目录里是这些文件:
20260310_0_0_0/ 分区 ID_起始块号_结束块号_合并层级
k.bin k.cmrk2 每列一个压缩数据文件,加一个 mark 文件
v.bin v.cmrk2
primary.cidx 稀疏主键索引,每个 granule(默认 8192 行)一条
minmax_d.idx 分区列的最小值和最大值
partition.dat count.txt columns.txt checksums.txt …
目录名的四段依次是分区 ID、这个 part 覆盖的起止块号、合并层级。新写的 part 层级是 0,和相邻的块合并一次之后变成 20260310_0_1_1(Merges)。mutation(ALTER … UPDATE / DELETE,最后一节讲)改过的 part 名字后面再加一段版本号,lab 里 1_0_0_0 改完变成 1_0_0_0_1(实验 08)。
part 有两种存法。Wide 每列一个文件,Compact 把所有列放进同一个文件;低于 min_bytes_for_wide_part 或 min_rows_for_wide_part 任一阈值的用 Compact(MergeTree),25.3 上字节阈值默认 10 MiB。按这个阈值,sink 写进来的十几行一个的小 part 都是 Compact。两种存法的差别在改数据时才显出来,放在最后一节。
part 的大小取决于数据从哪来#
一条大 INSERT 不止产生一个 part,服务端按块切,切法看数据怎么进来。lab 上把 300 万行用四种方式写进同一张表(实验 11):
| 来路 | 切出的 part(行数) | 规则 |
|---|---|---|
INSERT … SELECT |
1111953、1111953、776094 | 读取侧每块 max_block_size = 65409 行,写入侧攒到 min_insert_block_size_rows = 1048449 行才落一个 part。1048449 / 65409 ≈ 16.03,要攒满 17 块,17 × 65409 = 1111953 |
| 客户端发 TSV | 1048449、1048449、903102 | 按 max_insert_block_size = 1048449 行切 |
| 客户端发 JSONEachRow | 129722、1472176、1398102 | 并行解析先按字节切段,再攒到 min_insert_block_size_rows,顺序每次可能不同 |
| 客户端一批 3000 行 | 1 个 part | 小批写入一批就是一个 part,Kafka Connect 每批每个 Kafka 分区就是这种 |
行数之外还有一道字节上限 min_insert_block_size_bytes(268402944 字节,约 256 MiB),哪个先到按哪个切。lab 的表很窄,行数先到。现在那张明细表是宽表(按一份较早的建表语句有 34 列),每行在内存里约 400 字节,做 INSERT … SELECT 时大概率先到字节上限,估计每六七十万行一个 part,三亿行大约四五百个;这是按默认值推的,还没在真实量级上测过。
对这套系统要紧的是最后一行:每秒几十到上百个只有十几到几十行的小 part。merge 要合的 part 数、Keeper 要记的块数、块级去重的窗口能盖住多少秒,都跟着块的个数走。
分区:merge 和管理数据的边界#
分区键把表分成互不合并的组,merge 只在分区值相同的 part 之间做(分区键)。明细表按结算日分区,一天一个:
PARTITION BY toYYYYMMDD(toDateTime(settle_ms / 1000))
分区更大的用处在管理数据。ATTACH PARTITION … FROM、REPLACE PARTITION、FREEZE 都以分区为单位,在本地盘上只挂硬链接、不拷数据(实验 13、20);DROP PARTITION 整组删掉;查询带了分区条件,不相关的分区整个跳过。这个系列里清理重复行、在线换引擎、重算报表,做法都是先把一整天准备好、验过,再整个换进去,靠的就是这一层。
分区也不宜切得太细。每个分区各自攒小 part、互相合不到一起,分区键文档的说法是别分出超过「about a thousand」个分区。
现在那张明细表的存储策略是 tiered,新 part 先落在本地盘。表上还有一条搬迁 TTL:create_time 满 45 天的 part 搬到对象存储,按 part 里最新的那一行算(这条在建表语句里看不到,是从 system.parts 的 move_ttl_info 读出来的)。Aiven 默认的另一条规则也在:本地盘用到 80% 就开始把大 part 往对象存储搬(Aiven tiered storage、MergeTree 的 move_factor),只是本地盘现在只用到三到六成,轮不到它。搬迁的单位是 part,两亿行那套约三分之二的数据、三亿多行那套近九成已经在对象存储上。动一个老分区之前,先查 system.parts.disk_name 看它在哪一层。
merge:后台把小 part 合成大 part#
后台线程从同一个分区里挑块号相邻的几个 part,按排序键归并成一个更大的 part,层级加 1。被合掉的 part 标成非活动的,默认 8 分钟后删文件(old_parts_lifetime = 480 秒,Merges)。part 合到压缩后约 150 GiB 就不再往上合(max_bytes_to_merge_at_max_space_in_pool),一次最多合 100 个(max_parts_to_merge_at_once),这两个默认值在 25.3 上和源码逐项对过(实验 01)。
合并要花 CPU 和磁盘 IO:同一行数据随着 part 越合越大会被重写好几遍,还要和查询、写入抢资源。merge 跟不上写入时,单个分区的活跃 part 数到 parts_to_delay_insert(1000)开始故意拖慢 INSERT,到 parts_to_throw_insert(3000)直接拒绝,报 Too many parts。lab 上把两个阈值压到 20 和 25、停掉 merge 后一条一条插,先被拖慢了 5 次,第 26 条才被拒(实验 11)。先拖慢、后拒绝,中间那段就是预警窗口,怎么盯写在Keeper 故障和 part 过多那篇。现在那张明细表一共有两三千个活跃 part,大部分在历史分区上。
MergeTree 家族的其他引擎在合并时多做一件事:ReplacingMergeTree 把排序键相同的行只留一行,AggregatingMergeTree 把聚合的中间状态合成一行(Merges)。这些逻辑只在合并时生效,还没合并到的重复,普通查询照样读得到。去重方案和报表都建立在这一点上,查询时怎么处理写在查询路径那篇。
复制:另外两个副本怎么拿到这个 part#
Keeper 只存复制用的元数据,不存数据本身(Replication)。每张复制表在 Keeper 里有一个目录,里面是复制日志 log、去重标记 blocks、分配块号用的 block_numbers、mutations,以及每个副本自己的 replicas/<副本名>/queue、parts、is_active 等。
收到 INSERT 的副本写好 part,在 log 里记一条。另外两个副本把日志拉进自己的 queue 逐条执行:GET_PART 经副本间的 9009 端口把 part 拉过来,MERGE_PARTS 各自在本地合,REPLACE_RANGE 是换分区。system.part_log 里看得出谁写、谁拉,lab 里经 ch1 写一个块(实验 03):
副本 event_type
ch1 NewPart
ch2 DownloadPart
ch3 DownloadPart
一个块只在写入的那个副本上记 NewPart,用它数建块速率时三个副本可以直接相加(块级去重窗口那篇就是这么算的)。
这套复制是多主、异步的。三个副本都能接写入;INSERT 默认在收到它的那个副本上提交就返回(insert_quorum 默认 0),ALTER 默认只等当前副本执行完(alter_sync = 1)。三个副本之间会短暂不一致,而 Aiven 的连接随机落到任一节点(Aiven 服务架构),所以核对行数、看复制队列、确认 mutation 做完,都要用 clusterAllReplicas 问遍三个副本。清理重复行那几篇的闸都按这个口径写。
Keeper 出问题时,复制表有两种表现(实验 12)。Keeper 停掉,副本立刻转只读,INSERT 按默认参数重试约 143 秒后被拒;Keeper 卡住,INSERT 卡在提交那一步,客户端超时放弃,Keeper 回来后服务端照样把这条 INSERT 提交了。后一种是这条链路上重复行的起点,经过写在超时重投那篇。
现有集群用的是 Replicated 库引擎,DDL 自动在三个节点上执行,不用写 ON CLUSTER;分区操作在这种库里怎么传播,写在在线换引擎那篇。
块级去重:Keeper 里的 blocks/#
复制表提交一个块之前,先按块内容的哈希去 Keeper 的 blocks/ 里查重;INSERT 带了 insert_deduplication_token 就按 token 查,不再算哈希(Deduplicating inserts on retries)。同一个分区里认出来的重复块直接跳过,复制文档管这叫 INSERT 幂等。
这份幂等只在窗口内成立。25.3 默认只记最近 1000 个块、最长一周(实验 01),清理线程周期性地把多出来的记录裁掉。文档对失效条件的说法是:重试期间插进来的其他块超过窗口,「the same data can be inserted multiple times」。
被拦下的那次插入不写数据,part_log 里照样记一行 NewPart,只是 error = 389(INSERT_WAS_DEDUPLICATED,实验 03)。
两亿行那套每秒建约 124 个块,1000 个块只够记 8 秒左右;三亿多行那套每秒约 40 个,约 25 秒。而 Kafka Connect 超时之后的重投要等 30 到 90 秒。窗口怎么换算、怎么拿 error = 389 的条数做告警、旧块什么时候真被裁掉、调大窗口能做到什么程度,写在块级去重窗口那篇。
改数据也以 part 为单位#
part 写完不再改,改数据只能生成新版本的 part。ALTER TABLE … UPDATE 和 ALTER TABLE … DELETE 是 mutation,在后台把涉及的 part 一个个重写(ALTER UPDATE)。Wide part 上只重写被改的列,其余列以硬链接挂到新 part 上:lab 里改完一列,没改的 pad.bin 链接数是 2,和旧 part 共用一个 inode,改掉的 placed_at.bin 是 1(实验 08)。Compact part 没有单列的文件,改一列就把整个 data.bin 重写一遍。分区键和主键上的列不能改,报 Code: 420 … Cannot UPDATE key column。
轻量删除(DELETE FROM)只写一列删除标记 _row_exists,合并时才真正删掉,但它同样按 part 执行:不加 IN PARTITION,全表每个 part 都会出一个新版本(实验 18,代价写在轻量删除那篇)。
所以 ClickHouse 不适合频繁改单行。这个系列里改数据的做法有三种:重复行交给 ReplacingMergeTree 在合并时折掉,要改某一行的值就插一行新版本(去重方案);已经写进去的重复,把整个分区重建好再换进去(REPLACE PARTITION runbook);一列写错了,用带 IN PARTITION 的列级 mutation 补(补数方案评审)。
小结#
- 每次 INSERT 按分区切块,每块写成一个新 part,写完不再改。Kafka Connect 每批每个 Kafka 分区一个 part,这套系统每秒新建几十到上百个十几到几十行的小 part。
- merge 只在同一分区里把小 part 合大;跟不上时 part 数先让 INSERT 变慢、再让它被拒。
ReplacingMergeTree的折叠和AggregatingMergeTree的合并都只在 merge 时发生。 - 分区是 merge 的边界,也是管理数据的单位:挂载、替换、冻结在本地盘上都是硬链接。
- 复制是多主、异步的,副本之间会短暂不一致,核对要问遍所有副本。块级去重的记录存在 Keeper 里,25.3 默认只记最近 1000 个块。
- 改数据按 part 重写:Wide part 只重写改到的列,Compact part 整个重写,键列不能改。
参考资料#
- Parts、Merges、Custom partitioning key
- MergeTree:Wide 和 Compact 两种存法、
move_factor - Replication:Keeper 存什么、多主异步复制、
insert_quorum - Deduplicating inserts on retries:块级去重和窗口的失效条件
- ALTER TABLE … UPDATE
- Aiven for ClickHouse 服务架构、Aiven tiered storage
- clickhouse-lab:实验 01、03、08、11、12、13、18、20 的脚本和输出
相关文章#
- ClickHouse 交易明细系统的整体架构:系列总览和阅读顺序
- ClickHouse 的查询路径和 FINAL 的代价:写进去的 part 怎么被查出来
- ClickHouse 的块级去重窗口
- ClickHouse 的 Keeper 故障和 part 过多