ClickHouse 轻量删除清重复行的代价
目录
清理这次的重复行(来源见四个入口那篇),除了重写整个分区再换回去(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删,执行前还得确认三个副本已经同步,执行后也没有回滚。能在换分区之前把结果整份验一遍、坏了能回滚的分区重写,这次仍然更稳。
参考资料#
- Lightweight DELETE —
_row_exists掩码 - DELETE:How lightweight DELETEs work internally — 每个 part 先跑一次
SELECT count()判断是否命中 - ALTER TABLE … DELETE —
IN PARTITION,以及较新版本里ReplicatedMergeTree上optimize_mutations_with_partition_pruning的自动裁分区 - Settings.cpp @ v26.9.15.5-stable —
optimize_mutations_with_partition_pruning的定义,25.3 和 26.8 LTS 的源码里没有 - How we built fast UPDATEs for the ClickHouse column store – Part 2 — 轻量删除在内部是一次只写
_row_exists的 mutation - ClickHouse - Lightning Fast Analytics for Everyone(VLDB 2024,官方文档里的网页版) — 3.4 节:mutation 和轻量删除各自的代价
- clickhouse-lab 实验 18 — 输出在
results/18-lightweight-delete-by-offset.log