2026/9/18 3:34:53

ClickHouse 快速 UPDATE 实战:Mutation 与专用表引擎全解析

ClickHouse 快速 UPDATE 实战:Mutation 与专用表引擎全解析 很多人第一次用 ClickHouse 做线上业务都是在执行一条UPDATE的时候开始怀疑人生的。MySQL 里UPDATE ... WHERE id 123是毫秒级的事换成 ClickHouse你写一条ALTER TABLE ... UPDATE ...先得到一个异步 mutation 编号然后看着system.mutations里那个任务慢慢爬某些大表甚至要跑几分钟。这个体验落差本质上不是 ClickHouse 故意为难你而是列式存储的物理结构天然跟改一行这件事过不去。这篇文章想讲的就是 ClickHouse 如何在列式存储的底子上硬生生为 UPDATE 场景铺出几条能走的路。核心内容包括三块为什么列存让 UPDATE 变得别扭、ALTER TABLE ... UPDATE的 mutation 机制到底在底层干了什么、以及 ClickHouse 专门设计的那几个能承接快速更新语义的表引擎 —— ReplacingMergeTree、CollapsingMergeTree、AggregatingMergeTree 这些。适合正在用 ClickHouse 做业务查询、或者打算让它承接一部分实时更新的读者读完你至少能搞清楚一件事当需求里出现改数据三个字你该选 mutation 还是选专用引擎各自要付什么成本。1. 从列式存储的物理结构说起为什么 UPDATE 天生别扭1.1 列存的底层文件布局与读取链路要理解 ClickHouse 的 UPDATE 为什么这么重先得把列式存储的地基看清楚。ClickHouse 里一张 MergeTree 表数据在磁盘上按分区、按 part数据部件组织路径大概是/var/lib/clickhouse/data/{库名}/{表名}/{分区ID}/{part名称}/。一个 part 内部每一列都是一个独立文件常见结构里有data.bin列数据主体、data.mrk2标记文件、primary.idx主键索引、columns.txt、count.txt这些元数据。比如一张订单表有 20 个字段那么一个 part 里就至少躺着 20 个列文件。查询时如果要取某一行理论上需要从每一列的文件里各自定位到对应的 offset 再读取这也是为什么 ClickHouse 特别强调查询一定要只 select 需要的列——列选得越少IO 越小。这种设计对分析型查询极其友好你算sum(amount)时只需要读amount.bin这一个文件整列连续存储压缩率高、扫描速度快。但它的代价是行级别的随机写变得非常尴尬。因为一行数据的各个字段分别散落在不同的列文件里你想原地把第 5 行的 status 改掉首先得知道这行在每个列文件里的位置然后把那一列的 block 读出来、改掉、再写回去而且为了保证一致性可能还得动多个列文件。这个开销跟行式存储那种直接在数据页上改一个 slot完全不是一个量级。1.2 行式数据库的 UPDATE 和列式数据库的 UPDATE 差异行式数据库MySQL、PostgreSQL 这类存储的最小单位是行数据页里一行紧挨着另一行。更新一行本质是找到数据页、定位槽位、覆盖写或者标记删除加新版本涉及的数据量通常在一个页默认 16KB以内。加上 B 树索引对主键的精确导航单行更新的成本可以说是定点爆破。列式数据库则是另一个逻辑。ClickHouse 的 MergeTree 家族默认就不做 in-place 更新它的核心假设是数据一次性写入、后续只追加、通过后台合并来优化存储。所以它面对 UPDATE 请求时根本没法去改已经落盘的 part只能采用一种更绕的策略要么整段重写受影响的 part要么用新的写入覆盖旧的语义、在查询层做去重/折叠。这就是 ClickHouse 官方提供两条路的根源一条是显式的 mutation 路径用ALTER TABLE ... UPDATE触发后台把涉及的 part 整个重写一遍另一条是逻辑更新路径利用 ReplacingMergeTree 等专用引擎把更新转化为再插入一条新数据 在合并或查询时只认最新版本。这两条路的本质区别我会在后面的章节里拆开讲。先说结论mutation 是 ClickHouse 给你的兜底方案专用引擎才是它在设计上真正推荐的、能谈得上快速的更新姿势。2. ClickHouse 的官方答案ALTER TABLE ... UPDATE 到底做了什么2.1 突变mutation机制的全流程执行ALTER TABLE orders UPDATE status shipped WHERE order_id 12345之后ClickHouse 不会立刻改任何数据它只做一件事往system.mutations里登记一条 mutation 记录给这个任务分配一个递增的 mutation 版本号。之后后台的 mutation 线程会扫描表的所有 part对于每个 part检查它是不是已经在某个 mutation 版本之后生成过如果这个 part 包含了满足WHERE条件的行就会把整个 part 的列数据读出来逐行判断条件把需要改的值写进内存里的新 block然后重新压缩、写成一个全新的 part最后替换掉旧 part。这里面最容易忽略的代价是mutation 是整 part 重写不是只改那几行。哪怕你要更新的只有一条记录而它所在的那个 part 有 1000 万行、20 个列ClickHouse 也会把整个 part 的所有列全部读出来再写一遍。因为你没法在压缩后的列文件里做一个精准的定点修改唯一安全的做法就是整体重建。所以 mutation 的成本基本跟 part 体积成正比这也是为什么文档一直强调mutation 是给低频、大批量、后台可容忍的变更用的不是给在线业务高频点更新用的。这里有一个坑要提醒mutation 默认是全表扫描所有 part 的即使WHERE条件能用主键过滤ClickHouse 也会先通过primary.idx跳过一些 granule但如果条件列不在主键里那就是实打实的全 part 扫描。所以在设计表结构时就该想清楚将来可能要做条件更新的列尽量往排序键的前缀里放否则 mutation 会慢得更离谱。2.2 为什么轻量删除最后的实现也是标记删除跟 UPDATE 类似的还有 DELETE。ClickHouse 从 22.3 版本开始支持DELETE FROM table WHERE ...官方叫 lightweight delete。它不重写 part而是给满足条件的数据行打一个 delete mask删除标记查询时这些行会被过滤掉。表面上看起来快了不少但要注意被标记删除的行物理上仍然占着磁盘空间直到之后某个合并过程真正把带删除标记的数据从 part 里清理掉。这意味着轻量删除只是把重写延后了并没有消除它。如果你的业务里删除特别频繁parts 里的垃圾数据会越积越多磁盘水位跟着涨查询扫描时虽然不返回被删行但 IO 并没有省下来。所以本质上无论是 UPDATE 还是 DELETEClickHouse 的思路都是先记账、后算账真正的物理修订靠后台异步去做。理解这个基调才能理解专用引擎存在的必要性——它们把算账这一步做得更优雅。2.3 mutation 的真正成本在哪我把 mutation 的账单列一下你在评估能否接受时直接对照成本项说明量级感受磁盘 IO每个受影响 part 全量读写一遍100GB 表更新 1 行也要读写该 part 的几十 GBCPU 与压缩重写时重新压缩所有列大量 CPU 消耗可能挤占查询资源后台线程mutation 有独立线程池但会跟 merge 抢资源并发过多时合并会被拖慢临时空间新 part 写完前旧 part 不能删双倍磁盘占用大表 mutation 前要确认磁盘余量阻塞属性mutation 期间相应分区的新写入可能受影响实测高版本已优化但仍有资源竞争这张表基本可以回答为什么我不能把 ClickHouse 当成 MySQL 来用这个问题。mysql 的单行 update 是纳米级操作clickhouse 的 mutation 是批处理作业。官方也说得直白mutation 设计目标就是低频、大批量的历史数据修正比如把昨天导错的一个状态字段全部改回来而不是每秒几百次的业务更新。3. 快速 UPDATE 的工程出路专为更新设计的表引擎既然 mutation 扛不住高频更新ClickHouse 给出了一套更聪明的解法把更新建模为插入新版本 查询时取最新。这套玩法由 MergeTree 家族里几个专用引擎实现它们不修改旧数据而是在合并或查询阶段对同一逻辑主键的多行数据做归并处理。下面逐个拆。3.1 ReplacingMergeTree按版本去重的最终一致性ReplacingMergeTree 是实践里最常见的更新替代品。建表时可以指定一个版本列例如CREATE TABLE orders_replacing ( order_id UInt64, status String, update_time DateTime ) ENGINE ReplacingMergeTree(update_time) PRIMARY KEY order_id ORDER BY order_id;写入时永远走 INSERT同一条order_id可以插入多行update_time记录每次写入的新鲜程度。后台合并 part 时对于排序键相同的行ReplacingMergeTree 会保留update_time最大的一行其余丢弃。查询时如果不想等合并可以加FINALSELECT status FROM orders_replacing FINAL WHERE order_id 12345;注意FINAL会在查询时做一次归并数据量大时性能会明显下降尤其在大宽表上。更推荐的做法是让后台合并自然收敛查询侧通过max_threads和版本条件自行取最新。比如SELECT order_id, status FROM orders_replacing WHERE order_id 12345 ORDER BY update_time DESC LIMIT 1;这样写的好处是查询走排序键定位性能稳定不会因为FINAL全表归并而退化。我在实际项目里经常用这个写法效果比直接FINAL好很多。ReplacingMergeTree 适合的场景是状态最终一致即可比如订单状态流转、用户资料快照、配置表的每日全量覆盖。它不保证严格实时一致——在两次合并之间你可能会查到旧版本和新版本并存的多行所以如果你的业务要求改了之后立刻只能看到新值要么手动触发OPTIMIZE TABLE ... FINAL要么在查询里用上面的取最新写法。手动OPTIMIZE也是个重操作生产环境要挑低峰期。3.2 CollapsingMergeTree折叠删除的豆腐账思路CollapsingMergeTree 的思路非常巧妙它用一个sign列来标记每一行是真实数据还是抵消数据。约定sign 1表示有效行sign -1表示要抵消的旧行。合并时如果存在同一排序键下 sign1 和 sign-1 的两行它们会被互相抵消从存储里消失。举个例子订单状态发生变化时你先插入一行 sign-1、内容跟旧数据完全一致的行来注销旧数据再插入一行 sign1、内容为新状态的行。这样在存储层面旧行会被折叠掉新行留下。业务上你看到的依然是只有一份订单数据。这个模型对状态频繁变化、且历史版本不需要保留的场景特别合适比如库存实时变化、账户余额变化因为它不像 ReplacingMergeTree 那样保留所有历史版本直到合并而是主动用负向行去抵消旧行让数据量保持在一个有界范围内。使用上有个经典坑sign-1 的行除了 sign 本身其他列必须跟要抵消的旧行完全一致否则合并时匹配不上旧行永远残留。另外查询时必须加WHERE sign 1或者在聚合里用sum(sign)来判断否则会把正负行一起查出来结果直接翻车。SELECT order_id, sum(sign) AS valid FROM orders_collapsing GROUP BY order_id HAVING valid 0;如果你觉得维护完全一致太容易出错可以用它的升级版。3.3 AggregatingMergeTree / SummingMergeTree把更新变成聚合AggregatingMergeTree 的思路更彻底——它连行的概念都快放弃了直接把数据建模成聚合状态。建表时可以定义aggregateFunction类型的列比如CREATE TABLE metrics_agg ( metric_date Date, metric_name String, cnt AggregateFunction(sum, UInt64), max_val AggregateFunction(max, UInt64) ) ENGINE AggregatingMergeTree() ORDER BY (metric_date, metric_name);写入时用-State函数构造聚合状态查询时用-Merge函数把状态展开INSERT INTO metrics_agg SELECT today(), click, sumState(1), maxState(amount) FROM raw_events; SELECT metric_name, sumMerge(cnt), maxMerge(max_val) FROM metrics_agg GROUP BY metric_name;配套的 SummingMergeTree 更简单排序键相同的行数值列直接相加合并。它非常适合累加计数类更新需求。比如一个 PV/UV 计数器每次累加都插入一行增量后台合并时自动把增量归并成一条。这种更新就是追加聚合的思路在很大程度上绕开了列存不擅长随机写的物理限制把更新场景转换成了 ClickHouse 最擅长的追加写场景。如果你的业务里更新本质上是累计/度量优先考虑这两个引擎它们的性能和简洁度远超其他方案。3.4 VersionedCollapsingMergeTree当版本号遇到折叠VersionedCollapsingMergeTree 把 ReplacingMergeTree 的版本列和 CollapsingMergeTree 的 sign 列结合在了一起建表时同时指定版本列和 sign 列CREATE TABLE orders_vcollapse ( order_id UInt64, status String, version UInt64, sign Int8 ) ENGINE VersionedCollapsingMergeTree(sign, version) ORDER BY order_id;它的价值在于普通的 CollapsingMergeTree 在做抵消匹配时要求负向行和其他列完全一致这在并发下很容易出错。VersionedCollapsingMergeTree 引入了版本号折叠时只需要保证旧版本的 sign-1 能匹配到同一排序键下同版本的旧行即使其他字段有细微差异也不影响匹配。这让它在串联多个状态变更、或者从消息队列摄入变更流时容错性高很多。如果你在选型时纠结 Collapsing 和 Replacing我建议优先看一眼这个变体很多实时场景它才是正解。4. 引擎选型的三条经验法则4.1 更新的频率与延迟要求决定方案我把前面几种方案的适用边界整理成一张表方便直接对照方案更新频率可见延迟实现复杂度典型场景ALTER TABLE ... UPDATE极低小时级/天级分钟级低批量修正历史数据ReplacingMergeTree高秒级/毫秒级插入合并后或查询时最终一致中订单状态、资料快照CollapsingMergeTree高合并后最终一致中高需维护 sign余额、库存变化SummingMergeTree高合并后最终一致低计数器、累加指标AggregatingMergeTree高合并后最终一致中预聚合指标这里的核心权衡是你能否接受最终一致。如果业务必须读后即写、写后即读都看到最新值那任何靠后台合并收敛的方案都会有短暂窗口这时候你只能选 mutation——并且接受它的延迟和资源消耗。反过来如果你能像大多数分析型业务那样接受秒级甚至分钟级延迟那么插入新版本 查询取最新的引擎方案操作成本会低得多。4.2 排序键就是你的命运不管是 mutation 还是专用引擎排序键都在起决定性作用。ReplacingMergeTree 的去重是按ORDER BY键来的CollapsingMergeTree 的折叠配对也是按排序键匹配的。这就意味着你在建表时选排序键实际上是在定义什么算同一条记录。我见过不少项目把排序键定成了(order_id, update_time)然后在 ReplacingMergeTree 里怎么去重都去不掉因为每一行的排序键都不一样。所以排序键必须去掉版本列、时间列这些区分度太高的字段只保留真正意义上的业务主键。另外一个经验是排序键还要兼顾查询过滤需求比如经常按user_id查那把user_id放排序键前缀就是对的这样去重和查询过滤能同时受益。4.3 别让合并线程成为瓶颈专用引擎的更新最终都靠后台 merge 来落定。ClickHouse 的 merge 线程是全局共享的受background_pool_size老版本或background_merge_pool_size新版本控制。如果你的表数据量很大、分区很多、且一直有高频插入而更新又频繁到需要不断合并才能推进后台 merge 可能会积压导致你看到的结果迟迟不收敛。踩过这个坑之后我的建议是为高频更新表单独规划分区策略和时间窗口避免所有分区同时都在做大量合并。另外监控system.merges和system.mutations两张系统表观察积压量。如果 merge 长期堆积优先检查是不是分区设计太细、part 数量过多。part 数量跟写入频率直接相关插入越频繁、part 越多合并压力越大。适当调大parts_to_delay_insert阈值或者用 Buffer 表做写入缓冲能明显缓解这个压力。5. 实操一个实时订单更新场景的完整改造5.1 场景定义与性能基线假设我们有一个订单状态表核心需求是订单状态会从 created 流转到 paid、shipped、completed每个订单生命周期内可能更新 3~5 次查询要求按订单 ID 取最新状态。我起一个最简单的基准表CREATE TABLE orders_baseline ( order_id UInt64, status String, update_time DateTime ) ENGINE MergeTree() ORDER BY order_id;先用 mutation 方案压一下写入 1000 万订单然后模拟 10 万次状态更新每次发一条ALTER TABLE ... UPDATE。实测下来mutation 是异步的每条都会在system.mutations里排队而且因为每个 mutation 都要重写包含目标行的 part10 万个 mutation 任务在后台会叠成一座大山——新版本虽然做了合并优化但整个收敛过程仍然非常慢资源也被大量占用。这个对比不是要证明 mutation 不能用而是告诉你高频业务更新走这条路基本是自找苦吃。5.2 方案A直接 mutation接受延迟如果你的更新确实是低频的比如每天凌晨批量修正一批脏数据那 mutation 反而是最省事的写法ALTER TABLE orders_baseline UPDATE status refunded WHERE order_id IN (SELECT order_id FROM refund_list);这里提醒两个细节。第一mutation 的WHERE条件尽量走主键避免全表扫描第二大批量 mutation 前检查磁盘空间因为新旧 part 会短暂共存空间占用可能翻一倍。执行后通过SELECT * FROM system.mutations WHERE table orders_baseline观察执行进度等is_done 1再对表做后续操作。5.3 方案BReplacingMergeTree 版本化更新接着把它改成专用引擎方案CREATE TABLE orders_replacing ( order_id UInt64, status String, update_time DateTime ) ENGINE ReplacingMergeTree(update_time) ORDER BY order_id;每次状态变更不再执行 UPDATE而是插入一行新数据INSERT INTO orders_replacing VALUES (12345, paid, now());查询最新状态用排序键定位 版本排序取首行SELECT status FROM orders_replacing WHERE order_id 12345 ORDER BY update_time DESC LIMIT 1;实测下来这个方案在 1000 万数据量下单次状态变更的写入耗时在毫秒级查询走order_id索引响应也在毫秒级基本可以平替业务里的点更新需求。代价是表里会保留一个订单的多份历史行直到后台合并收敛。如果订单状态经常反复这个中间膨胀量需要考虑在内可以通过分区裁剪把活跃中订单和已完结订单分开完结订单所在分区合并压力自然小很多。5.4 方案CCollapsingMergeTree 逻辑删除如果除了能查到最新状态你还希望表里最终只保留一条有效数据不保留历史版本那就用 CollapsingMergeTree。流程是每次状态变更先插入一条 sign-1 的旧状态行再插入一条 sign1 的新状态行。查询时全部带WHERE sign 1后台合并会把正负行折叠掉。这个方案在存储层面最干净但应用层要维护旧状态插入的逻辑容易出错的地方在 sign-1 那行必须跟旧行完全一致。为了减少出错我一般会在同一批写入里把负行和正行放到同一个 INSERT 或同一个 block 里这样它们大概率落在同一个 part合并时可以更快配对。版本化思路解决完全一致这个痛点所以上面的 VersionedCollapsingMergeTree 值得优先考虑。5.5 肉眼可见的收益对比把三种方案放在一起看指标直接 mutationReplacingMergeTreeCollapsingMergeTree单次状态变更耗时秒级~分钟级异步毫秒级写入毫秒级写入查询最新状态需要等待 mutation 完成毫秒级order by limit毫秒级where sign1历史版本保留保留直到合并刻意折叠存储膨胀低中中低实现难度低低中我在真实项目里做过一次把高频状态更新从 mutation 迁到 ReplacingMergeTree 的改造效果是接口写入耗时从几百毫秒降到十几毫秒后台没有任何 mutation 堆积查询侧依然稳定在个位数毫秒。这就是为列式存储构建快速 UPDATE的真正答案别跟物理特性硬刚用专用引擎把更新转化为追加写。6. 常见坑与排查实录6.1 mutation 为什么一直在跑不完system.mutations里任务长期is_done 0常见原因有三个一是表数据量太大重写所有 part 本身就要很久这是预期内的二是后台 mutation 线程被 merge 挤占并发资源不足三是你的WHERE条件不走主键导致每个 part 都要全量扫描。排查时先看system.mutations的latest_failed_part和errors字段有报错先解决报错没报错就看parts_to_do数量如果这个数长期不降多半是资源竞争可以临时调大后台线程池配置或者错峰执行。6.2 重复数据查出来了怎么办用了 ReplacingMergeTree但SELECT还是查出同一个 order_id 的多行。原因几乎都是排序键设计问题——要么排序键里带了版本列或时间列导致同一业务记录在去重视角下并不是同一行要么你根本没加FINAL也没手动取最新在合并发生前查到了多个版本共存。前者需要重建表后者改查询就行。记住除非你手动OPTIMIZE ... FINAL否则任何靠后台合并收敛的引擎都存在一个未合并窗口期这是设计的一部分不是 bug。6.3 排序键改不了只能重建表ClickHouse 的ORDER BY一旦建表就不允许修改这点跟 MySQL 改索引完全不一样。如果你发现排序键选错了唯一干净的路是新建表、用INSERT INTO ... SELECT迁移数据、然后原子替换表名。数据量大的时候这个过程要规划停机窗口建议用ALTER TABLE ... RENAME两步走先切新表再删旧表把风险降到最低。6.4 合并合并合并查询越来越慢后台 merge 是 ClickHouse 保持查询性能的重要手段但 merge 积压时part 数量会暴涨查询时扫描的 part 变多性能反而下降。排查方法很直接查system.merges看有没有长时间运行的合并任务查system.parts看表里 part 总数。如果 part 数常年高于几百个优先优化写入节奏用 Buffer 表攒批写入或者增大min_insert_block_size_rows让每次落盘的行数更大、part 更少。这些调整不会影响业务语义但对整体稳定性提升非常明显。我个人在维护这类表时还有个习惯给关键业务表建一个每日监控专门盯system.parts数量和system.mutations积压数。这两个指标基本能提前暴露大多数更新场景的隐患。毕竟 ClickHouse 的列式存储底子决定了它不适合行级随机改但只要建模思路换过来把更新翻译成追加 归并它照样能承担很多实时更新的脏活累活。设计表的时候多想一步后面运维能省掉一大半麻烦。