news 2026/10/6 19:34:01

ClickHouse删除机制避坑指南:为何DELETE这么难用?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ClickHouse删除机制避坑指南:为何DELETE这么难用?

做ClickHouse运维这些年,我接到的每一个“帮我删一条数据”的需求,背后都藏着一颗可能把集群搞崩的定时炸弹。不是危言耸听,CK的delete从底层设计上就和MySQL、PostgreSQL的delete不是一回事,理解不了这一层,生产上迟早要出事故。

很多人第一次接触CK的删除操作,会下意识用标准的SQL思路去理解:delete from table where id = 1,执行完,这条数据就不该存在了,磁盘也会腾出来。但在ClickHouse里,这个直觉是错的。它不会瞬间把行删掉,也不会痛痛快快释放空间,甚至执行完那条SQL之后,数据还会“活”好一阵子。

我在这篇文章里会把CK的delete机制、为什么难用、以及生产环境里的典型事故完整拆开讲一遍,最后给出我在实际运维中总结的替代方案和保命手段。整篇内容基于真实踩坑经历,不堆概念,只说怎么踩的、怎么爬出来的。

1. 先弄清楚:一条delete在ClickHouse里到底干了什么

1.1 执行流程:它其实在后台“重写”全表

先看一条最普通的删除语句:

ALTER TABLE event_log DELETE WHERE user_id = 12345;

或者新版ClickHouse支持的标准语法:

DELETE FROM event_log WHERE user_id = 12345;

执行完之后,ClickHouse返回一个成功标识,看起来一切正常。但实际上,这条语句干的事情远没有“删除”两个字这么简单。

ClickHouse的底层存储模型是列式存储,表数据由大量的不可变数据块(part)组成,每个part内部按主键有序,并且是被压缩存放的。为了保证顺序读性能、压缩比和并行扫描能力,part一旦落盘就“不可修改”。既然part改不了,那删除操作怎么办?

答案是:重写。

当一条DELETE语句被下发到后台,ClickHouse会为命中的每个part生成一个新版本,把需要删除的行剔除掉,再把这个新part写回磁盘,然后老part再被标记为过期。整个过程就是一次实实在在的数据搬移。即使你只是删除一行,只要这一行所在的那个part里有其他不能删的数据,整个part也得全部重写一遍。

这是理解CK的delete为什么难用的第一步:它根本不是一个“删”的动作,而是一个“重写”的动作。

1.2 为什么ClickHouse要选择这种设计

有人会问:为什么不设计成像InnoDB那样,直接在那一行的位置上打一个删除标记,或者在一棵B+树上做节点删除?

这是由ClickHouse的定位决定的。它是一个分析型数据库,核心场景是要在几十亿行上做聚合、扫描、统计。为了在磁盘上高效地顺序读、高比例压缩,它把数据组织成“整块不可变part”而不是“可随机读写的行”。这种设计换来的是极致的分析性能,代价就是失去了点对点删除的能力。

可以做一个更生活化的类比:你的数据不是一本可以随时用橡皮擦掉某句话的便签本,而是一套已经装订好并压缩成册的档案。想改其中一句话,不能直接在原册上涂涂改改,只能把整本册子重新打印一遍,再用新册子替换旧册子。

所以,设计选择无所谓对错,但如果你把CK当成MySQL来用,就会立刻感受到这种底层数据结构带来的全部痛苦。

1.3 什么时候delete会慢,什么时候会快

并不是所有DELETE都必然造成灾难。它能快能慢,取决于一个核心变量:需要重写多少个part。

如果删除条件能精准命中一个很小的分区,比如一张按天分区的表,你只删某一天的数据,而这天只有一个part,那么重写量很小,删除任务很快就能完成。但如果删除条件散落在大量part上,或者更糟——条件无法利用分区裁剪,那么后台就要把所有相关part全部重写一遍,代价会成倍放大。

这里有一个很多新人会忽略的事实:DELETE的代价,跟你要删除的行数关系不大,主要跟命中的part总大小强相关。删1条行和删1000万行,如果它们落在同样的part集合里,付出的重写代价基本是一样的。这一点在下一节专门展开。

2. 一条条拆解:delete为什么这么难用

2.1 异步、不确定、不给你确认机制

ClickHouse的mutation(这个机制的名字)是异步执行的。默认情况下,执行完DELETE之后,你的SQL会立刻返回成功。但它只代表“这个任务已经提交到后台了”,并不代表数据已经被删掉。

