ClickHouse 的块级去重窗口
目录
一条 Kafka Connect sink 往 ReplicatedMergeTree 写数据,INSERT 卡过 30 秒超时之后框架把同一批原样重投,服务端两次都提交了,表里就多出 234 行逐字节相同的数据(这条链路怎么走到重投,写在ClickHouse 里的重复行来自 Kafka Connect 超时重投)。
ReplicatedMergeTree 自带块级去重(block-level deduplication),服务端记住最近若干个插入块的标识(有 token 用 token,没有就用块哈希),重复的块直接跳过。这张表本来就是 Replicated,这层默认是开着的;重投出去的记录、顺序、边界和第一次一模一样,插件给 INSERT 带的 insert_deduplication_token 也没变。认得出来的条件都在,它还是没拦住。原因在于这个窗口按块数算,而这张表建块太快。
窗口按块数算,默认 1000 个块#
查一下生效值:
SELECT name, value FROM system.merge_tree_settings WHERE name LIKE '%dedup%'
replicated_deduplication_window 1000
replicated_deduplication_window_seconds 604800
窗口是 1000 个块,_seconds 是一周。这两个值没人改过,就是 25.9 之前的上游默认值。上游后来把两个都动了:25.9 起 replicated_deduplication_window 从 1000 提到 10000(PR #86820),25.10 起 replicated_deduplication_window_seconds 从一周改成一小时(PR #87414,标了 backward incompatible)。这套生产跑的是 25.3,查出来的 1000 和一周就是这个版本的原厂默认值。
提窗口那个 PR 底下,作者 CheSema 遇到的情况和这篇接近。「I faced several cases when retries run out of deduplication window. The default retry window looks for me unjustifiable small」,同一条评论里给的换算是 1K 时 100 inserts/s 只有 10 秒窗口。上游自己也认为这个默认值不够用。
分母是这张表自己的建块速率#
1000 个块折合多长时间,要看这张表多快建一个块。分母容易拿错,InsertQuery 那类 ProfileEvent 是服务器级的,跨机器上所有表汇总,用它去除一个按单表算的窗口,算出来的秒数没有意义。要量的是这张表自己的建块速率,system.part_log 里的 NewPart:
SELECT hostName() AS host,
round(count() / 86400, 1) AS parts_per_sec,
round(avg(rows), 1) AS rows_per_part
FROM clusterAllReplicas('default', system.part_log)
WHERE database = 'default' AND table = 'events'
AND event_type = 'NewPart'
AND event_time >= now() - INTERVAL 24 HOUR
GROUP BY host ORDER BY host
三个副本要加起来。1 shard × 3 replicas 共享同一个 zookeeper_path,去重记录存在这个路径下的 blocks/ 里(replication 文档讲的是这套共享路径,代码里拼的是 zookeeper_path / "blocks" / block_id)。求和不会重复计数:这个集群上按副本 GROUP BY host 数过,一个块只在实际写入的那个副本上记 NewPart,另外两个记的是 DownloadPart。近 24 小时:
| 副本 | 块/s | 行/块 |
|---|---|---|
| A | 36.0 | 21.4 |
| B | 42.3 | 19.2 |
| C | 46.1 | 17.5 |
| 合计 | 124.4 | 约 19 |
124.4 × 86400 × 19 ≈ 2.04 亿行,和 sink 那边每天 2.05 亿的量对得上,两边可以互证。
1000 ÷ 124.4 ≈ 8 秒。重投的间隔是 32 到 124 秒,最短的一档也是这个窗口的四倍。_seconds 那个 7 天的条件没起作用,先到期的是块数。重试去重的文档写了这个失效条件:「if more than *_deduplication_window other insert operations occur during the retry sequence, deduplication may not work as intended」。
这个换算有个限制:part_log 只留 4 天,事故当天(07-30)的块速率现在量不到了。那天插入更少,窗口会比 8 秒长,但长到能覆盖 32 秒需要建块速率降到 31 块/s 以下,比现在低四倍,和当时监控上的插入速率对不上。
另一个 region 那条链路同口径量下来是 40.2 块/s、每块约 91 行,窗口折合约 25 秒,比这边宽一些,和它自己 24.4 秒的插入耗时尾部几乎贴平。
调大窗口之前,先确认这层去重当时是在工作的。第一次 INSERT 在服务端提交成功了(否则不会有第一份),它的 token 也就写进了 Keeper 的 blocks/;重投那批 token 相同、内容相同,只要还在最近 1000 个块里就会被认出来。它没被认出来,剩下的解释只有这 30 秒到两分钟里已经有超过 1000 个新块把它顶出去了。这一步吃一句推断:第一次 INSERT 真在服务端提交了,只是 ack 没回来(支撑它的证据在上一篇)。窗口不是没生效,是太短,调大确实能拦住这一次。
升到上游的新默认值也兜不住#
Aiven 上 25.3 要先升到 25.8 才能再往 26.3 走,而 25.8 排在 25.9 前面,升过去这两个默认值一个都不变,得一路走到 26.3。到了之后新默认是 10000 个块,按 124.4 块/s 折合 80 秒,32 秒那 39 组和 60 秒那 13 组会被拦住,90 秒那 158 组和 124 秒那 24 组还是会被写进去,234 组里剩 182 组。
为什么没有把窗口调大#
按 124.4 块/s 算,要覆盖 124 秒的重投间隔,窗口得从 1000 提到一万五千个块上下,这些记录全部存在 Keeper 里。Keeper 已经在积压,再往它上面加十几倍的状态量,方向和故障相反。#86820 底下另一个人(filimonov)反对的理由也是这个:「100 replicated tables (not smth uncommon) may create 100*10000 = 1mln znodes」,Keeper 到百万节点量级会明显退化。
第二个原因是重投间隔取决于停摆多久、又赶上多长的提交等待,这次是 124 秒,下次多少事前并不知道,窗口调到多大才算够没有依据。
官方文档推荐的 insert_deduplication_token,这个 sink 本来就在用。它解决的是「重投内容变了认不出」,这次的问题是「记不住」,它同样受这个窗口限制。
sink 自带的 exactly-once 为什么不直接开?开关是有的,exactlyOnce,默认 false,这条链路没开。文档里写它「powered by a new ClickHouse core feature named KeeperMap」,把每个 topic-partition 消费到哪的状态存进一张 connect_state 表。KeeperMap 还是存在 Keeper 上,和块级去重用的是同一套东西,只是换了一层。Keeper 健康时它能挡住这次这种重投,Keeper 自己出问题时,状态读写一样受影响。另外开了它就不能用 buffering,bufferCount 得是 0,吞吐要重新量。
块级去重的窗口、exactlyOnce 的 offset 状态、复制元数据,三样共用同一个 Keeper。幂等最好别只依赖这一层。
小结#
- 块级去重的窗口按块数算,不按时间。
replicated_deduplication_window在 25.3 上默认 1000,_seconds那个一周的条件在高吞吐表上基本轮不到它生效。 - 分母要用
system.part_log里这张表的NewPart速率,三个副本相加(一个块只在写入的那个副本上记NewPart)。服务器级的InsertQuery跨表汇总,算出来的秒数不成立。 - 这张表每秒建 124 个块,1000 个块折合 8 秒,而 sink 的重投间隔是 32 到 124 秒。去重不是认不出来,是记不住。
- 调大窗口的代价落在 Keeper 上。要覆盖 124 秒得提到一万五千个块,而 Keeper 当时正是那个变慢的组件;上游 PR 底下反对提高默认值的理由也是 znode 数量。
insert_deduplication_token和 sink 的exactlyOnce都不绕开这一层:前者受同一个窗口限制,后者的 KeeperMap 状态也存在 Keeper 上。
相关文章#
这四篇都在同一个 Aiven 托管的 ClickHouse 集群上。(一)是一次补数方案评审,(二)到(四)是一次重复行事故,从定位到清理。
- 用只读权限评审 ClickHouse 补数方案 — 同一个集群上的 mutation 评审:列级重写的成本怎么算、回滚为什么要按 id 名单而不是按状态
- ClickHouse 里的重复行来自 Kafka Connect 超时重投 — 重复行怎么产生的:30 秒超时是谁的默认值、框架什么时候把同一批再投一次
- ClickHouse 的块级去重窗口(本篇)
- 清理 ClickHouse 重复行的 REPLACE PARTITION runbook — 怎么低成本数出有多少、怎么清掉:临时表加 REPLACE PARTITION,以及跑之前要验的四件事
参考资料#
- Deduplicating inserts on retries — 块级去重的失效条件、
insert_deduplication_token的优先级高于数据哈希 - PR #86820 / PR #87414 — 两个默认值各自变更的出处:前者 2025-09-09 合入、25.9 起窗口从 1000 到 10000,后者 2025-09-30 合入(标了 Backward Incompatible Change)、25.10 起
_seconds从一周到一小时。文中引的作者理由和 filimonov 的反对意见都在这两个 PR 的评论区,不在 PR 描述里 - MergeTree settings — 这两个设置在当前版本的说法。逐版本取值我按 tag 读源码:
MergeTreeSettings.cpp@ v25.3.13.19-lts 是 1000 与7 * 24 * 60 * 60 /* one week */(:148–149),@ v25.9.7.56-stable 已是 10000(:911)、_seconds仍是一周(:931),@ v25.10.7.6-stable 的_seconds是60 * 60 /* one hour */(:1009)。两个 PR 在 API 里都没有 milestone,版本号以这三处源码为准 - Replication — 副本共享同一套复制元数据(含存去重记录的
blocks/) - StorageReplicatedMergeTree.cpp — 代码里拼
zookeeper_path / "blocks" / block_id的那几处,以及 commit 阶段写 block_id 的位置 - ClickHouse Kafka Connect Sink —
exactlyOnce开关和它背后的 KeeperMap 状态表 - Aiven for ClickHouse 服务架构 — 单 shard 三节点、无主从、连接随机落到任一节点,
MergeTree自动改写成ReplicatedMergeTree