清理 ClickHouse 重复行时行数校验拦不住的情形
目录
这次的重复行来自生产端重试(入口分析在四个入口那篇)。表还是这个系列一直用的那张按 settle_time 日分区的 ReplicatedMergeTree,下文仍叫它 events。一个一千多万行的日分区里,45 分钟的窗口多出一百多行,每个键正好两份。已经逐列核过的那部分重复,两份除 create_time 外完全相同,大约三分之二连 create_time 也一样,是同一秒写进来的。
准备的清理写法和 REPLACE PARTITION runbook 一样是临时表加 REPLACE PARTITION,整个日分区照样重写一遍,区别在出事的那段窗口:窗口里的行用 GROUP BY 加 argMin 逐列重建,窗口前后两段原样拷贝。我在本地三副本集群(25.3.14.14,生产是 25.3.14.1)上把每一条 SQL 原样跑了一遍,又补了几组实验去验它没覆盖到的情形。这套写法在生产上还没执行,下面的结论来自 lab 和官方文档,生产侧只用到了表结构、行数这些事先拿到的元数据。
窗口重建加换分区的写法#
表名换成了 events,列只留了几个,其余照原样:
CREATE TABLE events_tmp LIKE events;
-- 窗口里的行按键 GROUP BY,每一列取 create_time 最早的那一份
INSERT INTO events_tmp
SELECT id, settle_time, version,
min(create_time) AS create_time,
argMin(amount, create_time) AS amount,
argMin(status, create_time) AS status,
argMin(external_id, create_time) AS external_id
-- ……其余每一列都写成 argMin(列, create_time) AS 列
FROM events
WHERE settle_time BETWEEN {窗口起点} AND {窗口终点}
GROUP BY id, settle_time, version;
-- 窗口前后两段原样拷回
INSERT INTO events_tmp SELECT * FROM events
WHERE settle_time >= {当天零点} AND settle_time < {窗口起点};
INSERT INTO events_tmp SELECT * FROM events
WHERE settle_time > {窗口终点} AND settle_time < {次日零点};
-- 换分区之前两条校验:临时表窗口内重复数为 0;原表当天计数 - 临时表总数 = 多余行数
ALTER TABLE events REPLACE PARTITION '20260918' FROM events_tmp;
DROP TABLE events_tmp;
跑下来的问题后果一类比一类隐蔽:两处语法错误让它根本跑不起来;修掉之后,按位置对列能让窗口整段写错,校验照过;argMin 碰到 NULL 会拼出原来没有的行;换分区的时机和只看一个节点的核对,会让行丢掉,校验同样照过。下面按这个顺序一节一节说。
LIKE 和同名别名让前两步执行不了#
第一句在 25.3 上直接报错,ClickHouse 不认 MySQL 的 CREATE TABLE … LIKE:
Code: 62. DB::Exception: Syntax error: failed at position 25 (LIKE): LIKE events. Expected one of: token sequence, Dot, token, UUID, TO INNER UUID, ON, OpeningRoundBracket, storage definition, ENGINE, …
改成 CREATE TABLE events_tmp AS events 就能建,前提是原表的 Keeper 路径里带 {uuid},runbook 那篇写过怎么查。
第二步的 INSERT 报 184:
Code: 184. DB::Exception: Aggregate function min(create_time) AS create_time is found inside another aggregate function in query. (ILLEGAL_AGGREGATION) (version 25.3.14.14 (official build))
min(create_time) AS create_time 这个别名盖住了同名列,后面每个 argMin(x, create_time) 引用到的都是 min(create_time),成了聚合套聚合。prefer_column_name_to_alias 默认是 0,新旧两个 analyzer 都报这个错。两处都在解析阶段就停了,不会写入任何数据。下面几节给第二步加上 SETTINGS prefer_column_name_to_alias = 1,让它继续跑下去。
INSERT SELECT 按位置对列,行数校验拦不住#
INSERT … SELECT 不看别名,文档的原话是「Columns are mapped according to their position in the SELECT clause」,必要时还会做类型转换。手写的那串 argMin(…) AS 列 只要和表的物理列序有一处不一致,值就会落进别的列。lab 里的当天分区是十万行(每 864 毫秒一行,窗口里 3,125 行,占比和生产一样在 3% 左右),另外混进 18 份重复,然后 INSERT 语句不变,只改表的列序:
| 表的列序和 SELECT 列表比 | 两条校验 | 换完之后 |
|---|---|---|
| 一致 | 0 / 18 | 十万行,没有重复,每一行都能在原分区里找到 |
两个同为 String 的列顺序相反 |
0 / 18 | 行数对,窗口里 3,125 行全部两列互换 |
version 排在 settle_time 前面 |
0 / 18 | 窗口里 3,125 行全部消失 |
第二行坏的不止 18 份重复:GROUP BY 把窗口里每一行都重建了一遍,列序一错,整个窗口都错。第三行更隐蔽,UInt64 的毫秒时间戳按位置塞进 UInt16 的 version,不报错,直接截成低 16 位,settle_time 拿到的是 0,窗口里的行于是全部落进临时表的 19700101 分区。两条校验照样是 0 和 18,因为窗口里已经没有行可数,临时表的总行数也没少,只是分区错了。REPLACE PARTITION '20260918' 换进去的只有窗口前后两段,窗口整段从生产表里消失,要到换完之后再数一次分区行数才看得出来。
这两条校验都只数行数,内容错了看不见。更稳的写法是不手写列:没有重复的行用 SELECT * 原样拷,重复的键用 ORDER BY create_time LIMIT 1 BY id, settle_time, version 留最早那份,也就是 runbook 那两条 INSERT。整行原样拷的前提下,校验看三样就够:新分区行数等于原分区减去多余行数,原分区的键一个不少,新分区没有重复键。
lab 里我另外拿换分区之前的快照逐行对过,要求新分区每一行都能在快照里找到一模一样的,这是用来验写法本身的。写这种对照要知道 ClickHouse 的 EXCEPT ALL 不做多重集相减,A 里只要有一行和 B 里某行相等,这一组就整组去掉。lab 里 {x, x, y} EXCEPT ALL {x, y} 返回 0 行,拿它数「少了哪几份」是数不出来的。
argMin 跳过 NULL,哈希校验要包一层 tuple#
文档写明 argMin 的 arg 和 min 两部分都会跳过 NULL。两份副本里如果早的那份某个 Nullable 列是 NULL、晚的那份不是,argMin 就会拼出一行原来没有的数据,lab 里得到的是第一份的 create_time 配第二份的 memo。
runbook 的前置校验「每组重复的业务列哈希数必须是 1」本来能先拦下这种情况,但那篇里的写法 cityHash64(amount, status, …) 碰到 NULL 会漏:cityHash64(…, NULL) 的结果是 NULL,uniqExact 再把 NULL 跳过,两种内容只数出一种。写成 cityHash64(tuple(…)) 才数出 2。这次核过的那部分重复除 create_time 外逐列相同,不会出这个问题;换一份数据复用这段 SQL 时就不一定了。
换分区的时机:迟到写入、落后副本、只看一个节点#
runbook 列过「分区必须是静止的」和「换分区之前先确认三个副本同步」两条前置校验,其中静止那条当时只验了通过的时候不挡路。这次在 lab 里把三种失败情形造了出来,都对着上面那两条校验和单节点上的核对跑:
| 情形 | 两条校验和单节点核对 | 实际结果 |
|---|---|---|
| 临时表建好之后、换分区之前,经另一个节点往当天写进 50 行 | 全部通过 | 50 行被换分区抹掉,同时写进后一天的 20 行不受影响 |
| 建临时表时连到的副本少一个 part(停掉它的拉取,30 行只在另外两台上) | 都在这个副本上算,全部通过 | 换完之后三个副本都少了这 30 行 |
换完之后有一个副本还没执行 REPLACE_RANGE,临时表已经删掉 |
当前节点的 system.parts 行数和 system.replication_queue 都正常 |
连到落后那台的查询还能看到 18 个重复键;放开之后它从别的副本拉到新 part,追平了 |
第一种在生产上真实存在,这个分区在事故十几天后还被人工补发写进过几千行。第二种的根源是 INSERT … SELECT 只读当前节点上的数据,而连接落到哪个节点由负载均衡决定。第三种说明核对要问遍所有副本:system.parts 和 system.replication_queue 都是节点本地的表,换成 clusterAllReplicas 才能看到 ch3 上还挂着一条 REPLACE_RANGE。
快照、换分区前的计数和 part_log 上界#
改过的流程在 lab 里整套跑过,和 runbook 的差别集中在快照、换分区前一刻的计数和事后检查这几条语句上:
-- 快照:先记下时刻,再把原分区挂一份到快照表,之后从快照建临时表,不再读线上表
SELECT now64(6) AS t_snapshot;
CREATE TABLE events_bak_20260918 AS events;
ALTER TABLE events_bak_20260918 ATTACH PARTITION ID '20260918' FROM events;
-- ……按 runbook 的两条 INSERT,从 events_bak_20260918 建 events_dedup_20260918,过完校验……
-- 换分区前一刻再数一次线上当天行数,必须仍等于快照的行数,紧接着换
SELECT count() FROM events WHERE _partition_id = '20260918';
SELECT now64(6) AS t_swap;
ALTER TABLE events REPLACE PARTITION ID '20260918' FROM events_dedup_20260918;
-- 事后:快照之后、换分区之前,这个分区有没有新写入(每个副本都查)
SELECT sum(rows)
FROM clusterAllReplicas('default', system.part_log)
WHERE database = currentDatabase() AND table = 'events'
AND partition_id = '20260918' AND event_type = 'NewPart'
AND event_time_microseconds >= {t_snapshot}
AND event_time_microseconds < {t_swap};
-- 回滚:从快照反向再换一次
ALTER TABLE events REPLACE PARTITION ID '20260918' FROM events_bak_20260918;
快照靠的是 ATTACH PARTITION … FROM。分区操作文档只说它把分区从一张表拷到另一张、两边都不删数据,runbook 也记着 REPLACE 底层复不复用文件没实测过。这次看了 part 目录下的列文件:快照表和原表是同一个 inode,这条语句写入 0 行;REPLACE PARTITION 换进来的 part 和临时表的也是同一个 inode,同样写入 0 行,数据是之前那条建临时表的 INSERT 写的;回滚换回来,原表又指回快照那组文件。至少在本地盘上,两条语句都是挂硬链接,对象存储那一层仍然没验。快照建的那一刻不占额外空间,换分区之后原表不再引用旧文件,这块空间就由快照表留着,删掉快照表才释放。
换分区前一刻那次计数,拦下了快照之后经别的节点写进来的 40 行。计数和 REPLACE 之间那一瞬写进来的 25 行谁也拦不住,part_log 那条查询事后把它报了出来。写这条查询要注意上界:REPLACE_RANGE 换进来的 part 在 part_log 里同样记成 NewPart,哪怕那个副本是从别处拉来的(上面第三种情形里,ch3 在临时表已删的情况下追平,记的仍是 3 个 NewPart、合计十万行),查询不卡在换分区时刻之前,就会把换进来的整个分区也算成新写入。普通 INSERT 的块被其他副本拉走时不记 NewPart(块级去重窗口那篇按这一点算建块速率),REPLACE_RANGE 是另一回事。回滚救不回这 25 行,快照里同样没有它们,只能让写入源按键重发。执行窗口里先停掉对这个分区的补发,比事后补救省事。
lab 规模下,换分区那一条只花几十毫秒、写入 0 行,重建那两条 INSERT 写了整整十万行。生产一千多万行时,时间主要花在重建上。
删完明细,汇总表还要重算#
这条链路的下游有几张 AggregatingMergeTree 汇总表,由一个定时作业按 create_time 一段一段地取出新写入的明细,按半小时的时间桶 SUM 和 COUNT 之后写进去,过程中不按键去掉重复。副本一落地,就在它写入的那个窗口里被算进了汇总;之后从明细表把它删掉,汇总表不会跟着变。这和补数评审那篇里「物化视图看不到 mutation」是同一个道理,只是这里的下游是定时作业,不是物化视图。
换成 ReplacingMergeTree 也救不了这一层,副本在合并之前就已经被增量作业读走、算进汇总了。所以每次清理之后都要重算受影响的时间桶,桶按被删那些行的时间列来定位,汇总表有几套时间口径就要分别算。以前清理时的做法是先把重算结果用新的批次号插进去,和明细对账一致之后,再按时间窗删掉旧结果。顺序上清理在前、重算在后,同期如果还有因为补发要做的重算,合成一次做。
小结#
- 用
argMin逐列重建窗口的写法前两步在 25.3 上执行不了(LIKE、同名别名)。修掉之后,按位置对列能让整个窗口写错或消失,两条只数行数的校验照样通过。用SELECT *整行拷、重复键LIMIT 1 BY,校验看行数、键覆盖和重复键。 argMin跳过 NULL,会拼出原来没有的行;拦它的哈希校验要写成cityHash64(tuple(…)),否则 NULL 让校验漏报。- 换分区之前做快照(
ATTACH PARTITION … FROM,本地盘上是硬链接),从快照建临时表;换分区前一刻再数一次,换完用clusterAllReplicas核对每个副本,再查 part_log,上界卡在换分区时刻;回滚是从快照反向再换一次。计数和换分区之间那一瞬的写入只能事后发现,执行窗口里要停掉补发。 - 删完明细要重算受影响的汇总时间桶,按写入时间增量累加的汇总不会自己变回来。
相关文章#
这几篇都在同一个 Aiven 托管的 ClickHouse 集群上。(一)是一次补数方案评审,其余六篇是重复行:(二)(三)(五)讲重复从哪来、为什么没被挡住,(四)(六)(七)讲怎么清。
- 用只读权限评审 ClickHouse 补数方案 — 同一个集群上的 mutation 评审:列级重写的成本怎么算、回滚为什么要按 id 名单而不是按状态
- ClickHouse 里的重复行来自 Kafka Connect 超时重投 — 重复行怎么产生的:30 秒超时是谁的默认值、框架什么时候把同一批再投一次
- ClickHouse 的块级去重窗口 — 服务端那层为什么没兜住:窗口按块数算,这张表的建块速率折合 8 秒
- 清理 ClickHouse 重复行的 REPLACE PARTITION runbook — 怎么低成本数出有多少、怎么清掉:临时表加 REPLACE PARTITION,以及跑之前要验的四件事
- ClickHouse 重复行的四个入口和 sink exactlyOnce 的覆盖范围 — 生产端重试、超时重投、重启重放、人工补发,以及
exactlyOnce各挡住哪个 - 清理 ClickHouse 重复行时行数校验拦不住的情形(本篇)
- ClickHouse 轻量删除清重复行的代价 — 带子查询的 DELETE 在复制表上按 part 数重复执行,
IN PARTITION和同秒两份的问题
参考资料#
- INSERT INTO —
INSERT … SELECT按位置对列,必要时做类型转换 - argMin —
arg和min两部分都跳过 NULL - ALTER TABLE … PARTITION —
ATTACH PARTITION FROM拷贝分区、两边都不删数据;REPLACE PARTITION的原子性和两张表要满足的条件 - cluster / clusterAllReplicas — 一条查询问遍所有副本