团队分享 · 交易明细数据链路

ClickHouse 原理与
交易明细链路

从一条 INSERT 到一张报表:part 和 merge、Kafka sink 的至少一次写入、物化视图聚合,以及运维会碰到的几类问题

单日一千多万到三亿多行

ClickHouse 25.3,1 shard × 3 replicas

Kafka Connect sink 至少一次写入

素材来自博客的 ClickHouse 系列和 clickhouse-lab(25.3.14.14 三副本实验台)。标「方案」的内容在 lab 上验过,还没在现有集群上执行。

这套系统长什么样

Kafka

单 topic,8 个分区;峰值每秒约 1 万条。producer 重试时可能把同一条存成两份。

ClickHouse sink

clickhouse-kafka-connect v1.3.9,Confluent Cloud 托管。每次 poll 按分区各发一条带 token 的 INSERT;至少一次投递,没开 exactlyOnce。

明细表

ReplicatedMergeTree,按结算日分区,ORDER BY (settle_ms, id, rev);34 列、8 个跳数索引。单日一千多万到三亿多行。

报表表

方案

物化视图在明细插入时触发,聚合进 UTC 半小时桶,按天分区;日报、周报、月报按时区在查询时上卷。

Keeper

存复制日志、块号分配和块级去重标记。Aiven 在每个节点同机跑一个 ZooKeeper,运维碰不到这一层。

对象存储

tiered storage:create_time 满 45 天的 part 整个搬过去。B 档约三分之二、C 档近九成的数据已经在上面。

两类下游

backoffice 按时间范围查明细、做对账,读明细表;按不同时区出日报、周报、月报,读报表表。

按 region 分成几套独立部署,ClickHouse 是 Aiven 托管的 25.3,1 shard × 3 replicas,任一节点都能接写入。三档规格(每个节点 / 单日分区):A business-16,4 vCPU / 16 GB,一千多万行;B business-32,8 vCPU / 32 GB,约两亿行;C business-64,16 vCPU / 64 GB,三亿多行。

系统概览 · 出处:ClickHouse 交易明细系统的整体架构

02

一条 INSERT 怎么变成 part

1

服务端按分区键把这批行拆开,每个分区单独成一块

2

每块按排序键排序、拆成列、逐列压缩(默认 LZ4,约 1 MB 一个压缩块)

3

写进一个新目录,这个目录就是 part;稀疏主键索引也写在里面

4

复制表提交前去 Keeper 领块号、按哈希或 token 查重、记一条复制日志

part 写完就不再改。后面的事都从这一条来:重复为什么会直接落库,去重为什么要等合并,改一行为什么要给整个 part 出新版本,修数据为什么按分区整体替换。

lab 上新写的一个 Wide part 目录

20260310_0_0_0/

分区 ID、起止块号、合并层级

k.bin k.cmrk2

每列一个压缩数据文件,加一个 mark 文件

primary.cidx

稀疏主键索引,每个 granule(8192 行)一条

minmax_d.idx

分区列的最小值和最大值

count.txt columns.txt

行数、列清单、校验和等元数据

sink 每批每个 Kafka 分区一个 part,一条 INSERT 只有十几到几十行。B 档三副本合计每秒约 124 个块、每块约 19 行;C 档每秒约 40 个块、每块约 90 行。

小于 10 MiB 的 part 用 Compact 存法(所有列放一个文件),sink 写进来的小 part 都是;大的用 Wide(每列一个文件)。

基本原理 · 出处:ClickHouse 的写入路径(clickhouse-lab 实验 01、03、11)

03

后台 merge 把小 part 合大,分区是 merge 的边界

分区 20260310 里刚写进来的 6 个 part(层级 0,块号 0 到 5)

按排序键归并成一个大 part,层级加 1(例如 20260310_0_5_1)

被合掉的 part 标成非活动,默认 8 分钟后删文件;一次最多合 100 个,合到压缩后约 150 GiB 就不再往上合。合并要花 CPU 和磁盘 IO,和查询、写入抢资源。

