ClickHouse 轻量删除清重复行的代价
目录
清理这次的重复行(来源见四个入口那篇),除了重写整个分区再换回去(行数校验拦不住的情形),另一条路是轻量删除:按 _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 集群上。(一)是一次补数方案评审,其余六篇是重复行:(二)(三)(五)讲重复从哪来、为什么没被挡住,(四)(六)(七)讲怎么清。
- 用只读权限评审 ClickHouse 补数方案 — 同一个集群上的 mutation 评审:列级重写的成本怎么算、回滚为什么要按 id 名单而不是按状态
- ClickHouse 里的重复行来自 Kafka Connect 超时重投 — 重复行怎么产生的:30 秒超时是谁的默认值、框架什么时候把同一批再投一次
- ClickHouse 的块级去重窗口 — 服务端那层为什么没兜住:窗口按块数算,这张表的建块速率折合 8 秒
- 清理 ClickHouse 重复行的 REPLACE PARTITION runbook — 怎么低成本数出有多少、怎么清掉:临时表加 REPLACE PARTITION,以及跑之前要验的四件事
- ClickHouse 重复行的四个入口和 sink exactlyOnce 的覆盖范围 — 生产端重试、超时重投、重启重放、人工补发,以及
exactlyOnce各挡住哪个 - 清理 ClickHouse 重复行时行数校验拦不住的情形 — 按位置对列、argMin 跳过 NULL、迟到写入和落后副本,以及快照和 part_log 怎么补上
- ClickHouse 轻量删除清重复行的代价(本篇)
参考资料#
- Lightweight DELETE —
_row_exists掩码 - ALTER TABLE … DELETE —
IN PARTITION,以及ReplicatedMergeTree上optimize_mutations_with_partition_pruning的自动裁分区