清理这次的重复行(来源见四个入口那篇),除了重写整个分区再换回去(行数校验拦不住的情形),另一条路是轻量删除:按 _part 和 _part_offset 删掉每组里排名靠后的那份。

DELETE FROM events WHERE (_part, _part_offset) IN (
  SELECT _part, _part_offset FROM (
    SELECT _part, _part_offset,
           row_number() OVER (PARTITION BY id, settle_time, version
                              ORDER BY create_time, _part, _part_offset) AS rk
    FROM events
    WHERE settle_time BETWEEN {窗口起点} AND {窗口终点})
  WHERE rk > 1);

这条路常被直接否掉,理由是行数大,DELETE 会吃满 CPU 和磁盘 IO。这次的分区一千多万行,多余的一百多行集中在 45 分钟里。REPLACE PARTITION runbook 里写过「一次性清几百行用 lightweight delete 更便宜」,这篇在本地三副本集群(25.3.14.14)上把这个说法验了一遍。

带子查询的 DELETE 在复制表上被拒#

上面那条在复制表上原样执行,直接被拒:

Code: 36. DB::Exception: ALTER UPDATE/ALTER DELETE statement with subquery may be nondeterministic, see allow_nondeterministic_mutations setting. (BAD_ARGUMENTS) (version 25.3.14.14 (official build))

带子查询的 mutation 由每个副本各自执行子查询,结果不保证一致,所以默认不让跑,补数方案那篇里写过这一层。

放开之后,子查询按 part 数重复执行#

放开 allow_nondeterministic_mutations 之后结果是对的,三个副本都只剩每个键最早那份,但它读多少行取决于全表有多少个 part,和要删的行数无关。lab 里把窗口放大到十万行,看每个节点 system.events 里 SelectedRows 的增量:

写法 全表 part 数 读窗口的遍数 被改了一版的 part
放开 allow_nondeterministic_mutations 62 34.4 62,全部
同上 122 61.2 122,全部
外层再加 settle_time 的范围条件 122 61.0 122,范围条件不裁 part
加 IN PARTITION ID '20260918' 120 多 1.0 2,只有当天的

读窗口的遍数大约是全表 part 数的一半,子查询跟着 part 一起重复执行。生产那张表有两千多个 part,按这个比例推算,照原样放开的话每个副本要把窗口读一千遍左右,再把全表每个 part 都克隆一版。「吃满 CPU」的担心有道理,原因在 part 数。加上 IN PARTITION 之后它只读一遍窗口、只改当天的 part,反而是几种做法里最省的。

第三行和现在的 ALTER DELETE 文档对不上。文档写的是 ReplicatedMergeTree 上 optimize_mutations_with_partition_pruning 默认开启,会从条件里认出分区键、只改涉及的分区;25.3.14.14 上 settle_time 的范围条件一个 part 也没裁掉。这个设置从哪个版本开始有、25.3 上为什么没生效,还没查清 [need manual confirm]。在弄清之前,IN PARTITION 照写。

字面量写法:不加 IN PARTITION 照样改全表,同秒两份一起删#

不带子查询的写法也躲不开这一条。先把键和那份副本的 create_time 查出来、再贴成字面量去删,是确定性的,不需要放开那个设置,但不加 IN PARTITION 照样改全表:lab 里当时 124 个 part,124 个都被改了一版,其中 120 个在别的分区。

这种写法还有一个更直接的问题:两份 create_time 相同时,「删 create_time 较晚的那份」会把两份一起删掉,lab 里删完这个键一行不剩。create_time 精度到秒时,同一秒写进来的两份很常见,这次核过的那部分重复里大约三分之二就是这样,只能靠 _part_offset 或者整分区重写来分开。

system.parts 的 rows 不扣掉被删的行#

轻量删除只是把 _row_exists 标成 0(文档),system.parts 的 rows 仍然算着这些行,lab 里是 100,003 对 count() 的 100,000。用这种方法时核对要用 count()。

小结#

  • 带子查询的轻量删除在复制表上默认被拒;放开 allow_nondeterministic_mutations 之后,子查询跟着全表 part 数重复执行,全表每个 part 都被改一版。
  • 加上 IN PARTITION 它只读一遍窗口、只改当天的 part,是几种做法里最省的;字面量写法同样要加。
  • 两份 create_time 相同时,按 create_time 删会两份一起删掉,只能按 _part_offset 删,也就绕不开放开非确定性 mutation。
  • system.parts.rows 不扣掉被删的行,核对用 count()。
  • runbook 那句「一次性清几百行用 lightweight delete 更便宜」要补两个前提:带 IN PARTITION,以及两份副本分得开。这次有三分之二的副本靠 create_time 分不开,剩下的路只有放开非确定性 mutation 再按 _part_offset 删,执行前还得确认副本同步。能在换分区之前把结果整份验一遍的分区重写,这次仍然更稳。

相关文章#

这几篇都在同一个 Aiven 托管的 ClickHouse 集群上。(一)是一次补数方案评审,其余六篇是重复行:(二)(三)(五)讲重复从哪来、为什么没被挡住,(四)(六)(七)讲怎么清。

  1. 用只读权限评审 ClickHouse 补数方案 — 同一个集群上的 mutation 评审:列级重写的成本怎么算、回滚为什么要按 id 名单而不是按状态
  2. ClickHouse 里的重复行来自 Kafka Connect 超时重投 — 重复行怎么产生的:30 秒超时是谁的默认值、框架什么时候把同一批再投一次
  3. ClickHouse 的块级去重窗口 — 服务端那层为什么没兜住:窗口按块数算,这张表的建块速率折合 8 秒
  4. 清理 ClickHouse 重复行的 REPLACE PARTITION runbook — 怎么低成本数出有多少、怎么清掉:临时表加 REPLACE PARTITION,以及跑之前要验的四件事
  5. ClickHouse 重复行的四个入口和 sink exactlyOnce 的覆盖范围 — 生产端重试、超时重投、重启重放、人工补发,以及 exactlyOnce 各挡住哪个
  6. 清理 ClickHouse 重复行时行数校验拦不住的情形 — 按位置对列、argMin 跳过 NULL、迟到写入和落后副本,以及快照和 part_log 怎么补上
  7. ClickHouse 轻量删除清重复行的代价(本篇)

参考资料#