改数据同样以 part 为单位。mutation 给涉及的 part 出一个新版本:Wide part 只重写改到的列,Compact part 整个重写;键列不能改,没有 UPSERT。

merge 只在同一个分区里做

分区键按结算日,一天一个。ATTACH、REPLACE、DROP PARTITION 都以分区为单位,本地盘上只挂硬链接;文档建议分区别超过约一千个。

merge 跟不上,INSERT 先被拖慢、再被拒

单分区活跃 part 到 1000(parts_to_delay_insert)开始拖慢,到 3000(parts_to_throw_insert)报 Too many parts。lab 把阈值压到 20 和 25:先被拖慢 5 次,第 26 条才被拒。中间那段就是预警窗口。

折叠和聚合都只在 merge 时发生

ReplacingMergeTree 把排序键相同的行只留一行,AggregatingMergeTree 把中间状态合成一行,都是合并时才做。没合并到的重复,普通查询照样读得到。

基本原理 · 出处:ClickHouse 的写入路径(clickhouse-lab 实验 01、08、11)

04

三副本多主异步复制,协调全靠 Keeper

Keeper

log/ 复制日志 · blocks/ 去重标记 · block_numbers/ 块号

ch1 · 收到 INSERT 的副本

写好 part,在 log/ 里记一条;part_log 记 NewPart。默认在这里提交就返回。

ch2

把日志拉进自己的 queue,执行 GET_PART 把 part 拉过来;part_log 记 DownloadPart。

ch3

同 ch2。MERGE_PARTS 各自在本地合,REPLACE_RANGE 是换分区。

三个副本各自照着 Keeper 里的复制日志执行(虚线:只传元数据)

ch2、ch3 各自从 ch1 经 9009 端口拉 part(实线:传数据)

INSERT 在收到它的那个副本上提交就返回(insert_quorum 默认 0),ALTER 默认只等当前副本做完(alter_sync = 1)。三个副本之间会短暂不一致。

Aiven 的连接随机落到任一节点。所以核对行数、看复制队列、确认 mutation 做完,一律用 clusterAllReplicas 问遍三个副本。

基本原理 · 出处:ClickHouse 的写入路径(clickhouse-lab 实验 03、12)

05

查明细时按四层跳过数据,再只读用到的列

分区:条件里带分区键或 _partition_id,别的日分区整个跳过

part 的最值:settle_ms 范围对不上的 part 整个跳过

稀疏主键:每 8192 行一条,只读命中的 granule

跳数索引:minmax 和 bloom_filter 再筛一遍

只读用到的列,整块解压后按列计算

命中的 granule 怎么在文件里找到:每列一个 mark 文件,记着这个 granule 在哪个压缩块、块里的什么位置。位置不放进主键索引,索引才小到能常驻内存,用不到的列也不用读它的 mark。

这张表 PRIMARY KEY (settle_ms, id) 只取了排序键的前缀,rev 只影响同一个 (settle_ms, id) 内部的顺序。

344,064 行

lab 实验 14:一千万行、14 个 part 的表,按排序键查 50 秒的数据,命中 6 个 part × 7 个 granule,一千万多行里只读了这些。

主键对排序键的第一列最有效。这张表以毫秒时间戳 settle_ms 开头,按时间范围查最省;只按 id 单查,前一列基数太高排除不掉 granule,要靠 bloom filter,或者带上时间范围。

跳数索引只在值不散在每一段里时有效。id 每行不同,bloom filter 跳得动;tenant_id、currency_id 这类低基数、分布偏斜的维度列,大租户几乎每段都出现,很难跳过什么。

查得好不好怎么看:EXPLAIN indexes = 1 逐层列出裁掉多少;query_log 里 read_rows 和 result_rows 的比值,读了几百万行只返回几行就是前面几层没起作用。

基本原理 · 出处:ClickHouse 的查询路径和 FINAL 的代价(clickhouse-lab 实验 14)

06

sink 每次 poll 按 Kafka 分区各发一条 INSERT,至少一次投递

Kafka topic,8 个分区

P0

P1

P2

P3

P4

P5

P6

P7

