3. 实时计算基础:Apache Kafka 核心概念、Topic 与 Partition 机制、消息投递语义

聊实时计算,Kafka 是绕不开的坎。我见过不少团队,把 Kafka 当个“高级消息队列”用,结果线上出问题才回头补课。说白了,Kafka 的设计哲学跟传统 MQ 完全不一样——它更像一个分布式日志。今天咱们就把它的核心概念掰开揉碎,讲清楚。

3.1 核心概念:从 Producer 到 Consumer

先看一张整体架构图,心里有个全局印象。

Kafka 核心架构图 Kafka 核心架构 Producer 消息生产者 Kafka Broker 集群 Topic A Topic B Partition 0 Partition 1 Partition 2 Partition 3 Consumer 消息消费者 关键角色说明: • Producer:发送消息到指定 Topic,可指定 Partition 或由分区器决定。 • Broker:Kafka 服务器节点,负责存储消息、处理读写请求。 • Consumer:从 Topic 拉取消息,属于同一个 Consumer Group 的实例共同消费。 • ZooKeeper / KRaft:负责集群元数据管理、Leader 选举(新版本已逐步移除 ZK)。

Kafka 里最基础的几个角色,我列个表,方便你对照理解。

角色 职责 我见过的坑
Producer 发布消息到 Topic 不设置 acks,丢消息都不知道
Broker 存储消息副本,处理请求 磁盘选错,IO 打满直接崩
Consumer 拉取并处理消息 消费位移没提交,重启重复消费
Topic 消息的逻辑分类 Topic 数量过多,文件句柄爆了
Partition Topic 的分片单元 分区数设太少,消费能力跟不上
我的习惯:刚接触 Kafka 时,我总把 Topic 类比成数据库的表,Partition 类比成表的分区。这个类比虽然不完美,但对入门理解很有帮助。

3.2 Topic 与 Partition 机制

Topic 是消息的逻辑分类。你想想看,一个电商系统里,订单消息、支付消息、物流消息,各走各的 Topic,互不干扰。

但真正撑起 Kafka 高吞吐的,是 Partition。每个 Topic 可以分成多个 Partition,每个 Partition 是一个有序的、不可变的日志文件。消息追加到 Partition 尾部,Consumer 从指定偏移量开始拉取。

3.2.1 Partition 的存储结构

每个 Partition 在磁盘上对应一个目录,目录名是 topic-partitionId。目录里是一堆 .log 和 .index 文件。消息按顺序写入活跃的 .log 文件,写满后滚动生成新文件。

# 一个 Partition 的目录结构示例
/opt/kafka/data/orders-0/
├── 00000000000000000000.log
├── 00000000000000000000.index
├── 00000000000000000000.timeindex
├── 00000000000000001024.log
├── 00000000000000001024.index
└── 00000000000000001024.timeindex

这里有个关键点:Partition 内的消息是有序的,但跨 Partition 不保证顺序。如果你需要全局有序,只能设一个 Partition——但那样吞吐量就下来了。我一般在业务上做取舍:同一个订单的消息,通过 key 哈希到同一个 Partition,保证局部有序就够了。

3.2.2 分区策略

Producer 发消息时,怎么决定消息落到哪个 Partition?有三种常见策略。

  • 轮询(Round-Robin):没有 key 时默认策略,均匀分布。适合负载均衡场景。
  • Key 哈希:指定 key,对分区数取模。相同 key 的消息进同一个 Partition,保证顺序。
  • 自定义分区器:实现 Partitioner 接口,按业务逻辑路由。我做过一个项目,按用户地域分区,方便下游做地域聚合。

避坑指南:我曾经遇到过一个线上事故——某个 Topic 的分区数从 12 扩容到 24,结果旧数据还在 12 个分区里,新数据按 24 取模。相同 key 的消息被路由到不同分区,顺序全乱了。扩容后,一定要确认旧数据的消费逻辑能接受乱序,或者重建 Topic 重新生产。

3.3 消息投递语义

消息投递语义,说白了就是 Producer 和 Consumer 之间关于“消息到底有没有成功”的约定。Kafka 支持三种级别。

语义 Producer 端 Consumer 端 适用场景
At most once 发送后不重试 先提交位移,再处理消息 日志采集,丢几条无所谓
At least once 发送后等待 ack,失败重试 先处理消息,再提交位移 大部分业务场景,允许重复
Exactly once 启用幂等 + 事务 事务性消费 + 幂等写入 金融、支付、对账

3.3.1 Producer 端:acks 参数

Producer 的 acks 参数直接决定了投递可靠性。

  • acks=0:发出去就不管了。吞吐最高,但丢消息风险最大。我见过有人用这个配合作业日志,丢了也不心疼。
  • acks=1:Leader 写入本地日志就返回 ack。默认值,性能与可靠性的平衡点。Leader 挂了但还没同步给 Follower 时,消息会丢。
  • acks=all:等待所有 ISR(In-Sync Replicas)副本都写入后才返回。最安全,但延迟最高。
// Producer 配置示例
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("acks", "all");  // 最可靠的投递
props.put("retries", 3);   // 失败重试次数
props.put("enable.idempotence", true);  // 开启幂等,配合 acks=all 实现 Exactly once

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
注意:开启幂等(enable.idempotence=true)后,Producer 会自动去重,避免重试导致的消息重复。但幂等只在单会话内有效——如果 Producer 挂了重启,序列号重置,还是可能重复。要跨会话的 Exactly once,得用 Kafka 事务。

3.3.2 Consumer 端:位移提交

Consumer 消费完消息后,需要提交位移(offset),告诉 Broker“这条消息我处理完了”。位移提交的时机,决定了投递语义。

  • 自动提交(enable.auto.commit=true):每隔 auto.commit.interval.ms 自动提交一次。简单,但可能重复消费——比如刚拉取一批消息,还没处理完,自动提交了,然后 Consumer 挂了。重启后从新位移开始消费,上一批没处理完的消息就丢了。这是 At most once。
  • 手动提交:处理完消息后,手动调用 commitSync() 或 commitAsync()。先处理再提交,就是 At least once。
// 手动提交示例:先处理,再提交
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        // 处理消息
        process(record);
    }
    // 处理完再提交
    consumer.commitSync();
}
我的建议:大部分业务场景用 At least once 就够了。下游做幂等去重,比在 Kafka 层面折腾 Exactly once 简单得多。我做过一个支付系统,Consumer 处理完消息后,把处理结果写数据库,用唯一索引防重——成本低,效果好。

3.4 小结

这一章我们聊了 Kafka 的核心概念:Producer、Broker、Consumer、Topic、Partition。重点理解了 Partition 的存储结构和分区策略,以及三种消息投递语义的差异和配置方法。

嗯,这里要注意:Kafka 不是万能的。它擅长高吞吐、持久化的流式处理,但不适合小消息、低延迟的场景。选型时想清楚业务需求,别盲目跟风。