kafka原理和高可用

Apache Kafka 是一个开源的分布式事件流平台 (Distributed Event Streaming Platform)。

忘掉 “ 消息队列 “ 这个狭隘的标签。虽然它能当消息队列用,但这好比说一台 MacBook Pro 只是个打字机。它的核心是一个分布式的、分区的、可复制的、持久化的提交日志 (Commit Log)。数据被 “ 追加 “ 到日志末尾,并且可以被多个消费者在任意时间点、以任意速度重复读取。

“ 一句话 “ 类比

Kafka 就像一个拥有无数个频道的、永不停止的 “ 数字电视台 “。

  • 电视台 (Kafka Cluster): 整个服务系统。
  • 频道 (Topic): 数据的分类,比如 “ 订单频道 “、” 用户行为频道 “。
  • 节目 (Message/Event): 一条条的数据,比如 “ 用户 A 下单 “、” 用户 B 点击了按钮 “。
  • 节目制作方 (Producer): 负责制作节目并发送到指定频道。
  • 电视观众 (Consumer): 订阅自己感兴趣的频道。
  • 电视节目录像机 (Log & Offset): 电视台会把所有节目按顺序录下来(持久化日志)。每个观众都有自己的进度条(Offset),可以从任何时间点开始看、快进、甚至倒带重看(数据回溯),且每个观众的观看进度互不影响。

1. 基础介绍

1.1 解决问题

  • 系统解耦 (Decoupling): 生产者和消费者彻底分离。生产者只管往 Kafka “ 扔 “ 数据,不用关心谁来消费、消费了多少、消费者是否在线。消费者也只管从 Kafka “ 取 “ 数据,不用关心数据是谁生产的。这让系统维护和扩展变得极其灵活。
  • 削峰填谷 (Peak Shaving): 想象一个秒杀活动,瞬间涌入百万请求。如果直接打到数据库,数据库必垮无疑。Kafka 在中间充当一个巨大的缓冲区,让生产者以极高吞吐量写入数据,而消费者可以根据自己的处理能力平稳地消费,保护了后端服务。
  • 数据可回溯性 (Data Replayability): 这是 Kafka 的 “ 杀手锏 “。传统消息队列消息被消费后就没了。而 Kafka 的数据是持久化的(默认保留 7 天,可配置)。如果下游服务出现 bug 导致数据处理错误,修复 bug 后,你只需重置消费者的 Offset,就能重新消费和处理一遍历史数据,实现数据修复,这是很多业务场景的刚需。
  • 高吞吐与可伸缩性 (High Throughput & Scalability): Kafka 的设计就是为了海量数据而生。通过分区机制,它可以轻松实现水平扩展,达到每秒百万级别的消息处理能力。

1.2 组成部分

  • Broker (代理/节点): 一台 Kafka 服务器就是一个 Broker。多个 Broker 组成一个 Kafka 集群。Broker 负责存储数据、处理请求。
  • Topic (主题): 消息的逻辑分类,是 Producer 发布和 Consumer 订阅的基本单位。
  • Partition (分区): 这是 Kafka 实现高吞吐的关键。一个 Topic 可以被分为多个 Partition,这些 Partition 可以分布在不同的 Broker 上。Kafka 只保证在一个 Partition 内的消息是有序的,不保证整个 Topic 的全局有序。
  • Offset (偏移量): 一个单调递增的整数,唯一标识了一个 Partition 内的每一条消息。Consumer Group 会记录自己消费到每个 Partition 的哪个 Offset。
  • Producer (生产者): 数据发布的角色。它决定将消息发送到哪个 Topic 的哪个 Partition。
  • Consumer (消费者): 数据消费的角色。消费者通常以 Consumer Group (消费组) 的形式存在。一个 Topic 的消息可以被多个消费组订阅,但在一个消费组内,一个 Partition 最多只能被一个 Consumer 消费,以此实现负载均衡和并行处理。
  • KRaft (Kafka Raft): (替代 Zookeeper) 自 Kafka 2.8 版本后,Kafka 引入了基于 Raft 协议的 KRaft 模式来管理集群元数据(比如 Broker、Topic、Partition 的信息),替代了原来对 Zookeeper 的依赖,简化了部署和运维。我们应该着眼于未来,重点关注 KRaft。
graph TD
    subgraph "Golang App A"
        P1(Producer 1)
        P2(Producer 2)
    end

    subgraph "Kafka Cluster"
        B1(Broker 1)
        B2(Broker 2)
        B3(Broker 3)

        subgraph Topic: orders
            P1_0(Partition 0) --- P1_1(Partition 1)
        end
        B1 -- hosts --> P1_0
        B2 -- hosts --> P1_1
    end

    subgraph "Analytics Service"
        C1A(Consumer A)
        C1B(Consumer B)
    end

    subgraph "Notifier Service"
        C2A(Consumer C)
    end

    P1 -- "key=user123" --> P1_0
    P2 -- "key=user456" --> P1_1

    P1_0 --> C1A
    P1_1 --> C1B

    P1_0 --> C2A
    P1_1 --> C2A

    linkStyle 2 stroke:blue,stroke-width:2px
    linkStyle 3 stroke:blue,stroke-width:2px
    linkStyle 4 stroke:green,stroke-width:2px
    linkStyle 5 stroke:green,stroke-width:2px
    linkStyle 6 stroke:green,stroke-width:2px
    linkStyle 7 stroke:green,stroke-width:2px
  • 上图解读:
    1. Producer 1 发送一条 key 为 user123 的订单消息,Kafka 通过哈希 user123 决定将其放入 Partition 0
    2. Producer 2 发送 user456 的订单,被放入 Partition 1
    3. 消费组 1 (Analytics) 中,Consumer A 消费 Partition 0Consumer B 消费 Partition 1,实现了并行处理。
    4. 消费组 2 (Notifier) 中,只有一个 Consumer C,所以它消费了所有的 Partition (01)。
    5. 两个消费组的消费进度(Offset)是完全独立的。

1.3 注意事项

  • 消息的 Key 至关重要: 如果你需要保证某类消息(例如同一个用户的所有订单)的处理顺序,必须为这些消息设置相同的 Key。Producer 会根据 Key 的哈希值将消息路由到固定的 Partition,从而保证了这类消息在 Partition 内的有序性。不设置 Key 则会轮询发送,无法保证顺序。
  • 手动提交 Offset: 对于 Go 开发者,强烈建议使用手动提交 Offset (commit) 而不是自动提交。自动提交可能导致消息丢失(处理完业务逻辑,应用挂了,没来得及提交 Offset)或重复消费(消息没处理完,到时间自动提交了 Offset)。手动控制可以在你的业务逻辑成功执行后,再精确地提交 Offset。
  • 幂等性消费者 (Idempotent Consumer): 网络抖动或 Broker 重启可能导致消息重复发送。你的消费者逻辑必须设计成幂等的,即同一条消息处理一次和处理 N 次的结果应该完全相同。例如,在数据库操作前先检查记录是否存在。
  • 合理配置 acks Producer 的 acks 配置决定了消息的可靠性。
    • acks=0: 发了就走,不管死活,性能最高,但可能丢数据。
    • acks=1: Leader 副本收到就确认,性能和可靠性均衡。如果 Leader 刚收到就挂了,数据可能丢失。
    • acks=all (或 -1): Leader 和所有 ISR (In-Sync Replicas) 都收到才确认,可靠性最高,性能最低。生产环境推荐此配置。
  • 监控消费延迟 (Consumer Lag): 必须监控你的消费组相对于日志末尾的延迟。延迟过高意味着消费能力不足,需要告警并可能需要扩容 Consumer。

1.4 常见问题

  • Kafka 的顺序性保证只在 Partition 级别。如果需要全局有序,只能将 Topic 设置为 1 个 Partition,但这会完全丧失并行处理能力,违背了 Kafka 的设计初衷。在设计时就要接受 “ 分区内有序 “ 这个现实。
  • Consumer Group Rebalance (重平衡) 风暴。当消费组内有新的 Consumer 加入或旧的 Consumer 掉线时,会触发 Rebalance。期间整个消费组会停止消费,等待 Partition 重新分配。频繁的 Rebalance 会严重影响处理性能。
    • 确保 Consumer 应用稳定,避免因 OOM 或长时间 GC 导致被踢出组。
    • 适当调高 session timeout 时间,容忍短暂的网络波动。
    • 使用 Kafka 2.4+ 引入的静态成员 (Static Membership),可以在 Consumer 短暂重启后保持其原有的 Partition 分配,避免不必要的 Rebalance。
  • Key 分布不均导致 “ 热点 Partition”。如果你的分区 Key 设计不当(例如,用了某个字段,其值的分布非常不均匀),会导致大量消息涌向同一个 Partition,而其他 Partition 很空闲。这个 “ 热 “ 的 Partition 会成为整个系统的瓶颈。
    • 选择基数(Cardinality)高且分布均匀的字段作为 Key。如果找不到,可以考虑组合 Key 或者在 Key 后面拼接一个随机数来打散。