一次 poll(最多 500 条)把记录按 topic-partition 分组

最多 8 条 INSERT,每条带一个 token:insert_deduplication_token = topic-partition-minOffset-maxOffset。同步插入,一条十几到几十行。

ClickHouse:连接随机落到一个副本;服务端按 token(没有 token 时按块哈希)做块级去重,认出来的重复块直接跳过。

B 档一天约 2.05 亿行,峰值每秒约 1 万条;offset 默认每 60 秒提交一次。

超时和重投怎么发生(按 v1.3.9 源码和 Kafka 3.7 的 WorkerSinkTask)

INSERT 外面没有重试循环。插件的 retryCount 只管 ping 和 query;写超时被包成 RetriableException 交给框架,由框架重投。

30 秒是 clickhouse-java V1 client 的默认 socket_timeout,不是插件的 timeoutSeconds(那只管 ping)。想改它,配置里唯一能碰到的键是 jdbcConnectionProperties。

重投要等到下一个 offset 提交点,最长 offset.flush.interval.ms = 60 秒。所以一轮失败写出的两份,间隔落在 (30, 90] 秒;现有集群见过的 124 秒是两轮。

重投的是内存里保留的同一批:记录、顺序、边界、token 都和第一次一样。能不能被认出来,全看服务端的去重窗口还记不记得它。

Kafka sink · 出处:ClickHouse 里的重复行来自 Kafka Connect 超时重投

07

重复行的四个入口,以及块级去重窗口为什么拦不住

入口怎么产生见过的情形sink 的 exactlyOnce 挡得住吗
producer 重试请求其实写进了 broker,应答在抖动里丢了,重试又写一份;topic 里是两条 offset 不同的记录现有集群:一个 45 分钟窗口多出一百多行,每个键正好两份挡不住:对 connector 来说都是新数据
sink 超时重投INSERT 过了 30 秒 client 超时,但服务端已经提交;框架把同一批原样再交一次现有集群:234 行,写入间隔 32 到 124 秒挡不住:状态停在 BEFORE_PROCESSING,原样重插,仍靠去重窗口
task 重启重放重启前没来得及提交 offset,接替的 task 从上一个提交点重读,批次边界会变lab 实验 21 造过挡得住;但默认配置下崩溃重启常停在 State MISMATCH
人工补发发送超时的记录先存进重试表,事后补发时其中一部分其实早已写进去补发前先按键查了 ClickHouse,没补出新的重复挡不住:补发前按键查

1000 个块

25.3 默认的 replicated_deduplication_window:Keeper 只记最近 1000 个插入块的 token 或哈希,按块数算,不按时间

124 块/s

B 档三副本合计的建块速率,从 part_log 里 error = 0 的 NewPart 数出来;C 档约 40 块/s

8 秒 < 32–124 秒

1000 ÷ 124 ≈ 8 秒就把那批挤出窗口,而重投最快也要 32 秒才来。token 和内容都没变,输在记不住

Kafka sink · 出处:重复行的四个入口和 sink exactlyOnce 的覆盖范围;ClickHouse 的块级去重窗口

08

去重方案:接受至少一次,存储层按业务键折叠

方案 · lab 验过,未上线

层做法sink 超时重投task 重启重放producer 重试
入口块级去重窗口调大(B 档 2 万、C 档 1 万,按建块速率 × 最长重投间隔算);核实 producer 幂等窗口够大就拦得住拦不住,token 变了幂等只防会话内重试,超时后应用再发的防不住
存储加查询明细表换成 ReplicatedReplacingMergeTree(create_time),键不变 (settle_ms, id, rev),读带 FINAL 或 profile 设 final = 1能能能
派生数据sink 的写入带 deduplicate_blocks_in_dependent_materialized_views = 1;报表按天对账、从明细 FINAL 重算物化视图去重能拦只能靠重算只能靠重算
修复已经落库的重复,用快照加 REPLACE PARTITION 的 runbook 清事后清事后清事后清

为什么不开 sink 的 exactlyOnce

