轻量删除更新与变更语义

深入 ClickHouse 的数据变更语义:Mutation 的重量级实现与异步执行、Lightweight DELETE/UPDATE 的 _row_exists 掩码机制、删除标记的可见性与合并时机、幂等与并发语义、ReplacingMergeTree 去重替代方案,以及在高频更新场景下的选型与落地建议。

1. ClickHouse 的更新困境

要理解 ClickHouse 的删除与更新,必须先接受一个前提:MergeTree 的 part 是不可变的(immutable)。一次 INSERT 生成一批 part,此后 part 内的数据就固定了;所谓「删除」与「更新」,本质上都是在后台重写 part 或者用额外的标记位屏蔽旧行。这与行式数据库靠页内原地更新 + undo log 的模型完全不同。

这种设计换来的是极致的写入与扫描性能:没有原地更新,就没有页分裂、没有行锁、没有 undo 日志。代价是变更操作天生昂贵。ClickHouse 为此提供了两套机制:

机制实现方式触发时机开销
Mutation后台重写整个 part异步,排队执行高
Lightweight Delete/Update写 _row_exists 掩码语句返回即生效低

选错机制的后果很直接:把高频删除写成 ALTER TABLE ... DELETE,会让后台 mutation 队列积压,拖垮整个实例的合并能力。

1.1 与行式数据库的对比

维度MySQL/PostgreSQLClickHouse
更新粒度行级原地更新part 级重写 / 掩码
删除成本标记 + 后台清理重写 part 或掩码过滤
事务性完整 ACID有限(原子性以 part 为单位)
并发更新行锁 / MVCC无行锁,靠 part 隔离

ClickHouse 并不打算做 OLTP。它的删除更新能力是为数据治理服务的(GDPR 删除、TTL、数据修正),而不是为高频业务写入服务。若业务需要频繁按主键更新,应重新考虑是否该用 ClickHouse,或改用 数据库 MVCC 并发控制 更成熟的行式数据库。

2. Mutation:重量级变更

2.1 ALTER TABLE DELETE / UPDATE

-- 删除符合条件的行
ALTER TABLE events DELETE WHERE user_id = 10086;

-- 更新符合条件的行
ALTER TABLE events UPDATE value = value * 1.1 WHERE event_type = 'purchase';

这两条语句立即返回,但真正的重写是异步的。ClickHouse 会把变更记录为一次 mutation,后台逐 part 重写:读取每个 part、过滤或改写、写回新 part、替换旧 part。

2.2 观察 mutation 进度

SELECT
    database,
    table,
    mutation_id,
    command,
    create_time,
    is_done,
    parts_to_do,
    latest_fail_reason
FROM system.mutations
WHERE is_done = 0
ORDER BY create_time;

关键字段:

  • parts_to_do:还有多少 part 待重写,为 0 时 mutation 接近完成;
  • is_done:是否全部完成;
  • latest_fail_reason:失败原因(常见于内存不足或磁盘空间不足)。

一个积压的 mutation 队列是生产事故的常见前兆。监控 system.mutations 中 is_done = 0 的数量与 parts_to_do 总量,比监控查询耗时更能提前发现风险。

2.3 停止与取消

KILL MUTATION WHERE mutation_id = 'mutation_123.txt';

KILL MUTATION 会尝试停止尚未完成的 mutation。已经重写完的 part 不会回滚,所以取消后数据可能处于「部分行已删除、部分未删除」的中间状态。这也说明 mutation 不具备事务语义,业务侧不能依赖它的原子性。

2.4 为什么 mutation 慢

mutation 慢的根源是它触发了全量 part 重写:即使只删除一行,也要重写包含这一行的整个 part(可能上百万行)。写入放大叠加到后台合并压力上,就会形成「mutation 越积越多、合并越来越慢」的恶性循环。这正是 Lightweight Delete 诞生的动机。

3. Lightweight DELETE

3.1 语法与生效时机

从 23.3 版本起,ClickHouse 支持轻量删除:

DELETE FROM events WHERE user_id = 10086;

注意这与 ALTER TABLE ... DELETE 是两条不同的路径:前者走轻量删除,后者走 mutation。轻量删除不重写 part,而是为每个 part 生成一个 _row_exists 掩码列:

  • _row_exists = 1:行可见;
  • _row_exists = 0:行被删除(仍物理存在,只是查询时被过滤)。

语句返回时删除就逻辑生效了——后续查询立即看不到被删的行。

3.2 掩码的存储与代价

_row_exists 是一个 UInt8 掩码列,存储开销极小(每行 1 字节,压缩后更低)。它的代价体现在:

  • 每个受影响的 part 都要写一次掩码,产生额外的小 part 或元数据更新;
  • 查询时所有扫描都要额外读掩码并过滤,轻微增加 CPU;
  • 只有当 part 被后台合并时,被删行才会真正物理清除。

