3. 实时数据源接入:Kafka与Pulsar作为实时数据管道、Schema Registry管理特征Schema、Exactly-Once语义保证

好,咱们进入第三章。前两章我们把 Lakehouse 的基座和实时特征的整体架构聊清楚了。这一章,得落地了。

实时特征工程,第一步就是数据怎么进来。说白了,就是实时数据管道。你特征算得再快,数据进不来或者进错了,全是白搭。我在好几个项目里都见过,架构图画得漂漂亮亮,结果一上线,数据源那层先崩了。

这一章,我们就聚焦三个核心问题:用什么管道(Kafka vs Pulsar)、怎么管Schema(Schema Registry)、怎么保证不丢不重(Exactly-Once)。

3.1 实时数据管道选型:Kafka 还是 Pulsar?

先聊管道。现在市面上主流的实时消息队列,基本就是 Kafka 和 Pulsar 二选一。很多人问我:“到底选哪个?” 我的回答是:看场景。

核心观点:Kafka 适合吞吐优先、生态成熟的场景;Pulsar 适合多租户、存储计算分离、需要更灵活扩展的场景。

我个人习惯,先看团队熟悉度。如果团队全是 Kafka 老手,别硬上 Pulsar。但如果是从零开始建平台,我会认真考虑 Pulsar。

下面这张图,是我对两者核心架构差异的理解:

Kafka vs Pulsar 架构对比 Kafka 架构 Producer Consumer Broker (存储 + 计算耦合) 本地磁盘 / 页缓存 ZooKeeper (元数据) Pulsar 架构 Producer Consumer Broker (无状态计算层) BookKeeper (存储层) ZooKeeper (元数据) Kafka:Broker 同时负责存储和计算,扩缩容需要搬迁数据。Pulsar:Broker 无状态,存储交给 BookKeeper,扩缩容极快。

你看,Kafka 的 Broker 是“大包大揽”的,既管计算又管存储。Pulsar 则拆成了两层:Broker 只做计算,BookKeeper 专门存数据。这个区别在实际运维中影响很大。

我举个例子。有一次我们做活动大促,Kafka 集群需要扩容。因为数据在 Broker 本地磁盘,加机器后要等数据重平衡,这个过程慢得让人抓狂。Pulsar 呢?加 Broker 就行,数据在 BookKeeper 里,几乎秒级生效。

下面这个表格,是我自己总结的选型参考:

对比维度 Kafka Pulsar
存储计算 耦合,Broker 本地盘 分离,Broker + BookKeeper
扩缩容 慢,需数据重平衡 快,Broker 无状态
多租户 较弱,靠 Topic 隔离 原生支持,Namespace 级别
消息保留 基于 Offset,过期删除 基于 TTL 和 Backlog,更灵活
生态成熟度 极高,Flink/Spark 原生集成 较高,但部分工具需适配
延迟 毫秒级 毫秒级(略低)

我的建议:如果你们公司已经重度依赖 Kafka 生态(比如 Kafka Streams、KSQL),别折腾。如果是从零建实时数仓,且未来可能有多个业务线共用,Pulsar 的多租户能力会让你省心很多。

3.2 Schema Registry:别让数据变成一团乱麻

管道选好了,数据进来了。但有个问题:你怎么知道进来的数据长什么样?

我见过最惨的一次事故,上游改了字段类型,从 int 改成了 string,下游消费端直接报错,特征 pipeline 停了两个小时。为什么?因为没有 Schema 管理。

Schema Registry 就是干这个的。它统一管理数据的“契约”。生产者发布数据时,必须带上 Schema;消费者读取时,从 Registry 拉取 Schema 来校验和反序列化。

目前最主流的是 Confluent Schema Registry,支持 Avro、Protobuf、JSON Schema。我个人最推荐 Avro,原因很简单:体积小、序列化快、Schema 演化能力强。

看一个实际例子。我们用 Avro 定义一个用户点击事件的特征 Schema:

{
  "type": "record",
  "name": "UserClickEvent",
  "namespace": "com.feature.lakehouse",
  "fields": [
    {"name": "user_id", "type": "string"},
    {"name": "item_id", "type": "string"},
    {"name": "click_time", "type": "long", "logicalType": "timestamp-millis"},
    {"name": "page_type", "type": ["null", "string"], "default": null},
    {"name": "duration_ms", "type": "int", "default": 0}
  ]
}

