背景#

这套系统存的是交易明细:业务服务把每一笔明细写进 Kafka,Kafka Connect 的 ClickHouse sink 消费之后写进 ClickHouse,下游有两类用法,一类是按时间范围查明细、做对账,一类是按不同时区出日报、周报、月报。同一套业务按 region 分成几套独立部署,节点规格和单日数据量分三档:

档 Aiven 套餐 每个节点 单日分区
A business-16 4 vCPU / 16 GB 一千多万行
B business-32 8 vCPU / 32 GB 约两亿行
C business-64 16 vCPU / 64 GB 三亿多行

节点规格是这三个套餐在 AWS 区域的标称值,三个套餐都是 1 个分片、3 个节点(Aiven 的套餐数据)。这个系列从这套系统出过的几件事出发:Kafka 写进来的重复行,一列被写错了 18 天,一份准备在现有集群上执行的清理方案在 lab 上跑出了会丢数据的问题。为了给这几件事找解决办法,用一套同一条 25.3 LTS 的三副本实验台(clickhouse-lab)把能在本地重放的机制逐条跑了一遍,又把报表链路、换引擎这些方案放进去验过。

这一篇只写到组件和决策这一层,每个机制、每种异常的细节在专门的文章里,阅读顺序在最后。下文要分清两类内容:这套系统现在的做法,和在 lab 上验过、还没在现有集群上执行的方案,后者都会标出来。

整体架构#