2. 基础架构

img

2.1 Broker:邮局分部

  • 是什么:一台或多台服务器(节点)就构成了一个 Kafka 集群。集群中的每一台服务器,就是一个 Broker。
  • 做什么:Broker 是实际干活的,它负责存储消息数据、处理客户端(生产者和消费者)的请求。你可以把它看作是一个邮局分部,专门存放信件包裹。
  • 细节:集群中会有一个 Broker 被选举为 Controller(控制器)。这个 Controller 不负责数据传输,而是集群的 “ 大脑 “ 和 “ 总调度 “,负责管理集群元数据,比如创建/删除 Topic、分区 Leader 选举、监控 Broker 存活状态等。早期这个角色由 ZooKeeper 辅助完成,现在新的版本中通过 KRaft 协议,Kafka 已经可以不依赖 ZooKeeper 自行管理集群。

2.2 Topic:信件的分类

  • 是什么:消息的逻辑分类。比如,你可以创建一个叫 user-orders 的 Topic 来存放所有用户订单消息,一个叫 user-clicks 的 Topic 存放所有用户点击行为消息。它就像邮局里 “ 普通信件区 “、” 加急文件区 “ 的分类牌。
  • 它本身不存储数据,它只是一个逻辑概念。

2.3 Partition:信件的货架

  • 是什么:真正存储数据的物理单元。一个 Topic 可以被分成一个或多个 Partition。这些 Partition 可以分布在集群中不同的 Broker 上。

  • 为什么需要分区?

    这就是 Kafka 实现高吞吐量和可伸缩性的秘诀!

    1. 并行处理:多个 Partition 意味着生产者可以同时向多个 Partition 发送消息,消费者组也可以有多个消费者同时从不同的 Partition 读取消息,极大地提升了并发度。这就好比一个邮局窗口处理不过来,就开 10 个窗口同时处理。
    2. 数据分片:一个 Topic 的数据量可能非常大,单台机器的磁盘可能存不下。通过分区,可以将一个巨大的 Topic 分散存储到多个 Broker 上。
  • 关键特性:分区内有序

    • Kafka 只保证在一个 Partition 内部,消息是按照发送的顺序存储和消费的。
    • 它不保证整个 Topic 的全局有序。如果你需要保证某个用户的所有订单消息按顺序处理,你需要确保这个用户的所有订单消息都进入同一个 Partition。

640?wx_fmt=png

  1. 一个主题下面有多个分区,这些分区会存储到不同的服务器上面。
  2. 生产者在默认情况下把消息均衡地分布到主题的所有分区上,而并不关心特定消息会被写到哪个分区。不过,在某些情况下,生产者会把消息直接写到指定的分区。
  3. 每个 Partition 可以设置多个副本。它们会选取一个副本作为 Leader,而其余的作为 Follower。

我们的生产者在发送数据的时候,是直接发送到 Leader Partition 里面,然后 Follower Partition 会去 Leader 那里自行同步数据,消费者消费数据的时候,也是从 Leader 那去消费数据的。

2.4 Partition Replica:数据安全的双重保障

  • 是什么:副本(Replica)是为了实现高可用和容错。你可以为每个分区设置多个副本(比如 3 个),这些副本会分布在不同的 Broker 上。
  • 工作机制:Leader-Follower 模型
    • 在所有副本中,只有一个是 Leader(领导者),其余都是 Follower(跟随者)。
    • 写操作:生产者只向 Leader 副本写入数据。
    • 读操作:消费者也只从 Leader 副本读取数据。
    • 数据同步:Follower 副本会不断地从 Leader 副本那里拉取数据, 保持与 Leader 的数据同步。
  • 故障恢复(Failover)
    • 如果某个分区的 Leader 所在的 Broker 宕机了,Controller 会从它的 Follower 副本中选举出一个新的 Leader。这个过程对生产者和消费者是透明的,它们会自动连接到新的 Leader 继续工作,从而保证了服务的高可用性。
  • ISR (In-Sync Replicas,同步副本集)
    • 这是一个非常重要的概念,它决定了数据的可靠性。ISR 是 Leader 的 Follower 副本中,那些 “ 紧跟 “Leader 步伐、数据延迟在可接受范围内的副本集合(包括 Leader 自己)。
    • 如果一个 Follower 因为网络问题或负载过高,长时间没有同步到 Leader 的最新数据,它就会被踢出 ISR。
    • 这个机制与生产者的 acks 配置紧密相关,共同决定了数据的持久性级别。
image-20230907121322032
  1. AR:分区中所有的副本。
  2. ISR: 和 leader 保持一定程度的副本。默认情况下,只有 ISR 晋升为 Leader(也可以通过修改相应的参数配置来改变)
  3. OSR: 落后过多的副本。
  4. HW:高水位,消费者只能拉取到这个 offset 之前的消息。
img

2.5 生产者:高效的投递员

1. 生产策略 (发送到哪个分区?)

生产者如何决定一条消息该发往哪个 Partition 呢?主要有三种策略:

  1. 指定 Partition:在发送消息时,直接明确告诉 Kafka 要发到几号 Partition。这种方式不常用,不够灵活。
  2. 指定 Key (最常用):为消息设置一个 Key(比如订单消息用 UserID 作为 Key)。Kafka 的默认分区器会对这个 Key 进行哈希计算,然后用哈希值对 Partition 数量取模 ( hash(key) % num_partitions)。
    • 优点:Key 相同的消息总是会被发送到同一个 Partition。这就完美地解决了我们之前提到的 “ 保证同一个用户订单按序处理 “ 的需求。
  3. 不指定 Key:如果既不指定 Partition 也不指定 Key。
    • 旧版 Kafka:采用轮询(Round-Robin)策略,逐个将消息发送到每个 Partition,以实现负载均衡。
    • 新版 Kafka (Sticky Partitioner):采用粘性分区策略。它会随机选择一个 Partition,并尽可能地向这个 Partition 发送消息,直到这个批次(batch)满了或者等待时间到了,再换下一个 Partition。
    • 为什么用粘性分区? 因为这样可以减少网络请求次数,将多条消息打包成一个更大的批次一次性发送,从而降低延迟,提升吞吐量。

2. 可靠性配置:acks

这个配置决定了生产者在认为 “ 发送成功 “ 之前,需要得到多少个副本的确认。

  • acks=0 (性能最高,可靠性最低)
    • 生产者发送消息后,不等待任何 Broker 的确认,直接认为成功。
    • 风险:如果网络抖动或 Broker 恰好宕机,消息就会丢失。
  • acks=1 (默认值,性能与可靠性的平衡)
    • 生产者发送消息后,只需等待分区的 Leader 副本成功写入就认为成功。
    • 风险:如果在 Leader 确认后,数据还没来得及同步给 Follower,Leader 就宕机了,那么这条消息也会丢失。
  • acks=all (或 -1) (可靠性最高,性能最低)
    • 生产者发送消息后,需要等待 Leader 和 所有 ISR 中的 Follower 都成功写入才认为成功。
    • 这是最强的数据保证。配合 Broker 端的 min.insync.replicas(最少同步副本数)配置,可以严格防止数据丢失。例如,设置 acks=allmin.insync.replicas=2,即使有一个副本宕机,只要 ISR 中至少还有 2 个副本(包括新的 Leader)存活,写入才能成功。

2.6 消费与消费者组:协同工作的收件团队

消费者从 Topic 中拉取(pull)并处理消息。

1. 消费者组

  • 是什么:一个或多个消费者可以组成一个消费者组,它们共同消费一个 Topic。每个消费者组都有一个唯一的 group.id
  • 核心规则:在一个消费者组内,一个 Partition 最多只能被一个消费者消费。
  • 为什么这么设计?
    1. 负载均衡:如果一个 Topic 有 10 个分区,你启动了 10 个属于同一个消费者组的消费者,那么理想情况下,每个消费者会负责消费一个分区。
    2. 高可用:如果组内某个消费者宕机,它负责的分区会自动被重新分配给组内其他存活的消费者。
    3. 如果你希望一条消息能被多个不同的应用处理(比如,订单消息既要被库存系统消费,也要被积分系统消费),你只需要为这两个应用设置不同的 group.id 即可 (就是多个消费者组)。它们会各自维护自己的消费进度,互不干扰。

