我们内部某个业务线的埋点日志,一天大概几千万条,要准实时进入ClickHouse供运营分析。一开始我图简单,直接在Spring Boot服务里写了个批量插入线程池,定时往ClickHouse怼数据。结果上线没几天就被打脸:ClickHouse虽然单机写入很强,但扛不住无节制的并发连接和乱序batch,业务高峰期存储节点一抖动,主服务跟着遭殃。后来把链路改成“Spring Boot → Kafka → Flume → ClickHouse”,用Flume做中转,整个管道才算稳定下来。
这篇文章就来拆一下,在K8s环境里跑Spring Boot服务时,Flume怎么一步步把数据同步到ClickHouse。内容包括架构选型、K8s部署形态、Flume核心配置、Spring Boot侧的配合,以及我们实测遇到的几个坑和最终的监控手段。适合正在做日志采集、埋点分析、数据管道建设,并且服务已经容器化的团队参考。
1. 为什么最后选了“Flume + ClickHouse”这条链路
先说业务场景。我们这条业务线的埋点数据不是普通的服务日志,而是用户行为事件,比如点击、曝光、页面停留、按钮触发这些。单条大小在1KB左右,日产量峰值的时候大概5000万条,集中在上午10点和下午4点两个脉冲式高峰,打满时TPS能到1.2万左右。运营拿这些数据做漏斗分析和留存报表,对延迟的要求是:事件发生后5分钟内必须能看到。
高峰期这种量级,直接把数据从Java服务写到ClickHouse,不是一个可靠的方案。我们一开始的直连方案之所以失败,原因很直接:ClickHouse是分析型数据库,它的写入吞吐依赖大批次、少连接的方式,而业务服务里的批量线程池很难控制写入节奏。高峰期ClickHouse一慢,积压的数据全堵在业务进程里,线程池占满,接口响应跟着变慢。典型的雪崩链路。
1.1 我对比过的几种方案
当时团队里提过好几个方案,我把它们捋了一遍:
| 方案 | 缓冲能力 | 运维成本 | ClickHouse适配度 | 结论 |
|---|---|---|---|---|
| Spring Boot直连批量插入 | 无,靠业务侧线程池 | 低 | 差,节奏难控制 | 否 |
| Kafka + 自研消费端写入 | 强 | 高,要自己处理offset/重试/幂等 | 一般 | 不划算 |
| Logstash | 中,内存占用高 | 中 | 官方sink不成熟 | 否 |
| Filebeat + 自定义管道 | 弱,只适合文件采集 | 中 | 需要额外开发 | 否 |
| Flume + Kafka Channel | 强 | 低,配置驱动 | 好,sink可定制 | 是 |
自研消费端这条路我们算过工作量:要处理消费位点、批量写入失败重试、幂等控制、优雅停机,没有两周下不来,而且后续每个需求都要改代码。这不是一个“写个消费者”那么简单的事,数据管道最怕的是边界情况处理不全,比如进程被kill、ClickHouse临时不可用、网络抖动,每个都得有对策。
1.2 Flume真正打动我的地方
Flume的source/channel/sink三段式模型,用在“Kafka到ClickHouse”这条链路上非常贴切。source负责从Kafka消费,channel负责缓冲,sink负责批量写ClickHouse。任何一段出问题,其他两段还能各自工作,不会全链路雪崩。更关键的是,配置即代码:调整batchSize、channel容量、sink重试次数,改个配置文件重启就行,不需要发版。
另一个考虑是Flume的channel事务机制。数据从source写进channel,和从channel读出来交给sink,是两段独立的事务。只要channel选对了类型,Flume进程突然挂掉,数据也不会丢。这一点在K8s这种Pod随时可能被重新调度的环境里,非常重要。
2. Flume在K8s集群里的部署形态
Flume在K8s上的部署方式,和普通无状态服务不太一样。它虽然本质上是Java进程,但它的“状态”取决于你选的channel类型。我们一开始没想清楚这点,直接用Deployment起了3个副本,结果发现数据重复消费的问题,后面切了Kafka Channel才彻底解决。先说部署形态,再说为什么这样设计。
2.1 用Deployment多副本,不用StatefulSet
如果channel用的是Kafka Channel,那么Flume自身的所有状态——消费进度、缓冲数据——都在外部的Kafka里,实例本身是无状态的。这种情况下不需要StatefulSet,用Deployment跑多副本就行。
我们线上起了3个副本,并且在Pod调度上加了PodAntiAffinity,让这3个Pod尽量打散到不同节点。原因很朴素:Flume是数据管道的关键节点,如果3个副本全在同一个宿主机上,宿主机一挂,整个链路就断了。K8s默认调度策略不会帮你考虑这种拓扑分散,得自己在deployment里声明。
affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: flume-clickhouse topologyKey: kubernetes.io/hostname2.2 资源配置和JVM参数不能拍脑袋
Flume本质是Java进程,内存大头在heap和channel buffer。我们的Pod规格是4C/8G,JVM堆给3G,剩下留给堆外、元空间和操作系统页缓存。这里有第一个坑:K8s的容器limits.memory如果也是8G,JVM堆给了3G,看起来没问题,但实际运行中Flume的堆外内存(尤其是Kafka client的Direct Memory和sink的缓冲)会涨,一旦超过limit,Pod会被OOMKilled。
我建议的配法是:JVM堆控制在容器内存limit的一半到三分之二之间,留足堆外空间。比如容器limit 4G,那-Xmx给2G,这样即使Kafka client或sink有额外的直接内存分配,也不会触顶。别觉得2G小,Flume做的是IO密集型的搬运活,不是计算密集,堆太大反而容易因GC停顿影响吞吐。
JVM参数在K8s里通过环境变量传,不要写死在镜像里:
env: - name: JAVA_OPTS value: "-Xmx2g -Xms2g -XX:+UseG1GC -Dflume.monitoring.type=http -Dflume.monitoring.port=34545"2.3 ConfigMap管理配置,镜像只做依赖打包
Flume的flume.conf和flume-env.sh适合做成ConfigMap挂载进去,这样调参数不用重新构建镜像。我们是把整个/etc/flume/conf目录都挂载成了ConfigMap的volume:
volumeMounts: - name: flume-conf mountPath: /etc/flume/conf volumes: - name: flume-conf configMap: name: flume-config镜像构建里我们只做一件事:把Flume发行版和ClickHouse相关的依赖jar包放进去。不要用官方镜像直接跑,因为Flume自带的lib里有些jar版本偏旧,和线上Kafka集群的协议版本对不上,后面我会详细说。
2.4 探针怎么配:Flume没有HTTP健康接口
Flume本身没有内置的健康检查HTTP接口,所以不能像Spring Boot那样直接配一个/actuator/health给kubelet探活。我们的做法是:
- 存活探针:检查进程是否还活着,
pgrep -f "flume"这种。 - 就绪探针:TCP检查某个端口是否有监听,或者如果用到了NetcatSource,直接连它的端口。
实际配置里,我们用的是exec方式:
livenessProbe: exec: command: - sh - -c - "pgrep -f 'flume-ng agent'" initialDelaySeconds: 30 periodSeconds: 10这个方案不优雅,但够用。如果后面有时间,建议给Flume加一个自定义的HTTP source或者用JMX exporter,把探针和监控一起解决了。
3. Flume核心配置拆解:source、channel、sink怎么配
这是整篇文章最核心的部分。很多Flume的配置教程喜欢拿官方示例直接抄,但真正落到K8s + Spring Boot + ClickHouse这套组合里,有三个关键选择需要想清楚:source用Kafka还是taildir、channel用哪种类型、sink用HTTP还是JDBC。下面逐个说。
3.1 Source:直接消费Kafka,别用taildir
如果你在K8s里跑Spring Boot,千万别想着用Flume的taildir source去实时tail容器里的日志文件。原因很直接:容器里的日志文件是写在Pod本地磁盘上的,Pod一被重新调度,文件就没了,Flume的offset文件也经常因为挂载问题丢失。我们早期试过,数据丢得莫名其妙。
正确做法是让Spring Boot把事件数据先写到Kafka,Flume用Kafka Source去消费。这样数据的安全性由Kafka保证,而不是靠容器的临时磁盘。Kafka Source的配置:
agent.sources.kafka_source.type=org.apache.flume.source.kafka.KafkaSource agent.sources.kafka_source.kafka.bootstrap.sources=10.0.0.11:9092,10.0.0.12:9092,10.0.0.13:9092 agent.sources.kafka_source.kafka.topics=user_behavior_topic agent.sources.kafka_source.kafka.consumer.group.id=flume-clickhouse-group agent.sources.kafka_source.kafka.consumer.auto.offset.reset=latest agent.sources.kafka_source.batchSize=1000 agent.sources.kafka_source.batchDurationMillis=2000这里的batchDurationMillis是每批数据最大攒多久,batchSize是攒多少条触发一次。我们生产环境用了1000条 + 2秒的组合,目的很简单:让sink端能凑出足够大的批次,ClickHouse对大批次写入非常友好。
3.2 Channel选择:为什么最终用了Kafka Channel
Channel是Flume配置里最容易被忽略的环节。常见的选择有三种:Memory Channel、File Channel、Kafka Channel。我们三种都试过,说下结论:
| Channel类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Memory Channel | 性能最好,吞吐高 | 进程一挂数据全没 | 允许丢失的离线链路 |
| File Channel | 数据落盘不丢 | 需要挂持久化磁盘,K8s下麻烦 | 对数据安全要求高但不想引入Kafka读放大 |
| Kafka Channel | 数据安全,天然解耦 | 增加一次Kafka读写,吞吐有上限 | 数据必须不丢、已有Kafka依赖的场景 |
我们最终选了Kafka Channel。理由很简单:数据安全优先。Memory Channel看着性能好,但Flume Pod一重启,channel里缓存的数据直接丢掉,这在ClickHouse离线分析场景也许能忍,但在运营看板这种准实时场景是不行的。
Kafka Channel的配置:
agent.channels.kafka_channel.type=org.apache.flume.channel.kafka.KafkaChannel agent.channels.kafka_channel.kafka.bootstrap.servers=10.0.0.11:9092,10.0.0.12:9092,10.0.0.13:9092 agent.channels.kafka_channel.kafka.topic=flume-channel-topic agent.channels.kafka_channel.parseAsFlumeEvent=false agent.channels.kafka_channel.kafka.consumer.group.id=flume-channel-consumer注意parseAsFlumeEvent=false,意思是channel里的数据直接当成原始的字节数组处理,不包Flume的Event头。这样sink拿到的数据是我们Spring Boot那边发过来的原始JSON字符串,处理起来干净利落。
3.3 Sink:ClickHouse的HTTP接口是首选
ClickHouse官方提供了HTTP接口,支持大批量写入,格式可以用JSONEachRow或者TabSeparated。我们用的是flume-clickhouse-sink这个社区插件(也可以用自研sink),走HTTP接口写入。配置如下:
agent.sinks.ch_sink.type=com.youzan.flume.sink.clickhouse.ClickhouseSink agent.sinks.ch_sink.host=clickhouse-service.default.svc.cluster.local agent.sinks.ch_sink.port=8123 agent.sinks.ch_sink.database=analytics agent.sinks.ch_sink.table=user_behavior agent.sinks.ch_sink.batchSize=10000 agent.sinks.ch_sink.maxRetries=3 agent.sinks.ch_sink.chunkSize=1000 agent.sinks.ch_sink.format=json这里几个参数值得解释一下:
batchSize:每次调用ClickHouse HTTP接口写入的行数,我们设了10000。ClickHouse最擅长的就是大批次写入,批次越大吞吐越高。但不能无限大,太大会造成单次请求耗时过长,链路延迟变高。chunkSize:底层HTTP请求分段的大小。这个要配合ClickHouse的min_insert_block_size_rows(默认1048576)来理解。如果chunk太小,ClickHouse会认为这是个小插入块,可能触发后台merge的额外开销。maxRetries:写入失败后的重试次数。注意,这里的重试是同步重试,如果ClickHouse持续不可用,sink会阻塞,channel就会慢慢堆满。这是背压机制的一部分,不能去掉。
sink端我强烈建议不要用JDBC直连ClickHouse的方式。原因有两个:一是JDBC驱动需要额外的依赖管理,二是JDBC写入需要一条条set参数,性能上天然比不过HTTP + 原始数据块的方式。除非你的字段特别复杂,需要做类型转换,否则HTTP接口是首选。
3.4 数据格式约定:时间字段和时区
链路跑通之后我们发现,ClickHouse查出来的时间总是差8小时。问题出在Spring Boot那边:用的是LocalDateTime,序列化成JSON后没带时区信息。Flume原样搬过去,ClickHouse按服务器时区解析,一来一回就错位了。
后来统一了规范:所有事件时间字段用Long类型存epoch毫秒时间戳,ClickHouse建表用DateTime64(3),在sink端或者SQL查询时再转。这样无论K8s节点在哪个时区,数据进去之后都是绝对时间,不会出现时区漂移。
CREATE TABLE analytics.user_behavior ( event_id String, event_type String, user_id UInt64, device_id String, event_time DateTime64(3), biz_params String ) ENGINE = ReplacingMergeTree(event_time) ORDER BY (event_type, event_id)这里顺带提一句ReplacingMergeTree的用法。ClickHouse不支持更新,如果链路里有重复数据,就会变成脏数据。我们把event_id作为去重键,配合ReplacingMergeTree在后台合并时去重,算是给整个链路加了一道保险。
4. Spring Boot侧配合的细节
Flume只是管道,真正决定数据质量的是业务侧怎么写数据。这节讲Spring Boot服务在K8s里怎么配合Flume这条链路,包括埋点数据走Kafka还是日志、生产者参数怎么配、幂等怎么保证。
4.1 埋点数据走Kafka,服务日志另走EFK
我们内部明确了一条规则:要进ClickHouse的数据,必须通过Kafka,不要试图从容器日志里解析。服务日志走的是另一条EFK链路,用来排障,不进分析库。
这样设计有几个好处:
- Kafka作为缓冲和削峰,业务高峰期的脉冲流量先堆在Kafka里,Flume按自己的节奏消费,不会因为ClickHouse一时跟不上就把业务拖垮。
- 解耦了Spring Boot和存储层的生命周期。ClickHouse要升级、要重启,Flume会背压到channel,但Spring Boot完全无感知。
- 数据格式可以强制约束。走日志的话,团队成员写日志时格式容易乱,而走Kafka的生产者端我们可以用统一的序列化器。
4.2 Spring Boot写Kafka的生产者参数
我们用的是spring-kafka的KafkaTemplate,几个关键参数是这样的:
spring: kafka: producer: bootstrap-servers: kafka-service.default.svc.cluster.local:9092 key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all retries: 3 enable-idempotence: true linger-ms: 5 batch-size: 16384 buffer-memory: 33554432acks=all保证了leader和ISR都写入才算成功,配合enable-idempotence=true,生产者层面不会重复发送。linger-ms=5的意思是消息不是立刻发,而是攒5毫秒再发,这个参数让Kafka的生产端吞吐高了一大截,代价是增加了几毫秒延迟,对业务完全无感。
发送消息的代码,我们是异步发送 + 回调,失败时打日志并且重试:
kafkaTemplate.send("user_behavior_topic", eventId, jsonMessage).whenComplete((result, ex) -> { if (ex != null) { log.error("send kafka failed, eventId={}", eventId, ex); } });这个回调很关键。如果你只调send不关心结果,高峰期Kafka一抖动,消息在生产者端静默丢失,你根本不知道。用了回调之后,至少能第一时间发现问题。
4.3 幂等设计:ClickHouse不更新数据,所以必须去重
ClickHouse默认不支持单行更新,重复的数据一旦写入就是脏数据。我们避免重复的办法是双保险:
第一道是生产者的幂等发送(上面说的enable-idempotence=true),保证同一批消息不会重复发给Kafka。
第二道是业务侧给每条埋点生成全局唯一的event_id,ClickHouse直接用ReplacingMergeTree在后台按event_id去重。这样即使Flume消费端在极端情况下重复消费了Kafka的数据(比如offset提交和channel写入不一致导致的重启重放),存储层也能兜住。
生成event_id的规则是:时间戳 + 机器ID + 进程内自增序号。用UUID也行,但实测下来字符串排序对ORDER BY更友好,我们最后用了这种组合。
4.4 K8s网络下的连通性配置
Flume要访问Kafka,Spring Boot也要访问Kafka,ClickHouse要被Flume访问。在K8s里,组件间通信全部走内部Service域名,不要写死Pod IP。
我们在部署时给Kafka配了Headless Service,给ClickHouse配了普通Service。Flume配置里的地址是:
kafka.bootstrap.servers=kafka-headless.default.svc.cluster.local:9092 ch_sink.host=clickhouse.default.svc.cluster.local有个细节:如果Kafka是3节点的集群,Headless Service会让DNS解析返回所有Pod的IP,Flume的Kafka client能自动发现集群节点。如果用普通ClusterIP Service,流量只会落到一个VIP后面,万一那个Pod所在的broker没有对应的分区leader,client会自动跳到正确节点,但多了一次网络转发,不如Headless直接。
5. 实测中遇到的坑和完整排查过程
这条链路跑通不难,难的是稳定。我们上线后遇到了几个典型的坑,每一个都经历了“现象 → 排查 → 解决”的完整过程,写出来给大家参考。
5.1 ClickHouse报错:Too many partitions in single batch
现象:Flume的sink日志里不断刷Code: 117, Too many partitions in single batch, should contain no more than 100 partitions,然后写入失败,channel里的数据开始堆积。
排查过程:先看sink日志,发现报错的是ClickHouse的写入端。查ClickHouse文档和源码,这个错误的意思是:一次插入请求里数据跨越了超过100个分区,ClickHouse拒绝执行。我们建的表是按天分区的,正常一个batch的数据应该集中在某一天。为什么会出现跨分区?
进一步排查,发现数据里混着大量重放的历史数据,而且Spring Boot那边因为生产端重试机制,把几小时前的事件又重新发送了一次。单个batch里既包含今天的数据,也包含几小时前、几天前的数据,分区数自然就超了。
解决思路:
- 把sink的
batchSize从10000调小到3000,减少单批跨越的分区数。 - 在Spring Boot生产者端,调整
retries和linger.ms,尽量避免重放过久历史数据。 - 最根本的:ClickHouse表引擎换成
ReplacingMergeTree后,允许写入时带一个insert_quorum和insert_deduplication_token参数?这个方案没采用,因为我们不想改表结构。最终只是把sink的batchSize调小,并且配置里加了clickhouse.sink.ignoreRetryException=true?这个不建议乱开,会掩盖问题。
最终有效的是:在sink端把chunkSize和batchSize调到一个合适的比例,保证单个batch的数据时间跨度最小。同时让ClickHouse的max_partitions_per_insert_block设大一些(我们设为200)。改完后问题消失。
5.2 Flume Pod反复被OOMKilled
现象:新版本上线后,Flume的Pod隔几个小时就重启一次,状态一直是CrashLoopBackOff。kubectl describe pod看到事件是OOMKilled。
排查过程:先看Flume的日志,发现JVM打印的OutOfMemoryError指向的是Java heap,但我们的容器内存limit明明很宽裕。用kubectl top pod看实际内存使用,发现Java堆远没到limit,但整个进程的RSS接近了limit。
这是典型的容器内存和JVM堆不一致的问题。Flume进程的RSS = Java堆 + 元空间 + 线程栈 + 直接内存 + 页缓存。K8s cgroup统计的是RSS,而JVM的-Xmx只限制了堆。当Kafka client和ClickHouse HTTP client需要大量直接内存时,RSS飙升,超过cgroup limit就被kill。
解决思路:
-Xmx从原来的3G降到2G。- 容器
limits.memory从4G提到5G(如果节点内存还够)。 - 加上
-XX:MaxDirectMemorySize=512m,限制直接内存的无限增长。 - 关掉Flume的GC日志写文件,改为输出到stdout,方便采集(这个不算内存问题,但排查时发现日志文件占了不少磁盘)。
改完之后观察了一周,没有再发生OOMKilled。
5.3 Kafka重复消费导致ClickHouse数据翻倍
现象:某天运营反馈,ClickHouse里某个事件的count增速是预期的两倍。查Kafka的消息总量,和ClickHouse里的行数对不上。
排查过程:先确认Flume的消费没有丢数据,发现没有丢,反而是多了。接着查Kafka的consumer-group的lag,发现flume-clickhouse-group这个消费者组的lag永远为0,说明消费速度没问题。然后怀疑是重复消费。
Kafka Source的重复消费通常发生在channel写入成功后,offset还没提交,此时进程崩溃或重启,Kafka会把这个offset之前的数据重新推送一遍。我们当时用的是Memory Channel + Kafka Source,两者的组合天然会有这个问题:source从Kafka拉到数据,写入Memory Channel成功,但还没来得及向Kafka提交offset,Pod挂了。重启后,同一批数据又被拉取一次,写入了新的Memory Channel。
解决思路:换用Kafka Channel。Kafka Channel的机制把问题从根本上绕开了:source不再从Kafka拉数据再写入另一个存储,而是直接把数据写到Kafka的另一个topic,offset的提交和消费逻辑由Kafka原生的消费者组管理,天然不存在两套存储之间不一致的问题。
换完之后,重复消费的现象消失。如果你因为某些原因不能换Kafka Channel,可以用File Channel + 手动提交offset的方式,但复杂度会高不少。
5.4 高峰期ClickHouse写入慢导致背压传导
现象:每天下午4点高峰,ClickHouse的写入速度明显下降,Flume的sink日志里全是重试。channel的指标(ChannelFillPercentage)一度冲到90%以上,最后Kafka消费端的lag飙升到几十万条,链路延迟从秒级恶化到小时级。
排查过程:先看ClickHouse的CPU和内存,发现CPU并没有打满,但system.metrics里的InsertQuery排队很多。进一步看,是某个聚合报表任务在高峰期跑了大量扫描查询,把ClickHouse的CPU和IO资源吃掉了,写入请求被排在后面。
这是典型的OLAP场景冲突:分析查询和实时写入争抢同一份资源。ClickHouse虽然鼓励混合负载,但高峰期两者叠加,谁都不好过。
解决思路:
- 给Flume的sink加了一个简单的熔断逻辑:连续写入失败超过5次,暂停写入10秒,避免无意义的快速重试加剧ClickHouse压力。这个可以通过
sink的backoff参数实现。 - 把分析查询的任务错峰,重报表挪到晚上低峰期执行。
- 运维层面做了容量评估,高峰期临时给Flume扩副本(从3扩到5),加快消费速度,缓解Kafka的lag压力。
经过这一轮优化,链路在高峰期的延迟稳定在3分钟以内,达到了业务要求。
6. 数据验证与链路监控
链路搭好之后,最重要的不是它现在通不通,而是让它7x24小时都通。这节分享我们实际在用的验证和监控手段。
6.1 手动演练:如何快速确认数据真的进了ClickHouse
新环境部署完之后,我们第一件事不是看指标,而是手动发一条测试数据,走完整链路。做法是这样:
在Spring Boot的测试接口里触发一个事件,拿到它的event_id,然后等10秒左右,到ClickHouse里查:
SELECT event_id, event_time, event_type FROM analytics.user_behavior WHERE event_id = '2023101015340012345678901234' FORMAT Vertical;如果查到了,说明链路是通的。这条命令我每次部署完都要跑一遍,花不了1分钟,但能确认很多东西:Spring Boot能写Kafka、Flume能消费、ClickHouse能接收并正确存储。比任何监控指标都直观。
6.2 Flume自身监控:JMX指标接入Prometheus
Flume内置了JMX监控,启动时加两个参数就能打开:
-Dflume.monitoring.type=http -Dflume.monitoring.port=34545然后可以直接HTTP拉JSON指标:
curl http://<flume-pod-ip>:34545/metrics返回的JSON里有几个关键指标,我重点盯这几个:
| 指标 | 含义 | 我们设置的告警阈值 |
|---|---|---|
| ChannelFillPercentage | channel容量使用百分比 | 超过80%持续5分钟告警 |
| SinkIncomingBatchCount | 每批从channel取出的数据条数 | 趋势性下降表示sink出问题 |
| SinkBatchCompleteCount | sink成功完成的批次 | 持续为0表示写入被卡住 |
| SourceReceivedCount | source从Kafka拉取的数据条数 | 和Kafka生产速率对比判断是否堆积 |
我是用一个简单的sidecar容器定期抓这个接口的指标,转成Prometheus格式。如果你不想引入sidecar,也可以直接用Flume的JMX端口配合JMX exporter。
6.3 Kafka lag是链路健康最重要的红绿灯
整条链路里,最核心的监控指标其实是Kafka消费者组的lag。它的含义是:生产端写了多少条消息,Flume消费了多少条,差值就是积压量。
查看lag命令行:
kafka-consumer-groups.sh --bootstrap-server kafka-headless.default.svc.cluster.local:9092 \ --describe --group flume-clickhouse-group正常情况下,lag应该长期在0附近。如果lag开始持续上涨,说明消费速度跟不上生产速度。这时候要先看ClickHouse是不是写入变慢了,再看Flume的channel是不是满了,逐层往下查。
lag监控我们接入了Prometheus告警,阈值是:lag超过10000持续5分钟告警。10000条积压大概是几十秒的数据,如果超过这个量说明链路已经亚健康。
6.4 数据补偿机制:日志文件留7天兜底
即使链路再稳定,也有万一。我们的最后一道兜底是:Spring Boot本地日志文件保留7天,不因为走Kafka就关掉日志输出。如果真的发生了严重故障,比如Kafka数据因为某种原因丢了,可以用离线任务把日志文件重新解析、灌回Kafka,再由Flume消费一遍。
这个方法不优雅,但管用。在分布式数据管道里,有一个可以追溯的原始数据源,排查问题时底气是完全不同的。
7. 一点个人体会
这条链路跑了几个月,稳定下来的架构其实不复杂:Spring Boot写Kafka,Flume消费并转存到ClickHouse,中间只加了一个Kafka Channel做缓冲。但每一个环节的细节——生产者的幂等、channel的类型选择、sink的批次大小、JVM参数——都是踩过坑才确定的。
如果说有什么可以扩展的方向,那一定是这个Flume节点的水平扩展能力。目前At几千万条日活埋点,3个Flume副本绰绰有余。但如果有一天数据量再涨一个量级,比如日均几亿条,Flume的吞吐就会成为瓶颈,需要考虑用更专业的流处理框架来替换。
最后分享一个小经验:分布式链路上所有节点的时间一定要校准NTP。我们曾经因为某个数据节点的时钟漂移了一分钟,导致ClickHouse里的事件时间排序错位,排查了整整一天。这种小概率问题很隐蔽,不提前防范,真出事的时候非常痛苦。