写 ClickHouse 的人,十有八九都躲不过 ON CLUSTER 删表这个坎。平时一条DROP TABLE IF EXISTS xxx ON CLUSTER 'cluster_name'敲下去,几秒钟就返回,感觉比本地删表还省心。但一旦赶上 ZooKeeper 抖动、某个副本悄悄挂了,这条命令就会变成一把悬在头上的刀——要么卡住不动,要么报一堆看不懂的错误,更麻烦的是集群里有的节点表没了、有的节点表还在,整个元数据状态变成一锅粥。
这篇文章就把这事彻底讲明白。我会从分布式 DDL 的调度原理讲起,沿着一次真实故障的排查链路走一遍,最后给出几种不同场景下的恢复手段和日常预防参数。不管你是刚接触 ClickHouse 的初学者,还是已经被 ON CLUSTER 折磨过的运维老手,这篇都能帮你省下几个熬夜排查的晚上。
1. ON CLUSTER 删表的调度流程与依赖
1.1 分布式 DDL 的调度链路
先明确一个容易被忽略的事实:ClickHouse 里的ON CLUSTER并不是直接把一条 DDL 广播给所有节点执行,而是把这条语句当作一个任务,先写到 ZooKeeper(或者 ClickHouse Keeper,下面统一叫 ZK)的某个队列路径下,然后由集群内每个节点上的DistributedDDLWorker后台线程去拉取并执行。
这条队列路径一般是/clickhouse/task_queue/ddl。当你在任意一个节点执行DROP TABLE ON CLUSTER时,该节点会生成一个带唯一标识的 DDL 任务节点,写入这个队列,这个节点本身扮演的就是“协调发起者”的角色。其他节点上的后台线程会发现队列里有新任务,于是各自把它拉下来,在本机执行对应的 SQL。
这里有个关键点:虽然语句是在一个节点上发起的,但任务对所有节点是公平可见的。只要 ZK 正常、节点和 ZK 之间的会话没断,理论上所有存活节点都会各自执行一次删表操作。这个机制的好处是天然并发,坏处是——只要有一个节点和 ZK 会话出问题,它就不会执行这个任务,于是整个环境就变得不一致。
1.2 本地副本删除时依赖哪些 ZK 路径
DROP TABLE在语义上分成两步。第一步是从元数据里把表定义干掉,第二步是删掉本地数据目录里的物理文件。如果是ReplicatedMergeTree系列的表引擎,还会涉及和 ZK 的交互,因为副本注册信息是存在 ZK 里的。
具体涉及的关键路径大致有:
/clickhouse/tables/{shard}/{uuid}或旧版本里的/clickhouse/tables/{database}/{table},存放的是表级别的副本注册信息,包括每个副本的元数据版本、日志指针、活跃状态等。/clickhouse/task_queue/ddl,就是前面说的分布式 DDL 任务的队列目录。/clickhouse/session,节点与 ZK 建立会话的临时节点路径,会话过期的话这个路径下会有残留,也会影响副本状态判定。
一个 ReplicatedMergeTree 表在删表时,会先从 ZK 里删除自己这个副本对应的注册节点,然后清理本地的 metadata 文件、WAL、数据目录等。如果这些步骤中任何一环在 ZK 会话层面失败,那么本地文件可能删了一半,ZK 里的注册信息也可能还在,那一半的状态就非常难受。
1.3 DDL 任务超时与返回机制
distributed_ddl_task_timeout这个参数控制的是发起点等待集群内其他节点执行 DDL 任务的超时时间。默认值是 180 秒。这个参数不是“只要超时就失败”,而是超时后发起点会主动放弃等待,只返回当前已经收到响应的节点列表。
新版本里还有一个distributed_ddl_output_mode参数,用来控制返回结果的展示方式。比如none表示不等待响应直接返回,throw表示只要有一个节点报错就抛出异常。实际使用中很多人会遇到“命令执行了,但是报错说某节点没响应”,这大概率就是这个超时机制在起作用——任务还在 ZK 队列里没被执行,但发起点已经不想等了。
理解了这条链路,下面看故障现象就清楚多了。大概率不是 SQL 语法的问题,而是这个分布式调度链路里某个环节断了。
2. 故障现象与根因库
2.1 常见的故障表现分类
根据我遇到过的情况,DROP TABLE ON CLUSTER的故障大致能分成三类。
第一类是卡住不动。命令敲下去之后一直不返回,既不报错也不成功。这种一般有两种可能:要么发起点连不上 ZK,要么 ZK 里的 DDL 队列任务在等待某个不可用节点的响应,而那个节点已经联系不上了。
第二类是直接报错,错误信息五花八门。最常见的有Code: 342. DB::Exception: The replica is not active、All replicas are lost、Cannot drop table because it is in readonly state等等。这些错误翻译成人话就是:这个表有副本,但是副本的 ZK 会话已经断了,或者副本自己都觉得自己已经不健康了。
第三类最隐蔽,命令返回成功,但集群状态不对。有的节点表删了,有的节点表还在,甚至有的节点上的表变成了只读状态。这种问题最坑的地方在于,你以为删完了,实际上某个分片的数据还在磁盘上占着空间,后续重新建表还可能因为元数据残留而失败。
2.2 根因库速查表
结合实践经验,我把常见的根因整理成一个速查表,排查的时候可以先对照一下。
| 故障现象 | 核心根因 | 关键排查点 |
|---|---|---|
| 命令卡住不返回 | ZK 会话异常,或者某个节点失联 | system.replicas、ZK 节点状态 |
| 报 replica is not active | 某个副本的 ZK 会话过期 | system.replicas.is_active字段 |
| 部分节点成功部分失败 | 失败节点当时和 ZK 断连 | DDL 队列残留任务 |
| 表变成 readonly | 元数据和 ZK 状态不一致 | system.replicas的 readonly 字段 |
| 重新建表失败 | 本地 metadata 残留 | 检查 metadata 目录 |
| 一直显示 deleted 状态 | DDL 任务已标记删除但未清理 | ZK 任务队列残留 |
2.3 为什么不同节点状态会不一致
ClickHouse 的集群一致性并不是“强同步”的,它依赖 ZK 这个外部协调者来达成最终一致。每个节点都有自己的本地元数据,ZK 里存的是集群维度的公共状态。两者之间靠会话和心跳维持同步。
当某个节点的 ZK 会话超时后,这个节点上的副本会被标记为readonly,它不再接收写入,也不再参与副本同步。但它本地的元数据文件并不会自动消失。这个时候你发起一个DROP TABLE ON CLUSTER,健康节点正常执行删除,但这个断连节点不响应 DDL 任务,于是整个集群的元数据就不一致了。
更麻烦的是,如果这个会话长期没有恢复,ZK 里的副本注册信息也会变成失联状态。此时即使你手动在这个节点上执行本地DROP TABLE,也会因为 ZK 状态校验不过而报错。这就是为什么很多人最后只能选择停掉节点,手动清理元数据文件才能恢复。
3. 完整排查实录:从故障到定位
3.1 一次典型的 Drop On Cluster 卡住故障
之前一个业务团队遇到的情况非常有代表性。某天他们在例行清理过期分区时,执行了一条:
DROP TABLE IF EXISTS ods_order_temp ON CLUSTER 'cluster_01';命令敲下去之后,终端直接卡住,Ctrl+C 都救不回来。过了好几分钟才返回了一个类似这样的报错:
Received exception from server (Version 23.8.1): Code: 159. DB::Exception: Timeout exceeded while waiting for the DDL task to be executed on servers.这个报错翻译过来就是:发起节点已经把 DDL 任务写进 ZK 了,也等了一段时间,但有一些服务器没有在超时时间内确认执行完成。
当时我第一反应是去看system.replicas,因为删表卡住的本质往往是副本状态不健康,而不是 SQL 本身的问题。
SELECT database, table, is_readonly, is_session_expired, zookeeper_exception, replica_is_active FROM system.replicas WHERE database = 'default';结果非常直观:其中某个分片的一块副本is_session_expired = 1,replica_is_active = 0。也就是说,这个副本和 ZK 之间的会话已经过期了,它完全不知道自己应该执行什么任务。
3.2 顺着 DDL 队列查无用功
确认副本不健康之后,还需要确认 DDL 任务本身的状态。ClickHouse 在较新版本里提供了一个内部表system.distributed_ddl_queue,可以直接查到 DDL 任务的执行情况。
SELECT query, host, status, create_time, cluster FROM system.distributed_ddl_queue ORDER BY create_time DESC LIMIT 10;从结果里能看到这条DROP TABLE任务的状态是in_process,也就是还在等待中。而正常执行完的任务状态应该是finished。
另外一步值得做的操作是去 ZK 里直接看 DDL 任务队列目录。ClickHouse 提供了system.zookeeper表,可以像查普通表一样查询 ZK 节点内容:
SELECT name, value, num_children FROM system.zookeeper WHERE path = '/clickhouse/task_queue/ddl' LIMIT 20;这里能看到所有待执行的 DDL 任务节点。正常来说这个目录应该是空的,或者只有极少数正在执行的任务。如果你发现里面堆积了大量历史任务,说明之前有多次删表/建表操作失败过,残留的任务一直没被清理。
3.3 定位到问题副本并验证
确认问题副本后,下一步要判断这个副本还有没有救。当时我直接在一个健康的副本上执行:
SYSTEM SYNC REPLICA ods_order_temp ON CLUSTER 'cluster_01';结果也是超时,说明不只是当前 DDL 卡住,而是这个分片内部的副本同步链路已经断了。
再回头看system.replicas里的zookeeper_exception字段,里面会出现类似这样的报错:
All connection attempts to ZooKeeper failed这个字段是排查 ZK 会话类问题的金钥匙。一旦出现这个值,基本可以断定该节点和 ZK 之间的网络链路或者 ZK 自身的会话管理出了状况。
这个时候如果你不死心,想直接在问题节点上执行本地删表:
DROP TABLE IF EXISTS default.ods_order_temp;大概率也会报错。因为在 ZK 的副本注册信息里,当前节点可能已经不是 active 状态了,本地删表操作过不了校验。
3.4 复盘当时为什么没更早发现
这个案例到最后虽然救回来了,但复盘时发现一个很明显的问题:这个不健康的副本其实已经异常存在了一段时间,如果平时有巡检system.replicas的习惯,早就应该看到is_readonly = 1或者zookeeper_exception不为空。但因为这个表平时读取压力不大,业务侧也没发现异常,直到删表时才踩中。
所以我现在做任何 ON CLUSTER 操作之前,都会先看一眼集群整体副本健康度。与其等 DDL 卡住再去救,不如在动手前就把不健康的副本排除掉。这个习惯帮我避了好几次坑。
4. 分场景恢复方案与强制清理
4.1 场景一:单副本会话过期,表本身可以重建
如果只是会话过期,且表的数据已经不是很重要,最简单的方式是先把不健康的副本清理掉。ClickHouse 提供了SYSTEM DROP REPLICA之类的命令,可以删除本地这副本在 ZK 里的注册信息。
SYSTEM DROP REPLICA 'replica_name' FROM TABLE default.ods_order_temp;这个命令的作用是告诉 ZK:这个副本我放弃了,请移除它的注册信息。之后再回到这个节点上执行普通建表语句,让副本重新拉取元数据。
注意一个细节:replica_name不是随便填的,它是该节点配置的macros里的replica值。可以用这条 SQL 查到:
SELECT * FROM system.macros;确认replica的取值后,再执行SYSTEM DROP REPLICA。
4.2 场景二:表数据不重要,直接走强制清理
如果表已经没有任何保留价值,也没必要费劲修复副本,直接走强制清理流程。
步骤一,把所有节点的 ClickHouse 进程停掉。这一步是为了避免在清理过程中有后台线程悄悄改 ZK 或本地元数据。
步骤二,找到 ClickHouse 的数据目录(默认是/var/lib/clickhouse),在metadata/目录下找到对应的数据库文件。比如default库的元数据路径是/var/lib/clickhouse/metadata/default.sql。打开这个文件,搜到ods_order_temp这张表对应的CREATE TABLE语句,手动删掉。
步骤三,在metadata/{database}/目录下,可能会有该表的独立元数据文件,一并删掉。同时检查data/{database}/目录下有没有对应的物理数据目录,有的话也一起删除。
步骤四,再去看 ZK 里该表对应的路径是否还有残留。用之前的system.zookeeper查询方式,先找:
SELECT * FROM system.zookeeper WHERE path = '/clickhouse/tables';找到对应表路径后,确认没有其他副本还在使用的情况下,可以直接删掉这个 ZK 节点。不过这一步要非常谨慎,最好在确认所有节点都已经停掉之后再操作,否则容易引发其他副本的状态混乱。
4.3 场景三:DDL 队列残留导致新 DDL 永远卡住
还有一种情况:某次 DDL 超时后,那个任务节点一直残留在 ZK 的 DDL 队列里,导致后续所有 ON CLUSTER 操作都要排队,甚至直接卡住。
这种问题的解法相对简单,直接清掉这个残留任务节点。查询/clickhouse/task_queue/ddl,找到那个查询对应的节点名称,在 ZK 客户端里删除它就行。
用 clickhouse-client 操作的话,需要借助system.zookeeper找到精确路径,然后调用zookeeper删除接口。较新版本里 ClickHouse 提供了一个zk命令,可以直接操作:
clickhouse-keeper-client --connection-string=localhost:9181进入客户端后执行:
rmdir /clickhouse/task_queue/ddl/ddl_query_id_xxx但说实话,我一般不太推荐在生产环境直接对着 ZK 目录删东西。更稳的方式是确认 DDL 任务确实已经没用了,再重启下出问题的节点。ClickHouse 启动时如果发现 DDL 队列里有尚未完成的任务,会尝试重新执行或标记失败并清理。多数情况下重启节点就能把脏队列带出来。
4.4 场景四:表已经只读但还有历史数据要保留
如果表里还有需要保留的数据,不能直接丢弃,那就得先想办法恢复副本的读写状态。
第一步先排查 ZK 链路,看看zookeeper_exception是否已经恢复。如果 ZK 连接恢复了,直接在只读副本上执行:
SYSTEM RESTORE REPLICA;这个命令会尝试用本地的元数据状态去和 ZK 里的信息对齐,重建会话状态。执行完后再看system.replicas,如果is_readonly变成 0,说明副本恢复了。
如果执行SYSTEM RESTORE REPLICA失败,或者报错说 ZK 路径不存在,那说明 ZK 里的表注册信息已经被清理过了。这种情况想保留数据,只能先停掉节点,然后把本地的data目录复制出来,再重新建表导入数据。
5. 参数调优与日常预防建议
5.1 调整 DDL 超时与输出模式
经验不足的时候,很多人会直接把distributed_ddl_task_timeout调大,觉得等久一点总能成功。实际上这个策略是错的:调大超时只是让卡住的时间更长,并不能解决问题本身。
我的建议是把这个值调到一个“可接受的等待上限”,比如 60 秒。同时把distributed_ddl_output_mode设置为throw,这样只要集群内有节点反馈执行异常,发起点会第一时间把错误抛出来,而不是默默等超时。
<profile> <distributed_ddl_task_timeout>60</distributed_ddl_task_timeout> <distributed_ddl_output_mode>throw</distributed_ddl_output_mode> </profile>这样设置之后,删表操作如果遇到问题,会很快暴露出来,而不是卡到地老天荒。
5.2 删表前的副本健康检查三件套
在我自己的运维习惯里,只要涉及 ON CLUSTER 的写操作,尤其是删表这种破坏性操作,动手前必须先过三关。
第一关,看system.replicas,确认所有相关表的副本都处于 active 状态。
SELECT database, table, count() AS total_replicas, sum(replica_is_active) AS active_replicas FROM system.replicas GROUP BY database, table HAVING total_replicas != active_replicas;这条 SQL 会直接把有非活跃副本的表列出来。只要结果是空的,说明副本层面是健康的。
第二关,看 ZK 的 DDL 队列是否干净。
SELECT count() FROM system.zookeeper WHERE path = '/clickhouse/task_queue/ddl';这个数量最好常年为 0,偶尔有 1 到 2 个正在执行中的任务也正常。如果积压了几十个,就先排查历史故障原因。
第三关,确认 ZK 本身状态正常。如果用的是 ZooKeeper 集群,直接看监控里的节点连接数、延迟、leader 状态。如果用的是 ClickHouse Keeper,可以用内置的system.keeper相关表检查。
5.3 监控与告警:在故障之前接住它
其实大部分 DROP TABLE ON CLUSTER 故障都是可以提前发现的。关键在于监控里有没有盯住下面几个指标:
| 监控指标 | 推荐监控内容 | 阈值建议 |
|---|---|---|
| 副本活跃度 | replica_is_active为 0 的副本数 | 持续 0 |
| ZK 连接状态 | zookeeper_exception非空次数 | 持续 0 |
| DDL 队列积压 | /clickhouse/task_queue/ddl子节点数 | 超过 5 告警 |
| 只读表数量 | is_readonly为 1 的表数量 | 持续 0 |
这些指标在 ClickHouse 的 prometheus 导出接口里基本都有暴露,接入 Grafana 就能做成监控大盘。比起事后救火,提前盯住这几个指标能避免绝大多数麻烦。
5.4 规避性的运维规范建议
最后说几条我在实际生产环境里总结出来的运维规范,每条都踩过坑,写出来给大家避雷。
第一个规范:生产环境禁止直接对 ON CLUSTER 的 DROP 操作不加思考地执行。尤其是大表,先确认这张表是否有下游依赖,是否有备份,是否真的不需要了。可以用SHOW CREATE TABLE看一下表的引擎类型和集群定义,心里先有个底。
第二个规范:执行删除前先备份元数据。最简单的办法是先执行一次SHOW CREATE TABLE,把结果保存到本地。万一删表过程中元数据残留导致建表失败,有这条语句就能快速手工重建。
第三个规范:ZooKeeper 或 ClickHouse Keeper 的会话超时相关参数不要随便改。调大了会导致副本故障发现变慢,调小了又会造成误判。默认值通常是合理的,除非你有充分的理由,否则不要动它。
第四个规范:千万不要在集群内单个节点上直接执行不带 ON CLUSTER 的DROP TABLE。这在 ReplicatedMergeTree 表上往往不会成功,就算侥幸成功,也会立刻被其他副本把元数据同步回来,造成状态混乱。
最后再分享一个实操小技巧
如果你发现某个 ON CLUSTER 语句卡住了,但又不确定到底卡在哪个节点上,可以在执行新语句之前先手动清理一下本地 DDL 队列里的历史任务。
SYSTEM FLUSH DISTRIBUTED DDL;这命令会把本地已经完成但还没上报的 DDL 任务状态刷新到 ZK,能让后续状态查询更准确。虽然它不能直接解决卡住的问题,但能让你的排查视野更干净。
我个人的体感是,ClickHouse 的 ON CLUSTER 机制本身设计得并不复杂,真正复杂的是它依赖的外部协作者——ZK。大部分故障都不是 ClickHouse 自身坏了,而是它和 ZK 之间的关系出了问题。所以下次再遇到 Drop Table On Cluster 的故障,先别急着怀疑 SQL 写错了,静下心来查一查副本状态和 ZK 队列,答案往往就在那里。