注意看 page_type 字段,我用了 ["null", "string"],并给了默认值 null。这就是 Schema 演化的关键——向后兼容。老版本的消费者读到新数据时,遇到不认识或者为空的字段,可以用默认值填充,不会崩。

避坑指南:我曾经遇到一个团队,所有 Schema 都设成“完全兼容”,结果字段删了又加、加了又改,Registry 里积压了几百个版本。消费端每次启动都要拉取全量 Schema,性能急剧下降。我的建议是:明确兼容性策略,该用 BACKWARD 就用 BACKWARD,别一味追求 FORWARD。

在实时特征场景下,Schema Registry 的典型工作流是这样的:

Schema Registry 工作流 Producer Schema Registry Consumer Kafka / Pulsar ① 注册 Schema ② 拉取 Schema ③ 发送序列化数据 ④ 消费并反序列化

流程很简单:Producer 先向 Registry 注册 Schema,拿到 Schema ID。然后发消息时,只带这个 ID 和二进制数据,不重复传 Schema。Consumer 根据 ID 从 Registry 拉取 Schema,反序列化。这样既省带宽,又保证双方契约一致。

3.3 Exactly-Once 语义:实时特征的“圣杯”

好,管道有了,Schema 管好了。但还有一个终极问题:数据会不会丢?会不会重复?

在实时特征场景里,重复计算一个特征,可能导致推荐结果异常、风控误判。丢一条数据,可能漏掉一次关键转化。所以,Exactly-Once 语义是必须的。

说白了,Exactly-Once 就是“每条消息只被处理一次,且结果只生效一次”。但实现起来,没那么简单。

我把它拆成三个层面来看:

  1. Source 端:保证消息不丢。Kafka 通过 ACK 机制和 ISR 副本,Pulsar 通过 BookKeeper 的写入确认。
  2. 计算端:保证状态一致性。Flink 的 Checkpoint + 两阶段提交是经典方案。
  3. Sink 端:保证写入幂等。Lakehouse 表写入必须是幂等的,同一个批次写两次,结果一样。

这里我重点讲一下 Flink + Kafka 的 Exactly-Once 实现,因为这是实时特征工程最常用的组合。

Flink 的 Checkpoint 机制,说白了就是定期给整个作业的状态拍一张“快照”。当作业失败重启时,从最近一次成功的 Checkpoint 恢复。配合 Kafka 的 两阶段提交 Sink,就能做到端到端的 Exactly-Once。

看一段核心配置代码:

// Flink Kafka Producer 开启 Exactly-Once
Properties kafkaProps = new Properties();
kafkaProps.setProperty("bootstrap.servers", "localhost:9092");
kafkaProps.setProperty("transaction.timeout.ms", "60000");

FlinkKafkaProducer<String> kafkaSink = new FlinkKafkaProducer<>(
    "feature-output-topic",
    new SimpleStringSchema(),
    kafkaProps,
    FlinkKafkaProducer.Semantic.EXACTLY_ONCE
);

// 开启 Checkpoint
env.enableCheckpointing(5000); // 每 5 秒一次 Checkpoint
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(2000);
env.getCheckpointConfig().setCheckpointTimeout(60000);
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

关键点:Semantic.EXACTLY_ONCE 这个枚举值,告诉 Flink 使用两阶段提交协议。Flink 在 Checkpoint 完成时,预提交事务;当所有 Sink 都确认后,再正式提交。如果作业挂了,未提交的事务会被回滚,不会产生脏数据。

但这里有个坑。我曾经在一个项目里,Kafka 的 transaction.timeout.ms 设得太短,而 Flink 的 Checkpoint 因为数据量大,偶尔超时。结果事务被 Kafka 强制中止,作业频繁报错。后来我把这个值调大到 60 秒,才稳定下来。

注意:Exactly-Once 不是免费的。它会带来额外的性能开销(事务协调、状态快照)。如果你的场景允许偶尔丢几条数据(比如非关键统计指标),用 At-Least-Once + 下游去重,性价比更高。

最后,我把三种语义的对比整理成一张表,方便你选型时参考:

语义级别 保证 性能开销 适用场景
At-Most-Once 最多一次,可能丢 最低 日志采集、监控指标(可容忍少量丢失)
At-Least-Once 至少一次,可能重复 中等 大部分实时 ETL(下游需幂等或去重)
Exactly-Once 不丢不重 最高 金融风控、实时特征、交易计费

嗯,这一章的内容就到这里。实时数据源接入,说白了就是三件事:选对管道、管好 Schema、保证语义。这三件事做扎实了,后面的特征计算才能站得稳。