Kafka核心概念

在上一节中,我们已经搭建了Kafka环境并使用命令行和Java代码两种方式演示和如何收发消息。但这里面其实还有很多问题:消息发到了哪里?多个消费者同时消费会发生什么?消息是怎么保存的?这一章我们就来回答这些问题,建立起使用Kafka必须理解的概念模型。

Topic与Partition

Topic是消息的逻辑分类单元。 你可以把它理解成一个消息频道,生产者往某个Topic里写消息,消费者订阅这个Topic来读消息。不同业务的消息应当放在不同的Topic里,比如订单相关的消息放在order.topic,用户行为事件放在user.event.topic,互不干扰。

Partition是Topic在物理层面的拆分单元。 一个Topic可以被划分成多个Partition,每个Partition是一段独立的、有序的消息日志,分散存储在不同的Broker节点上。

下图展示了一个拥有3个Partition的Topic,消息被分散写入不同的Partition。

graph LR
    P["Producer"]
    P -->|msg1| PA["Topic: order-topic<br />Partition 0"]
    P -->|msg2| PB["Topic: order-topic<br />Partition 1"]
    P -->|msg3| PC["Topic: order-topic<br />Partition 2"]

    PA --> B0["Broker 0"]
    PB --> B1["Broker 1"]
    PC --> B2["Broker 2"]

为什么要分区

分区的引入主要解决三个问题:

吞吐量:单个Partition的写入能力受限于所在Broker的磁盘和网络,但如果我们有3个Partition分布在3台Broker上,三者可以并行写入,理论上吞吐量可以线性扩展,这也是Kafka号称高吞吐的理论支撑之一。

存储容量:一条消息只能存在于一个Partition中,如果数据量超出了单台机器的磁盘容量,就必须通过多个Partition把数据分散到多台机器上。

并发消费:同一个Consumer Group内,为了保证消息有序,Kafka规定一个Partition同时只能被一个Consumer消费。这意味着Topic的Partition数决定了该消费者组内最大的并发消费数量。例如Topic有3个Partition,那么最多就同时可以有3个Consumer并行工作;如果消费者数量超过Partition数,多余的Consumer会处于闲置状态。不过如果Partition数大于消费者数,一个Consumer可能去多个Partition拉取信息,不会出现某个Partition消息一直积压的情况。

分区内的顺序性

Kafka保证的是单个Partition内部消息的有序性,而不是跨Partition的全局有序,这一点非常重要。如果你的业务需要保证某一类消息的顺序(比如同一个订单的所有状态变更事件必须按顺序处理),正确的做法是在发送消息时指定相同的Key,Kafka会根据Key的哈希值将这些消息路由到同一个Partition,从而保证它们在该Partition内是有序的。

graph LR
    P["Producer"]
    P -->|"Key=order-001<br />msg:已创建"| PA["Partition 0"]
    P -->|"Key=order-001<br />msg:已支付"| PA
    P -->|"Key=order-001<br />msg:已发货"| PA
    P -->|"Key=order-002<br />msg:已创建"| PB["Partition 1"]
    P -->|"Key=order-002<br />msg:已支付"| PB

同一个订单的消息始终落在同一个Partition中,消费时就能保证按顺序处理。

副本与Leader/Follower

Partition在物理上的数据需要有备份机制,否则某台Broker宕机后,该节点上的Partition数据就永久丢失了,Kafka通过副本(Replica)来解决这个问题。每个Partition可以配置副本数(Replication Factor),例如设置为3,那么这个Partition就有1个Leader副本和2个Follower副本,分布在不同的Broker节点上。

graph TB
    subgraph "Broker 0"
        L["Partition 0<br />Leader ★"]
    end
    subgraph "Broker 1"
        F1["Partition 0<br />Follower"]
    end
    subgraph "Broker 2"
        F2["Partition 0<br />Follower"]
    end

    P["Producer / Consumer"] -->|读写| L
    L -->|同步| F1
    L -->|同步| F2

Leader是唯一处理读写请求的副本。 生产者写消息只写到Leader,消费者读消息也只从Leader读,Follower的职责是被动地从Leader同步数据,它仅作为冷备份存在。当Leader所在的Broker发生故障时,Kafka会从Follower中选举出一个新的Leader,整个过程对客户端是透明的,消费者和生产者重连后即可继续正常工作,这就是Kafka高可用的基本实现方式。

ISR同步副本集合

Kafka中,实际上并非所有Follower的数据都是和Leader保持完全同步。Kafka内部维护了一个叫做ISR(In-Sync Replicas)的集合,表示当前与Leader保持同步的副本集合。如果某个Follower由于网络延迟或者所在机器负载过高等原因长时间没有从Leader拉取数据,它就会被踢出ISR。当Leader需要选举时,只有ISR中的Follower才有资格成为新的Leader,这样可以避免选出一个数据严重落后的副本,造成消息丢失。

关于ISR的同步细节(HW/LEO推进机制)属于Kafka内部实现,本章暂不展开,理解“ISR是保持同步的副本集合,Leader宕机从ISR中选举”这个结论即可。

Offset

Offset是Partition中每条消息的位置编号,从0开始单调递增。每个Partition内的消息都有唯一的Offset,可以把它理解成消息在这段日志中的"行号"。对于消息它的Offset是不可变的,一旦消息写入,它的Offset就固定了。

