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 整体架构长什么样?

下面这张图是我自己画的一个标准架构。你看一眼,基本就能明白数据是怎么从业务系统流到特征监控引擎的。

实时数据采集架构图 实时数据采集架构:Kafka + Flink 流式方案 业务日志 App / Web / Server 消息队列 RocketMQ / RabbitMQ 数据库CDC Canal / Debezium Apache Kafka 集群 Topic: feature_raw_log | 分区数: 12 | 副本: 3 Flink 流处理作业 解析 | 清洗 | 特征提取 | 窗口聚合 | 格式标准化 特征监控引擎 实时数仓 (Doris/ClickHouse) 告警服务 / 消息推送

这个架构图里,我特意把数据源分成了三类:业务日志、消息队列、数据库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作业里,我通常会设计这么几个算子链:

  1. Source算子:从Kafka消费数据,设置好起始偏移量和消费策略。我习惯用setStartFromLatest(),因为监控场景下历史数据意义不大。
  2. 解析算子:把原始字节流解析成POJO。这里要注意异常处理——遇到解析失败的数据,不要直接丢弃,应该发到侧输出流(Side Output)里做后续分析。
  3. 清洗算子:过滤掉明显异常的数据。比如特征值超出合理范围、时间戳是未来的、关键字段为空等。
  4. 特征提取算子:从原始数据中提取出我们需要监控的特征字段。这一步可能会做简单的特征工程,比如归一化、分桶。
  5. 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_letter Topic里,后续人工排查。

嗯,这里要注意一点:Exactly-Once不是免费的午餐。它会让吞吐量下降10%-20%。如果你的场景允许少量重复(比如监控指标本身就有统计误差),用At-Least-Once就够了。

3.5 我曾经踩过的坑

最后分享一个真实案例。有一次我做特征监控上线,Flink作业跑了一个月都好好的。突然有一天,监控告警说特征分布异常。我排查了半天,发现是上游业务方改了日志格式,多了一个字段。Flink的解析算子没做兼容处理,直接抛异常了,整条数据被丢弃。

从那以后,我养成了一个习惯:所有解析算子都必须做Schema兼容。新增字段不报错,缺失字段给默认值。另外,解析失败的数据一定要走侧输出流,不能静默丢弃。你永远不知道上游会出什么幺蛾子。

实时数据采集架构,说白了就是「把数据从A点搬到B点,路上别出事」。Kafka + Flink这套组合拳,只要设计得当,能扛住99%的场景。剩下的1%,靠监控和告警来兜底。