2. Offset 和提交策略

  • Offset(偏移量):这是消费者端的 “ 书签 “。它是一个单调递增的整数,标记了消费者在一个 Partition 中已经消费到的位置。消费者需要定期提交(Commit)Offset,告诉 Kafka 集群 “ 这个分区我已经处理到这里了 “,以便下次重启或再平衡后能从正确的位置继续。
  • 提交 Offset 的方式:
    1. 自动提交 (enable.auto.commit=true):消费者会按固定的时间间隔(auto.commit.interval.ms)自动提交已经拉取到的最大 Offset。
      • 风险:可能会导致消息丢失(消息处理完但还没到提交时间,消费者就挂了)或重复消费(消息还没处理完,但 Offset 已经自动提交了)。不推荐在生产环境使用。
    2. 手动提交 (enable.auto.commit=false):在代码中显式调用 commitSync (同步) 或 commitAsync (异步) 来提交 Offset。这是最可靠的方式。
      • 最佳实践:先处理完一批消息(例如写入数据库),确保业务逻辑成功后,再手动提交 Offset。这样可以实现 “ 至少一次消费 “(At-Least-Once)的语义。

offset 是维护在消费者和还是消费者组身上?offset 是 topic 的进度还是分区的进度?每个消费者都有每个分区的 offset 吗?

  • 归属谁? Offset 逻辑上归属于消费者组(Consumer Group)。
    • 逻辑上,Offset 是消费者组的资产。
      为什么?想象一下,一个消费者组(比如 order-service-group)的使命是处理完 orders 这个 Topic 的所有消息。这个 “ 处理到哪儿了 “ 的进度记录,应该是整个团队(消费者组)共享的。如果成员 A(某个消费者实例)处理完了一部分任务后挂了,新来的成员 B 必须能知道团队的整体进度,然后从 A 之前停下的地方继续干活,而不是从头开始。
    • 物理上,Offset 存储在 Kafka Broker 上。Offset 并不存储在消费者客户端的内存或磁盘里。消费者在消费完消息后,会向 Kafka 集群发送一个 “ 提交 Offset” 的请求。Kafka 集群会将这个信息保存在一个内部的、特殊的 Topic 里,这个 Topic 叫作 __consumer_offsets
    • 这个 __consumer_offsets Topic 里的每一条消息,其内容就类似一个键值对:
      • Key: (group.id, topic_name, partition_number) -> (订单服务组, 订单主题, 分区0)
      • Value: offset_value -> 101 (表示下一条要消费的消息的编号)
  • 是什么的进度? Offset 是分区(Partition)的消费进度,而不是整个 Topic 的。
  • 每个消费者都有吗? 不是的。一个消费者只关心和维护它当前被分配到的那些分区的 Offset。

所以,完整的流程是:

消费者实例只是这个进度的执行者和汇报者。它从 Broker 拉取数据,处理完后,以消费者组的名义向 Broker 汇报:” 嘿,我们 order-service-group 这个组,在 orders 主题的 0号分区 上,已经处理到第 100 条了,下次请从 101 条开始给我。

3. 消费者组再平衡

  • 是什么:将消费者组内的分区所有权重新分配给消费者的过程。这是一个非常重要的机制,但也可能引发问题。

  • 触发时机:

    1. 组内有新的消费者加入。
    2. 组内有消费者离开(正常关闭或崩溃)。
    3. 订阅的 Topic 分区数量发生变化。
  • Stop the World 问题:在传统的 Rebalance 过程中,整个消费者组的所有成员都会停止处理消息,等待协调器(Coordinator)完成分区的重新分配。这个过程如果耗时过长,会造成整个应用的消费处理出现明显停顿。

  • 如何缓解:

    1. session.timeout.msheartbeat.interval.ms:合理配置心跳和会话超时时间,避免消费者被误判为 “ 死亡 “ 而触发不必要的 Rebalance。
      • session.timeout.ms 指定了消费者可以在多长时间内不与服务器发生交互而仍然被认为还 “ 活着 “,默认是 10 秒。
    2. max.poll.interval.ms:控制单次处理消息的最长时间。如果你的业务逻辑耗时很长,超过了这个时间还没拉取下一批消息,Broker 也会认为你 “ 僵死 “ 了。
    3. 增量协作式再平衡 (Incremental Cooperative Rebalancing):较新版本的 Kafka 引入了该机制,它不会一次性撤销所有分区,而是分批次进行,极大地减少了 “Stop the World” 的时间。
  • 消费者再均衡会触发什么问题

    • 重复消费

      一个消费者成功处理完一批消息,但在它提交 Offset 之前,Rebalance 发生了(比如因为这个消费者心跳超时,被协调器踢出组了)。新消费者接管可能会导致重复消费。

      这个问题无法在 Kafka 层面完全避免,必须由消费端的业务逻辑来保证幂等性(Idempotence)。

    • 消息处理丢失

      这种情况通常是由于不恰当的 Offset 提交策略导致的。使用自动提交 Offset 并且提交时机早于消息被真正处理完成的时机。

      消费者 C1 拉取了 Partition 0 的一批消息。Kafka 的自动提交机制(默认每 5 秒)在后台运行时,先提交了 Offset。例如,它提交了 Offset 120。在处理到一半时(比如刚处理到 Offset 110),C1 突然崩溃或被强制重启,触发了 Rebalance。

4. 一个完整的例子

假设:

  • 消费者组: log-analysis-group
  • Topic: app-logs (有 3 个分区: P0, P1, P2)
  • 组内有 2 个消费者: C1C2
  1. 启动与再平衡 (Rebalance)
    • C1C2 启动,加入 log-analysis-group
    • Kafka 协调器进行分区分配。一种可能的结果是:
      • C1 被分配到 P0P1
      • C2 被分配到 P2
  2. 消费与提交
    • C1 开始从 P0P1 拉取消息。当它处理完 P0 的前 50 条消息后,它会向 Kafka 提交:{ group: "log-analysis-group", topic: "app-logs", partition: 0, offset: 50 }
    • C2 开始从 P2 拉取消息。当它处理完 P2 的前 30 条消息后,它会向 Kafka 提交:{ group: "log-analysis-group", topic: "app-logs", partition: 2, offset: 30 }
    • 此时,C1 完全不知道 P2 的进度,C2 也完全不知道 P0P1 的进度。
  3. 故障与恢复
    • 突然,消费者 C1 崩溃了!
    • Kafka 检测到 C1 离线,立即触发第二次 Rebalance。
    • 现在组里只剩下 C2,于是协调器把所有分区都分配给了 C2P0, P1, P2
    • C2 接管了新的任务,它需要知道该从哪里开始。于是它向 Kafka 查询 log-analysis-groupapp-logs 这个 Topic 上的消费进度。
    • Kafka 从 __consumer_offsets 这个内部 Topic 查到:
      • P0 的 Offset 是 50。
      • P1 的 Offset 是 0 (之前 C1 还未来得及提交)。
      • P2 的 Offset 是 30 (这是 C2 自己之前提交的)。
    • 于是 C2 就知道了:
      • P0 的第 50 条消息开始消费。
      • P1 的第 0 条消息开始消费。
      • P2 的第 30 条消息继续消费。

这个例子完美地展示了:Offset 是属于消费者组的共享财产,它以分区为单位进行记录,并由当前负责该分区的消费者进行更新。 这套机制保证了消费者组的消费工作既能并行处理,又具备很强的容错能力。

2.7 关键配置

组件参数解释常见设置/建议
Brokernum.partitionsTopic 的默认分区数根据吞吐量和消费者数量预估
default.replication.factorTopic 的默认副本因子生产环境建议 3
min.insync.replicas保证写入成功所需的最少同步副本数建议 2 (配合 acks=all 使用)
Produceracks写入可靠性级别0, 1, all (-1)
partitioner.class分区器实现类默认粘性分区器,或自定义
retries失败重试次数大于 0 可以防止瞬时网络问题
Consumergroup.id消费者组 ID,必须设置e.g., “order-service-group”
enable.auto.commit是否自动提交 Offset强烈建议 false
auto.offset.reset当没有初始 Offset 或 Offset 失效时从何处开始消费earliest (从头), latest (从最新)
max.poll.interval.ms拉取消息之间的最大间隔根据业务处理时间调整