lab 用真的 Connect 测过:它挡得住批次边界变了的重放,挡不住同一批原样重投;打开后崩溃重启默认停在 State MISMATCH,只开 errors.tolerance=all 不配 DLQ 还会丢一段。根子是 ClickHouse 不能把 offset 和数据放进同一个事务提交。

要业务拍板的一件事

报表金额只能做到最终一致:窗口外的重复会先让报表多算,等对账、重算之后才改回来。要求任何时刻都不能多算,就得走写入前去重(OpenMeter 那种自写消费端加 Redis 去重),这个方案没有设计这一条。

Kafka sink · 出处:ClickHouse 明细表的去重方案(clickhouse-lab 实验 14、21、23、24、25、27)

09

backoffice 明细查询:怎么写才走索引,FINAL 要付多少

SELECT id, settle_ms, amount, status

FROM events FINAL

WHERE _partition_id = '20260310'

AND settle_ms >= {from_ms}

AND settle_ms < {to_ms}

AND tenant_id = {tenant}

ORDER BY settle_ms LIMIT 200

示意写法。FINAL 只在明细表换成 ReplacingMergeTree 之后才需要;现有集群的表还是 ReplicatedMergeTree。

1. _partition_id 把日分区写死,settle_ms 范围是排序键首列,主键索引靠它在这一天里跳 granule;去掉它就要把整个日分区扫一遍。

2. 只按 id 查单笔,靠 id 上的 bloom filter;最好让调用方同时带上结算时间范围。tenant_id 这类低基数条件基本跳不掉数据。

3. 大范围反复扫的报表类查询,改读增量聚合好的报表表,别每次对明细带 FINAL 现算。

FINAL 的代价(lab 实验 14:一千万行窄表混进 50 万行重投,停掉 merge,M2 Pro)

查询不带 FINAL带 FINAL
count()1 ms,读 1 行12 ms,读 1080 万行
按排序键查一个范围4 ms,读 34.4 万行9 ms,读 49.2 万行

和查询里手写去重比(重叠集中在前 10 万个键的那张表)

去重方式耗时内存
FINAL27 ms17.13 MiB
手写 GROUP BY169 ms1.01 GiB
手写 LIMIT 1 BY1287 ms1.15 GiB

FINAL 只归并互相重叠的键区间,代价跟着重叠范围走;按时间排序、只重投最近数据的表,重叠天然集中,落在便宜的一头。绝对耗时带不到现有集群,倍数关系也要在两三亿行的宽表上再测。

Kafka sink · 出处:ClickHouse 的查询路径和 FINAL 的代价(clickhouse-lab 实验 14)

10

报表:物化视图在插入时把明细聚合进半小时桶

方案 · lab 验过,未上线

明细表

ReplicatedReplacingMergeTree(create_time),按结算日分区,键不变。

物化视图

插入时的触发器:只看这一次新插入的块,看不到之后的合并、mutation 和删除。代价和写入量成正比。

报表表

ReplicatedAggregatingMergeTree;UTC 30 分钟桶 × 维度,按天分区,和明细的日分区一一对应,修一天只换一个分区。

查询时按时区上卷

toDate(bucket, 'Asia/Shanghai'):日报、周报、月报都从 30 分钟桶现算,读时一律带 GROUP BY 再聚合一遍。

同一张上海时区的日报,三天全读(lab)

3,275 行 · 2 ms · 4.04 MiB

读报表表:行数 = 桶数 × 当天的维度组合数,不跟着明细涨

1,175,832 行 · 18 ms · 24.62 MiB

从明细 FINAL 现算:读整天的明细

指标怎么存:可加的用 SimpleAggregateFunction(sum, …),去重用户数写 uniqExactState、读 uniqExactMerge;不用近似的 uniq,对账口径修不回来。

桶按业务时间 settle_ms 切、按 UTC 切,不按 create_time:重复的几份落在同一个桶里,明细的一天正好对应报表的一个分区。明细和报表必须按同一个时区切天,每套部署先查 SELECT timezone()。

时区实测(实验 25):+8:00、+5:30、−5:00 连夏令时那天都逐天一致;+5:45 的加德满都日界落在桶中间,对不上,要 15 分钟桶,报表行数翻倍。