flowchart TB APP["业务服务"] -->|"producer"| K["Kafka
单 topic,8 个分区"] K --> SINK["ClickHouse sink
Confluent Cloud 托管"] SINK -->|"每次 poll 每个分区一条 INSERT"| RAW["明细表
按结算日分区"] RAW -->|"插入时触发"| MV["物化视图(方案)"] MV --> RPT["报表表(方案)
UTC 半小时桶"] RAW -.->|"复制日志、块号、去重标记"| ZK["Keeper
每个节点同机一个 ZooKeeper"] RAW -.->|"create_time 满 45 天的 part 搬走"| OBJ["对象存储"] Q1["明细查询、对账"] --> RAW Q2["多时区报表"] --> RPT

ClickHouse 是 Aiven 托管的 25.3(现有集群是 25.3.14.1),1 shard × 3 replicas,每个节点存全量数据,任何一个节点都能接写入,连接随机落到其中一个(Aiven 服务架构)。图里物化视图和报表表那一段是方案:现有集群上的聚合表按写入时间 create_time 的窗口增量累加,不按键去重。

组件职责#

组件 负责什么 相关的文章
Kafka 单 topic、8 个分区,峰值每秒约 1 万条;producer 重试时可能把同一条存成两份 四个入口
ClickHouse sink clickhouse-kafka-connect v1.3.9,跑在 Confluent Cloud 上;每次 poll 按 Kafka 分区各发一条带 token 的 INSERT;至少一次投递,没开 exactlyOnce 超时重投、数据少了
明细表 ReplicatedMergeTree,ORDER BY (settle_ms, id, rev),按结算日分区(键是在现有集群的 system.tables 里查的);按一份较早的建表语句有 34 列、8 个跳数索引,现表没核过 写入路径、查询路径
Keeper 复制日志、块号分配、块级去重的标记;Aiven 在每个节点上同机跑一个 ZooKeeper 块级去重窗口、Keeper 故障
物化视图和报表表 方案:明细每插入一批就聚合进 UTC 半小时桶,报表按时区在查询时上卷 做交易报表、对账和重算
对象存储 tiered storage,表上有一条按 create_time 的 45 天搬迁 TTL,part 里最新一行满 45 天整个搬过去;B 档约三分之二、C 档近九成的数据已经在上面 写入路径

技术栈与选型理由#

托管这一层决定了不少做法。用 Aiven 省掉了 Keeper、升级和备份的运维,代价是能看到、能改的东西少:SHOW CREATE TABLE 对管理账号是拒绝的,system.zookeeper 也读不了(Aiven 文档同样把它列为不开放的系统表,System tables),query_log 和 part_log 只留约 4 天。Aiven 还改过一批服务端设置:删表没有宽限期(没有 UNDROP),删大表、大分区没有大小保险,每个节点的并发查询数有上限、INSERT 也算在内,max_threads 钉在节点的 vCPU 数。这些设置在误删之后的恢复和报表的对账和重算里会用到。

sink 是 Confluent Cloud 托管的 clickhouse-kafka-connect v1.3.9,托管运行时的 worker 配置看不到,lab 一律按 Apache Kafka 3.7.0 的默认值复刻。

lab 把这套拓扑在本地起了一份:ClickHouse 钉在 25.3.14.14(和现有集群同一条 LTS),1 个 Keeper 加三副本,Kafka 3.7.0 加和这套系统同版的 connector v1.3.9,25 个实验脚本各自带断言。它能验的是机制,验不了真实量级、对象存储和托管层的行为,每篇文章里都会写明某个结论来自现有集群、lab 还是推断。

关键设计决策#

下面五条决定了这套系统的结构。第一条和最后一条是这套系统现在的做法,中间三条是在 lab 上验过、还没在现有集群上执行的方案。

明细按结算日分区、按结算时间排序#

查询和对账大多按结算时间范围来,修数据也习惯按天。所以分区键是结算日 toYYYYMMDD(toDateTime(settle_ms / 1000)),排序键以毫秒时间戳 settle_ms 开头,按时间范围查能先跳过整个分区,再用稀疏主键按 granule 跳。

代价是按 id、租户、币种查要靠跳数索引,id 这种每行不同的列 bloom filter 跳得动,低基数的维度列不一定跳得掉(按跳数索引文档的原理推的,现有集群上没测过)。迟到的补发会写进两周前的老分区,修数据时每一步都要考虑这一点。分区按服务端时区切天,报表要和它用同一个时区。

写入按至少一次处理,重复交给存储层按键折叠#

ClickHouse 不能把 Kafka 的 offset 和数据放进同一个事务提交,托管 sink 的批次边界也不在自己手里。现有集群上见过三种重复:producer 重试、sink 超时重投、sink 重启重放,都是除 create_time 外整行相同。

方案是不开 sink 的 exactlyOnce,把明细表换成 ReplicatedReplacingMergeTree,键不变,读的时候带 FINAL(查询时把没合并的几份现合成一份);入口把块级去重的窗口(复制表在 Keeper 里记住最近 1000 个插入块的哈希或 token)调大、producer 开幂等,当作尽力而为的第一道过滤。代价是 FINAL 的读开销、合并的时机不由人定,以及报表只能做到最终一致。这一条在 lab 上验过,还没在现有集群上执行(去重方案、在线换引擎)。

报表在写入时增量聚合,靠对账和重算兜底#

明细一天两三亿行,报表每次现算太贵,所以在写入时用物化视图把明细聚合进报表表,按 UTC 半小时分桶、按天分区,时区放到查询时上卷。物化视图只看每次新插入的块,看不到之后的合并和改数据,窗口外的重复会让报表多算;窗口内的重投也要每条写入都带 deduplicate_blocks_in_dependent_materialized_views = 1 才不多算(25.3 默认是 0)。所以每天按桶对账,偏了的天从明细 FINAL 重算、过闸之后换分区。代价是报表的偏差要等对账发现,+5:45 这类时区要 15 分钟的桶。这一条也是方案。

修数据以分区为单位整体替换#

ClickHouse 的 part 写完不再改,改一行要给它所在的 part 生成新版本(Wide part 只重写改到的列,Compact part 整个重写),没有 UPSERT。所以已经写进去的重复,从快照把一整天重建好、验过,再用 REPLACE PARTITION 原子换进去;一列写错了,用带 IN PARTITION 的列级 mutation 补;换表引擎,用 ATTACH PARTITION … FROM 搬历史、EXCHANGE TABLES 切换。挂载、替换、回滚在本地盘上都是硬链接。代价是清理重复时要把整天重写一遍,还要处理快照之后的写入和多副本之间的先后,靠几道问遍所有副本的闸。这一条同样还是方案:清理 runbook、列级补数和换引擎都没在现有集群上执行过。

单分片三副本,协调靠 Keeper#

三个副本多主、异步复制,INSERT 默认在一个副本上提交就返回,副本之间会短暂不一致,所以核对一律用 clusterAllReplicas 问遍三个副本。所有复制表的协调都压在 Keeper 上:Keeper 停掉,写入全停;Keeper 卡住,INSERT 在客户端超时之后又在服务端晚到提交,这正是重复行的起点。托管服务下 Keeper 这一层看不见,只能从在途请求数、复制队列和 INSERT 耗时的尾部看前兆。

小结#

这套系统现在的样子是:Kafka 经托管 sink 至少一次地写进三副本 ClickHouse,明细按结算日分区,下游按写入时间窗口累加聚合。要还的债集中在重复行上,按键折叠的去重方案、物化视图加对账重算的报表链路、清理和换引擎的 runbook 都在 lab 上跑通了,还没在现有集群上执行。还没验的是量级:两三亿行的宽表上 FINAL、重算一天、迁移一天要多少资源,以及对象存储上的分区走不走硬链接。part_log 和 query_log 只留约 4 天、Keeper 看不见,是排障时最缺的两样。

参考与实现位置#

相关文章#

建议按下面的顺序读,前两组是基础,后三组是异常处理。

存和查:

  1. ClickHouse 的写入路径:一批明细怎么变成 part、怎么合并、怎么复制到三个副本
  2. ClickHouse 的查询路径和 FINAL 的代价:查明细时怎么跳过数据,去重之后读明细要付多少

报表:

  1. 用物化视图做 ClickHouse 交易报表:表怎么建,桶怎么切,多时区怎么上卷

重复行,从哪来、怎么防:

  1. ClickHouse 里的重复行来自 Kafka Connect 超时重投:Keeper 卡住、INSERT 晚到提交、框架原样重投
  2. ClickHouse 的块级去重窗口:重投的那批为什么没被服务端认出来
  3. ClickHouse 重复行的四个入口和 sink exactlyOnce 的覆盖范围:四种入口,以及 exactlyOnce 挡住哪些、打开之后会遇到什么
  4. ClickHouse 明细表的去重方案:至少一次加按键折叠,每一层管哪种重复
  5. 在线把明细表换成 ReplicatedReplacingMergeTree:换引擎的步骤和回滚,lab 上 sink 只停了零点几秒

重复行,已经写进去了:

  1. 清理 ClickHouse 重复行的 REPLACE PARTITION runbook:从快照重建一整天、过闸、换分区、回滚
  2. 清理 ClickHouse 重复行时行数校验拦不住的情形:runbook 里每一道闸挡的是什么
  3. ClickHouse 轻量删除清重复行的代价:另一条路,以及它什么时候反而最省
  4. ClickHouse 报表的对账和重算:哪些重复会让报表多算,怎么发现、怎么修

其他异常:

  1. ClickHouse 数据少了的排查顺序:从上游查到 connector 的错误处理和 DLQ
  2. ClickHouse 数字对不上的排查顺序:时区、JOIN 放大、uniq 误差、字段名变更
  3. ClickHouse 的 Keeper 故障和 part 过多:写入变慢或被拒时看什么
  4. ClickHouse 误删表和分区之后的恢复:上游默认能救回什么,现有集群上还剩什么
  5. 用只读权限评审 ClickHouse 补数方案:一列写错 18 天之后,用列级 mutation 补数,回滚按 id 名单