2.8 头脑风暴

  • Topic 是个逻辑概念,数据是在分区(Partition)是主从复制的,每块是有个 leader 的。
  • 消费者组,包含多个消费者。一个分区只能由一个消费者消费。
  • 消费者组之间互不影响,可以同时消费一个 Topic。
  • 为了应对重复消费,你的消费逻辑必须是幂等的。
  • 为了防止消息丢失,你必须使用手动提交 Offset,并确保在处理成功后再提交。

3. 消息保证

  1. Kafka 可以保证一个分区中的消息是有序的。
  2. 一条消息只有在被写入分区所有的同步副本时才被认为是 “ 已提交 “ 的(但不一定要冲刷到磁盘上)。
  3. 消费者只能读取已提交的消息。

3.1 消息可靠性保证

消息的可靠性不是单一组件的功劳,而是 Producer(生产者)、Broker(服务器集群)和 Consumer(消费者)三方共同协作、层层加固的结果。就像一个高安全性的快递服务,从发件、运输到收件,每个环节都有保障措施。

  • Producer 端:发出时的承诺

    • Producer 在发送消息时,可以决定自己需要多大程度的 “ 回执保证 “。这由 acks 参数控制。
    • (enable.idempotence=true)。开启后,Producer 会为每条消息分配一个唯一的序列号,Broker 会根据这个序列号去重,从而在 Producer 端就避免了因重试导致的消息重复。这是实现 “ 精确一次 “ 语义的第一步。
  • Broker 端:存储时的冗余

    • Broker 的可靠性核心在于分区副本机制 (Replication)。

    • Leader & Follower: 每个分区都有一个 Leader 副本和零到多个 Follower 副本。所有读写请求都由 Leader 处理,Follower 只负责从 Leader 拉取数据,保持与 Leader 的同步。

    • ISR (In-Sync Replicas): 这不是所有 Follower 的集合,而是一个动态的、被认为是 “ 同步良好 “ 的副本集合(包括 Leader 自己)。如果一个 Follower 因为网络延迟或故障,长时间没有跟上 Leader 的进度(由 replica.lag.time.max.ms 参数控制),它就会被踢出 ISR。

    • 高水位 (High Watermark, HW): 这是一个关键的 Offset 值,代表一个分区中所有 ISR 副本都已确认同步到的最新消息。只有 HW 之前的消息,才对消费者可见。这可以防止消费者读到那些尚未被完全备份、可能在 Leader 切换后丢失的数据。

    • min.insync.replicas: 这是一个 Broker 端的关键配置,它规定了当 Producer 使用 acks=all 时,ISR 中最少需要有几个副本才能成功写入。

      如果 replication.factor=3, min.insync.replicas=2,那么即使有一个副本宕机,只要还有 2 个副本在 ISR 中,写入就能成功。如果只剩 1 个副本,写入就会失败。这是一种写入可用性与数据一致性的权衡,防止数据被写入一个孤立的节点。

    • unclean.leader.election.enable, 默认值是 false。

      如果允许不同步副本成为首领,那么就要承担丢失数据和消费者读取到不一致的数据的风险。如果不允许它们成为首领,那么就要接受较低的可用性,因为必须等待原先的首领恢复到可用状态。

  • Consumer 端:消费端的可靠性由 Offset 提交机制保证。

    • 自动提交 (enable.auto.commit=true): Consumer 会按固定的时间间隔自动提交已经拉取到的最大 Offset。这非常危险,因为如果 Consumer 拉取了消息但在处理完成前崩溃,Offset 已经被提交,导致这些未处理的消息被永久跳过(丢失)。

    • 手动提交 (enable.auto.commit=false): 这是保证消费端可靠性的唯一正确方式。你在代码中完全控制何时提交 Offset。

      • commitSync (同步提交): 在业务逻辑处理成功后调用。它会阻塞,直到 Offset 提交成功。虽然会影响一点性能,但逻辑简单,可靠性高。
    • commitAsync (异步提交): 不会阻塞,性能更好,但需要处理回调来确认提交是否成功,逻辑更复杂。

    • auto.offset.reset

      没有有效的偏移量。一个是 earliest,从分区的开始位置读取数据,保证最少的数据丢失。另一个值是 latest,从分区的末尾位置读取数据,很有可能会错过一些消息。

  • 三者协作的黄金组合 (最高可靠性):

    1. Producer: acks=all + enable.idempotence=true
    2. Broker: replication.factor >= 3 + min.insync.replicas >= 2
    3. Consumer: enable.auto.commit=false + 手动提交 Offset。

3.2 怎么保证消息顺序

Kafka 只在单个分区(Partition)内保证消息的有序性,不保证整个 Topic 的全局有序。如果需要全局有序,唯一的办法是将 Topic 的分区数设置为 1。但这会完全牺牲掉 Kafka 的并行处理能力,非极端情况不推荐使用。

  • 如何利用这个特性实现业务上的有序?
    • 具有相同 key 的消息,总是会被发送到同一个分区。
    • 订单处理: 使用 订单ID 作为 Key,那么同一个订单的所有状态变更消息(创建、支付、发货、完成)都会按顺序进入同一个分区,从而保证被顺序处理。
    • 用户行为: 使用 用户ID 作为 Key,可以保证同一个用户的所有操作日志被顺序处理。
  • 注意: 如果 Topic 的分区数量发生变化(比如从 3 个增加到 5 个),那么 key 到分区的映射关系就会改变,这会打破之前消息的有序性保证。因此,分区数量的变更需要谨慎规划。

3.3 如何避免丢失消息

原因:生产者使用 acks=0,生产者缓冲区未刷出 (Asynchronous Send),副本数量不足,不安全的 Leader 选举。自动提交 Offset 且时机不当。

  1. 生产端:
    • 必须使用 acks=all-1。确保消息被多个副本持久化后再认为成功。
    • 配置合理的重试机制 (retries > 0),并配合幂等生产者 (enable.idempotence=true) 来防止重试带来的重复。
  2. Broker 端:
    • 副本数足够: replication.factor 建议至少为 3,部署在不同的物理机架上。
    • ISR 下限保证: min.insync.replicas 建议至少为 2。这等于给数据上了一道 “ 双保险 “,防止单点故障。
    • 选举机制: 确保 unclean.leader.election.enable=false (默认值)。这可以防止一个落后很多的 Follower 被选举为新的 Leader,从而导致数据丢失(High Watermark 之后的数据会丢失)。
  3. 消费端:
    • 关闭自动提交: 永远设置 enable.auto.commit=false
    • 先处理,后提交: 确保你的业务逻辑已经完全处理成功(比如数据已写入数据库并落盘),再调用 commitSync()commitAsync() 提交 Offset。

3.4 如何避免重复消费

原因:生产者重试导致重复(需开启 enable.idempotence=true),消费者未能及时提交 Offset,消费者 Rebalance (重平衡),消费处理时间过长 (Broker 会认为这个消费者已经 “ 假死 “,将其从消费者组中踢出,并触发 Rebalance)。

重复消费的根源在于 Kafka 的 “at-least-once” (至少一次) 投递语义。在出现网络问题、Rebalance 等情况时,系统为了保证不丢消息,宁愿选择重复发送。避免重复消费的责任,主要落在消费端。

核心思想:让你的消费逻辑具备幂等性 (Idempotence)。

  • 数据库唯一键/约束
  • 使用分布式锁 (如 Redis)
  • Kafka 事务: Producer 在一个事务中发送消息,Consumer 在一个事务中处理消息并提交 Offset。这可以实现跨分区的原子性操作,达到 Exactly-Once (精确一次) 的效果。但它会带来额外的性能开销,配置也更复杂,适用于对一致性要求高于一切的场景。

3.5 消息保留策略

Kafka 作为一个持久化的日志系统,消息在被消费后并不会立即删除。它的保留策略主要由 Broker 端的 cleanup.policy 参数控制,分为两种:

  1. delete (删除策略,默认)
    这是最常见的策略,Kafka 会根据配置的保留规则删除旧的消息。规则由以下两个参数共同决定,满足其一即可触发删除:
    • log.retention.hours (或 minutes, ms): 基于时间的保留。例如,设置为 168 小时,则 Kafka 会保留最近 7 天的数据。这是最常用的配置。
    • log.retention.bytes: 基于大小的保留。这是每个分区的大小上限。如果某个分区的数据量超过了这个阈值,就会开始删除最旧的数据。
  2. compact (压缩策略)
    这种策略非常特殊和强大。它不是简单地删除旧数据,而是为每个 Key 只保留其最新的一条消息。
    • 工作原理: Kafka 会定期在后台扫描日志,如果发现有两条消息拥有相同的 Key,它就会删除较旧的那一条。
    • 应用场景: 非常适合用于存储 “ 状态 “。例如,用 用户ID 作为 Key 来存储用户的最新个人信息,用 商品ID 作为 Key 来存储商品最新的价格。无论这个商品价格变了多少次,日志压缩后,这个分区里只会留下该商品 ID 对应的最新价格消息。它就像一个可回溯的数据库变更日志 (Changelog)。