报表 · 出处:用物化视图做 ClickHouse 交易报表(clickhouse-lab 实验 24、25)

11

报表会多算的地方,靠对账和重算兜底

方案 · lab 验过,未上线

实验 25:三天各 30 万行,逐种注入后看报表偏多少

注入的情形明细 FINAL报表lab 上偏了多少
sink 超时重投:同一批同一 token,窗口还记得对对不偏
task 重启重放:边界和 token 都变了对多算700 行,金额 360491.71
producer 重试:Kafka 里存了两份对多算和下一行合计 130 行,金额 64191.68
改数据:按 Replacing 惯例重发一条新版本新值新旧两份都算同上
人工补发的正常迟到数据对对不偏

明细这边按键折叠之后是对的,报表上的偏差只能靠重算改回来;对明细 OPTIMIZE … FINAL 也救不了。

1. 每天对账。报表按桶聚合,和明细 FINAL 按同样的桶现算,FULL JOIN 逐桶比,只输出偏了的天。lab 上准确找出了被注入的那两天,补发过正常迟到数据的那天不在其中。覆盖最近两周,人工补发会写进十几天前的分区。

2. 重算一天。建临时表;同一会话里 SYNC REPLICA 后从明细 FINAL 重算这一天(max_threads = 2);查 part_log 过闸,t0 之后这一天在任一副本上不能有新 part;REPLACE PARTITION 带 alter_sync = 2;换完再过一次闸。

3. 闸不能省。lab 上临时表写好之后迟到 50 行,不过闸直接换,报表反而少了 50 行、金额少 23939.59;过闸拦下、重来一次,前后两批 100 行都在。

4. 窗口内的重投不多算。所有写入一律带 deduplicate_blocks_in_dependent_materialized_views = 1(25.3 默认 0,26.2 起默认 1);报表表的窗口不能比明细表小;25.3 上不能和 async_insert 一起开。

报表 · 出处:ClickHouse 报表的对账和重算(clickhouse-lab 实验 24、25、26)

12

写入变慢或被拒:先看 Keeper 和 part 数

情形表现(lab 实验 11、12)怎么认、怎么处理
Keeper 停掉副本立刻转只读,读照常;INSERT 按默认参数重试约 143 秒后报 Code 242 TABLE_IS_READ_ONLY;Keeper 回来 2 到 12 秒内自愈,被拒的那几条不会补写。写入全停但数据不坏。242 在 connector 可重试的错误码里,这批留在内存等下一个提交点重投,表现是消费组 lag 上涨。
Keeper 卡住约 10 秒后才转只读;客户端 30 秒超时断开;Keeper 回来后服务端把那条 INSERT 照样提交,query_log 里是正常的 QueryFinish。更麻烦的一种:客户端以为失败、服务端其实成功,框架接着重投就是重复行的起点。判断一批写没写进去以 part_log 的 NewPart 为准。
part 过多单分区活跃 part 到 1000 开始拖慢(DelayedInserts 计数上涨,lab 里 INSERT 中位耗时 7 ms 变 311 ms),到 3000 报 Code 252 Too many parts;平均 part 超过 1 GiB 的分区不受这道闸管。盯 part 数,不盯错误率。常见起因是停了 merge 的迁移实验、一条大 INSERT … SELECT 一次切出几百个 part;merge 放回去、part 合掉,写入马上恢复。
并发查询到上限Aiven 在 profile 里锁了每个节点的并发查询数(A、B、C 档分别 100、375、200),INSERT 也算在内,超出报 Code 202 TOO_MANY_SIMULTANEOUS_QUERIES。查询里改设置绕不过去。重算、大范围聚合这类查询限 max_threads,别和 sink 抢;202 同样在可重试列表里,不丢数据。

读一直是好的,只看查询成功率发现不了 Keeper 出事。把超时调长只能让超时少一些:Keeper 卡得比新的超时还久,服务端照样晚到提交、框架照样重投,下游没有按键幂等那一层,重复还是会出现。

