2. 实时数据源接入:Kafka 核心概念、生产者/消费者 API、消息序列化与反序列化(Avro/Protobuf)

好,我们正式开始第二章。实时特征计算,数据从哪来?绝大多数场景下,第一站就是 Kafka。你可以把它想象成实时数据的高速公路——所有业务日志、埋点、数据库变更,都先往这条路上扔,下游的 Flink、Spark Streaming 再从路上取。

这一章,我不打算给你念 Kafka 官方文档。我会把重点放在:你在做实时特征工程时,必须搞懂的 Kafka 核心概念,以及生产者和消费者 API 怎么用才不出坑。最后,我会重点聊聊序列化——这块选错了,后面性能调优会非常痛苦。

本章知识体系速览

下面这张图,是我自己画的知识结构。你可以先扫一眼,心里有个全局感。

Kafka 实时数据源接入知识体系 Kafka 实时数据源接入知识体系 核心概念 Topic / Partition Offset 与消费位移 Consumer Group ISR / ACK 机制 日志保留策略 生产者 API 异步发送 + Callback 分区策略 幂等与事务 批量压缩 常见避坑 消费者 API 自动/手动提交 Rebalance 监听 消费暂停与恢复 位移越界处理 多线程消费 序列化与反序列化 Avro:Schema Registry + 兼容性 Protobuf:强类型 + 高性能 JSON vs Avro vs Protobuf 对比 Schema 演化实战经验 自定义序列化器 与实时特征协同 特征数据 Schema 设计 血缘追踪:消息来源标记 延迟与乱序处理 背压与流量控制 端到端 Exactly-Once 说明:本章重点在“核心概念”与“序列化”,生产/消费 API 给出关键代码模式,不逐行讲解。

2.1 Kafka 核心概念:不只是消息队列

很多人把 Kafka 当成普通消息队列用,其实有点浪费。Kafka 的设计哲学是日志即存储——消息写进去就不会丢,还能反复读。这一点对实时特征计算特别重要,因为特征数据经常需要回溯重算。

2.1.1 Topic 与 Partition

Topic 是逻辑分类,Partition 是物理分片。我习惯把 Partition 理解为「写日志的笔记本」——一个 Topic 有多个笔记本,每个笔记本里的消息是有序的,但笔记本之间不保证顺序。

这里有个关键点:Partition 数量决定了并行度。你的 Flink 算子并行度、消费者线程数,通常都和 Partition 数挂钩。我见过一个项目,Topic 只配了 3 个 Partition,下游 Flink 开了 10 个并行度——结果 7 个 Task 一直空闲,白白浪费资源。

概念 说明 特征工程中的影响
Topic 消息的逻辑分类 一个特征源对应一个 Topic
Partition 物理分片,内部有序 决定消费并行度,影响特征延迟
Offset 消息在 Partition 内的序号 用于断点续传、特征回溯
ISR 同步副本集合 影响数据可靠性,特征不能丢

2.1.2 消费位移与 Consumer Group

Consumer Group 是 Kafka 实现「一条消息只被组内一个消费者处理」的机制。说白了,就是负载均衡。但这里有个坑:如果 Group 内的消费者数量超过 Partition 数,多余的消费者会闲置。我曾经排查过一个线上问题,20 个消费者进程,Topic 只有 8 个 Partition,结果 12 个进程一直在空转,CPU 还居高不下——浪费啊。

我的习惯:Consumer Group 的消费者数量,永远等于 Partition 数。除非你有特殊的多线程消费需求,否则别打破这个规则。

2.2 生产者 API:怎么写才稳?

生产者 API 看起来简单——就一个 send() 方法。但实际用起来,细节不少。我直接给出一段我常用的生产代码模板,然后讲几个关键点。

// 生产者配置
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("acks", "all");                 // 等待所有副本确认
props.put("retries", 3);                  // 重试次数
props.put("enable.idempotence", true);    // 开启幂等
props.put("compression.type", "snappy");  // 压缩

KafkaProducer<String, GenericRecord> producer = new KafkaProducer<>(props);

// 异步发送 + Callback
producer.send(new ProducerRecord<>("feature-user-click", key, avroRecord),
    (metadata, exception) -> {
        if (exception != null) {
            log.error("发送失败,key={}", key, exception);
            // 这里可以写入死信队列或本地缓存重试
        } else {
            log.debug("发送成功,offset={}", metadata.offset());
        }
    });

2.2.1 异步发送必须带 Callback

很多人图省事,直接 producer.send(record) 不写 Callback。结果消息丢了都不知道。我建议:生产环境永远不要用 fire-and-forget 模式。Callback 里至少打一条失败日志,方便排查。

2.2.2 幂等与事务

实时特征场景下,消息重复是家常便饭。网络抖动、Leader 选举,都可能导致 Producer 重试,从而产生重复消息。enable.idempotence=true 可以保证单个 Partition 内不重复。如果你需要跨 Partition 的 Exactly-Once,那就得上事务 API 了。

