3. 实时数据采集架构:基于Kafka/Flink的流式采集方案设计
聊到实时特征监控,第一个绕不开的坎儿就是数据怎么进来。你想想看,特征质量监控要是等离线数仓跑完,黄花菜都凉了。我们要的是秒级甚至毫秒级的感知——特征分布突然偏移了、空值率飙升了、格式不对了,这些都得第一时间知道。
所以,实时数据采集架构是整个监控系统的「水龙头」。水龙头没拧好,后面再好的分析引擎也是白搭。我个人习惯把这一层叫做「数据接入层」,它的核心使命就三个字:接得住、传得快、不丢数。
3.1 为什么是Kafka + Flink?
市面上实时采集的方案不少,比如直接用Logstash、或者用Pulsar。但我这些年做过的项目里,Kafka + Flink的组合是最稳的。为什么?
- Kafka负责缓冲和解耦:生产端和消费端不用互相等,Kafka就像个大水池,上游随便倒水,下游按需取水。我在项目中遇到过上游业务方突然发了一波流量洪峰,如果没有Kafka做削峰填谷,Flink作业直接就被冲垮了。
- Flink负责计算和转换:原始数据往往不是我们想要的格式。比如原始日志里特征值是个JSON字符串,我们需要把它解析成结构化字段,再做一些简单的清洗、过滤、聚合。Flink的DataStream API干这事儿太顺手了。
- 两者天生互补:Kafka保证消息的持久化和顺序性,Flink保证Exactly-Once语义。说白了,就是数据不会丢,也不会重复算。
核心原则:实时采集架构不是越复杂越好,而是要在「吞吐量」、「延迟」、「准确性」三者之间找到平衡。Kafka + Flink是目前公认的最优解之一。
3.2 整体架构长什么样?
下面这张图是我自己画的一个标准架构。你看一眼,基本就能明白数据是怎么从业务系统流到特征监控引擎的。
这个架构图里,我特意把数据源分成了三类:业务日志、消息队列、数据库CDC。为什么?因为不同数据源的延迟要求和格式差异太大了。比如业务日志可以容忍几秒的延迟,但数据库CDC要求毫秒级同步。Flink在接入层就要做差异化处理。
3.3 关键设计细节
3.3.1 Topic 设计策略
Kafka的Topic设计是个学问。我见过不少团队把所有特征数据塞到一个Topic里,结果消费端解析逻辑越来越臃肿,出了问题也很难排查。
我建议按特征域来划分Topic:
| Topic 名称 | 数据来源 | 特征类型 | 分区数 | 保留时间 |
|---|---|---|---|---|
feature_user_behavior |
埋点日志 | 用户行为特征 | 12 | 7天 |
feature_item_profile |
数据库CDC | 物品属性特征 | 6 | 3天 |
feature_context |
服务端日志 | 上下文特征 | 4 | 1天 |
feature_model_score |
模型服务 | 模型输出特征 | 8 | 30天 |
经验之谈:分区数不要拍脑袋定。我一般按「预估峰值吞吐量 / 单分区吞吐量」来算。单分区写吞吐量大约10MB/s,读吞吐量大约30MB/s。留30%的余量比较稳妥。
3.3.2 Flink 作业的算子设计
Flink作业里,我通常会设计这么几个算子链:
- Source算子:从Kafka消费数据,设置好起始偏移量和消费策略。我习惯用
setStartFromLatest(),因为监控场景下历史数据意义不大。 - 解析算子:把原始字节流解析成POJO。这里要注意异常处理——遇到解析失败的数据,不要直接丢弃,应该发到侧输出流(Side Output)里做后续分析。
- 清洗算子:过滤掉明显异常的数据。比如特征值超出合理范围、时间戳是未来的、关键字段为空等。
- 特征提取算子:从原始数据中提取出我们需要监控的特征字段。这一步可能会做简单的特征工程,比如归一化、分桶。
- Sink算子:把处理好的数据写入下游。我一般会写两个Sink:一个写到特征监控引擎,一个写到实时数仓做备份。
// 伪代码示例:Flink作业核心逻辑
DataStream<String> rawStream = env.addSource(
new FlinkKafkaConsumer<>("feature_user_behavior",
new SimpleStringSchema(), kafkaProps));
// 解析 + 清洗 + 特征提取
SingleOutputStreamOperator<FeatureEvent> processedStream = rawStream
.flatMap(new JsonParser()) // 解析JSON
.filter(new OutlierFilter()) // 过滤异常
.map(new FeatureExtractor()); // 提取特征
// 侧输出流:记录解析失败的数据
OutputTag<String> parseErrorTag = new OutputTag<String>("parse-error") {};
processedStream.getSideOutput(parseErrorTag)
.addSink(new KafkaSink<>("feature_parse_error", kafkaProps));
// 主输出:写入监控引擎和实时数仓
processedStream.addSink(new FeatureMonitorSink());
processedStream.addSink(new DorisSink());
注意:Flink作业的并行度要和Kafka分区数匹配。我见过有人设置并行度远大于分区数,结果大量线程空转,白白浪费资源。最佳实践是并行度 = 分区数 × 1.2,留一点弹性。
3.4 数据一致性保障
实时采集最怕什么?怕丢数据,怕重复数据。尤其是特征监控场景,数据一旦丢失,监控指标就会出现空洞,导致误告警。
我总结了几条保障措施:
- Kafka端:设置
acks=all,确保消息被所有副本确认后才算写入成功。生产者重试次数设大一点,我一般设3次。 - Flink端:开启Checkpoint,间隔设30秒。状态后端用RocksDB,避免内存溢出。启用Exactly-Once语义,配合Kafka的幂等生产者。
- 异常兜底:在Flink作业里加一个「死信队列」逻辑。处理失败的数据先写到Kafka的
feature_dead_letterTopic里,后续人工排查。
嗯,这里要注意一点:Exactly-Once不是免费的午餐。它会让吞吐量下降10%-20%。如果你的场景允许少量重复(比如监控指标本身就有统计误差),用At-Least-Once就够了。
3.5 我曾经踩过的坑
最后分享一个真实案例。有一次我做特征监控上线,Flink作业跑了一个月都好好的。突然有一天,监控告警说特征分布异常。我排查了半天,发现是上游业务方改了日志格式,多了一个字段。Flink的解析算子没做兼容处理,直接抛异常了,整条数据被丢弃。
从那以后,我养成了一个习惯:所有解析算子都必须做Schema兼容。新增字段不报错,缺失字段给默认值。另外,解析失败的数据一定要走侧输出流,不能静默丢弃。你永远不知道上游会出什么幺蛾子。
实时数据采集架构,说白了就是「把数据从A点搬到B点,路上别出事」。Kafka + Flink这套组合拳,只要设计得当,能扛住99%的场景。剩下的1%,靠监控和告警来兜底。