运维 · 出处:ClickHouse 的 Keeper 故障和 part 过多(clickhouse-lab 实验 11、12)

13

平时盯什么:前兆在 Keeper、复制队列和写入耗时尾部

项查法(多副本一律走 clusterAllReplicas)线
单分区活跃 part 数system.parts 按库、表、分区数 active 的 part到 1000 的一半就处理
Keeper 在途请求system.metrics 的 ZooKeeperRequest(现有集群的 Prometheus 上叫 clickhouse_metrics_zoo_keeper_request)现有集群的基线是 5 到 15,涨到几百倍就是停摆的前兆
复制队列和延迟system.replicas 的 queue_size、absolute_delay持续高于基线
INSERT 耗时的尾部connector 侧的插入耗时指标,以及 query_log 里 INSERT 的分位数到 24 秒(30 秒超时的八成);另一个 region 已经到 24.4 秒
副本只读system.replicas.is_readonly,告警用窗口内的最大值(max_over_time)窗口内最大值为 1;瞬时采样会漏掉只闪一下的只读

写出重复行的那 3 小时:吞吐看着正常,readonly_replica 瞬时值是 0,只在 30 分钟窗的 max_over_time 里闪了两下;Keeper 在途请求从常态的 5 到 15 涨到 3,415、6,256、2,157、1,748。前一天持续变慢 11 小时、吞吐掉到常态的 7%,反而一行重复都没有。

先做一件事:part_log 和 query_log 现在只留约 4 天,事故之后能查的证据很快就没了。把这两张表的保留期拉长,是所有排查能继续下去的前提。

运维 · 出处:ClickHouse 的 Keeper 故障和 part 过多;重复行来自 Kafka Connect 超时重投

14

数据少了、数字对不上:按链路顺序查

少了:不报错,往往很久之后才发现

1. 先对上游和 Kafka 的量,对不上就是 producer 或 broker 的事。

2. 看 connector 的错误处理配置,坏批次去了哪(下表)。

3. 看 committed offset 和 log-end offset 的差:一直涨是写不进去,不动是卡住,越过去了就去 DLQ 或源 topic 找。

4. INSERT 真写进去没有,以 part_log 里 error = 0 的 NewPart 为准;query_log 有两个盲区:晚到提交的可能没有结束记录,被去重拦下的照样记 QueryFinish。

5. 可能写进了相邻分区:按 _partition_id 在前后几天反查。

不对:行数对得上,金额或人数差一截,全程不报错

1. 两边比的是不是同一条时间轴:事件时间还是写入时间,按哪个时区切天。UTC 16 点到 24 点的时刻,在 UTC 和上海两种列声明下会落进相邻两天的分区。

2. JOIN 有没有放大:右表键不唯一,INNER 和 LEFT 都翻倍;ANY 留哪一行由 join_any_take_last_row 定,要确定性就自己 LIMIT 1 BY。

3. 去重计数是不是 uniq:25.3 上误差两个方向都有,对账只用 uniqExact。

4. 上游改了字段名:schemaless JSON 不校验,那一列静默变成默认值,按天数 countIf(列 = 0)。

5. 读 ReplacingMergeTree 带没带 FINAL;6. 报表和明细 FINAL 逐桶对一遍。

connector 的配置(lab 实验 09)一条坏记录进来之后少了的数据在哪
errors.tolerance=none(默认)task 约 2 秒后 FAILED,它管的分区不再前进,重启还是停在这条上都还在 Kafka 里,信号是 task 状态和消费组 lag
errors.tolerance=all 加 DLQtask 不停;坏记录所在分区的那一整批连同好记录一起进 DLQDLQ topic,补数按整批重放
errors.tolerance=all 不配 DLQtask 不停;那一整批静默消失,offset 照常往前提交只剩源 topic 里那一份,task 和 lag 都看不出来,最危险

运维 · 出处:ClickHouse 数据少了的排查顺序;数字对不上的排查顺序(clickhouse-lab 实验 09、10)

15

修数据:以分区为单位整体替换,执行前能验、执行后能回滚