注意:事务 API 会带来额外的性能开销。我一般只在「特征数据用于金融风控」这种场景下才开启。普通推荐场景,幂等 + 下游去重就够了。

2.3 消费者 API:消费位移是命根子

消费者 API 的核心就两件事:拉取消息和提交位移。位移提交错了,轻则重复消费,重则丢数据。

2.3.1 自动提交 vs 手动提交

自动提交省事,但不可控。默认 5 秒提交一次,如果这 5 秒内进程挂了,重启后就会重复消费最近 5 秒的数据。对于特征计算来说,重复数据可能导致特征值异常。

我个人的习惯是:手动提交,而且是在业务处理完成之后提交。下面这段代码,是我在多个项目中用过的模式:

while (true) {
    ConsumerRecords<String, GenericRecord> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, GenericRecord> record : records) {
        try {
            // 1. 处理消息:更新特征状态
            processFeature(record.value());
            // 2. 处理成功,记录当前 offset
            lastProcessedOffset = record.offset();
        } catch (Exception e) {
            // 3. 处理失败,记录死信,继续下一条
            sendToDlq(record);
            log.warn("消息处理失败,offset={}", record.offset(), e);
        }
    }
    // 4. 批量提交位移
    consumer.commitSync();
}

2.3.2 Rebalance 监听

Consumer Group 发生 Rebalance 时,分区会重新分配。如果你在 Rebalance 之前没有提交位移,消费进度就丢了。我建议实现 ConsumerRebalanceListener,在分区被撤销前提交一次位移:

consumer.subscribe(Collections.singletonList("feature-user-click"), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // 撤销前提交位移
        consumer.commitSync();
        log.info("分区 {} 被撤销,已提交位移", partitions);
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        log.info("分区 {} 已分配", partitions);
    }
});

2.4 序列化与反序列化:选型决定生死

好,终于到了这一章我最想聊的部分。序列化选型,直接决定了你的特征数据体积、解析速度和Schema 演化的成本。

2.4.1 三种主流方案对比

方案 序列化速度 数据体积 Schema 演化 生态支持
JSON 慢 大(含字段名) 弱(需手动兼容) 通用
Avro 中等 小(二进制) 强(Schema Registry) Kafka 原生支持
Protobuf 快 极小 中等(需管理 proto 文件) 跨语言优秀

我个人在实时特征场景下的选择标准:

  • 如果团队已经用了 Confluent Schema Registry,无脑选 Avro。Schema 演化、兼容性检查都现成的。
  • 如果对性能有极致要求,比如每秒百万级消息,选 Protobuf。它的编解码速度确实比 Avro 快一截。
  • JSON 呢? 我只有在快速原型、调试阶段才用。生产环境用 JSON 做特征数据,带宽和解析开销都扛不住。

2.4.2 Avro 实战:Schema Registry 是灵魂

Avro 的精髓在于 Schema 与数据分离。消息里只存二进制数据,Schema 存在 Registry 里。这样消息体积小,而且 Schema 可以向后兼容。

我曾经遇到过一个经典问题:上游特征表新增了一个字段,下游消费端没更新 Schema,结果反序列化直接抛异常。后来用了 Schema Registry 的兼容性检查(BACKWARD 或 FORWARD 策略),新增字段必须设置默认值,才避免了这类问题。

// Avro Schema 定义(示例)
{
  "type": "record",
  "name": "UserClickFeature",
  "fields": [
    {"name": "user_id", "type": "string"},
    {"name": "item_id", "type": "string"},
    {"name": "click_time", "type": "long"},
    {"name": "click_count", "type": "int", "default": 0}  // 新增字段,必须给默认值
  ]
}

避坑指南:Schema Registry 的兼容性策略,我建议从项目一开始就定好。中途改策略,历史 Schema 可能全得重建。我个人偏好 BACKWARD 策略——消费者可以先升级,生产者后升级,对线上影响最小。

2.4.3 Protobuf 实战:管理好 .proto 文件

Protobuf 的优势是强类型和跨语言。在微服务架构中,特征数据可能被 Java、Python、Go 多个语言消费,Protobuf 的代码生成机制就很舒服。

但 Protobuf 也有个麻烦:.proto 文件的管理。我见过一个项目,.proto 文件散落在各个 Git 仓库里,版本完全对不上。后来我们统一放到一个独立的 Schema 仓库,用 CI 流程自动编译和发布,才解决了这个问题。

// Protobuf 定义(示例)
syntax = "proto3";
package feature;

message UserClickFeature {
  string user_id = 1;
  string item_id = 2;
  int64 click_time = 3;
  int32 click_count = 4;  // 新增字段,使用新编号
}

2.5 本章小结

这一章的内容,说白了就是实时特征计算的「数据入口」怎么搭。Kafka 的核心概念要理解透,尤其是 Partition 和 Offset 对并行度和可靠性的影响。生产者和消费者 API 的代码模式要形成肌肉记忆——异步发送带 Callback、手动提交位移、监听 Rebalance。序列化选型上,Avro 和 Protobuf 是主流,JSON 只适合调试。