Partition 0:
┌─────────────────────────────────────────────────────┐
│  offset=0  │  offset=1  │  offset=2  │  offset=3    │ ...
│  "msg A"   │  "msg B"   │  "msg C"   │  "msg D"     │
└─────────────────────────────────────────────────────┘
                                               ↑
                                       消费者当前读到这里

Offset对消费者来说最重要的意义是记录消费进度。 消费者每次消费完一批消息后,需要把当前消费到的Offset提交给Kafka,这个动作叫做提交Offset。下次消费者重启或重新连接时,Kafka会根据这个已提交的Offset告诉消费者“你上次读到这里了,从这里继续”,从而实现断点续读。

Kafka将各个消费者组的Offset记录存储在内部一个名叫__consumer_offsets的Topic中,这是Kafka自动管理的,对开发者透明。

Consumer Group与分区消费关系

Consumer Group(消费者组)是Kafka消费模型的核心设计,也是理解Kafka和传统消息队列区别的关键。Kafka的消息投递模型是发布/订阅的,但在Consumer Group内部又实现了类似队列的竞争消费。具体规则有两条:

  1. 同一个Consumer Group内,一个Partition只会被分配给一个Consumer
  2. 不同Consumer Group之间相互独立,同一条消息会被每个Consumer Group各消费一次

下图展示了这两条规则的示例。

graph LR
    subgraph "Topic: order-topic(3个Partition)"
        P0["Partition 0"]
        P1["Partition 1"]
        P2["Partition 2"]
    end

    subgraph "Consumer Group A(订单服务)"
        CA0["Consumer A-0"]
        CA1["Consumer A-1"]
        CA2["Consumer A-2"]
    end

    subgraph "Consumer Group B(数据分析服务)"
        CB0["Consumer B-0"]
    end

    P0 --> CA0
    P1 --> CA1
    P2 --> CA2

    P0 --> CB0
    P1 --> CB0
    P2 --> CB0

Group A有3个Consumer,每人负责一个Partition,并行消费互不干扰;Group B只有1个Consumer,它一人负责消费全部3个Partition。与此同时,Group A和Group B都能拿到完整的消息,两者相互独立,这就是Kafka中的发布订阅语义的体现。这个设计带来了两个实用推论:

  1. 如果想让多个服务各自独立处理同一批消息(比如订单消息要同时触发短信通知和数据统计),只需为每个服务配置不同的groupId即可,不需要任何额外改造
  2. Consumer Group内的消费并发上限由Partition数量决定,超出Partition数量的Consumer会闲置。如果发现消费速度跟不上,扩容消费者数量的前提是先扩容Partition数量

Rebalance机制

理解了Consumer Group和Partition分配关系之后,就很容易理解Rebalance了。

Rebalance是Consumer Group内Partition分配关系的重新调整过程。当Consumer Group中的成员发生变化,或者订阅的Topic的Partition数量发生变化时,Kafka就需要重新计算“哪个Consumer负责哪个Partition”,这个过程就叫Rebalance。触发Rebalance的常见场景可能有如下几种:

  • 有新的Consumer加入Consumer Group(比如服务扩容)
  • 某个Consumer退出了Consumer Group(比如服务下线、崩溃)
  • 某个Consumer长时间没有发送心跳,被Kafka认为已经离线
  • Topic的Partition数量发生了变化

Rebalance的整个过程如下图所示。

sequenceDiagram
    participant C0 as Consumer 0
    participant GC as Group Coordinator
    participant C1 as Consumer 1

    C1->>GC: 加入Consumer Group
    GC->>C0: 通知:即将Rebalance,停止消费
    GC->>C1: 通知:即将Rebalance,停止消费
    Note over C0,C1: 所有Consumer暂停消费(Stop-the-world)
    GC->>GC: 重新计算分区分配方案
    GC->>C0: 分配结果:负责 Partition 0
    GC->>C1: 分配结果:负责 Partition 1
    C0->>GC: 确认,开始消费 Partition 0
    C1->>GC: 确认,开始消费 Partition 1

Rebalance为什么会影响消费稳定性?Rebalance期间,Consumer Group内所有Consumer都必须暂停消费,等待分配结果下发,这段时间内消息会积压。对于消息量大、延迟敏感的系统来说,哪怕是短暂的暂停也会带来明显的消费延迟。更麻烦的是,Rebalance有可能引发重复消费。假设Consumer 0正在处理Partition 0的一批消息,还没来得及提交Offset,此时发生了Rebalance,Partition 0被重新分配给了Consumer 1。Consumer 1重新从上次已提交的Offset开始消费,那批正在处理中的消息就会被重新消费一遍。因此在实际开发中,我们通常需要注意以下几点:

合理设置心跳超时session.timeout.ms配置决定了Kafka多久没收到Consumer心跳就认为它已离线,设置过短会导致网络抖动时频繁触发Rebalance

避免Consumer长时间处理单批消息max.poll.interval.ms配置了Consumer两次poll之间的最大间隔,超时同样会触发Rebalance,如果单条消息处理逻辑复杂耗时长,需要适当调大这个值或减少每次poll拉取的消息条数

消费逻辑做幂等设计:Rebalance引发的重复消费无法完全避免,业务层面做好幂等处理才是根本解决方案,有关这部分内容会在消费者章节详细展开

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。