如果一条删除SQL下了之后,立刻去select,极有可能还是能查到数据。这就导致业务方和DBA之间经常出现一种经典对话:

业务:我刚删了,怎么还能查出来? DBA:后台还在执行mutation。 业务:那什么时候执行完? DBA:看情况。

“看情况”三个字,在生产里就是悬在头上的刀。如果删除的数据量很大,或者集群负载本身就高,mutation可能要跑几十分钟、几小时,甚至更久。在这个过程中,数据处于一种“删了一部分,没删干净”的中间状态,对业务逻辑来说是非常难处理的。

system.mutations表是唯一的观测窗口:

SELECT mutation_id, command, create_time, parts_to_do, is_done FROM system.mutations WHERE database = 'default' AND table = 'event_log' ORDER BY create_time DESC;

这里能清楚看到有多少part待处理、是否完成。但问题是,生产环境里没有人会每秒钟盯着这张表看,大多数时候mutation是在一个无人关心的角落里默默耗尽磁盘IO。

2.2 性能账单:删除成本和行数无关,和part大小强相关

假设一张表有500个part,总大小300GB。执行一条没有分区限制的DELETE,命中了全部500个part。那么后台就需要把这300GB的数据重新读一遍、过滤掉命中行、再重新压缩写回磁盘。

这意味着一次本意是“删掉几十万行”的操作,实际产生了至少300GB的写入量,同时读IO也接近300GB。算一下:集群磁盘写入速度假设是1GB/s,光写这一项就需要5分钟,这还不算压缩、校验、分发、副本同步等额外开销。如果磁盘本身已经接近水位线或者有其他查询在跑,整个集群的IO会瞬间被打满。

更麻烦的是,mutation执行期间,新旧part是同时在磁盘上存在的。要重写300GB的数据,磁盘可能在短时间内额外占用接近300GB的临时空间。一个“删点数据”的操作,反而可能把磁盘空间“越删越满”。生产环境里因为这个原因触发磁盘告警的情况,我见过不止一次。

2.3 轻量级删除:看起来快了,代价转嫁到查询上

ClickHouse后来的版本提供了一种所谓的“轻量级删除”(Lightweight Delete),标准写法就是DELETE FROM ... WHERE。它的设计初衷是解决那种“只想删少量行,但重写整个part太贵”的问题。

轻量级删除的实现方式是在part里写入删除标记,并不真正重写数据文件。查询时,扫描引擎会发现这些标记,并在返回结果时把对应行过滤掉。

听起来很不错吧?但生产环境里用起来有几个坑:

第一,空间不会释放。删除标记只标记了“逻辑上不存在”,底层的物理数据还在磁盘上。删完数据,磁盘占用可能一点都没降。

第二,读放大。每次查询都要额外判断删除标记,扫描时就得跳过这些行。如果表上积累了大量删除标记,查询性能会肉眼可见地下降。后台最终也要通过一次真正的mutation/merge来把这些标记清理掉,那一步仍然逃不掉重写part的命运。

第三,行为受限。轻量级删除通常有一些前提条件,比如不适合高频并发写入、不适合分布式表、或者某些版本下会自动退化成完整mutation。你以为是轻量删除,实际可能照样是重写,只是你一开始没发现。

所以轻量级删除只是把问题延后了,并没有从根本上让DELETE变得“便宜”。

2.4 分布式下的连锁反应

大部分生产ClickHouse集群是多副本的。这带来一个非常严肃的问题:mutation不是在某一个副本上独立执行的,它需要在每一个副本上都执行一遍。

我在一个三副本集群上经历过这样的场景:一条DELETE语句触发了mutation,三个副本各自开始重写part。其中一个副本正好赶上磁盘故障或者负载较高,迟迟没有执行完。结果这个mutation在system.mutations表里一直挂着,后面所有新的mutation也全部排队等待,因为ClickHouse要求针对同一张表的mutation按顺序执行。队列越积越长,新写入的数据也要等,查询要忍受越来越高的IO负载,最终整个集群的可用性都被拖下水。

多副本同步还会放大ZooKeeper或ClickHouse Keeper的协调压力。每执行一个mutation,控制节点都要给所有副本分发任务、记录状态、确认结果。频繁执行DELETE,哪怕每次删除的数据量不大,也会让控制节点疲惫不堪。

在分布式环境下,你执行的不是一条“DELETE”,你是在向整个集群广播一场需要每个节点都参与的数据重写运动。

2.5 没有事务、没有回滚

