数据变更------为什么 Update 会慢、会炸内存
引言
从 MySQL/PostgreSQL 转到 ClickHouse 的工程师,最常踩的坑就是:ALTER TABLE ... UPDATE 跑了 10 分钟还没完,内存飙到 90%,最后 OOM 被 kill。
ClickHouse 是列式存储 + 追加写入架构,天生不擅长单行修改。但业务总有数据修正、GDPR 删除、状态回填等需求。本篇讲清楚 Mutation 的机制,以及如何用正确的设计模式绕过这个限制。
1. Mutation 的工作机制
ClickHouse 的 UPDATE/DELETE 不是原地修改,而是通过 Mutation 实现:
sql
-- 这不是即时操作,而是提交一个异步任务
ALTER TABLE orders UPDATE status = 'cancelled' WHERE order_id = 12345;
ALTER TABLE orders DELETE WHERE created_at < '2023-01-01';
Mutation 的执行流程:
提交 Mutation → 写入 mutation 日志 → 后台逐个 part 重写 → 旧 part 标记删除
关键特征:
| 特征 | 说明 |
|---|---|
| 异步执行 | 提交后立即返回,后台逐步执行 |
| 整 part 重写 | 即使只改 1 行,也要重写整个 data part |
| 不可回滚 | 没有事务,提交后无法撤销 |
| 顺序执行 | 多个 mutation 排队,不能并行 |
查看 mutation 进度:
sql
SELECT
command,
is_done,
parts_to_do,
latest_fail_reason
FROM system.mutations
WHERE table = 'orders'
ORDER BY create_time DESC
LIMIT 10;
2. 为什么 ClickHouse 不擅长 UPDATE
根本原因在于存储架构:
行式数据库(MySQL)的 UPDATE:
- 通过 B-Tree 索引定位到具体行
- 原地修改该行的字段值
- 写 WAL,完成
列式数据库(ClickHouse)的 UPDATE:
- 找到包含目标行的 data part
- 读取该 part 的所有列文件
- 逐行判断 WHERE 条件
- 生成新的 part(所有列都要重写)
- 替换旧 part
一个直观的对比:
| 操作 | MySQL | ClickHouse |
|---|---|---|
| 更新 1 行 | 修改 1 个页(16KB) | 重写整个 part(可能数百 MB) |
| 更新 100 万行 | 100 万次随机 IO | 重写涉及的所有 part |
| 并发更新 | 行锁,可并行 | mutation 队列,串行执行 |
| 延迟 | 毫秒级 | 秒到分钟级 |
实际案例:一张 10 亿行的表,执行 UPDATE status = 'x' WHERE user_id = 123,即使只命中 1 行,也可能触发数 GB 的 part 重写。
3. Lightweight Update 的限制条件
ClickHouse 23.x 引入了 Lightweight Update(也叫 Lightweight Mutation),试图缓解这个问题:
sql
SET apply_mutations_on_fly = 1;
ALTER TABLE orders
UPDATE status = 'cancelled'
WHERE order_id = 12345;
工作原理:不立即重写 part,而是将修改记录在元数据中,查询时动态应用。
但它有严格的限制:
| 限制 | 说明 |
|---|---|
| 不支持修改排序键列 | ORDER BY 中的列不能用 Lightweight Update |
| 不支持修改分区键列 | PARTITION BY 中的列不能修改 |
| 性能开销转移到查询 | 查询时需要合并修改记录,读性能下降 |
| 积累过多会退化 | 未合并的修改记录过多时,查询性能显著下降 |
| 最终仍需 merge | 后台最终还是要重写 part 来合并修改 |
适用场景:低频、小批量的字段修正(如修改订单备注)。不适合高频或大批量更新。
4. ReplacingMergeTree + Version 的覆盖模型
对于"状态会变化"的数据(如订单状态、用户信息),更推荐的做法是用新行覆盖旧行:
sql
CREATE TABLE orders (
order_id UInt64,
user_id UInt64,
status String,
amount Decimal(10,2),
version UInt64,
updated_at DateTime
) ENGINE = ReplacingMergeTree(version)
ORDER BY order_id;
更新操作变成 INSERT:
sql
-- "更新"订单状态:插入一条 version 更大的新行
INSERT INTO orders VALUES
(12345, 100, 'cancelled', 99.00, 2, now());
ReplacingMergeTree 在后台 merge 时,会保留 version 最大的那一行,自动去重。
查询时的注意事项
merge 不是实时的,查询时可能看到多个版本。必须用 FINAL 或手动去重:
sql
-- 方式一:FINAL(简单但有性能开销)
SELECT * FROM orders FINAL WHERE order_id = 12345;
-- 方式二:手动去重(大范围查询推荐)
SELECT
order_id,
argMax(status, version) AS status,
argMax(amount, version) AS amount,
argMax(updated_at, version) AS updated_at
FROM orders
WHERE user_id = 100
GROUP BY order_id;
| 方式 | 优点 | 缺点 |
|---|---|---|
FINAL |
语法简单 | 单线程执行,大表慢 |
argMax 去重 |
可并行,性能好 | SQL 写法复杂 |
5. 以追加代更新的设计思想
ClickHouse 的最佳实践是把所有变更都转化为追加写入。这不仅仅是技术选型,而是一种设计哲学:
模式一:事件溯源
不存储"当前状态",而是存储"所有事件":
sql
CREATE TABLE order_events (
order_id UInt64,
event_type String, -- created, paid, shipped, cancelled
event_data String, -- JSON
event_time DateTime
) ENGINE = MergeTree()
ORDER BY (order_id, event_time);
查询当前状态时,取最新事件:
sql
SELECT
order_id,
argMax(event_type, event_time) AS current_status
FROM order_events
WHERE order_id = 12345
GROUP BY order_id;
模式二:快照 + 增量
定期写入全量快照,中间用增量追加:
sql
-- 每天凌晨写入全量快照
INSERT INTO user_profiles
SELECT *, toDate(now()) AS snapshot_date, ...
FROM external_source;
-- 查询时取最新快照
SELECT * FROM user_profiles
WHERE snapshot_date = (SELECT max(snapshot_date) FROM user_profiles)
AND user_id = 100;
模式三:标记删除
不物理删除,而是标记:
sql
CREATE TABLE users (
user_id UInt64,
name String,
is_deleted UInt8 DEFAULT 0,
version UInt64
) ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;
-- "删除"用户
INSERT INTO users (user_id, name, is_deleted, version)
VALUES (100, '', 1, 2);
-- 查询时过滤
SELECT * FROM users FINAL WHERE is_deleted = 0;
6. 什么时候必须重写表
有些场景确实无法用追加模式解决,必须重建数据:
| 场景 | 原因 |
|---|---|
| 修改排序键 | ORDER BY 变更后,已有数据的物理排列不对 |
| 修改分区键 | 数据需要重新分布到新的分区 |
| 大规模数据修正 | 超过 30% 的行需要修改,mutation 太慢 |
| 列类型变更 | 如 String → UInt64,需要重新编码 |
| 数据去重/清洗 | 历史数据有大量脏数据需要清理 |
重写表的标准流程:
sql
-- 1. 创建新表
CREATE TABLE orders_new AS orders;
-- 2. 写入修正后的数据
INSERT INTO orders_new
SELECT
order_id,
user_id,
if(status = 'error', 'pending', status) AS status,
amount,
version,
updated_at
FROM orders;
-- 3. 原子交换
EXCHANGE TABLES orders AND orders_new;
-- 4. 确认无误后删除旧表
DROP TABLE orders_new;
EXCHANGE TABLES 是原子操作,不会有查询中断的窗口期。
总结:UPDATE/DELETE 决策树
需要修改数据?
├── 低频、少量字段修正 → Lightweight Update
├── 状态字段频繁变化 → ReplacingMergeTree + 追加写入
├── 需要审计/溯源 → 事件溯源模式
├── GDPR 合规删除 → 标记删除 + 定期重写
└── 大规模数据修正 → EXCHANGE TABLES 重建
| 要点 | 说明 |
|---|---|
| Mutation 是重写 part | 即使改 1 行也要重写整个 part |
| 避免高频 mutation | 会排队、占内存、拖慢写入 |
| 优先用追加代替更新 | ReplacingMergeTree / 事件溯源 |
| 查询去重 | FINAL 或 argMax |
| 大改用 EXCHANGE | 原子交换,零停机 |
下一篇我们将讨论 ClickHouse 的分布式架构与集群部署。