ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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里。某天有人对这张表执行了一次DELETEmutation开始在后台处理这个超大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。
返回列表