ZooKeeper 顺序节点计数器溢出导致 ClickHouse 复制表无法写入
目录
复制表的 INSERT 某一刻起全部失败,三个副本报同一个错,读不受影响:
Code: 244. DB::Exception: Unexpected logical error while adding block 0 with ID '…': Node exists, path /clickhouse/tables/<uuid>/01/log/log-. (UNEXPECTED_ZOOKEEPER_ERROR)
原因是 ZooKeeper 里给复制日志编号的计数器到了 int32 上限。这句报错出自 clickhouse-lab 的实验 28:在 ClickHouse 25.3.14.14 和 Apache ZooKeeper 3.8.4 上,把一张复制表的计数器推到上限,复现了这个问题,也验证了只用 SQL 就能恢复。
失败的是在 log/ 下建复制日志那一步#
ClickHouse 提交一个 part 时,把几件事放进同一个 ZooKeeper 事务:登记这个 part、记下去重用的块哈希、在 log/ 下建一条复制日志(ReplicatedMergeTreeSink.cpp:865)。报错的 path 指向 log/log-,说明失败的是建复制日志这一件。log/ 下的条目是顺序节点,ClickHouse 只给前缀 log-,序号由 ZooKeeper 追加,按理不会重名。log/ 里放的是什么、另外两个副本怎么照着它拉 part,写入路径那篇写过。
ZooKeeper 的序号来自父节点上的一个 int32#
ZooKeeper 的文档说,这个序号来自父节点维护的一个有符号 4 字节整数,「the counter will overflow when incremented beyond 2147483647」(ZooKeeper Programmer’s Guide)。3.8.4 的源码里,新建顺序节点时名字取父节点计数器的当前值、补零到 10 位,名字已经存在就抛 NodeExistsException,新的计数器是旧值加 1(PrepRequestProcessor.java:690-706)。落盘时只在新值比旧值大的时候才更新计数器(DataTree.java:476-486),注释说这是为了重放事务日志时不把计数器改回去。删子节点不动这个计数器。3.9.3 的这两段逻辑相同。
把这几条放在一起:计数器到 2147483647 之后,加 1 溢出成负数,落盘时被拒,计数器停在 2147483647。下一次新建拿到的名字还是 log-2147483647,它已经存在,于是报 Node exists,之后每一次都一样。
文档说溢出后名字会变成 -2147483648,那是老版本的行为:3.5 及更早的版本每次都把新值写回去(3.5.10 的 DataTree.java:466),计数器会变成负数;3.6.0 的 ZOOKEEPER-3249 加上只在新值更大时才更新的判断之后,溢出就变成了停在上限。
每次 INSERT 和每次 merge 各用掉一个序号#
计数器涨多快,取决于往 log/ 里建了多少条。实验 28 第一段(节选):
[符合] 10 次单行 INSERT(每次一个新的月分区)让计数器涨了:10
[符合] log_max_index 就是最后一条的序号(计数器 - 1):9
[符合] 一次 1000 行的 INSERT(一个分区)涨了:1
[符合] 一次 INSERT 跨 3 个月分区(切成 3 个 part)涨了:3
· 202602 的 3 个 part 合成 1 个(等了约 0s):这一段计数器涨了 3,减去 2 次 INSERT,merge 占了 1 个
一次 INSERT 按分区切成几个 part,就在 log/ 下建几条,1000 行和 1 行一样只占 1 个;一次 merge 也建一条。实验里用来计数的 INSERT 每次写一个新的月分区:只有一个 part 的分区不会被选去 merge,后台 merge 就不会中途占走序号,计数才准。
按每秒 100 个序号算,2147483647 个大约够 248 天;每秒 1000 个,不到 25 天。所以最先到顶的是小批量、高频写入的表:每次只写几行,用掉的序号和一次写一千行一样多。
计数器到顶之后的 INSERT、merge 和读#
lab 默认的协调服务是 ClickHouse Keeper,代表不了 ZooKeeper 的行为,所以实验 28 单独起了一个 ZooKeeper 3.8.4,挂成 ClickHouse 的 auxiliary ZooKeeper apache_zk,实验表的引擎路径写成 'apache_zk:/clickhouse/tables/{uuid}/{shard}'(Replication)。
真把计数器跑到上限要二十多亿次 INSERT,所以实验先停掉 ZooKeeper,用它自己的 FileTxnSnapLog 读出快照,只把这张表 log 节点的计数器改成 2147483644,存成新快照再启动(lab 里的 tools/zk-cversion)。改的只有这一个数,之后每一步都是 ClickHouse 和 ZooKeeper 自己的行为。实验 28 第三段(节选):
[符合] 计数器:2147483644
· 同一时刻 zkCli stat 给的是:cversion = -18 numChildren = 10 (计数器 × 2 溢出了,直接读会以为是负数)
[符合] 前 4 次 INSERT 都成功,log_max_index 依次是:2147483644 2147483645 2147483646 2147483647
[符合] 第 4 次之后计数器停在上限(加 1 溢出成负数,落盘时被拒):2147483647
[符合] 报 Code 244 UNEXPECTED_ZOOKEEPER_ERROR,Node exists,path 指向 log/log-(1 = 是):1
[符合] log/ 下有 log-2147483647,没有 log--(文档说的负数名字):1/0
[符合] 换 ch2 写同样报 Node exists:1
[符合] 换 ch3 写同样报 Node exists:1
[符合] SYSTEM RESTART REPLICA 之后再写,还是 Node exists:1
[符合] 读不受影响:行数是前面写进去的 + 成功的那 4 行:1059
[符合] OPTIMIZE 报 KEEPER_EXCEPTION,同样是 log/log- 上的 Node exists(1 = 是):1
前 4 次 INSERT 把计数器用到 2147483647,第 5 次报的就是开头那句。log/ 下有 log-2147483647,没有文档说的负数名字。计数器存在 ZooKeeper 里,三个副本共用一个,所以换副本写、重启副本都没用。读照常,行数是到顶之前写进去的全部。
merge 同样要在 log/ 下建条目,手动 OPTIMIZE 报的是 Coordination::Exception: Transaction failed (Node exists): Op #0, path: …/log/log-. (KEEPER_EXCEPTION)。后台分配 merge 的任务也会这样失败,失败之后它的等待间隔按倍数拉长,到 max_merge_selecting_sleep_ms(默认 60 秒)封顶(StorageReplicatedMergeTree.cpp:4194-4220、MergeTreeSettings.cpp:101)。
这种情况也有别人报过:ClickHouse #67100 是 20.8 上同样的报错,log/ 下最后一条正好是 log-2147483647;ZOOKEEPER-5092 报的是 3.8.7 上新建的顺序节点名字一直是 …_2147483647。
怎么确认是计数器到顶#
不用直接读 ZooKeeper,system.replicas 的 log_max_index 就是 log/ 下最大的那个序号(system.replicas),实验第一段里它等于计数器减 1。到顶之后它停在 2147483647。
用 zkCli 看 log 节点要小心,stat 里的 cversion 不是这个计数器。ZooKeeper 返回给客户端的是「计数器 × 2 − 子节点数」(DataNode.java:162),计数器一到 1073741824(2 的 30 次方),乘 2 就溢出。实验里计数器是 2147483644 的时候,zkCli 给的是 cversion = -18、numChildren = 10,直接读会以为是个负数。要换算回计数器,把这两个数相加,按 32 位无符号数取值再除以 2:-18 + 10 = -8,按 32 位是 4294967288,除以 2 是 2147483644。实验 28 就是这样读出计数器的。
同一句 Node exists 也可能另有原因。ClickHouse #10839 原帖没有结论;评论里另一位用户(ClickHouse 20.8、ZooKeeper 3.4.10)发现三台 ZooKeeper 上 log/ 的内容对不上,删掉出问题那台的数据、从另外两台重新同步之后恢复了,那一例 log/ 的序号只到 89402 左右。所以先看 log_max_index:到了 2147483647 是计数器用完,离得很远就要往别的方向查。
新建一张表恢复写入#
计数器改不回去。ZooKeeper 的客户端没有修改它的接口;实验里能把它改大,靠的是停掉整个 ZooKeeper 去改快照文件。照源码看,就算这样把它改小也没用:每个副本在 ZooKeeper 里记着自己读到了 log/ 的哪一条(log_pointer),拉日志时跳过名字比它小的条目(ReplicatedMergeTreeQueue.cpp:639-671),计数器改小之后,新建的条目全都会被跳过。上游也还没有处理溢出,ZOOKEEPER-2947 提议溢出时直接报错,状态是 Open,没有标修复版本。
能做的是给这张表换一份新的复制元数据,新路径下的 log/ 从 0 开始计数。实验 28 第四段只用 SQL 做到了这一步:
-- 照原表的建表语句建。路径里有 {uuid},新表拿到新的 ZooKeeper 路径,log/ 从 0 开始
CREATE TABLE seq_flow_new ON CLUSTER default
(id String, account_id UInt64, amount Decimal(20, 8), flow_time UInt64, create_time DateTime DEFAULT now())
ENGINE = ReplicatedReplacingMergeTree('apache_zk:/clickhouse/tables/{uuid}/{shard}', '{replica}', create_time)
PARTITION BY toYYYYMM(toDateTime(flow_time / 1000)) ORDER BY (account_id, id);
-- 坏表的每个分区各挂一次。只建硬链接,不往坏表的 log/ 写东西
ALTER TABLE seq_flow_new ATTACH PARTITION ID '202607' FROM seq_flow;
-- 每个副本上各跑一次,等另外两个副本把分区拿到
SYSTEM SYNC REPLICA seq_flow_new;
-- 按副本核对行数,都和坏表一样再往下走
SELECT hostName() AS h, count() AS c
FROM clusterAllReplicas('default', currentDatabase(), seq_flow_new)
GROUP BY h ORDER BY h;
-- 互换表名,之后的写入落到新表
EXCHANGE TABLES seq_flow AND seq_flow_new ON CLUSTER default;
-- 现在叫 seq_flow_new 的是坏表,连同它在 ZooKeeper 里的路径一起删掉
DROP TABLE seq_flow_new ON CLUSTER default SYNC;
ATTACH PARTITION … FROM 要求两张表的结构、分区键、排序键、主键、存储策略相同,新表还要包含旧表全部的索引和 projection(ATTACH PARTITION FROM),所以新表照原表的建表语句建。挂分区只建硬链接、不重写数据,是实验 20 测的。实验里三个副本上新表的行数都是 1059,和坏表一样;EXCHANGE 之后写入恢复;坏表在 ZooKeeper 里的路径随 DROP … SYNC 一起删掉了。这套动作和换引擎那篇一样,这里更简单:坏表已经写不进去,搬分区期间不会有新数据落进旧表。
到顶之后失败的那些 INSERT 一行都没写进去,实验里坏表的 1059 行里没有它们。恢复写入之后,这部分数据要由写入方重新写。
减少写入次数,把 log_max_index 加进巡检#
计数器只增不减,能做的是让它涨得慢一点,并且提前看到。
写入次数少了,用掉的序号就少。客户端攒够一批再写,或者在 INSERT 上开 async_insert,带上 wait_for_async_insert=1,等服务端真正写完再确认(Asynchronous inserts)。实验 28 第二段:20 次并发的同步 INSERT 让计数器涨了 21(20 个 part,加上之后的一次 merge),同样 20 行走 async_insert 只涨了 1,后面要合并的小 part 也跟着少了。
async_insert 有两个限制。它只对带数据的 INSERT(VALUES、FORMAT)生效,INSERT … SELECT 照样同步执行(executeQuery.cpp:1235-1244);表上挂着依赖去重的物化视图时,25.3 上它不能和 deduplicate_blocks_in_dependent_materialized_views 一起开(executeQuery.cpp:1269-1273,报表的对账和重算写过)。
另一件事是把 log_max_index 加进巡检,隔一天查一次,按涨幅推还能撑多久:
-- 'default' 换成实际的集群名
SELECT database, table, max(log_max_index) AS max_log_index,
round(100 * max_log_index / 2147483647, 1) AS pct_of_int32
FROM clusterAllReplicas('default', system.replicas)
GROUP BY database, table
ORDER BY max_log_index DESC
LIMIT 10;
实验 28 只在 Apache ZooKeeper 上做过。协调服务是 ClickHouse Keeper 时,log_max_index 一样能看,到顶时的行为没有测。ClickHouse Keeper 的顺序号在 PR #99120(2026-03-24 合并,25.3 里没有)里放宽到了 int64,按 #112171 的说法,要把 write_snapshot_version 设成 7 才用得上。
小结#
- 复制表的每次 INSERT(每个分区一个 part)和每次 merge,都在 ZooKeeper 的
log/下建一个顺序节点。序号来自父节点上的 int32 计数器,删日志不减。 - ZooKeeper 3.6.0 起,计数器到 2147483647 之后停住,之后每次新建都拿到
log-2147483647:INSERT 全报Code: 244 … Node exists,merge 分配不出来,读照常,换副本、重启副本都没用。 - 看剩余量用
system.replicas.log_max_index。zkCli 里的cversion是「计数器 × 2 − 子节点数」,计数器过了 2 的 30 次方,读出来可能是负数。 - 计数器改不回去。恢复只用 SQL:照原表建新表,
ATTACH PARTITION … FROM挂分区,各副本核对行数之后EXCHANGE TABLES,再删掉坏表;到顶期间失败的 INSERT 要重新写。 - 预防:攒批或
async_insert减少写入次数,log_max_index加进巡检。
参考资料#
- ZooKeeper Programmer’s Guide (3.8.4):顺序节点的计数器是父节点上的有符号 4 字节整数
- PrepRequestProcessor.java @ release-3.8.4、DataTree.java、DataNode.java:序号怎么取、计数器什么时候更新、客户端看到的
cversion怎么算 - ZOOKEEPER-3249(3.6.0):计数器只在新值更大时才更新;之前的写法见 DataTree.java:466 @ release-3.5.10
- ZOOKEEPER-2947:溢出时报错的提议,状态 Open,没有修复版本
- ZOOKEEPER-5092:3.8.7 上顺序号停在 2147483647,状态 Open
- ReplicatedMergeTreeSink.cpp @ v25.3.13.19-lts:
Unexpected logical error while adding block这句报错 - StorageReplicatedMergeTree.cpp @ v25.3.13.19-lts、MergeTreeSettings.cpp:分配 merge 失败之后怎么退避
- ReplicatedMergeTreeQueue.cpp @ v25.3.13.19-lts:副本按
log_pointer拉日志,跳过名字更小的条目 - executeQuery.cpp @ v25.3.13.19-lts:
async_insert什么时候生效,和物化视图去重的冲突 - ClickHouse #67100:20.8 上同一种情况,
log/下最后一条是log-2147483647 - ClickHouse #10839:同一句报错,评论里有一例是 ZooKeeper 节点之间数据不一致
- ClickHouse PR #99120、#112171:ClickHouse Keeper 的顺序号放宽到 int64,以及要用的快照版本
- Replication:auxiliary ZooKeeper 的配置和
'name:/path'写法 - system.replicas:
log_max_index - ALTER … PARTITION:
ATTACH PARTITION FROM对两张表的要求 - Asynchronous inserts
- clickhouse-lab:实验 28 的脚本、输出和改快照用的
tools/zk-cversion,实验 20(ATTACH PARTITION … FROM走硬链接)
相关文章#
- ClickHouse 的写入路径:
log/、blocks/这些 Keeper 目录各放什么 - ClickHouse 的 Keeper 故障和 part 过多:Keeper 停掉、卡住时的表现和平时盯的指标