4. 高级特性

4.1 性能高的原因

图片
  1. 批量处理 (Batching) & 压缩 (Compression):

    • 原理:生产者可以将多条消息打包成一个批次(Batch)再发送,消费者也可以一次性拉取一个批次。
      • 为何高效:极大地减少了网络请求的次数,分摊了网络延迟和 I/O 开销。同时,对整个批次进行压缩(如 GZIP, Snappy, LZ4),可以显著降低网络带宽和磁盘空间占用。
  2. 顺序 I/O (Sequential I/O):

    • 原理:Kafka 将消息追加到日志文件末尾,这是一个纯粹的顺序写操作。同样,消费者也是按顺序读取。
    • 为何高效:机械硬盘的磁头移动(寻道)是性能瓶颈。顺序读写几乎消除了磁头寻道时间,速度可以比随机读写快几个数量级。即使在 SSD 上,顺序 I/O 也能更好地利用硬件特性。
  3. 页缓存 (Page Cache):

    • 原理:Kafka 并不自己管理缓存,而是把这个任务完全交给操作系统。读写操作实际上是和内存中的页缓存交互,OS 会在后台异步将数据刷到磁盘。这利用了成熟的 OS 优化,并减少了 JVM GC 的压力。
    • 页缓存是内存,速度极快。OS 会在后台将页缓存中的 “ 脏 “ 数据(Dirty Page)异步刷到磁盘,对应用透明。
  4. 零拷贝 (Zero-Copy):(消费者)

    • 原理:在将数据从磁盘发送到网络时,传统方式需要 4 次数据拷贝和 2 次内核态/用户态切换。Kafka 使用 sendfile 系统调用,让数据直接从内核空间的页缓存拷贝到网卡缓冲区,全程无需经过用户态应用程序。
    • 为何高效:减少了 CPU 消耗和内存带宽占用,是 Kafka 作为分发数据(消费端)性能极高的关键。

提问问题

  • 我们知道 Kafka 严重依赖操作系统的页缓存(Page Cache)来获得高性能。请设想一个场景,在这种场景下,过度依赖页缓存可能会带来什么潜在的问题或风险?

    • 邻居效应 “(Noisy Neighbor Effect):一台服务器上通常不止运行 Kafka 这一个进程。如果其他 I/O 密集型应用(比如数据库、日志收集工具)也在大量读写文件,它们就会和 Kafka 争抢宝贵的页缓存空间。这可能导致 Kafka 的热数据被 “ 挤出 “ 缓存,当消费者或副本需要读取这些数据时,会突然从极快的内存读取降级为缓慢的磁盘读取,引发性能抖动和延迟尖峰。

    • “ 冷启动 “ 问题 (Cold Start):当一个 Kafka Broker 重启后,它的页缓存是 “ 冷的 “(空的)。此时,所有对该 Broker 的读请求都会直接穿透到磁盘,导致初期性能很差。它需要一段时间的 “ 预热 “(Warming Up),通过服务读请求逐渐将热点数据加载到页缓存中,才能恢复到正常的高性能状态。这在需要快速恢复服务的场景下是个挑战。

    • 数据丢失的微小可能 (虽然概率极低):生产者发送消息,当 Broker 将其写入页缓存后,就可以向生产者发送 ack 了。此时数据在内存里,操作系统会在后台异步将它刷到(flush)磁盘。如果在刷盘完成前的瞬间,整个操作系统崩溃(比如断电),那么这部分已 ack 但未持久化的数据就会永久丢失。

  • kafka 有用到内存池吗?在哪个阶段使用的?

    • Kafka 明确地使用了内存池技术,主要应用在生产者 (Producer) 客户端。其核心目的是减少内存碎片和 GC 开销,从而获得更平稳、更高效的发送性能。
    • 为了提高吞吐量,生产者并不会来一条消息就发一条,而是会将多条消息收集起来组成一个批次 (Batch),然后一次性发送给 Broker。
    • 当生产者需要为某个分区创建一个新的消息批次时,它会向 BufferPool 申请一块固定大小的内存(由 batch.size 控制,默认 16KB)。
      当这个批次被成功发送到 Broker 后,这块内存会被归还到内存池中,以供下一个批次复用。
  • kafka 有用到 IO 多路复用吗?在哪个阶段使用的?

    • IO 多路复用是 Kafka 网络通信层的基石,主要应用在 Broker 端。
    • IO 多路复用使得 Kafka Broker 可以用极少数的 Processor 线程(通常等于 CPU 核心数)来高效地管理海量的客户端连接。
  • kafka 的稀疏索引是什么意思?在哪个阶段使用的?

    • 它不提供精确的 “ 一对一 “ 映射,而是提供了一个 “ 范围 “ 的起点,将一个巨大的查找问题,缩小成一个 “ 快速定位 + 小范围扫描 “ 的高效过程。
    • 使用阶段:消息读取时 (最核心),假设要查找 offset=350 的消息:
      1. 定位日志段 (Segment):Broker 首先根据 offset 确定这条消息应该在哪个日志段里。
      2. 在内存中搜索索引:Broker 将该日志段的 .index 文件加载到内存(或利用操作系统的页缓存),然后使用二分查找法在索引文件中查找不大于 350 的最大偏移量。
      3. 找到索引条目:二分查找会非常快地定位到 (offset: 320, position: 4096) 这条索引记录。
      4. 定位物理地址:Broker 从索引记录中拿到物理地址 4096
      5. 从数据文件扫描:Broker 直接从 .log 文件的第 4096 个字节开始,顺序地向后读取消息,并检查每条消息的 offset,直到找到 offset 为 350 的那条消息。由于索引间隔不大(4KB),这个扫描范围非常小,速度极快。
      6. 返回数据:从 offset=350 的消息开始,读取消费者请求数量的数据,然后返回给消费者。

4.2 存储原理

Kafka 它不像传统数据库那样随意读写,而是将所有消息(Records)视为一个不可变的、只能追加(Append-Only)的日志序列。这个日志被物理地存储在磁盘文件中,并借助操作系统的页缓存(Page Cache)来实现 “ 磁盘价格,内存速度 “ 的读写性能。

kafka 存储的都是文件吗? 那么格式是什么?

Kafka 的核心存储模型就是文件系统。 Topic 的每个分区(Partition)都对应着磁盘上的一个文件夹,所有消息数据和索引都以普通文件的形式存放在里面。

文件格式:日志段 (Log Segment)

一个分区文件夹里并不是一个单一的巨大文件,而是由多个日志段 (Log Segment) 组成的。每个日志段都包含三个核心文件:

  1. .log 文件 (数据文件):这是真正存储消息(Record)的地方。消息被顺序追加到文件末尾。文件内部的格式是一系列的 “ 消息批次 (Record Batch)”。每条消息都包含了偏移量 (offset)、消息大小、CRC 校验、魔法值、属性、时间戳、键 (key)、值 (value) 等信息。
  2. .index 文件 (偏移量索引文件):这是一个稀疏索引。它并不为每一条消息都建立索引,而是每当 .log 文件写入一定量的数据后,就在 .index 文件里增加一条索引记录。这条记录是 <相对偏移量, 物理地址> 的键值对,意味着 “ 某个偏移量的消息,存储在 .log 文件的某个字节位置上 “。
  3. .timeindex 文件 (时间戳索引文件):与偏移量索引类似,这也是一个稀疏索引,记录的是 <时间戳, 相对偏移量> 的键值对。它使得 Kafka 可以根据时间戳来查找消息。

这种设计的精妙之处在于:

  • 顺序写入:生产者写入时,Broker 只是在当前活动的 .log 文件末尾进行追加,这是磁盘最高效的操作方式,也是 Kafka 吞吐量极高的关键原因之一。
  • 快速定位:消费者消费时,比如要从 offset X 开始读。Broker 首先通过二分查找确定 X 属于哪个日志段,然后在该段的 .index 文件中再次用二分查找,快速定位到不大于 X 的那条索引记录,拿到物理地址,再从 .log 文件的这个地址开始顺序扫描,很快就能找到 offset X 的确切位置。这个过程避免了对巨大数据文件的全盘扫描。

