
ClickHouse里的delete用过的人大概都能吐出一肚子苦水。我见过不少人第一次写ALTER TABLE ... DELETE眼巴巴等着SQL返回成功结果一查system.mutations状态还是STARTED一挂就是几个小时。更刺激的场景是你在生产库上执行一条delete过几分钟磁盘使用率从60%飙到90%集群开始报警打嗝。今天这篇想把它聊透ck的delete背后到底是什么机制为什么难用以及生产环境下最稳的删法是什么。适合正在用ck做数仓、或者刚接手ck集群并准备做数据清理的同学哪怕你是刚入门ClickHouse只要对存储引擎有点概念后面这些内容也能看懂。1. 先说清楚ck的delete到底是个什么操作很多人第一次在ClickHouse里删数据下意识会用MySQL那套思维去理解。你想删掉表里符合条件的几行写完SQL按回车等结果。但ClickHouse从底层存储模型就没打算让你这么干如果还用“删行”去理解它后面所有坑都踩得莫名其妙。1.1 存储模型决定了你没法“删行”ClickHouse的MergeTree系列表底层数据是按data part存储的每个part本质上是一组不可变的文件段。part之间通过后台的merge过程持续合并新写入的数据会形成新的part然后慢慢和老的part合并成更大的part。这里有个关键点part一旦落盘就不会再修改了。这个设计跟LSM-Tree那套思路类似好处是写入非常简单——追加文件就行没什么随机IO也不存在行级锁。但代价就是任何“修改”操作都变得极其别扭。你想象一下把一张表当成一本几十万页的印刷书籍。MySQL这种行存储的delete相当于用铅笔在某个页面上轻轻擦掉几行字页面还在其他内容不受影响。ClickHouse里可没有“擦除”这个概念delete操作实际上是把某些页面全部重印一遍再从原书上把旧页面撕掉。这个类比虽然糙但精准命中了一个问题在ClickHouse中执行delete不是为了删除那几行而付出成本而是为了“得到一个删除后留下的新part”而付出整块数据的重写成本。1.2 你写了条delete后台流程全貌在ClickHouse里无论是老版本的ALTER TABLE ... DELETE WHERE ...还是后续版本引入的轻量级DELETE FROM ... WHERE ...底层都绕不开mutation机制——除非你走的是分区删除或TTL后面我会单独讲。一条完整的ALTER TABLE ... DELETE提交后后台大致经历这么几步元数据注册ClickHouse为这条mutation生成一个版本号记录到system.mutations表里状态为STARTED或PENDING。逐个part处理对表里现有的每个part如果该part的版本低于mutation版本就会进入待处理队列。涉及到具体part时会读取part中的数据过滤掉满足WHERE条件的行然后把剩余数据写成一个全新的part。原子替换新part写好后旧part会被标记为inactive不再参与查询但物理文件还留在磁盘上。后台清理标记为inactive的旧part等到集群里的merge和清理线程有闲暇时才会被真正删掉释放磁盘空间。所以你会发现ALTER TABLE这个SQL本身很快就会返回成功——它只负责“提交”一个异步任务绝不负责等待任务完成。这就是第一层坑你以为删完了实际刚起跑。如果你把mutations_sync参数改成1或2SQL会同步等待当前节点或所有副本上的mutation执行完毕但生产实测中大表上这么干很容易让客户端长时间挂起更像是一种“自欺欺人的同步”。注意mutation是有版本身份的每个part都记录了它已经应用到了哪个mutation版本。一个part如果处理完了新提交的mutation它的版本号就会前移。这也是为什么同一个mutation不会重复处理同一个part。2. 为什么它“难用”三个绕不开的硬伤知道了机制再回头看“难用”这件事就很好解释了。不是ClickHouse团队故意跟DBA过不去而是这套设计天然带来几个副作用在生产环境下都会被放大。2.1 异步执行你永远拿不到“删除成功”的回执这是最伤人的一点。在MySQL里delete执行完返回Query OK那就是真的删完了事务提交了后续读不到。在ClickHouse里执行完ALTER TABLE ... DELETE返回的是“mutation已提交”。那数据什么时候可查不可查实际上只要某个part处理完mutation这个part的查询结果就“包含删除语义”了。但不同part处理进度不一样所以整个表会处于一种“部分已删、部分未删”的中间状态对于已经处理完mutation的part查询时看不到那些行对于还没处理的part查询时依然能看到。如果你在删除后立刻去查可能还能查到一堆已提交删除的行直到mutation跑完。这在线上的数据对账、存量清理场景里非常要命——下游任务看到的数据不一致而且这种不一致是“动态变化”的。所以正规的做法是delete提交后通过system.mutations表轮询is_done列确认整个mutation完成之后才能让下游任务继续。但问题又来了——一个大表mutation可能跑几小时这几小时里你的调度系统都得挂起等待业务上完全没法接受。2.2 一条删除整表“翻新”一遍更让人无语的是成本。很多刚接触ck的同学以为删除用户ID123456的数据最多把那几行“抠掉”就行。实际上ClickHouse根本不知道哪些part里有用户ID123456的数据——除非WHERE条件恰好命中主键或分区键且能触发分区裁剪partition pruning。大多数情况下mutation会去扫描所有part把匹配条件不存在的那些part也统统“翻新”一遍读取全部数据、判断条件、重新写出新part。即使一个part里一条匹配数据都没有只要它的版本低于mutation版本它也得被重写一次。我见过最夸张的一次是清理一张包含两年历史数据的大表WHERE条件仅仅命中了其中3天的数据但全表所有几百个part全部被重写。磁盘空间先涨了一倍然后慢慢降下来。redis的磁盘空间告警期间整个集群的IO都被拖垮查询毛刺特别明显。所以说白了一條delete的成本不是你删了多少数据而是你那张表有多大——数据量越大无差别重写的成本越高。2.3 功能裁剪得“只够删不够用”第三个硬伤来自功能边界。ClickHouse的ALTER TABLE ... DELETE不是万能的它的条件限制比MySQL多很多只支持MergeTree系列引擎一个Log引擎表你想删数据没门只能整文件处理。如果WHERE条件命中不了主键或分区键执行效率就会断崖式下降因为变成了全量扫描重写。对语法限制也比较严格不支持子查询、不支持JOIN、不支持LIMIT条件写起来束手束脚。多个mutation同时提交时后台还有并发限制排在后面的mutation只能一直等。这些限制叠加到一起“删数据”就成了一件需要提前规划、小步慢跑的事情完全不像数据库该有的样子。很多团队尝试在生产库上直接删数据删到最后都是欲哭无泪要么空间被吃掉半个节点要么mutation排队排到天荒地老。3. 生产环境中的真实灾难现场纸上谈兵没意思说几个我在生产环境里真实见到的“灾难”场景。虽然具体业务各不相同但模式大同小异踩过坑的人应该会有共鸣。3.1 大表删除引发存储爆炸我们有一次做用户数据清理一张约1.2TB的表需要删除其中某个业务类型的历史数据大约占全表的20%。按正常思路先ALTER TABLE ... DELETE WHERE biz_typexxx提交预计量不大就没太关注。结果不到20分钟监控告警节点磁盘使用率达到85%之前是55%。我一查mutation还在跑后台正把包含目标数据的part全部重写新part写出来了旧part还没清掉两块空间同时存在。这个阶段任意时刻磁盘占用都接近“旧数据 新数据”的总和。如果没有预留足够的空间余量这个操作能直接把磁盘打满mutation失败不算最糟最糟的是磁盘满了之后整个节点的写入、merge全部阻塞集群基本瘫痪。从那以后我定了条规矩任何大表的delete操作执行之前必须先看磁盘余量预留出“整表大小”的空闲空间再决定要不要执行。3.2 “删不动了”mutation排队与积压另一个常见场景是mutation排队。ClickHouse后台处理mutation不是无限并发的它跟merge共用后台线程池而且默认还限制mutation最多占一半的merge资源max_mutations_running_ratio默认是0.5。当你连续提交多条delete时背后的情况很尴尬队列里第一条mutation还在重写part第二条、第三条只能卡在PENDING状态一直等着越积越多后面提交的mutation越排越后面。这时候你查system.mutations会看到一堆is_done0的行每行的create_time还在往前涨。如果没有监控表里mutation堆积量你可能完全无感知直到业务方反馈“某个数据删了一天都没生效”。打开一看前面三条大mutation已经把队列占满了你那条小delete排到最后前面的阻塞多久它就得等多久。3.3 删完就卡查询/合并性能雪崩除了存储和排队还有一个隐藏杀手part数量失控。刚才讲过mutation会重写part每处理一个part就生成一个新part同时旧part变成inactive等待清理。如果一张表本来part数就偏多一条跨大量part的delete会在一小时内让part数量翻倍。part数一多SELECT查询就要扫描更多文件性能明显下降写insert时MergeTree会检查part数量超过阈值parts_to_throw_insert默认300直接抛TOO_MANY_PARTS异常。这时候新增写入都会失败业务等于半停产。我遇到过最离谱的一次一张业务表part数从120涨到600多后台merge线程全力追赶但mutation还在不断生成新part等于一边清一边加。当晚凌晨两点我手动把那条还在跑的mutation停掉再强制OPTIMIZE TABLE ... FINAL把part压下去才缓过来。后来查统计数据那条delete操作本身没跑完但已产生的临时part占空间超过400GB。3.4 轻量级删除也不是万能药ClickHouse从v22.3开始引入了轻量级DELETE FROM ... WHERE很多同学觉得这下舒服多了。但真要指望它翻身还差点意思。轻量级删除的原理并不是做了数据物理删除而是通过_row_exists_mask在part内部的索引中标记某些行为“已删除”查询时会自动过滤。好处是删除动作本身很快不会触发全量part重写坏处是磁盘空间不会立刻释放被标记删除的行仍然占据物理空间只有等后续merge把这些行真正合并掉空间才会恢复。如果业务上频繁大量执行轻量级delete_row_exists_mask会越来越大查询性能会随着删除比例升高而恶化。轻量级删除对表引擎、语法和版本仍有诸多限制比如早期版本里它不能和ALTER UPDATE混用兼容性需要仔细核对官方文档。所以它适合那种“需要快速让旧数据查不到”的场景不能替代真正的物理空间回收。真想把空间腾出来你还是得回到mutation或分区删除那套老路上去。4. 生产环境怎么稳着删可落地的替代方案聊完为什么难用、会遇到什么灾难再说点真正能落地的方案。核心思路很简单能不加锁就不加锁能整分区删就别做行级删能让TTL自动跑就绝不用手删。4.1 按分区删DROP PARTITION是yyds如果你在建表时就设计了合理的分区键——最常见的就是按天或按月——那恭喜你你拥有生产环境里最快的删除武器ALTER TABLE your_table DROP PARTITION 20250101;这个操作不走mutation不重写part它做的只是把这个分区对应的目录整个丢掉磁盘空间立刻释放速度极快执行成本极低。条件只有一个你设计表的时候就要考虑好后续怎么按分区清理分区键必须和主流清理维度对齐。如果你的表是可以补建分区的我强烈建议把它作为清理数据的首选方案。很多业务场景比如日志、明细、事件流水天然就是按时间累积的按天/按周分区然后定期DROP PARTITION这可能是ClickHouse里最优雅的删数据方式。当然它的代价是没有行级过滤能力——你要删的就是整个分区不能删分区里某几个用户的数据。如果业务确实要做行级清理那你只能考虑下面几种方案了。4.2 TTL做自动过期ClickHouse的TTL功能虽然也走后台任务但比手写mutation省心太多。你可以在表引擎参数或列上直接配CREATE TABLE your_table ( event_time DateTime, data String ) ENGINE MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY event_time TTL event_time INTERVAL 90 DAY DELETE;这样到了90天过期数据就会被后台的TTL任务自动清掉不需要你每天手动提交delete。生产实践里绝大多数“定期清理历史数据”的需求都应该用TTL来解决而不是让DBA半夜爬起来写ALTER语句。注意TTL并不是精确到秒实时清它依赖merge流程触发所以会有一定延迟。合理的做法是设置一个略大于业务实际保留时间的TTL比如业务要求保留90天你设95天或100天给自己留缓冲避免卡在边界上产生数据缺失。4.3 重建表临时表INSERTEXCHANGE如果是“一次性的大规模复杂删除”比如要把一张2TB的表删掉60%条件又复杂到不能走分区删除那么老老实实重建表往往比一条mutation跑通宵更可控。流程大概是建一张新表your_table_tmp结构和原表一致引擎参数、分区键、排序键都保持一样。把需要保留的数据插入到新表INSERT INTO your_table_tmp SELECT * FROM your_table WHERE 保留条件;原子交换表名EXCHANGE TABLES your_table AND your_table_tmp; -- 或者 ALTER TABLE ... RENAME TO ...验证数据量、查询样例确认无误后DROP TABLE your_table_tmp此时它保存的是旧表数据。这个方案本质上是“全量重建”期间磁盘占用还会先翻一倍但好处是整个过程完全可控、可预测、随时可以停止不像mutation那样黑盒跑几小时。实际操作中我会建议在低峰期做并且先在测试环境跑一遍把耗时和binlog量级估出来再动生产库。4.4 业务层“假删除”墓碑标记定期清理最后再介绍一个我目前最常用的方案特别适合用户主档、订单、活动数据这类需要频繁逻辑删除的场景。做法很“土”但非常稳表结构加一列is_deleted UInt8 DEFAULT 0作为墓碑标记业务上要删数据时不是提交delete而是执行轻量级更新ALTER TABLE ... UPDATE is_deleted1 WHERE ...所有查询SQL统一追加AND is_deleted0条件让业务侧感知不到已删除数据每天晚上低谷期跑一次真正的mutation把is_deleted1的行物理清掉或者用TTL把这些墓碑行定时清掉。这样做的本质是把“一次大规模异步删除”拆成了“两次低成本删除”业务侧的实时可见性靠标记实现物理回收留给后台慢慢磨。虽然最终还是逃不脱mutation那套底层逻辑但至少你掌握了执行节奏不是在别人都在用库的时候捅一刀。5. 实操心得与避坑速查5.1 我们团队后来怎么用delete的踩了这么多坑之后我们团队现在处理ClickHouse数据删除基本遵循一套固定流程分享给你参考先评估再动手。任何一次delete前先跑一遍SELECT count(), sum(bytes) FROM system.parts WHERE tablexxx AND active1看清楚这张表有多少part、实际占多大空间再结合磁盘余量决定走mutation还是重建表。控制频率绝不并发。同一张表同时只允许一条mutation在跑。多张表也不要同时发起大mutation毕竟后台线程池是共享的。设置合理参数。max_mutations_running_ratio不要无脑调高留一部分线程给merge否则mutation会加剧part数量暴涨如果mutation长期堆积可以临时调大后台线程池但要记得事后退回。首选分区操作。能在建表层面设计好分区后续全部用DROP PARTITION和TTL这是最省心的路线。行级删除只留给真正必须的场景。全程盯监控。delete执行期间至少每10分钟看一眼磁盘使用率、system.mutations里is_done状态、part总数变化。任何一个指标异常立刻评估要不要杀任务止损。5.2 DELETE相关常见问题速查表问题常见原因排查/解决方案delete提交后很久状态还是STARTED同一张表已有mutation在跑或后台线程被占满查system.mutations看command和create_time确认没有更早的mutation阻塞必要时清理积压任务磁盘空间在delete期间暴涨mutation正在重写part新旧part并存预留整表大小的空间余量如果空间不足立刻取消mutation并清理inactive part执行delete后查询仍然能看到数据part尚未全部处理完删除语义还没覆盖全表用system.mutations确认is_done1或者改用分区删除写入报TOO_MANY_PARTSmutation/高频写入导致part数量失控做OPTIMIZE TABLE ... FINAL合并part低峰期操作调高parts_to_throw_insert只是临时缓解副本上mutation迟迟不完成ReplicatedMergeTree副本落后或ZooKeeper/Keeper压力大查副本延迟和delayed mutations状态优先修复落后副本轻量级删除后空间没释放删除只是标记行物理文件等merge后才清理接受延迟或手动OPTIMIZE TABLE ... FINAL加速merge还有一条容易被忽视的ALTER TABLE ... DELETE这类mutation一旦开始正常情况下是不能像cancel query那样直接中断的你只能等它跑完或者杀掉对应线程后通过后台机制恢复。所以在生产库上提交大mutation之前请务必多问自己一遍能不能走分区删除能不能用重建表能不能用TTL如果答案都是“能”就别碰mutation——这不是偷懒这是保命。我在实际运维中最大的体会是ClickHouse的delete设计思路本质上是逼着你提前思考数据生命周期。把删除的粒度拆到分区、把清理的动作交给TTL、把实时可见性交给标记位让mutation只做最后的兜底回收这样生产环境才能稳得住。别再天真地拿它当MySQL的delete用数据量和并发上来以后一次随意的delete赔进去的可能就是整个集群的可用性。