清理 ClickHouse 重复行时行数校验拦不住的情形
目录
这次的重复行来自 producer 重试(入口分析在四个入口那篇)。表还是这个系列一直用的那张按结算日分区的 ReplicatedMergeTree,下文叫它 events,排序键 (settle_ms, id, rev)。一个一千多万行的日分区里,45 分钟的窗口多出一百多行,每个键正好两份。已经逐列核过的那部分重复,两份除 create_time 外完全相同,大约三分之二连 create_time 也一样,是同一秒写进来的。
准备交给 SRE 执行的清理方案,和 REPLACE PARTITION runbook 一样是临时表加 REPLACE PARTITION,整个日分区照样重写一遍,区别在出事的那段窗口:窗口里的行用 GROUP BY 加 argMin 逐列重建,窗口前后两段原样拷贝。方案还没在 Aiven 上的现有集群里执行,评审时在 clickhouse-lab 的三副本集群(25.3.14.14,现有集群是 25.3.14.1)上把每一条 SQL 原样跑了一遍(实验 16),又补了几组实验去造它没覆盖到的情形(实验 17 到 19)。现有集群这一侧只用到了表结构、行数这些事先拿到的元数据。
这篇写的是这次评审跑出来的问题;按这些结论改过的 runbook 是上面链的那一篇,下文提到的 R0、R4、R6 这些步骤编号都出自那里。
窗口重建加换分区的写法#
表名换成了 events,列只留了几个,其余照原样:
CREATE TABLE events_tmp LIKE events;
-- 窗口里的行按键 GROUP BY,每一列取 create_time 最早的那一份
INSERT INTO events_tmp
SELECT id, settle_ms, rev,
min(create_time) AS create_time,
argMin(amount, create_time) AS amount,
argMin(status, create_time) AS status,
argMin(ext_id, create_time) AS ext_id
-- ……其余每一列都写成 argMin(列, create_time) AS 列
FROM events
WHERE settle_ms BETWEEN {窗口起点} AND {窗口终点}
GROUP BY id, settle_ms, rev;
-- 窗口前后两段原样拷回
INSERT INTO events_tmp SELECT * FROM events
WHERE settle_ms >= {当天零点} AND settle_ms < {窗口起点};
INSERT INTO events_tmp SELECT * FROM events
WHERE settle_ms > {窗口终点} AND settle_ms < {次日零点};
-- 换分区之前两条校验:临时表窗口内重复数为 0;原表当天计数 - 临时表总数 = 多余行数
ALTER TABLE events REPLACE PARTITION '20260918' FROM events_tmp;
DROP TABLE events_tmp;
方案里还有一个被否掉的「方法一」:用轻量删除按 _part、_part_offset 删掉每组里排名靠后的那份。否掉的理由要改,它在 lab 上的代价单独写在轻量删除那篇。
跑下来的问题一类比一类隐蔽:两处语法错误让它根本跑不起来;修掉之后,按位置对列能让窗口整段写错,校验照过;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;按 lab 评审文档的记录,新旧两个 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 行全部两列互换 |
rev 排在 settle_ms 前面 |
0 / 18 | 窗口里 3,125 行全部消失 |
第二行坏的不止 18 份重复:GROUP BY 把窗口里每一行都重建了一遍,列序一错,整个窗口都错。第三行更隐蔽:lab 表里 rev 是 UInt16,UInt64 的毫秒时间戳按位置塞进去,不报错,直接截成低 16 位,settle_ms 拿到的是 0,窗口里的行于是全部落进临时表的 19700101 分区。两条校验照样是 0 和 18,因为窗口里已经没有行可数,临时表的总行数也没少,只是分区错了。REPLACE PARTITION '20260918' 换进去的只有窗口前后两段,窗口整段从原表里消失,要到换完之后再数一次分区行数才看得出来。
这两条校验都只数行数,内容错了看不见。更稳的写法是不手写列:没有重复的行用 SELECT * 原样拷,重复的键用 ORDER BY create_time LIMIT 1 BY id, settle_ms, rev 留最早那份,也就是 runbook 里 R3 那两条 INSERT。整行原样拷的前提下,校验看三样就够:新分区行数等于原分区减去多余行数,原分区的键一个不少,新分区没有重复键。
argMin 跳过 NULL,哈希校验要包一层 tuple#
文档写明 argMin 的 arg 和 min 两部分都会跳过 NULL。两份重复里如果早的那份某个 Nullable 列是 NULL、晚的那份不是,argMin 就会拼出一行原来没有的数据,lab 里得到的是第一份的 create_time 配第二份的 memo。
runbook 的 R2 有一道闸「每组重复的业务列哈希数必须是 1」,本来能先拦下这种情况,但写成 cityHash64(amount, status, …) 碰到 NULL 会漏:cityHash64(…, NULL) 的结果是 NULL,uniqExact 再把 NULL 跳过,两种内容只数出一种。写成 cityHash64(tuple(…)) 才数出 2。这次核过的那部分重复除 create_time 外逐列相同,不会出这个问题;换一份数据复用这段 SQL 时就不一定了。
换分区的时机和落点:迟到写入、落后副本、只看一个节点、临时表没复制完#
方案只在换分区之前做两条校验,换完再在当前节点上核对一次。实验 17 把四种失败情形造了出来,都对着方案自己的校验和核对跑:
| 情形 | 方案的校验和核对 | 实际结果 |
|---|---|---|
| 临时表建好之后、换分区之前,经另一个节点往当天写进 50 行 | 全部通过 | 50 行被换分区抹掉,同时写进后一天的 20 行不受影响 |
| 建临时表时连到的副本少一个 part(停掉它的拉取,30 行只在另外两台上) | 都在这个副本上算,全部通过 | 换完之后三个副本都少了这 30 行 |
换完之后有一个副本还没执行 REPLACE_RANGE,临时表已经删掉 |
当前节点的 system.parts 行数和 system.replication_queue 都正常 |
连到落后那台的查询还能看到 18 个重复键;放开之后它从别的副本拉到新 part,追平了 |
往临时表的 INSERT 落在 ch1,REPLACE PARTITION 落在还没拉到临时表 part 的 ch3 |
校验在 ch1 上跑,原表减临时表正好 18,通过 | 三个副本上的当天分区都变成 0 行,前后两天不受影响 |
第一种在现有集群上真实存在,这个分区在事故十几天后还被人工补发写进过几千行。第二种的根源是 INSERT … SELECT 只读当前节点上的数据,而连接落到哪个节点由负载均衡决定;REPLACE_RANGE 换掉的是这个分区里块号在范围内的全部 part,正常副本上的那 30 行也一起被换掉。第三种说明核对要问遍所有副本:system.parts 和 system.replication_queue 都是节点本地的表,REPLACE PARTITION 默认(alter_sync = 1)只等发起它的副本做完。
第四种是负载均衡把相邻两条语句分到了不同节点。往临时表的 INSERT 默认 insert_quorum = 0,在 ch1 上提交就返回,ch2、ch3 稍后自己去拉;实验里先停掉 ch3 对临时表的拉取,换分区前三个副本上临时表的行数是 100000,100000,0。紧接着的 REPLACE PARTITION 在 ch3 上执行,拿的是 ch3 本地那个空的临时表分区,语句照样成功,写进复制日志的 REPLACE_RANGE 让三个副本都把这一天换成了空分区,当天的十万行全部丢失。
每种情形由哪一道闸拦下#
改过的 runbook 先用 ATTACH PARTITION … FROM 给当天做快照,之后只从快照建临时表,原表再有写入也不会混进重建的数据。剩下的问题落在两处:快照和原表之间有没有差,以及临时表、快照表是不是已经复制到了每个副本。实验 19 把这几种情形一种一种注进去,看哪一道闸拦下:
| 注入的情形 | 拦下它的闸 | lab 结果 |
|---|---|---|
| 有副本落后 30 行 | R0:建快照之前,三个副本的当天行数必须相等,当天分区的复制队列为空 | 拦下 |
| 快照之后、R4 之前经别的节点写进 40 行 | R4:换分区前一刻,原表在三个副本上的当天行数都必须仍等于快照 | 拦下 |
| 快照之后写进 20 行,还没复制到执行节点 | R4,前提是数全部副本 | 只数执行节点的旧 R4 放行,换完之后这 20 行在三个副本上全没了;数全部副本的 R4 拦下,R6 事后也报出了这 20 行 |
| R4 之后、换分区之前写进 25 行 | 拦不住,R6 事后查 part_log 报出来 |
25 行被抹掉,R6 报出 25 行 |
| 临时表还没复制到 ch3(停掉 ch3 对它的拉取) | R3 往临时表写时带 insert_quorum = 3;漏写了还有 R4 数临时表在三个副本上的行数 |
R3 报 Code: 319 停下;漏写时 R4 读到临时表 100000,100000,0,拦下 |
第三行是 R4 最早那个版本的洞:执行 runbook 的 ch1 停掉拉取,补发任务经 ch2 往当天写进 20 行,ch2、ch3 有、ch1 还没有。只数 ch1 的 R4 读到的仍是快照行数,放行了;数全部副本的 R4 读到 100018,100038,100038,队列里还有 1 条。REPLACE PARTITION 换掉的是这个分区里块号在范围内的全部 part,执行节点自己有没有拉到都一样,所以换分区前一刻那一次计数必须数全部副本,只数执行节点会放行还没复制过来的写入。
最后一行对应实验 17 的第四种情形。R4 原先只数原表,不核对临时表有没有复制到每个副本;现在原表、临时表、快照表三张表在三个副本上各数一遍,写工作表时带 insert_quorum = 3,三个副本都写进去才返回。复制队列那一条也收窄到当天分区,并排除后台 merge 和 mutation:实验 19 里让后一天的分区挂着一个 MERGE_PARTS,按整张表数队列就不空,R4 会被这个和当天无关的 merge 拦住。
R6 查 part_log 时上界要卡在换分区时刻之前,证据就是上面表格里第三种情形:ch3 在临时表已删的情况下追平,记的是 3 个 NewPart、合计十万行,换进来的 part 也记成 NewPart。这条查询的写法和另一个必加的条件 error = 0,在 runbook 那篇的 R6。
明细清完之后,下游按 create_time 窗口增量累加的汇总表不会跟着变,受影响的时间桶要按被删那些行的 create_time 定位后重算;换成 ReplacingMergeTree 也救不了这一层,重复的那份在合并之前就已经被算进汇总了。报表改成物化视图之后的对账和重算写在报表的对账和重算,那条链路还没部署到现有集群。
小结#
- 用
argMin逐列重建窗口的写法,前两步在 25.3 上执行不了(LIKE、同名别名)。修掉之后,按位置对列能让整个窗口写错或消失,两条只数行数的校验照样通过。改用SELECT *整行拷、重复键LIMIT 1 BY,校验看行数、键覆盖和重复键。 argMin跳过 NULL,会拼出原来没有的行;拦它的哈希校验要写成cityHash64(tuple(…)),否则 NULL 让校验漏报。- 迟到写入、落后副本、只看一个节点的核对,三种情形都会丢行而校验照过;换分区落到临时表还没复制到的副本上,三个副本的整天数据一起变成 0 行。改法是先做快照、从快照建临时表,写工作表带
insert_quorum = 3,换分区前一刻把原表、临时表、快照表在每个副本上各数一遍(只数执行节点会放行还没复制过来的写入),事后查part_log(带error = 0、上界卡在换分区时刻)。
参考资料#
- INSERT INTO —
INSERT … SELECT按位置对列,必要时做类型转换 - argMin —
arg和min两部分都跳过 NULL - ALTER TABLE … PARTITION —
ATTACH PARTITION FROM拷贝分区、两边都不删数据;REPLACE PARTITION的原子性和两张表要满足的条件 - cluster / clusterAllReplicas — 一条查询问遍所有副本
- clickhouse-lab — 实验 16(方案原文逐条跑)、17(时机和多副本)、19(改过的 runbook 整套跑)
相关文章#
- ClickHouse 交易明细系统的整体架构:系列总览和阅读顺序
- 清理 ClickHouse 重复行的 REPLACE PARTITION runbook:按这些结论改过的 R0 到 R8
- ClickHouse 轻量删除清重复行的代价:方案里被否掉的方法一