ClickHouse的mutation不是事务性的。它没有“如果条件不满足就自动回滚”的说法。一旦执行成功,它就会持续推进。

如果发现条件写错了,比如本来只想删一个月的数据,结果条件没限好把一年的数据都标记删除了,这时候你没办法通过“撤销上一条SQL”来恢复。你只能祈求备份还在,或者从上游重新导入数据。而备份恢复在动辄几百GB甚至TB级的数据量下,恢复时间是以小时甚至天来计算的。

正是这一点,使得“在生产环境里敢不敢执行一条DELETE”变成了一个极其严肃的决策问题。

3. 生产里的几个真实灾难场景

3.1 场景一:一条DELETE把集群CPU打到100%

当时有个业务要找出一批异常用户ID,从一张十几亿行的用户行为表里删掉。执行同学是这么写的:

ALTER TABLE user_behavior DELETE WHERE user_id IN (SELECT user_id FROM abnormal_users);

这条SQL任谁看了都觉得理所当然。但它命中了一张巨大表的全部part,子查询先扫一遍异常用户,然后每个part都要重写。第二分钟,集群CPU从20%直接干到100%,磁盘IO打满,其他业务的查询排队时间指数上涨。

我们后来在system.mutations里看到,这个任务的part重写量接近400GB,占用了整整四十分钟才跑完。四十分钟内,集群的查询延迟从几十毫秒飙到了十几秒。那天的线上告警十个手指头都数不过来。

3.2 场景二:删完数据才发现,旧数据已经被后台合并吃掉了

另一个场景是误操作。业务方给了一个需求:把某张表里标记为失效的记录删掉。DBA同学执行的时候漏了时间范围条件,写成:

DELETE FROM coupon_record WHERE status = 'invalid';

执行完发现,这类记录分布在所有分区,占了全表数据的85%。也就是说,这张表几乎要被“重写”一遍。但那时候mutation已经开始跑,后台正在逐个part重写。

当时大家觉得“删错了没事,旧part还在,可以抢救”。实际上,mutation执行的过程中,部分老part已经和重写后的新part发生了合并,旧数据文件被标记清理,再也没办法从表里捞回来了。最后整个团队花了两天时间从离线数仓重新回补数据,其间业务查询一直处于一种“半残废”状态。

这件事给我的教训是:千万别以为“还没跑完”就等于“还能恢复”。mutation是持续前进的,你先要确定它跑到哪一步了,才能判断是否可救,而这一步的判断时机往往转瞬即逝。

3.3 场景三:轻量删除产生的“删不掉”的幽灵数据

某个业务需要对单用户执行删除,数据量很小。用了新版本支持的轻量级删除之后,当时看起来是同步完成的,查询也查不到了。可是过了一天,业务反馈:被删除的用户又出现在报表结果里。

排查发现,轻量删除只是标记了part里的行,后来触发了part合并,合并过程中新旧part里的删除标记没有按预期继承,某些行又“复活”了。这是一个非常隐蔽的问题,不是每次都会发生,但一旦发生,对业务数据正确性的打击是毁灭性的。

虽然新版本在持续修复这类边界问题,但这条经历让我彻底明白:把核心业务的删除需求放在ClickHouse上,本质上是把宝压在了一个“并不擅长删数据”的引擎上。

3.4 场景四:mutation堆积成雪崩

最后还有一个典型的雪崩路径。一张表的分区设计不合理,比如按周分区,导致一周的数据全部堆积在一个超大part里。某天有人对这张表执行了一次DELETE,mutation开始在后台处理这个超大part。

由于part太大,重写速度极慢,期间又有新写入的数据不断产生新的part。而这些新part为了保持主键有序,也可能需要合并。可是后台线程已经被mutation占满了,合并任务排队,新写入量继续累积。mutation还没跑完,另一个开发又提交了一条新的DELETE,继续排队。

最终结果:表后台堆积了几百个待处理的mutation,磁盘空间因为新旧part共存开始告急,查询变慢,insert延迟变大,整个集群进入恶性循环。

这种雪崩一旦形成,止损只能靠KILL MUTATION,但已经被重写掉的part不会自动恢复,磁盘空间也不会立即释放,现场会乱成一团。

4. 生产环境里,正确的删数姿势是什么

讲完了灾难,该说说怎么避免灾难。我的核心观点很明确:不要在ClickHouse里把DELETE当成日常操作。你需要把它当成一种“极端情况下的兜底手段”,并且围绕这个认知去设计表结构和运维流程。