因此轻量删除是「逻辑即时、物理延迟」。若删除后长期不合并,被删数据会一直占着磁盘。可以用 OPTIMIZE TABLE ... FINAL 强制合并来物理清理,但代价高,建议让后台合并自然完成。

3.3 与 mutation 的对照

维度Lightweight DELETEALTER TABLE DELETE
返回时生效立即(逻辑)否(异步)
part 重写否是
物理空间释放延迟到合并mutation 完成即释放
对合并的压力低高
适用频率高频低频、批量

结论:能轻量就轻量。只有需要「立即物理回收空间」或「跨分区批量清理」时,才用 mutation。

3.4 掩码的物理布局

要理解轻量删除的性能特征,可以看它写进 part 目录的额外文件:

202610_1_1_0/
├── _row_exists.bin        # UInt8 掩码列
├── _row_exists.mrk2       # 掩码列的标记文件
├── event_time.bin
└── ...

_row_exists 与普通列一样参与压缩与索引。查询时,读算子会在扫描每个 granule 时先读掩码、跳过全为 0 的块(这正是它的优化点:整块被删的行可以整块跳过,而不是逐行判断)。因此,被删除的行越集中(连续),轻量删除的查询收益越大;随机散布的删除则几乎无法整块跳过,代价接近全扫描。

这也是为什么轻量删除适合「按租户/按天批量删」这类删除条件与排序键相关的场景,而不适合「随机删几个 id」的场景。

3.5 观察轻量删除的效果

SELECT
    name,
    formatReadableSize(sum(bytes_on_disk)) AS size,
    sum(rows)                              AS rows
FROM system.parts
WHERE table = 'events' AND active
GROUP BY name
ORDER BY rows DESC
LIMIT 10;

对比删除前后同一 part 的 rows:轻量删除后 rows 可能不变(掩码是独立列),直到合并发生才真正减少。想确认有多少行被掩码屏蔽,可以查询:

SELECT count() FROM events WHERE NOT _row_exists;

(_row_exists 是虚拟列,仅在表存在掩码时可查询。)

4. Lightweight UPDATE

从 24.x 版本起,ClickHouse 引入了轻量更新(UPDATE ... SET ... WHERE ...,即不带 ALTER TABLE 前缀的写法),机制上同样依赖掩码:把被更新的行标记为删除,同时插入一份新行。

UPDATE events SET value = value * 1.1 WHERE event_type = 'purchase';

4.1 限制与注意事项

  • 轻量更新并非所有版本默认可用,需确认版本并在设置中开启;
  • 它的语义是「删旧插新」,因此会增加行数(旧行以掩码隐藏,新行追加);
  • 更新频繁时,同一主键会积累多份历史版本,需要配合 ReplacingMergeTree 或定期 OPTIMIZE 收敛;
  • 不保证跨 part 的原子性。
-- 查看当前设置
SELECT name, value FROM system.settings
WHERE name LIKE '%lightweight%';

4.2 何时该用 ReplacingMergeTree 替代

如果业务模式是「按主键 upsert」,那么用 Lightweight UPDATE 只是权宜之计。更符合 ClickHouse 哲学的方案是 ReplacingMergeTree:

CREATE TABLE users_state
(
    user_id UInt64,
    name String,
    version UInt64,
    updated_at DateTime
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;

相同 user_id 的多行在后台合并时保留 version 最大的一行。查询时若要立刻拿到去重结果,用 FINAL 或 argMax:

-- 方式一:FINAL(简单但慢)
SELECT * FROM users_state FINAL WHERE user_id = 10086;

-- 方式二:argMax(推荐,可下推)
SELECT
    user_id,
    argMax(name, version)       AS name,
    argMax(updated_at, version) AS updated_at
FROM users_state
WHERE user_id = 10086
GROUP BY user_id;

ReplacingMergeTree 的合并机制在 /clickhouse-merge-tree-principle/ 中有完整推导,这里只强调一点:它把「更新」转化成了「追加 + 合并去重」,天然适配不可变 part 模型,是 ClickHouse 里最正统的更新方案。

5. 变更语义与可见性

5.1 删除的可见性时序

一次轻量删除的完整生命周期:

  1. DELETE FROM ... 执行,写入掩码;
  2. 后续查询立即过滤掉被删行(逻辑删除生效);
  3. 后台合并该 part 时,物理丢弃被删行,掩码列消失;
  4. 若在合并前又对该 part 做了 mutation,mutation 也会尊重掩码。

理解这个时序有助于解释一个常见困惑:「为什么删了数据磁盘没变小」——因为物理回收要等合并。

5.2 幂等与重复执行

ALTER TABLE ... DELETE 与轻量 DELETE 都幂等:重复执行同一条删除不会出错,也不会重复扣减。但要注意:

  • mutation 的 mutation_id 由语句内容哈希生成,相同语句不会重复入队;
  • 轻量删除重复执行会重复写掩码,属于无害的冗余写。

5.3 复制环境下的传播

在 ReplicatedMergeTree 上,mutation 与轻量删除都会通过 ClickHouse Keeper 传播到所有副本。因此:

  • 在一个副本上发起删除,其它副本会同步执行;
  • 若某副本离线,恢复后会补做未完成的 mutation;
  • 跨机房双活时要注意删除指令的传播延迟,相关容灾细节见 /clickhouse-replicated-tables-disaster-recovery/。

5.4 并发变更的顺序保证

多个 mutation 同时提交时,ClickHouse 按 mutation_id(由语句内容生成)的字典序执行,并保证同一 part 上的 mutation 串行。这意味着:

  • 对同一张表连续提交两条 mutation,它们会依次应用到每个 part;
  • 顺序与提交时间不一定一致(取决于 id 排序),若业务有严格先后依赖,应通过表名/分区隔离或应用层串行化;
  • 轻量删除与 mutation 可以共存,但合并时要同时尊重掩码与 mutation 结果。

5.5 一次典型变更的代价实测

用一个具体例子量化三种删除方式的开销(1000 万行、单 part):

方式语句返回耗时后台耗时空间回收
DROP PARTITIONALTER TABLE ... DROP PARTITION< 1s0立即
Lightweight DELETEDELETE FROM ... WHERE ...< 1s合并时延迟
Mutation DELETEALTER TABLE ... DELETE WHERE ...< 1s数十秒~分钟mutation 完成

三者的返回耗时几乎一样(都是异步登记),差别全在后台。因此判断「用哪个」不能看语句快慢,而要看后台代价与空间回收需求。

6. 高频变更的替代架构

如果业务的更新/删除频率高到 ClickHouse 原生机制扛不住,通常有两条出路。

6.1 版本化 + 视图收敛

把源表设计成「只追加」,用版本列区分新旧,查询侧统一走一个去重视图:

CREATE VIEW events_current AS
SELECT
    id,
    argMax(payload, version) AS payload,
    max(version)             AS version
FROM events_append
WHERE is_deleted = 0
GROUP BY id;

删除变成写入一行 is_deleted = 1。这样写入永远是顺序追加,没有任何 mutation 或掩码开销。

6.2 冷热分层 + 分区级清理

对于「按时间过期」的场景,不要用 DELETE,用分区 TTL 或直接 DROP PARTITION:

-- 删除整月分区(瞬间完成,无重写)
ALTER TABLE events DROP PARTITION '202601';

DROP PARTITION 是元数据操作,几乎零成本,是最廉价的删除方式。把数据按分区组织好,就能把大部分「删除」需求转化为分区操作。这与 TTL 生命周期管理的思路一致,细节可参考 /clickhouse-mutation-ttl-deep-dive/。

6.3 选型决策表

场景推荐机制
按分区整块过期DROP PARTITION / TTL
少量行删除、要求立即生效Lightweight DELETE
批量清理历史数据ALTER TABLE ... DELETE
按主键 upsertReplacingMergeTree
需要物理立即回收空间Mutation + OPTIMIZE
极高频更新版本化追加 + 视图

7. 常见陷阱

  • 在循环里逐行 DELETE:每条都写一次掩码,产生海量小 part,应合并成一条批量 DELETE ... WHERE ... IN (...)。
  • 删除后立刻 OPTIMIZE ... FINAL:会强制全量合并,成本极高,且阻塞后续合并。
  • 把 mutation 当同步操作:返回不等于完成,业务侧若依赖删除结果必须轮询 system.mutations。
  • 忽略 _row_exists 的查询开销:宽表 + 大范围扫描时,掩码过滤的 CPU 开销会累积。
  • 复制表上频繁 mutation:删除指令会传播到所有副本,放大整个集群的合并压力。

小结

ClickHouse 的变更语义围绕「不可变 part」展开:Mutation 靠后台重写、代价高但物理立即回收;Lightweight DELETE/UPDATE 靠 _row_exists 掩码、逻辑立即生效但物理延迟;而 ReplacingMergeTree 与分区操作则是把「变更」转化为 ClickHouse 最擅长的「追加」与「元数据操作」。选型时先问三个问题——删除是否按分区、是否要求物理立即回收、更新频率有多高——答案会直接指向唯一合适的机制。默认策略应该是:能用分区就不用删除,能用轻量就不用 mutation,能用追加就不用更新。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 容量规划与成本优化
  2. 从 MySQL/PostgreSQL 迁移的 SQL 差异
  3. 查询并发控制与资源隔离