提问问题

  • 生产者和消费者都是怎么各自操作文件的?

    • 生产者和消费者从不、也绝不能直接操作这些文件!所有对物理文件的读写操作都由 Kafka Broker 统一封装和管理。
  • kafka 的 分区分段结构是什么意思?

    • 可以理解为 Topic -> Partition -> Segment
    • 为什么要引入 Segment 这一层?
      • 便于日志清理:Kafka 的数据不是永久保存的,它会根据配置的保留策略(比如保留 7 天或达到 10GB)删除旧数据。如果一个分区是一个巨大的文件,删除旧数据就需要复杂的读写操作。有了 Segment,清理工作就变得极其简单高效:直接删除过期的整个 Segment 文件即可。
      • 提升查找效率:将索引文件分散到多个较小的 Segment 中,可以加快索引查找的速度。
      • 日志压缩 (Log Compaction):对于需要保留 key 最新值的 Topic,Kafka 会进行日志压缩。这个压缩过程是在非活动的 Segment 上进行的,避免了对正在写入的活动 Segment 的影响。
    image-20240704230248276

4.3 Kafka 事务

生产者告诉物流总调度(Transaction Coordinator),” 我要寄送 A、B、C 三个包裹(消息)到不同城市(分区)”。只有当所有城市的中转站都确认 “ 收到并准备好签收 “ 后,总调度才会下令 “ 全部派送 “;如果中途有任何一个包裹丢失,总调度就会下令 “ 全部退回 “,保证了这批货物的原子性。

Kafka 事务确保了这种 “ 一源多发 “ 的场景下的数据绝对一致性,是构建可靠流处理应用的核心基石。Kafka 事务的核心是引入了事务协调器 (Transaction Coordinator) 和 幂等生产者 (Idempotent Producer)。

幂等性是基础:

  • 为了防止单纯的网络重试导致消息重复,Kafka 引入了幂等生产者。它为每个生产者分配一个 PID,并为每条消息附加一个从 0 开始递增的序列号 Sequence Number。Broker 会缓存 <PID, SeqNum>,如果收到重复的,就直接丢弃。这保证了单分区单会话内的消息不重不丢。

事务参与角色:

  • Producer: 事务的发起者。
  • Transaction Coordinator: 事务的 “ 总指挥 “,是一个特殊的 Broker 进程,负责管理事务状态。
  • Data Partitions: 消息最终写入的地方。
  • __transaction_state Topic: 事务日志主题,一个内部主题,专门用来持久化存储所有事务的当前状态。

详细流程 (基于两阶段提交 2PC)

  1. 查找协调器 (FindCoordinator): 生产者根据配置的 transactional.id 计算哈希值,然后向任意一个 Broker 发送请求,找出管理这个 transactional.id 的 Transaction Coordinator 是谁。

  2. 初始化 PID (InitPid): 生产者向协调器发送 InitPid 请求。协调器会返回一个唯一的 Producer ID (PID) 和一个 Epoch(纪元号)。PID 在生产者实例的生命周期内不变,而 Epoch 每次 initTransactions() 都会递增。Epoch 是用来防止 “ 僵尸实例 “(旧的、已超时的生产者实例)干扰当前事务的关键(即 Fencing 机制)。

  3. 开始事务 (beginTransaction): 这只是一个本地调用,标记生产者内部状态为 “ 事务中 “。

  4. 发送消息 & 注册分区 (Produce & AddPartitionsToTxn):

    • 生产者像往常一样发送消息。这些消息被标记为 “ 事务性消息 “。
    • 对于事务中涉及的每一个新的分区,生产者会自动向协调器发送 AddPartitionsToTxn 请求。协调器收到后,会将这个 <transactional.id, partition> 的关系记录到它的事务日志中。这样,协调器就知道事务结束后需要去哪些分区写标记。
  5. 提交或中止事务 (CommitTransaction or AbortTransaction): 生产者完成所有消息发送后,向协调器发起最终请求。这是两阶段提交的开始。

    • 第一阶段:写入 “ 准备 “ 状态 (Prepare Phase)
      • 协调器将一条状态为 PREPARE_COMMITPREPARE_ABORT 的记录写入内部的 __transaction_state 主题。
      • 关键点:一旦这条记录写入成功,事务的最终结果就确定了,即使协调器此刻崩溃,接管的新协调器也能通过读取这个日志来完成后续步骤。
    • 第二阶段:写入控制标记 (Markers Phase)
      • 协调器向该事务涉及的所有数据分区的 Leader Broker 发送 WriteTxnMarkers 请求。
      • 这些 Leader Broker 会在各自的数据分区日志末尾写入一个控制标记(COMMITABORT)。这个标记对消费者是可见的(如果 isolation.level 设置正确),用来告诉消费者这个事务内的消息应该被消费还是忽略。
  6. 写入最终状态 (Finalize Phase):

    • 当所有的控制标记都成功写入后,协调器会向 __transaction_state 主题写入最终的状态记录,如 COMMITTEDABORTED
    • 至此,事务完全结束,协调器向生产者返回成功或失败的响应。

