清理这次的重复行(来源见四个入口那篇),除了重写整个分区再换回去(REPLACE PARTITION runbook),另一条路是轻量删除:按 _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_ms, rev
                              ORDER BY create_time, _part, _part_offset) AS rk
    FROM events
    WHERE settle_ms BETWEEN {窗口起点} AND {窗口终点}
      AND (id, settle_ms, rev) IN (
        SELECT id, settle_ms, rev FROM events
        WHERE settle_ms BETWEEN {窗口起点} AND {窗口终点}
        GROUP BY id, settle_ms, rev HAVING count() > 1))
  WHERE rk > 1);

否掉它的理由是表一天两亿行,直接 DELETE 会吃满 CPU 和磁盘 IO。这次的分区其实是一千多万行,多余的一百多行集中在 45 分钟里;runbook 最早的版本里还写过「一次性清几百行用 lightweight delete 更便宜」。两种说法都在本地三副本集群(clickhouse-lab 实验 18,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 里把窗口放大到十万行,看 ch2 上 system.events 里 SelectedRows 的增量,折算成「子查询执行了几次」:子查询的两层各把窗口读一遍算一次,也就是读的行数除以两倍的窗口行数。

写法 全表 part 数 子查询执行次数 出了新版本的 part
放开 allow_nondeterministic_mutations 62 34.4 62,全部,其中 60 个在别的分区
同上,再补 60 个日分区 123 67.9 123,全部,其中 120 个在别的分区
外层再加 settle_ms 的范围条件 124 67.6 124,范围条件不裁 part
加 IN PARTITION ID '20260324'(当天 2 个 part) 120 多 1.6 2,只有当天的

轻量删除在内部是一次 mutation:DELETE 被改写成 ALTER TABLE … UPDATE _row_exists = 0(How we built fast UPDATEs – Part 2)。和别的 mutation 一样,它按 part 执行,对每个 part 都要先跑一次 SELECT count() … WHERE 条件,判断这个 part 有没有被命中(DELETE 文档),条件里的子查询跟着重跑。lab 里执行次数约为全表 part 数的 0.55 倍,part 从 62 个增加到 123 个时,从 34.4 次涨到 67.9 次,这个系数没再往下拆。别的分区那些 part 全都没命中,出的新版本是硬链接克隆,写盘 0 字节,但目录、元数据、Keeper 里的 mutation 记录一样都不少。现在那张明细表有两千多个 part,按这个比例推算,照原样放开的话每个副本要把窗口读一千遍左右,再给全表每个 part 都出一个新版本。方案担心的「吃满 CPU」有道理,只是代价跟着全表的 part 数涨,和一天写多少行关系不大。

加上 IN PARTITION 之后,别的分区直接跳过,执行次数只跟当天分区的 part 数走(当天 part 多了照样放大,执行前先看一眼当天的活跃 part 数),只改当天的 part。这时它反而是几种做法里最省的:只写 _row_exists 一列,其余列硬链接。

第三行和当前版本的 ALTER DELETE 文档对不上,文档描述的是 26.9 起的行为。文档写的是 ReplicatedMergeTree 上 optimize_mutations_with_partition_pruning 默认开启,会从条件里认出分区键、只改涉及的分区。这个设置在 25.3 里不存在,26.8 LTS 的源码里也还没有,26.9 才加进来(Settings.cpp @ v26.9.15.5-stable),所以在 25.3 上只能靠 IN PARTITION 限定范围。

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

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

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

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

轻量删除只是把 _row_exists 标成 0(文档),system.parts 的 rows 仍然算着这些行,lab 里 count() 是 100,000,sum(rows) 是 100,006。用这种方法时核对要用 count()。被删的行要等之后的 merge 才真正清掉,在那之前每次查询都要多过滤这一列,所以 SELECT 会变慢(ClickHouse 团队 2024 年发表的论文,3.4 节)。

小结#

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

参考资料#

相关文章#