4.1 最好的delete是“没有delete”:用分区和TTL消灭行级删除

ClickHouse真正擅长的是整块整块地丢弃数据,而不是一行一行地删除。如果你在设计表结构的时候就把“数据生命周期”考虑进去,绝大多数删除需求根本不会走到DELETE这一步。

第一招:按时间分区。

绝大多数分析场景都需要按时间筛选数据。如果你维护一张按天分区的表,要清理某个历史月份的数据,直接用:

ALTER TABLE event_log DROP PARTITION '2024-06';

这个操作是分区级别的,基本不涉及行级重写,后台直接把整个分区摘除并删除文件,速度比mutation快一个数量级,而且几乎不影响其他分区的查询。生产环境里,能用DROP PARTITION解决的清理需求,就千万别用DELETE。

第二招:用TTL自动淘汰过期数据。

ClickHouse的TTL机制可以根据时间列自动删除过期数据,例如:

ALTER TABLE event_log MODIFY TTL event_time + INTERVAL 90 DAY;

TTL也是后台执行的,但它是基于part整体处理的,代价远比行级mutation小。设置好之后,你甚至不需要人工介入,过期数据会自动在后台悄悄消失,磁盘水位也能稳定控制住。

第三招:如果删除条件本身就是某个分类或者状态,考虑把它设计成分区键的一部分,而不是随意用DELETE去扫。

一个合理的表结构,能让你在生产里直接绕开delete这个坑。这句话值得再读一遍。

4.2 数据订正场景:重建表代替mutation

如果确实需要做一次性的行级数据订正,比如要从几亿行里删除几百个ID,我不会直接在原表上执行DELETE,而是选择“重建表”的方式。

大致步骤是这样的:

-- 1. 创建一张同结构的新表,建议加一个临时后缀 CREATE TABLE event_log_new AS event_log; -- 2. 把需要保留的数据写入新表 INSERT INTO event_log_new SELECT * FROM event_log WHERE user_id NOT IN (SELECT user_id FROM delete_list);

写入完成之后,用RENAME TABLE做切换:

RENAME TABLE event_log TO event_log_old, event_log_new TO event_log;

确认新表数据正确后,再手动DROP TABLE event_log_old。

这个方式看起来要重建整张表,听起来很笨,但它有一个巨大优势:整个过程是确定的、可控的、可分步验证的。你随时可以检查新表的数据量是否符合预期,发现问题也可以继续往新表里补写,不用担心mutation跑到一半停不下来。而且这个操作不占用mutation队列,不会影响到其他正常的后台合并任务。

如果你的表非常大,可以按天或者按分区一段一段地INSERT INTO ... SELECT,分批推进。虽然总的上层数据搬移量可能和mutation差不多,但从运维视角看,它的风险是分散的、可管理的。

4.3 非用delete不可时的安全操作清单

如果因为某些原因必须用DELETE,那我建议至少严格遵守下面这套操作清单,一条都不能省。

第一,先量化影响范围。在执行DELETE之前,先看命中多少数据、涉及多少分区、多少part,对重写成本有数:

SELECT partition_id, count() FROM system.parts WHERE table = 'event_log' AND active GROUP BY partition_id;

第二,缩小范围到极限。DELETE条件必须把分区键或者日期范围卡得死死的,比如:

SET mutations_sync = 2; ALTER TABLE event_log DELETE WHERE event_date = '2024--06-01' AND user_id = 12345;

不要写那种“理论上只影响几行”但没有分区约束的条件,那等于让后台全表扫一遍。

第三,选低峰期执行,并且提前在告警群里公示。让所有人知道接下来集群IO可能异常,避免在mutation执行期间叠加重的分析查询。

第四,执行前做一份快速备份。ClickHouse原生的FREEZE操作可以快速给当前表做一个一致性快照,虽然它不是全量备份的替代品,但关键时刻能救命:

ALTER TABLE event_log FREEZE;

第五,盯着system.mutations看进展,一旦发现磁盘占用率快速上升或者集群IO异常,立刻准备KILL MUTATION止损:

KILL MUTATION WHERE database = 'default' AND table = 'event_log';

注意,KILL MUTATION不代表已经重写的part会恢复原状,它只是停止后续待执行的part,所以止损要趁早。

4.4 监控mutation和备份兜底

最后一点,也是很多团队最容易漏掉的:把mutation队列当成一种需要长期监控的指标。

