ClickHouse系列(八):ClickHouse 的 UPDATE / DELETE 正确姿势

数据变更------为什么 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 / 事件溯源
查询去重 FINALargMax
大改用 EXCHANGE 原子交换,零停机

下一篇我们将讨论 ClickHouse 的分布式架构与集群部署。

相关推荐
fire-flyer3 小时前
ClickHouse系列(七):Materialized View 与多分辨率 Rollup 设计
大数据·数据库·clickhouse·架构
码云之上3 小时前
从 SQL DDL 到 ER 图:前端如何优雅地实现数据库可视化
前端·数据库·数据可视化
AKA__Zas3 小时前
SQL查询技巧全 Strategy Guide
数据库·sql·学习方法
luoganttcc3 小时前
华为 的 npu 架构如何 进行 flash attention
数据库·华为
Chasing__Dreams3 小时前
Mysql--基础知识点--94.1--嵌套子查询转关联查询
数据库·mysql
qq_283720053 小时前
Python 操作 MySQL 数据库全解:增删改查、事务、连接池与性能优化
数据库·python·mysql
爱码小白3 小时前
MySQL 系统函数专项练习题
数据库·python·mysql
Wenweno0o3 小时前
Ubuntu 系统配置 VS Code C++ 开发环境
数据库·c++·ubuntu
ayt0073 小时前
Netty NioEventLoopGroup源码深度剖析:高性能网络编程的核心引擎
服务器·前端·数据库