ClickHouse 的块级去重窗口
目录
一条 Kafka Connect sink 往 ReplicatedMergeTree 写数据,INSERT 卡过 30 秒超时之后框架把同一批原样重投,服务端两次都提交了,表里就多出 234 行除写入时间外逐字节相同的数据(这条链路怎么走到重投,写在ClickHouse 里的重复行来自 Kafka Connect 超时重投)。
ReplicatedMergeTree 自带块级去重(block-level deduplication),服务端记住最近若干个插入块的标识(有 token 用 token,没有就用块哈希),重复的块直接跳过。ClickHouse 团队 2024 年发表的论文说,这层去重就是为超时重发设计的:客户端遇到超时,分不清写没写进去,就把同一批原样再发一遍,交给服务端去重(3.5 节)。这张表本来就是 Replicated,这层默认是开着的;重投出去的记录、顺序、边界和第一次一模一样,插件给 INSERT 带的 insert_deduplication_token 也没变。认得出来的条件都在,它还是没拦住,因为这个窗口按块数算,而这张表建块太快。
窗口按块数算,默认 1000 个块#
查一下生效值:
SELECT name, value FROM system.merge_tree_settings
WHERE name IN ('replicated_deduplication_window', 'replicated_deduplication_window_seconds')
replicated_deduplication_window 1000
replicated_deduplication_window_seconds 604800
窗口是 1000 个块,_seconds 是一周。这两个值没人改过,就是 25.9 之前的上游默认值。查询里写两个具体的名字,是因为 25.3 上用 LIKE '%dedup%' 会多出四行别的设置。
上游后来把两个值都动了:25.9 起 replicated_deduplication_window 从 1000 提到 10000(PR #86820),25.10 起 replicated_deduplication_window_seconds 从一周改成一小时(PR #87414,标了 backward incompatible)。Aiven 上的现有集群跑的是 25.3,查出来的 1000 和一周就是这个版本的原厂默认值,和 lab 上 25.3.14.14 逐项读到的一致(clickhouse-lab 实验 01)。
提窗口那个 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 error = 0
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)。求和不会重复计数:一个块只在实际写入的那个副本上记 NewPart,另外两个记的是 DownloadPart,这个集群上按副本 GROUP BY host 数过,lab 上也复现了(实验 03)。
error = 0 这个条件不能省。被块级去重拦下的插入也会在 part_log 里记一行 NewPart,只是 error = 389(INSERT_WAS_DEDUPLICATED,实验 03)。不加这个条件,窗口每拦下一次重投,建块速率就多算一个块。反过来,单独数 error = 389 的条数,就是窗口拦下了多少次重投,可以拿来做基线告警。
近 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 亿的量对得上,两边可以互证。这组数是按加上 error = 0 之前的口径查的,被窗口拦下的那几次也算进了分母,只会让速率偏高一点、8 秒偏短一点,方向不变;part_log 只留 4 天,同一个 24 小时没法再按新口径重查。
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)的块速率现在量不到了。那天监控上的 INSERT/s 是 102 到 137,和现在同一量级;要让窗口长到能覆盖 32 秒,建块速率得降到 31 块/s 以下,比现在低四倍,和当时的插入速率对不上。
另一个 region 那条链路同口径算下来是 40.2 块/s、每块约 91 行,窗口折合约 25 秒,比这边宽一些,和它自己 24.4 秒的插入耗时尾部几乎贴平。
超出窗口的块什么时候真被删掉#
上面那个「8 秒」说的是块数什么时候超出 1000,旧块的记录并不在那一刻消失。blocks/ 由 ReplicatedMergeTreeCleanupThread 周期性地裁剪,块数超了、但裁剪还没跑的时候,旧块照样认得出来。lab 上把窗口压到 2 个块,按顺序做了一遍(实验 02):
| 步骤 | 表里的行数 | 说明 |
|---|---|---|
| 经 ch1 写入一批 | 2 | |
| 同一批原样重投,这次经 ch2 | 2 | 拦住了,去重状态是三个副本共享的 |
| 中间插进 3 个别的块 | 5 | blocks/ 里 4 个记录,已经超出窗口 |
| 裁剪之前再重投 | 5 | 仍然拦得住 |
建表后约 35 秒,第一次裁剪把 blocks/ 裁到 2 |
5 | |
| 裁剪之后原样重投,经 ch3 | 7 | 第二份落地 |
裁剪的间隔不是固定的。cleanup_delay_period(30 秒)是下限,max_cleanup_delay_period(300 秒)是上限,每一轮跑完,线程按这一轮清掉了多少东西(折成 points,期望值 150)给自己排下一轮:清得少就往 300 秒退,清得多就贴着 30 秒,再加 0 到 10 秒的随机量;只有表启动后的第一轮不做这个调整(ReplicatedMergeTreeCleanupThread.cpp:82-110)。
lab 上的表一启动先跑一轮,这一轮不做调整,把下一轮排在 30 到 40 秒之后;建表后约 35 秒的那一轮,才第一次真正裁掉东西。它只清掉两个块,points 只有零点几,三个副本都把下一轮排到了 300 秒,system.text_log 里那行 Scheduling next cleanup after 300000ms 直接读得到。接着又插了几个块、等过 60 秒去重投最老的那个,还是被拦住;下一次裁剪在约 307 秒之后才发生,之后重投才落地。
裁剪 blocks/ 只在 leader 上做,25.3 上三个副本都是 leader,各自跑各自的裁剪线程,blocks/ 是谁先跑到谁裁。
推到现有集群:那张表每秒建 124 个块,每轮要清几千个对象,points 远超 150,三个副本的间隔都贴在下限 30 到 40 秒,相位互不相干,合起来十来秒就有一个副本裁一次。所以有效窗口是「1000 个块的 8 秒」再加上「到下一次有副本裁剪的那一小段」,32 秒以上的重投被拦下的机会很小。这一句是按源码的调度规则推的。结论不变,窗口还是那个约束;只是在写入少的表上,超出窗口的块能在 blocks/ 里多留约 5 分钟。
第一次的 token 写进过 blocks/,重投前已被裁掉#
第一次 INSERT 在服务端提交成功了(否则不会有第一份),它的 token 也就写进了 Keeper 的 blocks/;重投那批 token 相同、内容相同,只要还在窗口里就会被认出来。它没被认出来,剩下的解释只有:这 30 秒到两分钟里,新块已经把它挤出了窗口,裁剪线程也已经把它删掉了。
这里面「第一次 INSERT 在服务端真的提交了,只是 ack 没回来」这一环,现有集群上的证据在上一篇,lab 上后来用真的 Kafka Connect 复现过:同一个 token 在第一次之后 53 到 54 秒原样重投,窗口还在的那张表拦下了,part_log 里记成 error = 389;把窗口设成 0 的那张表落下了第二份(实验 21)。窗口一直在生效,只是太短,调大能拦住这一次。
升到上游的新默认值也兜不住#
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 组。
窗口调多大,代价在哪#
窗口至少要等于「建块速率 × 最长的重投间隔」。一轮超时重投的间隔落在 30 到 90 秒之间(30 秒超时,加上等到下一个 offset 提交点的 0 到 60 秒),现有集群上见过的 124 秒是两轮。按 124.4 块/s 盖住 124 秒,窗口要一万五千个块上下;去重方案里取的是 2 万,另一个 region 每秒 40 块,取 1 万。两轮以上的重投最长能到三分钟,2 万个块按这个速率只能盖住约 160 秒,未必盖得住;事前也不知道下一次停摆会有多久。
代价落在 Keeper 上:这些记录全部存在 Keeper 里,裁剪线程每轮也要多删这么多。事故那几个小时 Keeper 正在积压,再往它上面加二十倍的状态量,只会让它更吃紧。#86820 底下另一个人(filimonov)反对提高默认值的理由也是这个:「100 replicated tables (not smth uncommon) may create 100*10000 = 1mln znodes」,Keeper 到百万节点量级会明显退化。
所以调大窗口的定位是尽力而为的第一道过滤:能多拦一些超时重投,但对账口径的正确性不能靠它。方案里兜底的是把明细表换成按业务键折叠的 ReplacingMergeTree,读的时候带 FINAL(去重方案)。
token 和 exactlyOnce 都绕不开这一层#
官方文档推荐的 insert_deduplication_token,这个 sink 本来就在用。有了 token,重投的批次内容变了也能认出是同一批;但 token 同样记在 blocks/ 里,同样受这个窗口限制。
sink 自带的 exactlyOnce(默认 false,这条链路没开)也绕不开。实验 21 用真的 Connect 测过:对「同一批原样重投」,它的状态停在 BEFORE_PROCESSING(插入结果不明),源码的处理就是再写一遍、交给 ClickHouse 去重,窗口认不出时照样落下第二份,和不开没有区别。它防的是批次边界变了的重放,而且状态存在 KeeperMap 表里,也在 Keeper 上。它管什么、不管什么、打开之后会遇到什么,写在四个入口那篇。
块级去重的窗口、exactlyOnce 的 offset 状态、复制元数据,三样共用同一个 Keeper。幂等最好别只依赖这一层。
小结#
- 块级去重的窗口按块数算,不按时间。
replicated_deduplication_window在 25.3 上默认 1000,_seconds那个一周的条件在高吞吐表上基本轮不到它生效。 - 分母要用
system.part_log里这张表error = 0的NewPart速率,三个副本相加。error = 389的那些是被窗口拦下的重投,单独数它可以做告警基线。 - 这张表每秒建 124 个块,1000 个块折合 8 秒,而 sink 的重投间隔是 32 到 124 秒。token 和内容都没变,重投到达时,第一次的记录早已被挤出窗口。
- 块数超出窗口和旧块真被删掉之间隔着一次裁剪,间隔在 30 到 300 秒之间自适应:写入多的表贴着 30 秒,写入少的表能多留约 5 分钟。
- 窗口调到盖住一两轮重投(这张表约 2 万)能多拦一些,代价在 Keeper 上,定位是尽力而为;正确性靠存储层按键折叠。
参考资料#
- 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,版本号以这三处源码为准 ReplicatedMergeTreeCleanupThread.cpp@ v25.3.13.19-lts — 裁剪线程按上一轮清掉多少东西自适应地排下一轮- Replication — 副本共享同一套复制元数据(含存去重记录的
blocks/) - StorageReplicatedMergeTree.cpp — 代码里拼
zookeeper_path / "blocks" / block_id的那几处,以及 commit 阶段写 block_id 的位置 - ClickHouse Kafka Connect Sink —
exactlyOnce开关和它背后的 KeeperMap 状态表 - Aiven for ClickHouse 服务架构 — 单 shard 三节点、无主从、连接随机落到任一节点,
MergeTree自动改写成ReplicatedMergeTree - ClickHouse - Lightning Fast Analytics for Everyone(VLDB 2024,官方文档里的网页版) — 3.5 节:块级去重为超时重发设计,复制表的块哈希存在 Keeper 里
- clickhouse-lab — 实验 01(25.3 的默认值)、02(窗口挤掉之后重投落地,裁剪周期自适应)、03(
NewPart/DownloadPart和error = 389)、21(真的 Connect 超时重投)