
刚处理完一个 ClickHouse 集群的诡异故障一条DROP TABLE ON CLUSTER下去整张表在部分节点上消失了在部分节点上还活着还有几个节点直接报了“表不存在”但 ZooKeeper 里的元数据节点却还残留着。连续重试了几次问题不但没解决反而把分布式表也拖进了半删除状态。这篇文章就把这次故障的完整分析过程、ClickHouse 分布式删表的底层机制以及我最终总结出的一套可复用的排查恢复流程完整拆开讲清楚。如果你也在多分片多副本的 ClickHouse 集群上做过批量删表尤其是需要同时清理本地表和分布式表的场景这篇文章应该能帮你避免好几个小时的排查时间。1. 故障现场:一次看似常规的集群删表操作1.1 事故起源:执行一条清理SQL当时所在的集群规模不算小,某项目X使用了一个 3 分片 2 副本的 ClickHouse 集群,部署版本是当时较新的稳定版本。因为测试环境不需要保留了,准备清理一张名为company_test.event_log_local的副本表和对应的分布式表company_test.event_log。操作命令很简单,先删本地表,再删分布式表:clickhouse-client --host 10.0.0.1 --query DROP TABLE IF EXISTS company_test.event_log_local ON CLUSTER my_clickhouse_cluster第一次执行这条命令时,等待了大概 2 秒后返回成功。我下意识认为两张表都清掉了,紧接着又执行了分布式表的删除:clickhouse-client --host 10.0.0.1 --query DROP TABLE IF EXISTS company_test.event_log ON CLUSTER my_clickhouse_cluster这一次,命令卡了大概 15 秒,然后部分返回了成功,部分节点返回了超时,还有节点直接抛出了异常。1.2 诡异的状态:每个节点看到的结果都不一样等命令完全结束后,我用分布式查询去看了全集群的表状态:SELECT hostName(), database, table, engine FROM clusterAllReplicas(my_clickhouse_cluster, system.tables) WHERE database company_test AND table LIKE event_log% ORDER BY hostName();结果让人头皮发麻:节点event_log_localevent_log节点A已删除已删除节点B已删除存在节点C存在存在节点D已删除存在节点E报告出错存在只要在集群任意一个节点上重跑DROP TABLE IF EXISTS event_log_local ON CLUSTER,都会得到类似下面的报错:Received exception from server: Code: 60. DB::Exception: Table company_test.event_log_local doesnt exist. (UNKNOWN_TABLE)但如果只针对残留的某个节点执行本地删除,又会报:Received exception from server: Code: 49. DB::Exception: Table company_test.event_log_local already exists. (TABLE_ALREADY_EXISTS)更麻烦的是,在 ZooKeeper 里检查时,event_log_local的元数据路径还完整存在,event_log的分布式表元数据也还在。也就是说,本地文件系统和 ZK 的元数据状态已经彻底分裂了。提示:IF EXISTS在 ON CLUSTER 场景下并不是万能药。它只会让“执行到某个节点时表不存在”的节点跳过报错,但不会清理其他节点残留的元数据,更不会解决 ZK 节点残留的问题。这一连串结果说明,DROP TABLE ON CLUSTER并不是一个“要么全部成功、要么全部失败”的原子操作。理解它为什么会变成这样,需要先搞清楚这套分布式删除机制底层到底是怎么跑的。2. 理解基础:分布式DDL与元数据在ClickHouse里到底怎么流转2.1 一条本地 DROP TABLE 的完整执行链路先看最简单的场景:在单个节点上执行DROP TABLE company_test.event_log_local。ClickHouse 本地删除一张表时,大体经历以下几个步骤:在system.tables中查找表对象,如果表不存在,直接返回UNKNOWN_TABLE错误。获取该表的元数据锁,阻止其他查询和写入继续访问这张表。把本地元数据文件从/var/lib/clickhouse/metadata/company_test/目录中删除,元数据文件的名字通常是event_log_local.sql。删除表数据目录/var/lib/clickhouse/data/company_test/event_log_local/,这一步会真正清理数据。如果表是ReplicatedMergeTree引擎,还会向 ZooKeeper 发起删除操作,清理对应表在 ZK 上的路径。在单节点上,这五步依次执行,如果中间某一步失败,最坏情况下表停留在“元数据已删但数据还在”或者“数据已删但元数据还在”的状态,排查起来相对可控。但一旦套上ON CLUSTER,情况就完全不一样了。2.2 ON CLUSTER 到底是怎么把命令广播到所有节点的ON CLUSTER的本质,是调用一次分布式 DDL 接口。命令到达任意一个节点后,该节点会作为分布式 DDL 的发起者,把任务写到一个协调服务队列中,然后所有节点上的 DDLWorker 会监听这个队列并执行同一个任务。具体流程可以拆成四步:客户端把带ON CLUSTER的 DDL 发给任意一个节点。该节点在 ZooKeeper 的 DDL 队列路径下创建一个任务节点,例如/clickhouse/task_queue/ddl/query-0000000001,节点内容就是完整的 DDL 语句。集群内所有节点的 DDLWorker 都监听这个路径。当新任务出现在队列中时,每个节点都会尝试获取任务并执行。每个节点执行完成后,会在任务节点下写入自己的执行结果作为 ack 信息,发起者负责收集这些结果并返回给客户端。这一步最大的特点是:发起节点只负责把任务分发到队列里,并不保证每个节点都执行成功。只要有任何一个节点在处理任务期间发生网络抖动、节点重启、ZooKeeper 会话过期、本地元数据异常,这个节点就可能没有执行、执行一半、或者执行成功了但没有正确回报。DDLWorker 的重试机制只保证“任务一定会被执行”,但无法保证“全部节点在同一时间点达到一致状态”。这就是分布式删除表时出现多个节点状态不一致的最底层原因。2.3 副本表与分布式表在删除时的本质差别要理解这次故障中event_log_local和event_log为什么表现完全不同,得先看清两类表的引擎差异。event_log_local是ReplicatedMergeTree表,它在 ZooKeeper 上有一套完整的元数据路径,结构类似于:/clickhouse/my_clickhouse_cluster/tables/company_test/event_log_local/ /metadata /replicas/ /replica1 /replica2 /leader_election/删除副本表时,ClickHouse 不仅仅要删除每个节点本地的元数据文件,还必须把所有副本在 ZK 上的信息清理干净。如果这个清理过程不完整,比如本地的元数据文件已经删了,但 ZK 路径没删干净,那么再执行DROP TABLE时,节点会认为表不存在,但 ZK 上又残留着“这张表曾经存在”的证据,互相矛盾,就会产生各种奇怪的报错。event_log是Distributed引擎,它本身在 ZK 上没有复杂的节点协调逻辑,更像一张“路由表”,记录了company_test.event_log_local在各节点上的位置信息。删除分布式表时,只需要把每个节点上的本地元数据文件删掉即可,不涉及副本协调。但分布式表有个最容易被忽略的问题:如果分布式的目标本地表已经不存在了,删除分布式表本身的语句也可能因为“目标表不存在”而失败,具体表现会随版本不同而变化。在某些版本里,删除分布式表时并不会去校验底层表,但在另一些版本里,内部元数据加载阶段就会返回UNKNOWN_TABLE,导致整条 DDL 在这个节点上失败。好,原理梳理清楚了,下面回到这次事故本身,看看它踩到的三个具体的坑。3. 根因定位:三种常见的 DROP TABLE ON CLUSTER 故障模式3.1 模式一:半删除状态——ZK元数据残留与本地状态分裂这是本次事故的核心问题。在集群的部分节点上,event_log_local的本地元数据文件已经被删除,但 ZooKeeper 中/clickhouse/my_clickhouse_cluster/tables/company_test/event_log_local/这个路径依然存在。这种情况下,如果对这张表再次执行 DROP,节点会因为本地元数据缺失而返回UNKNOWN_TABLE,但 ZK 节点无人清理,就形成了“幽灵元数据”。为什么会产生这种半删除状态?最典型的场景是:DDL 任务在一个节点上执行删除本地文件成功后,节点进程因为内存压力被 OOM Killed,或者 ZooKeeper 会话发生了超时,后续清理 ZK 路径的操作没有继续执行。而 ClickHouse 的 DROP 流程里,本地文件删除和 ZK 路径清理并不是同一个强一致事务,中间任何一个环节中断,都会把表留在这种残缺的状态。更麻烦的是,只要 ZK 上的路径不清理,这张表在集群元数据层面就还是“存在的”。即使所有节点本地都已经删除了这张表,SYSTEM RELOAD TABLES也救不回来,因为 ZK 里的路径不会自动消失。排查这个问题的核心命令是这样:# 进入 ZK 客户端 zkCli.sh -server zookeeper1:2181 # 查看副本表的 ZK 路径是否残留 ls /clickhouse/my_clickhouse_cluster/tables/company_test/event_log_local # 如果返回节点列表,说明 ZK 元数据依然存在同时在 ClickHouse 侧检查:SELECT hostName(), database, table, is_readonly, total_replicas, active_replicas, zookeeper_path FROM system.replicas WHERE database company_test AND table event_log_local;如果发现某些行报错,或者某些节点上zookeeper_path仍然指向这张表,就说明 ZK 元数据还在。3.2 模式二:分布式表与本地表删除顺序的坑这次事故中还有一个非常隐蔽的问题:先删了本地表event_log_local,然后才去删分布式表event_log,顺序反了。正常情况下,删除分布式表应该先于本地表。因为Distributed表在启动时或重载元数据时,会读取配置中指向的本地表信息。如果本地表已经被删掉,部分节点上分布式表的元数据加载过程就会异常,导致删除操作无法顺利进行。实际表现在:部分节点上的event_log分布式表已经成功删除,部分节点上则因为底层event_log_local不存在,执行 DROP 时返回UNKNOWN_TABLE。这一类问题的排查方式,是检查每个节点上分布式表的元数据文件是否还存在于/var/lib/clickhouse/metadata/company_test/event_log.sql。如果文件还在,但查询时又报底层表不存在,基本可以确认是删除顺序踩了坑。注意:如果分布式表指向的本地表已经全部删除,残留的分布式表在查询时会报底层表不存在,不但影响业务,还会影响后续的元数据操作。3.3 模式三:DDL任务堆积导致“卡死”与超时执行第二条 DDL 时,命令卡了 15 秒左右,部分节点超时,这本身也是一个典型的信号。当集群比较大,或者同一时间有多个 ON CLUSTER 的 DDL 任务排队时,DDLWorker 的任务队列会堆积。特别是有节点在 ZK 上的会话状态不稳定时,该节点可能一直无法从队列中获取任务,ZooKeeper 那边等待 ack 的发起者就会超时。最典型的特征就是:同一个 DDL 任务,在system.distributed_ddl_queue中可以看到明显的积压记录,部分节点的status列长期处于Process状态,而其他节点已经Finished。SELECT hostName(), database, table, status, create_time, host_port FROM system.distributed_ddl_queue WHERE table event_log_local OR table event_log;如果发现部分节点status一直是Process,但实际执行早就完成了,不用怀疑,这就是发起者等待 ack 超时留下的脏记录。这类脏记录如果不清除,会在后续创建同名表时产生干扰。当然,DDL 任务堆积不只是这一个原因。如果某个节点长时间内存高水位,ZK 交互被频繁 block,也会拖慢整个 DDL 的收敛时间。这也是分布式删表操作在集群压力大的时段更容易出问题的原因。4. 排查与恢复:一套可复制的处理流程这次事故从发生到完全恢复,前后花了不少时间。为了让大家少走弯路,我把完整的排查和恢复流程整理成四个步骤。以下操作都以干净、安全为前提,全程不需要重启集群。4.1 第一步:先摸清全集群的表与元数据状态不要急着清理任何东西,先做一次完整的元数据快照。我用下面三组查询,基本能在 1 分钟内看清全局。查看每个节点上,这两张表的最终状态:SELECT hostName(), database, name, engine FROM clusterAllReplicas(my_clickhouse_cluster, system.tables) WHERE database company_test AND name IN (event_log_local, event_log) ORDER BY hostName(), name;查看副本表在 ZK 上的登记状态:SELECT hostName(), database, table, is_readonly, total_replicas, active_replicas, zookeeper_path, replica_name FROM clusterAllReplicas(my_clickhouse_cluster, system.replicas) WHERE database company_test AND table event_log_local ORDER BY hostName();查看 DDL 队列的积压情况:SELECT hostName(), database, table, status, create_time FROM clusterAllReplicas(my_clickhouse_cluster, system.distributed_ddl_queue) WHERE (table event_log_local OR table event_log) AND status ! Finished ORDER BY create_time;这三条命令的意义在于:第一条告诉我“实际存活情况”,第二条告诉我“ZK 层面的残留情况”,第三条告诉我“DDL 协调层还压着哪些任务”。三者结合,基本能定位出每一类异常发生在哪些节点。4.2 第二步:在处理ZK残留之前先判断是否安全很多人一上来就想直接删 ZK 节点,这个操作风险很大,必须先判断本地副本的真实情况。如果某个节点上system.tables还显示event_log_local存在,同时 ZK 路径也还在,那说明这个节点是完整的,不应该动 ZK。只有当所有节点上system.tables都已经看不到这张表,但 ZK 路径还残留时,才需要执行 ZK 清理。我当时经过确认后,所有节点的本地表都已经删干净,但 ZK 残留路径依然存在。于是通过 zk 客户端手工清理:zkCli.sh -server zookeeper1:2181 rmr /clickhouse/my_clickhouse_cluster/tables/company_test/event_log_local清理完成后,再次查看system.replicas,这张表就不再出现在任何节点上了。还有一种情况,ZK 上的 replica 节点残留但表路径还在,这时可以使用 ClickHouse 自带的恢复命令来清理单个副本:SYSTEM DROP REPLICA replica_name FROM ZKPATH /clickhouse/my_clickhouse_cluster/tables/company_test/event_log_local这条命令的作用是:只在 ZK 层面删除指定的副本注册信息,不动本地数据。适合副本元数据残留在 ZK 上,但本地表已经不存在或已经 DETACH 的场景。提示:手工删除 ZK 节点是最后的兜底手段。每一次操作前,建议先用get命令把节点内容备份下来,确认路径名没有写错,再执行删除。宁可多查一遍,不要删错路径。4.3 第三步:针对不同节点的异常状态采取差异化恢复我这次遇到的情况其实比较复杂,残留节点被分成了几类。针对不同状态,处理方式完全不同。第一类:本地表数据目录还在,但元数据文件丢失。表现为system.tables能查到表,但DROP TABLE操作报文件找不到。这种情况下,直接用 DROP 大概率会失败。正确做法是先执行 DETACH:DETACH TABLE company_test.event_log_local ON CLUSTER my_clickhouse_cluster;DETACH 操作比 DROP 温和很多,它只需要把元数据文件标记为 detached,不涉及数据目录删除,也不强制走 ZK 删除流程。等 DETACH 成功后,再执行:DROP TABLE IF EXISTS company_test.event_log_local ON CLUSTER my_clickhouse_cluster;第二类:本地表数据目录和元数据文件都没了,但 ZK 路径还在。这种就是 3.1 节描述的半删除状态,可以直接走 4.2 的 ZK 清理流程,不需要在节点上做额外操作。第三类:节点上表还在,但没有任何副本,ZK 也正常。这种情况最容易出现在平时没有真正配置副本复制,却创建了 ReplicatedMergeTree 表的环境里。处理方式比较直接:DROP TABLE company_test.event_log_local ON CLUSTER my_clickhouse_cluster;如果这条命令还是报错,可以退一步,先单节点本地 DROP,再单独清理 ZK 路径。4.4 第四步:清理DDL队列中的脏任务与收尾验证恢复过程中,我建议最后再检查一次 DDL 队列。因为前面多次重试DROP TABLE时,失败的任务记录可能残留在/clickhouse/task_queue/ddl/路径下。正常情况下,执行成功的任务会保留一段时间后由 ClickHouse 自动清理。但失败、超时、处于Process状态的任务,在部分版本中会一直残留。这种残留不会影响后续同名的普通查询,但会影响后续执行同名的 ON CLUSTER DDL,比如重新创建一张同名的表,或者再次执行 DROP 时,可能出现奇怪的等待现象。处理方式同样是用 zk 客户端,把对应时间段的 DDL 任务节点清理掉。先查看:ls /clickhouse/task_queue/ddl/找到执行时间对应、且确信已经不需要的任务节点,再执行删除:rmr /clickhouse/task_queue/ddl/query-00000000xxx清理完后,做一次全集群的表一致性和副本一致性验证:SELECT hostName(), database, name, engine FROM clusterAllReplicas(my_clickhouse_cluster, system.tables) WHERE database company_test AND name IN (event_log_local, event_log) ORDER BY hostName(), name;确保所有节点返回结果为空,再执行以下命令确认 ZK 侧没有残留:SELECT hostName(), database, table, zookeeper_path FROM clusterAllReplicas(my_clickhouse_cluster, system.replicas) WHERE database company_test;如果结果也为空,就说明本次故障已经彻底清理干净。5. 运维建议:把这类故障从根上防住5.1 删表前一定要先做的检查清单经历这次故障之后,我给自己定了一条规矩:任何 ON CLUSTER 的 DROP 操作,执行前都必须过一遍检查清单。第一步,查看分布式表与本地表的删除顺序。如果同时有 Distributed 表和底层的 ReplicatedMergeTree 表,一定先删分布式表,再删本地表。这样避免分布式表指向一个不存在的底层表时,元数据加载异常影响删除流程。第二步,查看副本表当前的 ZK 路径和副本状态。用system.replicas确认这一张表在所有节点上的副本数都是完整的,没有任何置灰、只读或 inactive 的副本。第三步,如果有条件,先把表 RENAME 成一个临时名字,观察一段时间。例如:RENAME TABLE company_test.event_log TO company_test.event_log_delete_me ON CLUSTER my_clickhouse_cluster;观察确认业务没有访问了,再执行真正的 DROP。这个操作成本很低,却能避免很多“删完才发现还要用”的悲剧。第四步,对数据量大的副本表,确认磁盘空间足够完成 DROP 操作,因为删除大量数据的过程中,合并线程和后台任务可能会临时增加 IO 压力。5.2 与删表相关的关键参数和权限配置ClickHouse 提供了一些可以避免 DROP 操作失控的开关。比较常用的是这几个:allow_drop_detached。这个参数控制是否允许删除 detached 状态下的分区或表。默认情况下,如果一张表处于 DETACH 状态,执行 DROP 会失败,这其实是保护机制。不建议随便打开,只有在明确要清理 detached 残留时才设置。distributed_ddl_task_timeout。这个参数在 config.xml 中配置,控制 ON CLUSTER DDL 在收集节点执行结果时的等待超时时间。默认值通常较小,如果集群节点较多,容易出现超时。可以根据集群规模适当调大,但不建议设成无限大,否则 DDL 发起者会长时间阻塞在等待 ack 上。distributed_ddl_task_timeout180/distributed_ddl_task_timeout此外,建议给高危操作配置单独的账号权限。ClickHouse 的 RBAC 可以精确到DROP TABLE权限。比如给日常运维账号加上限定:GRANT DROP TABLE ON company_test.* TO op_user;这样即使 DDL 写错了,也不会影响其他库的表。对于权限更大的账号,可以加一层“只能 DROP 指定库表”的白名单约束。5.3 监控DDL队列和ZooKeeper状态分布式 DDL 的故障,通常不是一瞬间爆发的,而是会在 DDL 队列和 ZK 状态上留下早期信号。给这两个地方加上监控,能显著减少这类故障的影响范围。第一个监控点是system.distributed_ddl_queue。每 30 秒采样一次,关注状态不是Finished的任务数量。正常情况下,超过 1 分钟的未完成任务就应该触发告警。这个监控能抓住绝大多数 DDL 堆积问题。第二个监控点是 ZK 的会话数和延迟。如果 ClickHouse 节点与 ZK 之间的延迟持续上升,或者会话频繁超时,那么 DDL 执行的不确定性就会急剧增加。此时应该先处理 ZK 的健康问题,而不是继续执行 DDL。第三个监控点是system.replicas中is_readonly和active_replicas的异常波动。只读副本或非活动副本一旦出现,就应该及时介入,尽量避免在副本状态不健康的情况下执行 ON CLUSTER 的 DROP 操作。这次故障给我的最大教训是:在分布式环境下,DDL 的“成功”并不等于“所有节点都成功”。你以为删掉了一张表,实际上可能只是发起节点告诉你“任务已分发”,而每个节点真正执行到什么程度,完全是另一回事。后来我在处理任何大集群的 DROP 操作时,都会先把全节点表状态快照下来,执行完再统一比对一遍。这个看似多此一举的习惯,后来帮我避免了好几次潜在的元数据事故。希望这篇文章也能帮你养成同样的习惯。