提问问题

  • 幂等性与事务有什么关系
    • 混淆幂等性与事务:认为开启幂等性 (enable.idempotence=true) 就实现了事务。陷阱是,幂等性只保证单个分区内写入不重复,而事务保证的是跨多个分区、多个主题写入的原子性。事务是幂等性的超集。
  • 事务是保证生产者全部成功的还是消费者全部成功,还是一起全部成功?
    • Kafka 事务直接保证的是【生产端的原子性】。也就是说,它保证一个事务内,由生产者发送到多个分区的消息,要么全部成功写入并对消费者可见,要么全部被标记为中止而对消费者不可见。它解决的是 “ 原子写入 “ 的问题。
    • 它不直接保证消费者能成功处理。消费者在拉取到一批事务性消息后,完全有可能在处理过程中自己发生崩溃。如果消费者没有相应的机制,下次重启后可能会重新处理这批消息,这就破坏了端到端的 “Exactly-Once”。
  • 如何实现端到端的 Exactly-Once?
    • 这需要一个 “ 读 - 处理 - 写 “ 的原子循环,通常在流处理应用(如 Kafka Streams)中实现:

      1. 消费:一个消费者(同时也是一个生产者)从上游 Topic 消费消息。
      2. 处理:对消息进行业务逻辑处理。
      3. 生产:将处理结果作为新消息,发送到下游 Topic。
      4. 提交偏移量:将消费过的消息的偏移量(Offset)也发送给事务协调器。

      这整个 “ 读 - 处理 - 写 - 提交偏移量 “ 的过程,被包裹在同一个 Kafka 事务中。只有当处理结果成功写入下游,并且偏移量也成功记录后,整个事务才提交。如果中途任何一步失败,整个事务回滚,消息会留在上游未被消费(偏移量没提交),处理结果也不会出现在下游。这就实现了真正的端到端 “ 一次且仅一次 “ 处理。

    • 原理是将消费者的偏移量(Consumer Offset)也视为一种需要写入的数据,并把它也塞进了生产者的事务里。

      1. 读 (Read):这一步在事务之外。你的应用(作为一个消费者)调用 consumer.poll() 从上游 Topic 拉取一批消息。比如,拉取了消息 A、B、C,它们的偏移量分别是 10, 11, 12。
      2. 处理 (Process):这一步也在事务之外。你的应用代码对消息 A、B、C 进行计算、转换,得出了结果 X 和 Y。这是纯粹的内存计算。
      3. 开始原子操作 (beginTransaction): 现在,关键来了。你的应用(作为一个生产者)启动一个事务。
      4. 写 (Write):在这个事务内,你执行两个截然不同的 “ 写入 “ 操作:
        • 写入处理结果:你调用 producer.send() 将结果 X 和 Y 发送到下游 Topic。
        • 写入消费进度:你调用一个特殊的 API (producer.sendOffsetsToTxn()),告诉事务:” 我(作为消费者)已经成功处理完了上游 Topic 中偏移量到 12 为止的所有消息。” 这个 API 会把消费组的偏移量信息发送给事务协调器。
      5. 提交原子操作 (commitTransaction): 当 commitTransaction() 被调用时,Kafka 事务会 原子性地完成两件事:
        1. 让下游 Topic 中的消息 X 和 Y 对外可见。
        2. 将上游 Topic 对应的消费组偏移量更新为 12。
    • 其实是将业务数据的产出和上游数据消费进度的更新这两个看似独立的操作 “ 捆绑 “ 在了一起,形成了一个逻辑上更大的原子单元。

  • 既然 Kafka 的事务机制能保证 “ 一次且仅一次 “,为什么在实际开发中,我们不会给所有的生产者都启用事务功能呢?它的主要代价(Trade-off)是什么?
    • 额外的网络往返(Round-Trips):一个事务流程中,生产者需要与事务协调器(Transaction Coordinator)进行多次额外的通信,如获取 PID、注册分区、请求提交/中止等。这些都会增加消息发送的端到端延迟。
    • 协调器成为瓶颈:所有事务的元数据状态都由事务协调器管理,它需要处理来自大量生产者的请求,并向内部的事务日志(__transaction_state)进行读写,这会给 Broker 带来额外的负载,可能成为整个集群的瓶頸。
    • 对于日志收集、用户行为分析、物联网数据上报等场景,” 至少一次 “ (At-Least-Once) 的投递语义通常就足够了。偶尔的重复数据可以在下游处理(比如做幂等消费),为了换取最高的吞吐量和最低的延迟,我们应该避免使用事务。
  • 一个消费者正在消费某个分区的数据,它的 isolation.level 设置为 read_committed。此时,一个开启了事务的生产者发送了 5 条消息到这个分区,然后成功执行了 commitTransaction()。请描述一下,消费者将如何 “ 看到 “ 这 5 条消息和那个 COMMIT 标记?是一条条看到,还是一下子看到?为什么?
    • 消费者不是一条一条地看到这 5 条消息,而是在事务提交后,仿佛这 5 条消息是瞬间、同时出现的。它要么一条也看不到(事务未提交),要么一次性看到全部(事务已提交)。这就是事务在消费端的原子性保证。
  • Kafka 如何使用事务
    • 生产者配置 (Producer Config):你必须在生产者的配置中设置两个关键参数:

      • enable.idempotence: true (如果设置了下面的 transactional.id,此项会自动变为 true)。事务功能构建于幂等性之上。
      • transactional.id: 必须设置。这是一个由你定义、在整个应用中必须唯一且稳定的字符串。它用于识别生产者的不同实例,是实现僵尸隔离和事务恢复的核心。
    • 消费者配置 (Consumer Config)

      • 为了只读取已提交的事务性消息,消费者必须配置:isolation.level: read_committed。它的默认值是 read_uncommitted,会读到包括未完成事务在内的所有消息。
    • 生产者代码逻辑 (以 Java 为例)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      // 1. 使用事务性配置创建生产者
      Properties props = new Properties();
      props.put("bootstrap.servers", "kafka-broker-1:9092");
      props.put("transactional.id", "order-processor-tx-01"); // 唯一且稳定的ID
      // KafkaProducer 会自动设置 enable.idempotence=true 和 acks=all
      Producer<String, String> producer = new KafkaProducer<>(props);

      // 2. 初始化事务(必须调用一次)
      producer.initTransactions();

      try {
      // 3. 在需要原子操作的地方,开始一个事务
      producer.beginTransaction();

      // 4. 在事务中发送多条消息(可以到不同主题或分区)
      producer.send(new ProducerRecord<>("topic-orders", "order_123"));
      producer.send(new ProducerRecord<>("topic-inventory", "item_ABC", "-1"));

      // 假设这里还有一些数据库操作...

      // 5. 如果一切顺利,提交事务
      producer.commitTransaction();

      } catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
      // 这些是不可恢复的严重错误,应关闭生产者
      System.err.println("发生严重错误,关闭生产者: " + e.getMessage());
      producer.close();
      } catch (KafkaException e) {
      // 对于其他可恢复的异常(如网络超时),中止事务
      // 中止后,可以尝试重试整个事务
      System.err.println("发生可恢复错误,中止事务: " + e.getMessage());
      producer.abortTransaction();
      }

      // 6. 关闭生产者
      producer.close();

4.4 消息队列对比

维度Kafka (火车)RocketMQ (全能货运车队)RabbitMQ (精准快递员)
定位 & 设计哲学分布式流处理平台 (Streaming Platform)。它本质是一个不可变的、可重复读的提交日志(Commit Log)。金融级、企业级消息中间件。为大规模、高可靠、功能丰富的业务消息传递而生。通用、全功能的传统消息代理 (Broker)。遵循 AMQP 协议,强调消息路由的灵活性和可靠性。
核心模型Topic -> Partition。分区是并行处理和顺序保证的基本单位。Topic -> Message Queue。类似于 Kafka 的分区,但功能更丰富。Exchange -> Binding -> Queue。生产者发给交换机,交换机根据绑定规则将消息路由到一个或多个队列。
消息消费模型拉模式 (Pull)。消费者主动从 Broker 拉取数据,自主控制消费速率。推拉结合 (Push/Pull)。默认是 Broker 推送(长轮询实现),但本质上仍是客户端主动发起的拉取。推模式 (Push)。Broker 主动将消息推送给已订阅的消费者。消费者也可以主动拉取 (Basic.Get)。
消息存储磁盘文件 + 页缓存 (Page Cache)。所有消息顺序写入磁盘,充分利用操作系统的页缓存实现高速读写。消息可持久化并重复消费。磁盘文件 (CommitLog + ConsumeQueue)。所有消息顺序写入 CommitLog,然后为每个队列建立一个轻量级索引文件 (ConsumeQueue),实现高效读写分离。内存为主,可持久化到磁盘。默认情况下消息在内存中,以追求低延迟。队列堆积过多时性能下降明显。
性能 & 吞吐量极高 (10 万 +/秒)。得益于顺序 I/O 和零拷贝技术,是三者中吞吐量的王者。非常高 (接近 10 万/秒)。设计吸收了 Kafka 的优点,吞吐量巨大,且在复杂场景下表现稳定。中等到高 (万级/秒)。在消息不堆积、路由简单时延迟最低,但吞吐量上限低于前两者。
高级特性• Kafka Streams/ksqlDB (原生流计算)
• Kafka Connect (数据集成)
• 精确一次处理 (事务)
• 延迟消息 (定时消息)
• 事务消息
• 消息过滤 (Tag/SQL92)
• 消息回溯
• 消息优先级
• 灵活的路由策略
• 死信队列 (DLX)
• 插件化架构
可靠性 & 顺序性分区内有序。通过副本机制 (Replication) 保证高可用。事务功能保证端到端精确一次。分区内有序。有同步刷盘、异步刷盘、同步/异步副本等多种高可靠模式。队列内有序。通过发送方确认 (Publisher Confirms) 和消费者应答 (ACK) 保证消息可靠投递。
适用场景• 大数据领域:日志收集、用户行为分析、ETL
• 流式处理:实时监控、事件溯源 (Event Sourcing)
• 作为大型系统的 “ 数据总线 “
• 核心业务系统:电商交易、金融支付、物流通知
• 需要高级特性的场景,如订单超时处理(延迟消息)
• 对可靠性和稳定性要求极高的场景
• 业务解耦、系统集成:微服务间的通信
• 需要复杂路由的场景,如根据消息内容分发
• 对单条消息低延迟要求高的任务
社区 & 生态极其活跃强大。Confluent 公司商业支持,生态系统无与伦比。增长迅速。由阿里巴巴主导,在国内有广泛应用,国际影响力在提升。非常成熟稳定。作为 AMQP 的标杆实现,拥有最广泛的客户端语言支持。

可用性 / 可靠性对比

  • Kafka:分布式日志系统(火车)
    • 自身不带延迟消息、消息优先级等复杂功能,需要借助上层应用实现。
    • 由于分区只能被一个消费者消费,如果单个分区的数据量过大,会成为消费瓶颈(即 “ 热点分区 “ 问题)。
  • RocketMQ:金融级消息引擎(货运车队))
    • 原生支持普通消息、顺序消息、事务消息和至关重要的延迟消息。
    • 相比 Kafka 庞大的流处理生态,RocketMQ 更聚焦于 “ 消息传递 “ 本身。
  • RabbitMQ:全能消息代理(快递员)
    • RabbitMQ 会将消息尽可能地保存在内存中。当内存压力大时,它会进行 “ 流控 “(Flow Control)来阻止生产者继续发送,或者将内存中的消息 “ 换页 “(Page Out)到磁盘。这导致消息大量堆积时性能会急剧下降。
    • 与前两者相比,吞吐量有较大差距,不适合海量数据处理场景。
    • 消息不支持回溯:消息一旦被消费并确认,就会从队列中删除,无法重复消费。

