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/PostgreSQL | ClickHouse |
|---|---|---|
| 更新粒度 | 行级原地更新 | 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 DELETE | ALTER 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 删除的可见性时序
一次轻量删除的完整生命周期:
DELETE FROM ...执行,写入掩码;- 后续查询立即过滤掉被删行(逻辑删除生效);
- 后台合并该 part 时,物理丢弃被删行,掩码列消失;
- 若在合并前又对该 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 PARTITION | ALTER TABLE ... DROP PARTITION | < 1s | 0 | 立即 |
| Lightweight DELETE | DELETE FROM ... WHERE ... | < 1s | 合并时 | 延迟 |
| Mutation DELETE | ALTER 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 |
| 按主键 upsert | ReplacingMergeTree |
| 需要物理立即回收空间 | 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,能用追加就不用更新。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。