已经落库的重复行:REPLACE PARTITION runbook,R0 到 R8

  • R0 只读检查:三副本当天行数相等、复制队列为空、没有未完成的 mutation
  • R1 用 ATTACH PARTITION … FROM 做快照,硬链接,写入 0 行
  • R2、R3 从快照找重复键、重建去重后的分区;哈希闸包一层 tuple
  • R4 换分区前一刻再数每个副本;R5 REPLACE PARTITION 带 alter_sync = 2
  • R6 事后查 part_log:error = 0,上界卡在换分区时刻
  • R7 回滚是从快照反向再换一次;快照表留到下游确认后再删

一列写错了 18 天:列级 mutation,带 IN PARTITION

  • ALTER TABLE … UPDATE 列 = 正确值 IN PARTITION … WHERE 谓词,逐分区提交
  • 856 GiB 的 18 个分区只重写 11 GiB:Wide part 只重写改到的列,其余硬链接;成本从 system.parts_columns 算,不要估
  • 忘写 IN PARTITION 不报错,但 810 个分区、对象存储上 17.41 TiB 全进队列
  • 回滚按身份不按状态:补数前先把 id 名单存进表,字面量拼进语句
  • 验收用 clusterAllReplicas,三个副本都 is_done = 1 才算完

换表引擎:ATTACH 挂历史,EXCHANGE TABLES 切换

  • 照旧表建新表(含索引和搬迁 TTL),新表 STOP MERGES,行数闸靠它
  • sink 不停,一个分区一个分区 ATTACH PARTITION … FROM,硬链接
  • 暂停 sink,SYNC REPLICA … LIGHTWEIGHT 排空,按 part_log 找被写过的分区 REPLACE 补齐
  • 逐副本、逐分区比行数过闸,EXCHANGE TABLES,恢复 sink,START MERGES
  • lab 上 sink 只停了 0.34 到 0.50 秒;回滚是同样的路反过来

不要这样做:轻量删除不带 IN PARTITION(全表每个 part 都出新版本,两千多个 part 要把窗口读一千遍左右);OPTIMIZE … FINAL DEDUPLICATE(留哪行由合并定,没有回滚);多表 RENAME 当原子切换(不原子,用 EXCHANGE);不排空就切换(正在跑的 INSERT 绑定的是旧表)。

运维 · 出处:REPLACE PARTITION runbook;用只读权限评审补数方案;在线换引擎(clickhouse-lab 实验 08、18、19、20、27)

16

两条冷门但致命:误删没有后悔药,ZooKeeper 计数器会用完

误删表和分区(lab 实验 07、13)

删法上游 25.3 默认Aiven 上的现有集群
DETACH PARTITIONATTACH 回来,可逆同左,不算删除
DROP PARTITION只能靠事先 FREEZE 的备份只有备份
DROP TABLE 不加 SYNC480 秒内能 UNDROP宽限期是 0,没有 UNDROP
DROP TABLE … SYNC救不回来救不回来
删超过 50 GB 的表或分区报 Code 359 被拦下大小保险是 0,不拦

Replicated 库里一条 DROP 删掉三个副本。备份由 Aiven 做,还原是从备份 fork 出一个新服务再切连接,上一次备份之后的数据靠上游重发。所以删之前的确认比删之后的补救重要:runbook 里的工作表、换引擎后的旧表,留到对账和下游确认之后再删,删之前再核一次表名和大小。

ZooKeeper 顺序节点计数器溢出(lab 实验 28)

复制表每次 INSERT(每个分区一个 part)和每次 merge,都在 log/ 下建一个顺序节点;序号来自父节点上的 int32 计数器,删日志不减。按每秒 100 个序号约 248 天用完,每秒 1000 个不到 25 天,小批量高频写入的表最先到顶。

到 2147483647 之后:INSERT 全报 Code 244 UNEXPECTED_ZOOKEEPER_ERROR,Node exists,path 指向 log/log-;merge 分配不出来;读照常;换副本、重启副本都没用。

怎么确认:system.replicas 的 log_max_index 停在 2147483647。zkCli 里的 cversion 是「计数器 × 2 − 子节点数」,过了 2 的 30 次方会显示成负数,别直接读。