5. 提问问题

  • 如何保证同一个用户的所有订单能够被按序处理?

    • 将用户 ID (UserID) 作为消息的 Key。Kafka 的默认分区策略会对 Key 进行哈希计算 (hash(key) % numPartitions),然后将消息发送到固定的 Partition。
    • 如果某个超级大卖家(比如一个平台自营店)产生了系统大部分的订单,那么他对应的 UserID 会导致所有消息都涌入一个 Partition,造成该 Partition 负载极高,而其他 Partition 很空闲,这会成为新的瓶颈。
  • 你的一个 Go 消费者服务从 Kafka 读取消息,进行复杂的业务计算,然后将结果写入 PostgreSQL。你如何设计你的消费逻辑(特别是 Offset 提交),以确保即使服务在写入数据库后、提交 Offset 前突然崩溃,系统也能恢复正常且不会漏掉数据?(提示:考虑 “ 至少一次消费 “ 和 “ 幂等性 “)

    1. 从 Kafka 拉取一批消息 (FetchRecords)。

      关闭自动提交 Offset。

    2. 遍历每一条消息:
      a. 开始数据库事务 (BEGIN TRANSACTION)。
      b. 执行所有业务逻辑(计算、查询、写入…)。在写入前,可以先根据唯一键(如订单 ID)查询,判断记录是否已存在,以此实现幂等性。
      c. 提交数据库事务 (COMMIT)。
      d. 如果数据库事务提交成功,才将这条消息的 Offset 标记为待提交。
      e. 如果任何一步失败(比如数据库连接中断),则回滚数据库事务 (ROLLBACK),并且不提交这条消息的 Offset。程序可以退出或重试。

    3. 当一批消息都处理完后,一次性手动提交这批已成功处理的 Offset (CommitOffsets)。

    4. 这样设计,即使在第 3d 步之后、提交 Offset 之前服务崩溃,下次重启时,它会从上一个已提交的 Offset 重新消费。由于你的数据库操作是幂等的,重复处理这条消息也不会产生副作用,数据的一致性得到了保障。

  • 如果你的团队决定用 Kafka 替换掉现有的用于发送邮件通知的 RabbitMQ 集群,你认为这可能是一个好的主意吗?为什么?请从它们设计的根本差异上阐述你的观点。

    • 与用户行为日志相比,邮件发送的 QPS 通常不高。Kafka 的百万级吞吐量在这里属于 “ 杀鸡用牛刀 “。
    • 邮件发送是一个独立的、短暂的任务。消息被消费(邮件被发送)后,通常就不再关心它了。
    • Kafka 的 “ 数据回溯 “ 特性在这里反而可能是个灾难。想象一下,因为一次 bug 修复,你重置了 Offset,导致系统给用户重发了一周前的 “ 您的包裹已发出 “ 的邮件。
    • 复杂的路由/重试逻辑:有时你可能需要更灵活的策略,比如 “ 某个类型的邮件发送失败后,等待 5 分钟再试 “,或者 “ 连续失败 3 次后,将它投入 ‘ 死信队列 (Dead-Letter Queue)’ 交由人工处理 “。这些是 RabbitMQ 这类传统 AMQP 消息队列的强项。所以 RabbitMQ 更好。
  • pull 和 push 模式是个啥? 为什么使用 pull ?

    • Push (推送) 模式: 由 Broker 主动将消息推送给已订阅的 Consumer。
    • Pull (拉取) 模式: 由 Consumer 根据自己的节奏,主动向 Broker 发起请求拉取消息。
    • 传统的 Pull 模式有一个缺点:如果 Topic 中长时间没有新消息,Consumer 会不断地发起轮询请求,这会消耗大量 CPU 和网络资源,造成 “ 忙等 “(busy-waiting)。
      • Kafka 的解决方案:长轮询 (Long Polling)。

      • 当 Consumer 调用 poll() 方法请求数据时,如果 Broker 上没有可用的消息,这个请求不会立即返回空结果。相反,Broker 会挂起 (hold) 这个连接,等待一段时间(由 fetch.wait.max.ms 配置,默认 500ms)。

      • 在这段时间内,一旦有新消息到达,Broker 会立即将消息返回给 Consumer,请求结束。如果等待超时,还没有任何新消息,Broker 才会返回一个空结果。

  • 精确语义是什么意思,精确一次什么意思,kafka 是哪一种?

    • At-Most-Once (至多一次):消息最多被传递一次,可能会丢失,但绝不会重复。如 acks=0
    • At-Least-Once (至少一次):消息保证至少被传递一次,绝不会丢失,但可能会重复。如 acks=1all
    • Exactly-Once (精确一次): “ 精确一次 “ 并不是指消息在网络上真的只传输了一次。在有重试机制的背景下,这是不可能的。它指的是,即使消息在网络中被重传,其最终对业务状态产生的影响也和只有一次投递完全一样。
      • 幂等生产者 (Idempotent Producer): 解决生产者重试导致的消息重复问题。
      • 事务 (Transactions): 将 “ 消费 - 处理 - 生产 “ 这一系列操作捆绑成一个原子单元。
    • Kafka 默认提供的就是 “ 至少一次 “ 语义。它通过 acks=all、副本机制和手动提交 Offset 等方式,在系统层面保证了消息不会丢失,但将处理重复的责任交给了消费端。
    • kafka 本身默认是 “ 至少一次 “ 系统,但它提供了强大的工具(幂等生产者 + 事务),让开发者可以构建出 “ 精确一次 “ 的应用。
  • 幂等生产者是什么意思? 是怎么实现的

    • 从 Kafka 0.11 版本开始,通过引入幂等生产者和事务,Kafka 提供了实现端到端 “ 精确一次 “ 语义的能力。
    • Broker 端的去重:
      • 生产者发送的每条消息都包含了 <PID, Partition, SequenceNumber>

      • Broker 会在内存中为每个 <PID, Partition> 组合缓存最新的序列号。当它收到一条新消息时,会进行如下检查:

        • 情况 A (新消息): 如果收到的序列号是 缓存中的序列号 + 1,说明这是预期的下一条新消息。Broker 会接受它,并把缓存的序列号更新为收到的序列号。

        • 情况 B (重复消息): 如果收到的序列号小于或等于缓存中的序列号,说明这是一条重复消息(重试导致的)。Broker 会直接丢弃这条消息,但依然会像成功处理一样返回一个 ACK 给生产者。这样生产者就能正常结束,而不会因为消息被丢弃而报错。

        • 情况 C (乱序消息): 如果收到的序列号比 缓存中的序列号 + 1 还要大,说明中间有数据丢失了。Broker 会拒绝这条消息,并抛出 OutOfOrderSequenceException 异常。

  • 如果你希望一条消息能被多个不同的应用处理(比如,订单消息既要被库存系统消费,也要被积分系统消费),怎么做?

    • 使用多个消费者组
  • 发送者消息的 Key 是什么回事?

    • 键默认情况下是 null。可以不用 key,通常是用来指定发送到特定的分区。
  • 发送的分区,可以自定义策略吗?

    • Kafka 允许你通过实现一个接口来完全掌控消息该进入哪个分区,这个过程叫做自定义分区器 (Custom Partitioner)。
  • 消费者可以从分区副本的 follower 读数据吗?

    • 不可以。在 Kafka 的标准设计模型中,所有的读写请求都只由 Leader 副本处理。这是一个非常核心且重要的设计原则,其背后的原因是为了保证最强的一致性和简单性。
    • 从 Kafka 2.4 版本开始,通过 KIP-392 (Kafka Improvement Proposal) 引入了一项新功能,允许消费者在特定条件下从 Follower 副本读取数据。
  • Kafka 有死信队列吗?

    • 死信队列:将无法处理的消息发送到一个专门的 “ 死信主题 “,然后正常提交 Offset,继续处理下一条消息。这样既不会阻塞主流程,也保证了问题数据不丢失,可以后续进行分析和处理。
    • Kafka 本身没有内置的、开箱即用的 “ 死信队列 (Dead Letter Queue, DLQ)” 机制。实现死信队列通常在消费端实现。
      • 创建一个专门用于存放死信消息的 Topic,例如 my-topic-dlq。

      • 启动一个独立的消费者组,专门订阅这个 my-topic-dlq。

  • 使用 kafka 的最佳实践是什么?各个组件应该怎么配合??

    1. Producer: acks=all + enable.idempotence=true
    2. Broker: replication.factor >= 3 + min.insync.replicas >= 2 + unclean.leader.election.enable = false
    3. Consumer: enable.auto.commit=false + 手动提交 Offset + 业务逻辑处理时间不能太长 + 实现死信队列模式

6. 参考资料