
如果你维护过ClickHouse集群大概率听过一句调侃ClickHouse没有UPDATE只有INSERT和SELECT。这句话对了一半。事实上ClickHouse不仅支持DELETE还支持高性能的UPDATE路径只是它不按传统数据库的方式去做而是通过一套专门设计的合并引擎MergeTree家族在后台“偷偷”完成更新。这篇文章我想把思路拆开聊聊列式存储为什么反感更新以及ReplacingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree、AggregatingMergeTree这四类专用引擎分别是怎么把“更新”做成“快速追加”的。这是“ClickHouse原理”系列的第一篇聚焦“专用引擎”——也就是不需要动数据文件、通过合并逻辑实现更新语义的那批方案。下一篇我打算再讲ALTER UPDATE这种轻量级突变操作的底层实现但今天咱们先把引擎家族讲透。1. 为什么列式存储做UPDATE这么别扭1.1 列式存储的“出厂设定”就是只读追加ClickHouse的底层存储是标准的列式布局同一列的数据连续存放在一起不同列各自独立。这种布局对分析型查询极其友好——扫描一千万行只需要读那几个涉及的列I/O量比行式存储小一个数量级。但代价是如果你想修改一行数据必须同时修改这一行在各个列文件里的位置。比如有一张订单表按“订单ID、金额、状态”三列存成三个文件。传统数据库更新一行状态时只需找到行所在页并原地改写该字段。ClickHouse如果要原地UPDATE就得在三个列文件里分别定位还要保证原子性、崩溃恢复、索引一致性。更麻烦的是列文件按块granule组织每块都有自己的压缩和索引改一个字节可能让整个块的压缩失效、索引需要重建。所以ClickHouse从一开始就没打算支持传统意义上的任意行更新。它的出路很简单既然列文件不可变那么就用“新数据覆盖旧数据”的逻辑让“新版本”天然替代“旧版本”。1.2 不可变数据块与后台合并机制ClickHouse每次INSERT都会生成一个新的数据片段data part这个片段内所有列文件一旦落盘就不可修改。所谓“更新”实际上是一条带新数据的INSERT语句执行后新数据作为一个新part躺在磁盘上。后台有一个合并线程池会不断把互相重叠的小part合并成大part。合并时会读取多个part的数据按排序键重新排列输出一个新的part最后删除旧part。这个合并过程就是所有“更新专用引擎”发挥魔法的地方它们利用合并时机对相同排序键的多个版本做去重、折叠或聚合。所以你会发现ClickHouse的“快速UPDATE”本质上是把更新拆成了两半插入阶段只是追加新part速度极快合并阶段在后台异步完成去重不阻塞写入。这套机制和LSM-Tree的思路几乎一脉相承只不过ClickHouse把“合并计算”做到了极致。1.3 传统UPDATE在ClickHouse中会带来什么问题有人会问那为什么不直接给ClickHouse加上UPDATE语法内部实现为“定位文件-改写数据”技术上不是完全不可能但代价太惨烈定位一行的代价高。列式存储没有行级别的物理地址只能通过索引定位到粒度的起始位置再扫描粒度内的行。做一次单行UPDATE可能要把一个列块的全部数据读出来、改掉、再压缩写回写放大非常严重。并发控制会变成灾难。多线程同时UPDATE不同行如果都去修改同一个part需要复杂的锁机制或MVCCClickHouse的分布式架构注定不适合这种高并发随机写。会破坏压缩率。列式压缩依赖相邻数据的高相似性频繁修改会造成碎片和压缩率下降存储成本直线上升。因此ClickHouse社区的选择不是“硬上UPDATE”而是换一套语义把UPDATE拆成“插入新版本 随后台合并消除旧版本”。这也是为什么官方文档里明确写着如果业务需要频繁UPDATE请考虑使用ReplacingMergeTree或者CollapsingMergeTree。专用引擎就是为这个场景存在的。2. 换个思路不修改用“新版本”去覆盖2.1 MergeTree的排序键就是“主键”的化身很多人以为ClickHouse没有主键只有排序键。严格来说MergeTree的PRIMARY KEY和ORDER BY可以不同但大多数场景它们是一致的。这个“排序键”决定了数据在part内部按什么顺序排列。排序键不是唯一约束。同一个part内可以出现多行排序键相同的数据。而各专用更新引擎正是靠“排序键相同”这一规则来识别“这是同一逻辑行的不同版本”。比如ReplacingMergeTree合并时如果发现两行排序键相同就只保留最新插入的那一行CollapsingMergeTree则要求你为每行打一个sign标记1或-1合并时把sign1和sign-1且排序键相同的两行互相抵消留下没有被抵消的行。这套机制的根基就是排序键提供了稳定的“逻辑主键”基准。2.2 专用引擎家族的共同语言插入 合并去重所有专用引擎底层的更新流程都遵循同一个范式应用层把“更新操作”翻译成“插入一条新版本数据”。新版本数据携带业务时间戳、版本号或者sign标记。后台合并线程在适当时机扫描同一part或跨part的相同排序键。按照引擎定义的规则保留最新、抵消、聚合生成合并后的无重复数据。最终对查询而言旧数据被“隐藏”淘汰实现了更新语义。这个范式决定了使用专用引擎时必须接受一个事实合并是异步的不是立即生效。在合并发生之前查询可能会看到旧版本和新版本同时存在。所以采用这套方案的系统通常对“读写一致性”要求不能是强一致而是“最终一致”。2.3 为什么说“快速UPDATE”的本质是“快速追加”回到标题里的“快速UPDATE”。我理解的关键点在于ClickHouse把更新操作降维成了顺序追加而顺序追加是所有存储系统里最高效的写入方式。传统数据库一条UPDATE要经过“查找行-修改缓冲-事务提交-刷脏页”等流程随机写居多。ClickHouse则直接把更新数据当成新行追加写入性能基本等同于INSERT依赖列式压缩和批量写入单节点每秒轻松写几万行甚至几十万行。真正的计算开销被推迟到了合并阶段而合并又是后台批量进行的不阻塞前台查询。这也是为什么很多大型分析系统用ClickHouse存储“日志型事实数据”每次变化都追加一行最后通过专用引擎在查询时收敛出最新状态。它不是传统面向行的数据库但它用自己的方式把“更新”这事给办了。3. 四大专用引擎逐个拆解3.1 ReplacingMergeTree按排序键保留最后一条ReplacingMergeTree是最直观的更新引擎。建表时只需要加上ENGINE ReplacingMergeTree([version])并指定排序键CREATE TABLE orders_r ( order_id String, status String, amount Decimal(10,2), updated_at DateTime ) ENGINE ReplacingMergeTree(updated_at) ORDER BY order_id;这里的version参数是可选的它必须是数值、日期或日期时间类型。合并时如果多个part里有相同的order_idReplacingMergeTree取version最大的一行作为最终结果。如果没有指定version则默认保留“最后插入的那一行”也就是按插入顺序判断。写“更新”时非常简单直接插入一行新状态INSERT INTO orders_r VALUES (1001, PAID, 99.00, now()); INSERT INTO orders_r VALUES (1001, SHIPPED, 99.00, now() INTERVAL 1 MINUTE);后台合并后order_id1001的旧状态会被新状态替代。如果查询时不加FINAL你可能会看到两行加FINAL后ClickHouse会立刻对查询涉及的数据做一次“合并版本”处理只返回最新状态。注意FINAL是在查询时才去重需要额外的计算开销。如果表里积累了特别多未合并的partFINAL查询会变慢。但比等待后台合并完成要可控得多。ReplacingMergeTree适合“实体最新状态”类场景用户最后登录时间、订单最终状态、商品最新价格。它不处理删除也不处理多次修改的中间态只关心最终值。3.2 CollapsingMergeTree用sign折叠成对数据CollapsingMergeTree的思路则是“抵消”。建表时要有一个特殊的列通常命名为sign取值只能是1或-1。如果一行数据是“有效数据”sign1如果一行数据是对之前某行有效数据的“取消”sign-1。CREATE TABLE orders_c ( order_id String, status String, amount Decimal(10,2), sign Int8 ) ENGINE CollapsingMergeTree(sign) ORDER BY order_id;更新操作分两步先插入一条与旧数据排序键相同的sign-1行表示撤销旧状态再插入一条sign1的新行表示新状态。INSERT INTO orders_c VALUES (1002, CREATED, 100.00, 1); INSERT INTO orders_c VALUES (1002, CREATED, 100.00, -1); INSERT INTO orders_c VALUES (1002, PAID, 100.00, 1);合并时CollapsingMergeTree会把排序键相同并且sign互为正负的成对数据折叠掉最终只保留未配对的sign1行也就是最新状态。如果你写了sign-1却没有对应旧行它也能被正确保留下来不会无故消失。CollapsingMergeTree的经典使用场景是“记录变化明细”比如跟踪一个订单从创建、支付到发货的状态变迁每个状态变化用一条负行加一条正行表示。它比ReplacingMergeTree更严谨的地方在于它需要你显式标记“哪一行是要被替换的”所以能处理“取消一个根本不存在的更新”这类复杂情况。但是CollapsingMergeTree有一个坑如果同一个排序键的多个part同时存在且合并顺序不理想可能造成折叠不彻底。官方建议配合INSERT的顺序保证同一排序键的数据尽可能落在一个part里或者依赖后台合并多次迭代。3.3 VersionedCollapsingMergeTree折叠版本双保险VersionedCollapsingMergeTree是CollapsingMergeTree的改良版。它同时使用版本列和sign列在折叠之前优先按版本去重再按sign折叠。这样能避免CollapsingMergeTree在某些乱序插入场景下的误折叠。建表语法CREATE TABLE orders_vc ( order_id String, status String, amount Decimal(10,2), version UInt64, sign Int8 ) ENGINE VersionedCollapsingMergeTree(sign, version) ORDER BY order_id;这里的version字段用来标识同一排序键的多个版本的先后顺序。合并规则是先按排序键分组同一分组里version最大且sign1的行是最终有效行其他比它版本旧的、以及sign-1的对应行都会被折叠。换句话说VersionedCollapsingMergeTree告诉你“我不但会用version挑最新的还会用sign抵消掉旧状态”。这比纯CollapsingMergeTree多了版本控制比纯ReplacingMergeTree多了显式删除能力是更稳妥的方案。业界用它来处理“订单状态流最新快照”的复合场景既保留了完整状态变迁靠负行正行的成对数据又能在查询时快速得到最新状态。如果在实际业务中既有频繁更新又担心数据乱序我建议优先使用这个引擎。3.4 AggregatingMergeTree用聚合函数“增量更新”AggregatingMergeTree的思路不是“保留最新”而是“把多个版本聚合成一个汇总值”。它适合的更新场景是你根本不在乎历史细节只想要某个维度下的累计结果。CREATE TABLE metrics_agg ( metric_date Date, metric_type String, total_cnt AggregateFunction(sum, UInt64), max_val AggregateFunction(max, UInt64) ) ENGINE AggregatingMergeTree() ORDER BY (metric_date, metric_type);插入时需要使用-State聚合函数INSERT INTO metrics_agg SELECT now()::Date, visit, sumState(1), maxState(5);查询时使用-Merge函数展开聚合状态SELECT metric_date, metric_type, sumMerge(total_cnt) AS total_cnt, maxMerge(max_val) AS max_val FROM metrics_agg GROUP BY metric_date, metric_type;后台合并时AggregatingMergeTree会把同名排序键的聚合状态合并而不是简单覆盖。这其实是一种“更新累加值”的能力每天你只需要INSERT新增量最后合并生成当日汇总。它不适合精细化的行级更新但适合“计数器累加、事件量汇总”这类场景。不过需要注意AggregatingMergeTree本身只会在part合并时触发聚合日常插入和查询之间还存在未合并的part。所以在使用它时查询脚本里最好保留GROUP BY并配合-Merge函数处理多个part的中间结果。3.5 对比总览表什么时候选哪个引擎更新规则是否需要版本是否需要取消标记典型场景ReplacingMergeTree保留每个排序键的最新/最大版本行可选推荐有否实体最新状态、用户信息快照CollapsingMergeTree相同排序键的正负行互相抵消否是sign±1订单状态流、可回滚的变更历史VersionedCollapsingMergeTree先按版本取最新再按sign折叠是是sign±1乱序数据下的状态流快照AggregatingMergeTree相同排序键的聚合状态合并否否指标汇总、事件累计、计数器选型时我自己的经验是如果你只关心“当前值是什么”ReplacingMergeTree最简单如果业务里需要知道“谁把谁取消了”或者要支持类似软删除的能力CollapsingMergeTree如果排序键可能乱序到达那一定选VersionedCollapsingMergeTree如果只是要累计结果AggregatingMergeTree才是正道。4. 实操从建表到查询的完整路径4.1 一个真实的“订单状态更新”场景假设你在维护一个电商分析系统订单表有order_id、statusCREATED / PAID / SHIPPED / DONE、amount、updated_at。业务上需要高频更新订单状态同时保留每次状态变化的时间痕迹。你最终决定用VersionedCollapsingMergeTree因为订单状态事件可能从不同服务异步到达存在乱序风险。我们先把表建出来并设计测试数据CREATE TABLE order_status ( order_id String, status String, amount Decimal(10,2), version UInt64, sign Int8 ) ENGINE VersionedCollapsingMergeTree(sign, version) PRIMARY KEY (order_id) ORDER BY (order_id, version);注意我把ORDER BY设成了(order_id, version)。这样在part内部同一个订单的多个版本会相邻排列后台合并时能更快地完成分组与折叠。如果你把version放到后面查询时也方便按版本排序。4.2 用VersionedCollapsingMergeTree实现“更新”插入、合并、查询我们先插入一个订单的初始状态INSERT INTO order_status VALUES (8001, CREATED, 200.00, 1, 1);订单被支付后应用层产生两条新记录一条取消旧版本sign-1, version1一条新版本sign1, version2INSERT INTO order_status VALUES (8001, CREATED, 200.00, 1, -1), (8001, PAID, 200.00, 2, 1);发货后同样追加取消新版本INSERT INTO order_status VALUES (8001, PAID, 200.00, 2, -1), (8001, SHIPPED, 200.00, 3, 1);此刻查询应该能看到所有行的原始数据。为了看到“更新后的快照”可以加FINALSELECT order_id, status, amount FROM order_status FINAL WHERE order_id 8001;结果应该只有一行8001, SHIPPED, 200.00。在FINAL执行时ClickHouse会读取所有part按排序键分组找到version最大的sign1行作为最终结果。这就是这套引擎的“快速UPDATE”语义——业务侧不需要UPDATE语句只需要两条INSERT。4.3 用ReplacingMergeTree实现更简化的更新如果你的业务不需要维护“取消历史”只关心订单当前状态用ReplacingMergeTree更简洁CREATE TABLE order_simple ( order_id String, status String, amount Decimal(10,2), updated_at DateTime ) ENGINE ReplacingMergeTree(updated_at) ORDER BY order_id;插入更新时不需要成对插入直接插入新版本INSERT INTO order_simple VALUES (9001, PAID, 300.00, now()); INSERT INTO order_simple VALUES (9001, DONE, 300.00, now() INTERVAL 10 MINUTE);日常查询我建议使用FINAL关键字或者用聚合查询手动分组取最大值SELECT order_id, argMax(status, updated_at) AS status, argMax(amount, updated_at) AS amount FROM order_simple GROUP BY order_id;用argMax实际上比FINAL更可控尤其在排序键性别复杂的时候。如果表数据量极大对高基数order_id做FINAL会比较吃力这时再用GROUP BY argMax也来得及。4.4 强制合并实验与性能观察想要观察“更新真正生效”的过程你可以手工触发一次合并。注意ClickHouse没有官方推荐日常使用的手工合并但在实验和验证时很有用OPTIMIZE TABLE order_simple FINAL;这会让后台合并线程立即对该表的所有part做大合并。执行OPTIMIZE后再次查询就不用加FINAL了因为磁盘上只保留了一个包含最新状态的大part。我在测试时习惯对比三个时间点插入完成后立即查询看到多个版本共存加了FINAL查询返回最新状态执行OPTIMIZE后不加FINAL查询同样返回最新状态性能上的差异非常明显。如果part数量很多FINAL查询可能比普通查询慢几倍而OPTIMIZE是一个阻塞式的后台运算可能在高峰期拖慢合并线程。所以生产环境不建议频繁执行OPTIMIZE宁可依靠后台自动合并。4.5 等不到的合并final关键字与后台合并的时机官方文档里说后台合并会在“适当的时候”发生但这个“适当”对你来说就是不可预期。如果业务对“新状态可读”有时间要求千万别等合并。你只有两条路查询时用FINAL牺牲一定的查询性能在应用层自己合并数据比如用GROUP BYargMax来选最新版本。在我负责的一个项目里我们要求订单状态更新最长5秒内可见。我们采用ReplacingMergeTree查询时统一在SQL末尾加FINAL。刚开始担心性能实际跑了两个星期订单量日均千万平均查询耗时增加不到30%完全在可接受范围。后来发现很多订单其实不会被更新FINAL带来的额外开销其实集中在那些确实有多版本的订单上。这里想强调一点FINAL不是魔法它不会重写part而是在查询时执行一次“在线合并”读取粒度和数据量都比普通查询多。对于大多数宽表场景使用FINAL的查询计划会退化为对相关part的完整读取所以设计SQL时最好能把过滤条件放到子查询里减少FINAL的取值范围。5. 常见问题与避坑指南5.1 为什么查询结果和预期不一致最常见的原因是你还没执行FINAL或OPTIMIZE查询读到了多个part里的旧版本和新版本。记住专用引擎只在part合并时“做决策”而part合并是异步的。另一个原因是排序键设计不对。比如ReplacingMergeTree判断“两行是同一实体”依赖ORDER BY字段完全相同。如果你的排序键没有包含业务主键或者使用了rand()这类不稳定字段那么“同一订单”的两行会被当成两行不同数据永远无法去重。经验建表之前先把“业务唯一键”明确出来排序键必须能唯一标识一行逻辑数据。对分析表来说这是更新引擎能正常工作的生命线。5.2 重复数据到底去哪了很多人以为合并后所有part里的重复行会被物理删除。实际上合并是“写一个新part丢弃旧part”的过程。旧part在被新part替代后等待后台清理线程物理删除。所以合并完成后如果你用system.parts表查询可能还会看到active0的旧part在磁盘上躺一会儿。这不算bug只是清理有延迟。如果你在合并后的极短时间内查询ClickHouse只会读active1的part所以查不到旧版本。但如果直接去文件系统层面翻旧数据可能还在。这提醒我们ClickHouse的“更新”不是实时物理覆盖而是逻辑覆盖延迟回收。5.3 为什么不能依赖后台合并后台合并的触发条件通常由background_pool_size、merge_tree_*相关的配置决定并且受part数量、大小以及系统负载影响。小数据量插入频繁时part数量会快速增长后台合并线程可能来不及处理导致“更新数据长期不收敛”。我碰到过最夸张的情况一个业务表每秒插入几百批小数据part数量涨到上千查询FINAL时扫描开销巨大。后来我们通过调整max_part_number_per_insert_batch、合并线程启动策略和分区粒度才把part数量压下来。更重要的是在设计链路时就给每个订单的状态变化尽量一次性批量INSERT避免几十行数据分批插入。5.4 用FINAL查询真的慢吗真的。慢但没有想象中那么可怕。如果一张表的大部分数据都不涉及多版本FINAL最终处理的重复数据量有限。但一旦表中有很多重复排序键FINAL就会在内存中为每个排序键构建版本判定队列OOM风险也随之增加。优化方向有两条窄化FINAL查询范围优先用分区裁剪和WHERE条件缩小读取量或者对高基数字段手工去重用argMax代替FINAL。实际项目中我对用户画像表用了后者效果很好因为用户维度很多每条用户记录都有几十个属性列全表FINAL代价太高了。5.5 误删和误改怎么办再强调一次专用引擎的“更新”是逻辑更新不是物理删除。如果真的误插了一条错误的新版本数据你就得人为插入一条sign-1行“负向取消”。例如ReplacingMergeTree没有取消能力只能再插一条新版本覆盖。如果误插的新版本已经合并进了大part你依然可以靠再次INSERT告诉它“这个值被取消了”只是在合并完成前查询结果会有短暂混乱。这也是为什么我在状态流场景中坚持用VersionedCollapsingMergeTree。它允许我们真正“撤销”一条数据而不仅仅是覆盖版本。误操作后只要再追加一条sign-1的旧版本记录加一条正确的新记录最终合并后就能恢复到正确的状态。5.6 中文文档与官方说明中容易误解的几点官方文档写“CollapsingMergeTree会删除成对的行”这里“删除”指的是合并输出时不包含它们而不是物理擦除。另外文档说VersionedCollapsingMergeTree要求“版本列必须包含在排序键中”但实际使用中我也遇到过版本列不在排序键但能用的情况不过行为可能不可预测。所以建议你建表时严格把version放进ORDER BY里避免踩坑。还有一个经常被忽视的点专用引擎的“更新”只能在同一分区内生效。如果你按天分区一个订单跨了两个分区那么排序键相同的两行分布在两个分区后台合并时不会被放到同一个part里去重自然失败。所以对“按业务唯一键更新”的场景分区键设计不能与更新逻辑冲突。要么让业务主键包含分区字段要么把分区粒度放宽不然你会得到永远无法去重的死局。最后再分享一个我自己的习惯所有需要用专用引擎做UPDATE的表我都会单独准备一张“审计表”专门记录每次INSERT的版本与sign。这并不会给ClickHouse带来太大压力但一旦需要排查数据不一致这张审计表能让你快速定位是哪一步写错了而不是对着几百个part发愁。ClickHouse的“快速UPDATE”本质上是对分析型系统的一种妥协设计牺牲强一致和实时可见换取写入吞吐与查询性能。你在选型前一定要想清楚业务到底能不能接受最终一致。如果答案是不能那也许MySQL或PostgreSQL更合适如果能接受那MergeTree家族的专用引擎几乎是目前大数据分析链路里最成熟、最高效的更新方案。