我建议在公司内部的ClickHouse监控面板上至少加三个指标:

  • system.mutations里is_done = 0的任务数量。这个数字长期大于0就要警惕。
  • 磁盘使用率变化。特别是mutation执行期间,如果磁盘使用不降反升,说明正在重写,要小心容量上限。
  • 后台ReplicatedMergeTree部分的Merge/Mutation线程的占用情况。

备份这件事就不展开讲了,但我在生产里见过太多没有备份的ClickHouse集群。一旦误删数据,别说恢复,连从哪儿捞数据都不知道。clickhouse-backup或者原生的BACKUP TABLE都能用,关键是你要真正做过一次恢复演练,而不是只在文档里写了一行配置。

5. 踩过几次坑以后,我对delete的看法

在我带过的团队里,我对所有开发和DBA立过一条不成文的规矩:凡是新增的业务需求,只要包含“删除单条/部分数据”这个动作,第一反应不应该是怎么在ClickHouse里把DELETE写得高效,而是应该先问一句——这个数据真的需要放在ClickHouse里删吗?

ClickHouse最擅长的是顺序写、批量查、大范围聚合,它天生不是一个支持随机点删的数据库。你可以在架构上把需要频繁删除的数据放到另外的存储里,或者通过TTL、分区管理在ClickHouse内部做“自然的淘汰”,而不是在查询链路里用一个DELETE来强行实现。

如果真的必须在CK里执行删除,也别把它当日常操作,而是当成一次需要走变更流程的运维操作。写SQL前的评估、写SQL时的范围限制、执行后的监控、备份的完整性,每一条都是在事故边缘拉你一把的栏杆。个人经验是:所有生产级的delete操作,宁可在评估阶段多花半小时,也好过在事故处理阶段熬一个通宵。

这行干得越久,越觉得很多事故不是不懂原理,而是太相信“一条SQL就能解决问题”这句话。在ClickHouse这里,DELETE尤其不是那个能让你省心的SQL。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 19:32:09

三相桥式整流电路原理与工程实践全解析

1. 什么是三相桥式整流电路:从工厂电机控制柜到新能源充电桩,它到底在干啥?你拆开一台工业变频器的外壳,或者打开新能源汽车直流快充桩的主控模块,十有八九会看到一块密密麻麻布满六只大功率晶闸管或IGBT的金属散热板—…

作者头像 李华
网站建设 2026/10/6 19:32:07

普通降压DCDC怎么输出负压?原理、选型与调试全解析

工程师朋友聚会的时候聊起电源,很多话题最后都会落到同一个坑上:板子上明明有一路稳定的正压,却偏偏还需要一路负压。运放偏置、LCD对比度、IGBT负压关断、音频电路负轨,全都在等这路电。专门买一个负压DCDC模块,贵不说…

作者头像 李华
网站建设 2026/10/6 19:31:56

AI Agent技能封装实战:从零设计可复用Skills的完整指南

最近团队在搭 Agent 应用,好几个同事不约而同跑来问我同一个问题:网上到处都在讲 Skills、Skills,到底怎么落到自己的项目里?我翻了一下手头的代码和文档,发现大家卡住的点其实不是“不会写 Prompt”,而是缺…

作者头像 李华
网站建设 2026/10/6 19:28:45

基于 Claude Code 的营销技能包:SEO 审计与 CRO 分析自动化实践

1. 项目缘起:为什么我要把营销方法论塞进 Claude Code 做增长和独立站这行的朋友大概都有同感:SEO 和 CRO 的知识体系极度碎片化。关键词研究在 Ahrefs 里,结构化数据在 Search Console 里,落地页转化分析在 Clarity 里&#xff0…

作者头像 李华
网站建设 2026/10/6 19:27:58

MyBatis核心机制与Spring Boot整合:缓存、动态SQL与常见坑

做Java后端这些年,持久层框架用过不止一种,从最早的裸JDBC到自己封装DAO模板,再到Hibernate、JPA、MyBatis,最后在绝大多数企业级项目里稳定落地的,反而是被很多人觉得“不够高大上”的MyBatis。这篇文章不是做框架选型…

作者头像 李华
网站建设 2026/10/6 19:27:36

QT客户端与服务器状态监控:心跳机制与超时判定的实战方案

做C/S架构项目的时候,最让人头疼的从来不是“把数据发出去”,而是“我怎么知道对面还活着”。我接手过好几个QT客户端和服务器端的项目,每次联调第一周几乎都在处理同一个问题:服务器日志里显示客户端在线,实际上客户端…

作者头像 李华