ClickHouse 明细表的去重方案
目录
背景#
这条链路是业务服务写 Kafka(单 topic、8 个分区,峰值每秒约 1 万条),Confluent Cloud 托管的 ClickHouse sink(clickhouse-kafka-connect v1.3.9)消费后写进 Aiven 上的三副本 ClickHouse 25.3。sink 是至少一次投递,没开 exactlyOnce;明细表是 ReplicatedMergeTree,ORDER BY (settle_ms, id, rev),按结算日分区。
这条链路上的重复有三种,都是除写入时间 create_time 之外整行相同:
| 来源 | 见过的情形 | 特征 |
|---|---|---|
| producer 重试 | 现有集群,一百多行 | 集中在一个 45 分钟的窗口里,每个键正好 2 份,broker 里存了两条 offset 不同的消息 |
| sink 超时重投 | 现有集群,两百多行 | 同一批原样再写一次,间隔 32 到 124 秒 |
| sink 重启重放 | lab 上造过(实验 21) | task 重启后从上一个提交点重读,批次边界会变 |
人工补发是第四个入口:发送超时的记录事后补发时,其中一部分可能早已写进去。
超时重投怎么产生、为什么没被服务端拦下,见超时重投和块级去重窗口;producer 重试和重启重放见四个入口。这篇回答的是选了什么方案:明细表在对账口径上没有重复,下游报表不因为重复多算,出了重复能发现、能修。
方案在 clickhouse-lab 的三副本实验台上验过(实验 02、03、14、18、21、23、24、25、27、29),还没在现有集群上执行。
为什么不靠 sink 的 exactlyOnce#
官方 sink 有一个 exactlyOnce 开关,按 offset 区间加一张 KeeperMap 状态表判断一批该不该写。lab 上用真的 Kafka Connect 测过两件事(实验 21、23):
- 现有集群上 234 行那一种「同一批原样重投」,它管不住。INSERT 在服务端提交了、客户端超时,状态停在
BEFORE_PROCESSING(插入结果不明),同一区间再来时源码的处理就是再写一遍,交给 ClickHouse 的块级去重拦,开不开结果一样。 - 它防得住批次边界变了的重放,但打开之后,worker 崩溃重启时状态常常对不上,默认配置下 task 直接停住;配了
errors.tolerance=all而没配 DLQ 时,有一段数据会丢。
细节写在四个入口那篇。根子在 ClickHouse 没法把 Kafka 的 offset 和数据放进同一个事务提交:它的事务还是实验特性,只支持 Atomic 库里不带复制的 MergeTree(Transactional (ACID) support)。所以官方 connector 和 ClickPipes 的 exactly-once 做的是同一件事:记下每一批的 offset 区间,失败后按原区间重放、带同一个 token,交给块级去重拦下。ClickPipes 的 token 是 topic:partition:firstOffset-lastOffset,文档写明 token 出了去重窗口之后再重放「can be inserted again」,并建议按最坏的重放延迟调大窗口(ClickPipes for Kafka best practices)。开不开 exactlyOnce,同一批的重投都要靠窗口兜着。
同类系统怎么取舍#
ClickHouse 本身不提供「只写一次」的开关,官方去重策略页列的是 ReplacingMergeTree 和两种 Collapsing 引擎,去重在合并时发生,任何时刻表里都可能还有重复(Deduplication strategies)。能查到的几种做法按去重放在哪一层分开看:
- 接受至少一次,靠表引擎按键去重,接受最终一致。Sentry 的 Snuba 文档原话是 consumer 保证每批至少一次交给 ClickHouse,选对表引擎去重,「we can achieve exactly once semantics if we accept eventual consistency」(Snuba architecture)。PostHog 用
ReplicatedReplacingMergeTree,同时提醒去重不是保证(PostHog 的 ClickHouse 说明)。 - 重复直接等于多收钱的,写入之前就去重。OpenMeter 做用量计费,自己写了消费端(原因之一是一条坏记录会让 Kafka Connect 整批进 DLQ),先在批内去重,再查 Redis 去掉见过的事件,然后才写 ClickHouse(OpenMeter,2023-10)。代价是自己养一套消费端和去重存储。
- 目标库能把 offset 和数据一起提交,才是真正的 exactly-once。Snowflake 的 Snowpipe Streaming 把 offset token 和数据在同一个 channel 里提交(Snowpipe Streaming channels)。ClickHouse 不在这一类。
取舍看三件事:目标库能不能原子地提交 offset;重复的代价多大,分析类容得下短时间的重复,计费类容不下;批次边界在不在自己手里,托管 connector 不在。这套是 ClickHouse 加托管 connector,用途是分析和报表,所以走「至少一次 + 按键折叠」。
这里有一件要业务拍板的事:报表金额能不能接受最终一致。明细按键折叠之后,多出来的那份在合并或 FINAL 之后就看不到了;报表是增量累加的,窗口外的重复会先让它多算,等对账、重算之后才改回来。要求报表金额任何时刻都不能多算,就得走写入前去重,这个方案没有设计这一条。
每一层管得住哪种重复#
| 层 | 做法 | sink 超时重投 | sink 重启重放 | producer 重试 |
|---|---|---|---|---|
| 入口 | 块级去重加 token,窗口调大;或改用异步插入,token 记进 async_blocks/ |
窗口够大就拦得住 | 拦不住,token 变了 | 拦不住,offset 和 token 都不同 |
| 入口 | producer 幂等 | — | — | 按默认已开;防得住会话内的重试,超时报失败之后应用再发的那类防不住 |
| 存储加查询 | ReplacingMergeTree 加 FINAL |
能 | 能 | 能 |
| 派生数据 | 物化视图跟着去重;报表能重算 | 物化视图去重能拦 | 只能靠重算 | 只能靠重算 |
| 修复 | REPLACE PARTITION runbook |
事后清 | 事后清 | 事后清 |
只有按键折叠那一层同时管住三种,所以它是方案的主体,入口那几层是尽力而为的第一道过滤。按键折叠管不到的是派生数据:物化视图在插入时就触发,看不到后来的合并。
明细表换成 ReplicatedReplacingMergeTree,键不变#
排序键 (settle_ms, id, rev) 本身就是业务键。三种重复都是除 create_time 外整行相同,排序键相同,合并时会折成一行;人工补发的迟到数据按 settle_ms 落进原来那个日分区,同样会被折叠。没合并之前的重复,读的时候带 FINAL 或在 profile 里设 final = 1(Settings.cpp)就看不到。lab 上一千万行混进 50 万行重投,物理行数 1050 万,FINAL 读出来正好 1000 万(实验 14)。官方博客讲这类引擎时提醒过一条限制:ReplacingMergeTree 只能按排序键认定同一行,排序键要变就得重建表(How we built fast UPDATEs – Part 1)。
FINAL 是要付读代价的。实验 14 测到它跟着重叠范围走,按排序键范围查大约贵一倍,比查询里手写 GROUP BY 去重省一个数量级以上的内存。后来在 25.8 上用 3 亿行的单日分区又测了一轮:全分区 count() FINAL 的峰值内存是 149 到 336 MiB,精确去重要约 18 GiB(实验 30–34)。具体数字和不能外推的部分在查询路径那篇。合并是异步的,没合并的重复在不带 FINAL 的读里一直能看到。
迁移的路子是 ATTACH PARTITION … FROM 把旧表的分区挂进新表(本地盘上是硬链接),停 sink、排空、补齐之后用 EXCHANGE TABLES 原子切换,迁移前就有的重复在新表上带 FINAL 读时被折掉了(实验 27)。整套步骤和回滚写在在线换引擎那篇。
留下哪一份#
这一项要和报表的使用方一起定。create_time 是写入时由默认值 now() 填的,三种重复里后到的那份 create_time 不早于先到的;producer 重试那批约三分之二是同一秒写进来的,两份相等。用 create_time 做版本列,留下的是版本最大的那份,版本相同时留最后写入的;不带版本列,留下的是最后写入的那份(ReplacingMergeTree 的 ver 参数)。两种规则都留下后到的那一份。
报表按 settle_ms 分桶时,留哪一份不影响报表,重复的几份落在同一个桶里。报表还按 create_time 的窗口切的话,这一行最后会算进更晚的窗口;要它算在第一次写入的窗口,得加一列随时间递减的版本,旧表也要先加上这一列才能 ATTACH。
入口:调大去重窗口,核实 producer 幂等#
块级去重的窗口在 25.3 上默认是 1000 个块。窗口至少要等于「建块速率 × 最长的重投间隔」(机制见实验 02):一轮超时重投最长约 90 秒,现有集群上见过 124 秒,是两轮。两亿行那套部署每秒约 124 个块,124 × 124 ≈ 1.5 万,方案取 2 万;三亿多行那套每秒约 40 个块,取 1 万。物化视图的目标表也要一起调,不能比明细表小(实验 24,原因在报表对账那篇)。
ALTER TABLE events MODIFY SETTING replicated_deduplication_window = 20000;
窗口只管 sink 超时重投这一种。代价在 Keeper:每张表多存这么多个块节点,裁剪线程每轮多删这么多。上游在 25.9 已经把默认值提到了 1 万,对两亿行那套仍然不够(块级去重窗口那篇)。
producer 重试那一种,producer 端的幂等只能挡一部分。这条链路的 producer 用的是 kafka-clients 3.4.0,没有覆盖 acks 和 enable.idempotence,按默认幂等是开着的,重复照样出现了:幂等只管一个 producer 会话内部的重试,超过 delivery.timeout.ms 报失败的那条写没写进 broker 不确定,应用再把它发一次,就不在幂等的范围里(四个入口那篇)。所以这一项做的是核实:enable.idempotence=true、acks=all、max.in.flight.requests.per.connection 不超过 5,没有冲突的配置把幂等关掉。Kafka 的文档写明,设了冲突的配置、又没有显式打开时,幂等会被关掉(producer 配置)。超时之后应用层的重发,还是要靠存储层按键折叠。
服务端异步插入:三个开关要一起开#
源表上没有靠 deduplicate_blocks_in_dependent_materialized_views 去重的物化视图时(原因见下一节),入口还能让服务端把 sink 的小 INSERT 攒成大 part。sink 每次 poll 按分区各发一条十几行的 INSERT,每条都是一个新 part、一条复制日志;开了异步插入,同一个刷新窗口里的 INSERT 先进服务端的缓冲区,到时间或攒够大小再一起写(Asynchronous inserts)。实验 29 在 25.3 的三副本上,20 条并发的小 INSERT 合成了 1 个 part,复制日志只多了 1 条。
sink 的每条 INSERT 都带 insert_deduplication_token,异步插入要不要按它去重,由 async_insert_deduplicate 决定,25.3 上默认是 0(Settings.cpp:1717)。只开 async_insert = 1 时,一个刷新桶落盘只拿桶里第一条 INSERT 的 token 去 blocks/ 查重、记账,后面几条的 token 被丢掉。实验 29 第一段测出两个后果:
- 桶里第二条 INSERT 被原样重投,
blocks/里没有它的 token,第二份照样写进表里。 - 重投的那条正好第一个进桶时,同桶里其他全新的 INSERT 跟着它一起被判成重复,一行也没写进去。
第二种情况服务端照常返回成功,sink 不会再发,这些行就丢了。三个开关一起开时,实验里两种情况都没再出现:
SETTINGS async_insert = 1, wait_for_async_insert = 1, async_insert_deduplicate = 1
这时每条 INSERT 的 token 各自记进 Keeper 的 async_blocks/。实验 29 第二段造了三种重投,都拦住了:同一个桶里出现两条相同的 token,只写进一份;第一次和别的 INSERT 合批写入之后,单独重投到另一个副本;旧 token 的重投和新 token 的首投落进同一个桶,旧的被剔除,新的照常写入。桶里有一条数据解析出错时,只有它自己报 Code: 27 回滚。wait_for_async_insert = 1 让 INSERT 等数据写进表里才返回,sink 收到成功时数据已经落盘,和同步插入一样。
用的时候还有三处要注意:
- token 要放在请求的设置里传,sink 本来就是这么传的(ClickHouseWriter.java:1000-1003)。写成 SQL 里的
SETTINGS insert_deduplication_token = '…',每条 INSERT 的文本都不一样,服务端按 INSERT 的文本分桶,攒批就失效了,实验里两条拆成了 2 个 part。 - 同步插入的 token 记在
blocks/,异步的记在async_blocks/,两边互不查对方。切换之前同步写进去的一批,切换之后重投不会被认出来,反过来也一样,所以开关要挑没有重投积压的时候切。 - 异步这边的窗口是另一个设置
replicated_deduplication_window_for_async_inserts,25.3 默认 10000(MergeTreeSettings.cpp:150),按async_blocks/下的节点数裁剪,一条 INSERT 的 token 占一个节点。sink 发 INSERT 的次数没变,按两亿行那套每秒约 124 条推算,10000 条只盖得住 80 秒左右,90 秒以上的重投照样漏过去,这个窗口也要按前面同步窗口的算法调大。
实验 29 在 25.8.33.6 上复跑过,默认值和结果都一样。实验里这几个设置是放在 HTTP 参数里传的;落到现有集群上,打算在 sink 用的账号上用 ALTER USER … SETTINGS 下发,sink 那边不用改,这一步还没验过,Aiven 上能不能改用户级设置也要先核。
派生数据:物化视图跟着去重,报表能重算#
ReplacingMergeTree 救不了物化视图喂的报表。lab 上同一行先正常写一次,再换 token 重放一次(模拟窗口外的重放),再按 ReplacingMergeTree 的惯例重发一条新版本、金额从 100 改成 150:明细 FINAL 读到的是 150,物化视图累加成 100 + 100 + 150 = 350,两张表都合并之后还是 350(实验 24)。所以派生数据要两样一起上:
- 在 sink 的
clickhouseSettings里加deduplicate_blocks_in_dependent_materialized_views=1,让窗口拦下的重投不再在物化视图里多算一次。25.3 默认是 0,这时源表拦下了重投,物化视图照样又算一次。 - 窗口外的重复和改数据,物化视图一定会多算,靠按天对账、从明细
FINAL重算、过闸之后换分区来修(实验 25)。
这两条都有前提和坑,比如这个设置要对所有写入一律生效、在 25.3 上不能和 async_insert 一起开(两个都开时 INSERT 报 Code: 344),重算不过闸会抹掉迟到写入,都写在报表的对账和重算。还有一个前提要先核:现有集群上的下游聚合是不是物化视图喂的。如果是定时跑的 INSERT … SELECT,第一条没用,聚合读到的是明细表里已经存在的重复;这时也就没有和 async_insert 冲突的设置,入口可以改用上一节的异步插入。
发现和修复#
对账作业常态化:按小时切窗,算 count() - uniqExact(id, settle_ms, rev),口径只用精确的 uniqExact。同时盯 system.part_log 里 event_type = 'NewPart' AND error = 389 的条数,它就是窗口拦下了多少次重投(实验 03);条数突增说明有重投在发生,窗口拦不住的那些不会出现在这里,要靠对账发现。
已经写进去的历史重复,用快照加 REPLACE PARTITION 的 runbook 清(REPLACE PARTITION runbook),清完之后受影响的报表按天重算。
不当常规手段的几种做法#
| 做法 | 原因 |
|---|---|
sink 开 exactlyOnce |
见上文「为什么不靠 sink 的 exactlyOnce」 |
查询里手写 GROUP BY、LIMIT 1 BY 去重 |
比 FINAL 更贵,内存差一个数量级以上(实验 14);内存跟着键数涨,3 亿行的分区上精确去重约 18 GiB,4 vCPU / 16 GB 的节点在 2 亿行时就超限(实验 31–33) |
定期 OPTIMIZE … FINAL DEDUPLICATE BY … |
每次重写整个分区;同一组留下哪一行由合并决定,执行前看不到结果,也没有回滚 |
| 定期轻量删除 | 25.3 上必须带 IN PARTITION;按 create_time 删分不开同一秒写进来的两份;没有回滚(实验 18,见轻量删除那篇) |
实施顺序#
- 先只读地核对这套系统:下游聚合是物化视图还是定时作业,sink 的 INSERT 现在带没带物化视图去重的设置,窗口有没有被改过,producer 的配置,Aiven 上能不能
MODIFY SETTING。再和报表的使用方定两件事:报表金额能不能接受最终一致,桶按settle_ms还是create_time切。 - 低成本、能马上见效的:物化视图跟着去重、核实 producer 幂等、对账和告警;调大窗口(明细表和物化视图的目标表一起调)也放在这一步。下游不是物化视图的话,物化视图那一条换成异步插入。
- 结构性的:换引擎和报表重算一起做,先定留下哪一份。
- 历史重复用 runbook 清,带上重算报表那一步。
小结#
- 这条链路只能做到至少一次,sink 的
exactlyOnce管不住同一批原样重投(234 行那一种),打开之后还会引入状态冲突,所以不开。 - 方案的主体是把明细表换成
ReplicatedReplacingMergeTree,键不变,读的时候带FINAL或设final = 1,三种重复都能折掉。 - 入口把块级去重窗口调到盖得住一两轮重投、核实 producer 幂等,定位是尽力而为的第一道过滤。源表上没有靠去重的物化视图时,也可以开服务端异步插入攒批,
async_insert_deduplicate要一起开。 - 物化视图看不到合并:设置物化视图跟着去重,窗口外的重复靠对账和重算修。报表金额只能做到最终一致,这一点要业务确认。
参考资料#
- Deduplication strategies、ReplacingMergeTree
- ClickPipes for Kafka best practices:exactly-once 模式同样受去重窗口约束
- Snuba architecture、PostHog 的 ClickHouse 说明:至少一次加按键去重
- OpenMeter:Building a consistent Kafka consumer(2023-10):写入前去重
- Transactional (ACID) support:事务的限制
- Snowpipe Streaming channels:offset 和数据一起提交
- Kafka 3.7 producer 配置:
enable.idempotence - How we built fast UPDATEs for the ClickHouse column store – Part 1:把更新写成插入、靠后台 merge 收拾的几种引擎,
FINAL,以及按排序键认行的限制 - ClickHouse - Lightning Fast Analytics for Everyone(VLDB 2024,官方文档里的网页版):3.3 节,合并时的变换不保证表里没有过时的行,要即时结果就带
FINAL - Asynchronous inserts:服务端怎么攒批、
wait_for_async_insert的含义 - ClickHouseWriter.java @ v1.3.9:sink 把 token 放在请求设置里传
- clickhouse-lab:实验 02、03、14、18、21、23、24、25、27、29,以及 25.8 上的规模压测 30–34(
docs/scale-300m.md)