恢复只用 SQL:照原表建新表(路径带 {uuid},新路径的 log/ 从 0 起)→ ATTACH PARTITION … FROM 逐分区挂过去 → 各副本核行数 → EXCHANGE TABLES → DROP 坏表 SYNC。到顶期间失败的 INSERT 要由写入方重写。

预防:攒批或 async_insert 减少写入次数(lab 上 20 次并发同步 INSERT 涨 21,async_insert 只涨 1);把 log_max_index 加进巡检。

运维 · 出处:ClickHouse 误删表和分区之后的恢复;ZooKeeper 顺序节点计数器溢出导致复制表无法写入

17

带走的五条

1

part 写完不再改,没有 UPSERT。改数据按 part 出新版本,修数据按分区整体替换:执行前能验、执行后能回滚。

2

这条链路只能做到至少一次。入口的去重窗口是尽力而为,正确性靠存储层按业务键折叠、读带 FINAL(方案)。

3

物化视图是插入触发器,看不到合并。报表只能最终一致,要能按桶对账、能按天重算,重算要过 part_log 的闸。

4

三副本多主异步。核对一律 clusterAllReplicas 问遍三个副本,换分区带 alter_sync = 2,一批写没写进去以 part_log 为准。

5

前兆在 Keeper 在途请求、复制队列和 INSERT 耗时尾部,不在错误率。part_log 和 query_log 只留 4 天,先把保留期拉长。

现状:ReplicatedMergeTree 明细表,托管 sink 至少一次写入,聚合表按 create_time 的窗口增量累加、不按键去重。

方案(lab 验过,还没在现有集群上执行):换成 ReplacingMergeTree 加 FINAL、调大去重窗口、物化视图加对账重算、清理和换引擎的 runbook。

小结

18

参考资料

博客 ClickHouse 系列,按架构篇末尾的阅读顺序

存和查

ClickHouse 交易明细系统的整体架构 · ClickHouse 的写入路径 · ClickHouse 的查询路径和 FINAL 的代价

报表

用物化视图做 ClickHouse 交易报表 · ClickHouse 报表的对账和重算

重复行,从哪来、怎么防

ClickHouse 里的重复行来自 Kafka Connect 超时重投 · ClickHouse 的块级去重窗口 · ClickHouse 重复行的四个入口和 sink exactlyOnce 的覆盖范围 · ClickHouse 明细表的去重方案 · 在线把明细表换成 ReplicatedReplacingMergeTree

 

重复行,已经写进去了

清理 ClickHouse 重复行的 REPLACE PARTITION runbook · 清理 ClickHouse 重复行时行数校验拦不住的情形 · ClickHouse 轻量删除清重复行的代价 · 用只读权限评审 ClickHouse 补数方案

其他异常

ClickHouse 数据少了的排查顺序 · ClickHouse 数字对不上的排查顺序 · ClickHouse 的 Keeper 故障和 part 过多 · ClickHouse 误删表和分区之后的恢复 · ZooKeeper 顺序节点计数器溢出导致 ClickHouse 复制表无法写入

clickhouse-lab(github.com/meirongdev/clickhouse-lab):ClickHouse 25.3.14.14 三副本加 Kafka 3.7.0 和 connector v1.3.9 的本地实验台,experiments/ 下的脚本各自带断言,results/ 是实跑输出;本 deck 里标「实验 NN」的数字都出自这里。它能验机制,验不了真实量级、对象存储和托管层的行为。

一手资料:ClickHouse 团队 2024 年 VLDB 论文「ClickHouse - Lightning Fast Analytics for Everyone」(存储第 3 章、查询第 4 章)· 官方文档 MergeTree、Replication、Deduplicating inserts on retries、Incremental materialized view、ALTER … PARTITION · ClickHouse Kafka Connect Sink 文档与 v1.3.9 源码、Kafka 3.7 的 WorkerSinkTask · Aiven for ClickHouse 的服务架构、tiered storage、system tables 文档。

参考

19