Kafka 内核探秘(0):俯瞰Kafka架构全景与消息的生命周期

51 阅读18分钟

Apache Kafka 自2011年问世,历经十四年发展,已当之无愧地成为事件流处理领域的事实标准。其卓越的分布式架构设计,使其至今仍拥有活跃的社区和旺盛的生命力。如下图所示,Google趋势数据毋庸置疑地表明,Kafka在该领域始终保持着强大的吸引力和影响力。

你可能已经在工作中接触和使用过 Kafka,但可能从未深入了解过它的架构。很多时候我们只需要把 Kafka 当成一个黑盒子。得益于它巧妙的设计,我们只需要编写生产者代码,然后不断向这个黑盒子发送消息。Kafka 会保证消息被安全地发送和存储在 Broker 端。然后我们可以编写任意数量的 Kafka 消费者,从这个“黑盒子”中轮询获取数据。

这一切都井然有序,Kafka 为我们隐藏了分布式系统的所有复杂细节。

但是如果你对 Kafka 非常感兴趣,并希望深入了解其内部机制,例如它如何实现高可用和容错、为什么速度极快、存储机制如何工作,那么本文将为您庖丁解牛,揭示这些核心技术谜题。

在开始深入探讨之前,有两点需要提前说明:本文假设您已具备基本的 Kafka 使用经验,因而不会涉及入门知识;同时,随着 Kafka 3.3.0 在 2022 年发布,其控制器已正式从 ZooKeeper 迁移至 KRaft 模式。因此,我们将聚焦于这套全新的 KRaft 架构,解析其内部原理。关于 Kafka 为什么要用 KRaft 替代 ZooKeeper 这一重大架构演进,其背后的深层原因和权衡,我已在另一篇文章# KIP-500:用自管理元数据法定人数替换 ZooKeeper中进行了详细剖析,推荐您结合阅读。

从一万米高空俯瞰 Apache Kafka 的架构

从宏观上看,Kafka 集群由两大功能平面构成:数据平面(Data Plane)和控制平面(Control Plane)。

其中控制平面用于协调集群、监控数据平面节点健康状况、管理集群中的所有元数据(例如主题创建、配置、分区分配和数据平面节点注册等功能)等,在 Kafka 3.3.0 之前使用 ZooKeeper ,自 3.3.0 起,则被 Kafka 自研的、基于 Raft 协议的 KRaft 组件完全取代。

在 KRaft 控制器集群中,任意时刻仅有一个节点能担任活动控制器(Active Controller ,它负责处理所有来自 Broker 的 RPC 请求。其余控制器节点作为跟随者( Follower ,处于热备状态,会持续从活动控制器复制状态数据。一旦活动控制器发生故障,跟随者会迅速发起选举,选举出新的活动控制器,以保障集群的持续可用。

这与经典的 Raft 算法原理一致:Raft 集群中也仅有一个领导者( Leader ,其他节点作为跟随者( Follower 。当 Follower 检测到 Leader 故障时,会转换为候选者(Candidate) 角色并发起新一轮选举。

KRaft 与标准 Raft 的一个核心区别在于日志复制方式:在 Raft 中,由 Leader 主动推送( Push 日志;而在 KRaft 中,则由 Follower 主动拉取(Pull) 最新元数据变更。

数据平面:业务数据的处理管道

数据平面则负责处理我们写入到 Kafka 或者从 Kafka 读取的实际数据,其内部可分为计算层(Compute Layer)存储层(Storage Layer)

  1. 存储层:Kafka存储层的核心设计可归结为三点:分区(Partition) 实现并行与扩展,副本( Replica 保证高可用与数据安全,顺序追加写入零拷贝等技术则共同铸就了其极高的I/O效率。

  2. 计算层:Kafka 的计算层主要包含两个核心 API 组件:

    1. 生产者 API (Producer API) :接收并处理来自生产者客户端的消息发送请求;
    2. 消费者 API (Consumer API) :接收并处理来自消费者客户端的消息拉取请求。

从一千米高空俯瞰 Apache Kafka

以上,我们从万米高空宏观了解了Apache Kafka的架构。现在,让我们将视角拉近到一千米,深入剖析Kafka核心组件的组织方式与运行流程。

生产者的奇幻漂流 —— 一条消息从应用到Broker的完整旅程

1. 一条消息(或者记录,在后文我们统一成消息)的冒险旅程是从生产者开始的。 如下图所示,在Kafka中,一条消息由可选的键(key)、值(value)、可选的自定义头(Headers)以及时间戳组成。Kafka 的一个基本特性是客户端和 Broker 之间通过 TCP/IP 二进制协议进行数据传输。客户端和 Broker 之间仅传输二进制数据,而不关心消息的具体格式和内容。因此生产者处理的第一步是将该消息转换为字节(bytes)数据。

2. 完成序列化之后,生产者拿到了一组字节数据,但接下来它需要决定将这些字节发送到哪个主题分区(topic partition)。 如果在第(1)步中发送的消息有键,那么客户端默认的分区器会对键进行哈希,从而确定正确的分区。如果消息没有键,则使用分区策略(Partition Strategy)来平衡各分区中的数据负载。当然你也可以在构建 Kafka 消息时,在 produce() 方法中手动指定数据要发送到的目标分区。

在这这一步结束的时候,生产者已经知道了这条消息应该发送到哪个分区。

如果生产者还不知道这个分区的领导者被托管在哪个 Broker 上,生产者可以主动发起元数据获取请求,从 Broker 集群的任意节点获取和接收分区托管元数据信息,轻松找到哪个 Broker 拥有该领导者分区,从而准备将消息发送到该 Broker。由于这些元数据信息不会经常变化,因此生产者端可以安全地进行元数据缓存。

如果缓存的元数据信息错误,当生产者向异常的 Broker 发送消息时,Broker 会返回错误信息,指示领导者可能已经更换,生产者会再次请求和缓存元数据。当然 Kafka 客户端为我们屏蔽了这些复杂性。

3. 如果生产者在调用 produce() 方法发送一条消息后立即通过网络将这条消息传输到 Broker 的话,这会造成巨大的网络开销。因此为了提高吞吐量,消息会在客户端进行以主题分区的维度累积成一个消息批次,并最终以批量的形式将消息发送到 Broker,从而减少单条消息传输的开销。

消息累积到什么程度后生产者才能发送消息批次到 Broker 端呢?这取决于下面两个因素:

  • 大小(Size) :最大批次大小由生产者配置中的 batch.size 属性确定,默认值为 16 KB。当数据累积触发批次的大小阈值,它就会被发送出去。
  • 时间(Time) :然而,如果没有足够的记录要发送,可能很长时间都无法达到批次大小的阈值。为了解决这个问题,客户端引入了另外一个 linger.ms 的时间参数,它定义了生产者在发起生产请求之前最多应等待多长时间。 无论是否已经达到 batch.size 的阈值,这些消息批次将会被清空,形成一个生产请求(Produce Request),并发送到包含这些分区的领导副本(Leader Replica)所在的 broker。

默认情况下,linger.ms 设置为零,这意味着当发送线程空闲并且可用于发送请求时,批次会被立即发送。

Broker 发送确认时,也是以批次的形式进行消息确认。如果生产者客户端未收到确认,那么整个批次就会被重新发送。重试的次数由另外一个 retries 属性来确定,在最新版本的 Kafka 中,这个属性的默认值是最大整数。但是并不意味着客户端会无限期重试。它由以下一些超时属性来控制。

  • delivery.timeout.ms 设置报告成功或失败的上限。此时间范围涵盖了整个周期,包括重试和等待确认。
  • request.timeout.ms 指定生产者等待服务器对每个单独请求作出响应的时间,强调它侧重于单个响应的时间,而不是整个过程。此属性影响各种类型的生产者请求,例如发送消息、接收确认或执行元数据查找。
  • max.block.ms 确定在没有足够的内存来缓冲记录的情况下阻塞的上限。如果此属性指定的时间到期,则会抛出异常。

4. 通过上面三步中,我们已经获得了:

  1. 原始字节数据
  2. 目标分区和目标 Broker
  3. 累积的更多的原始字节数据

但是 Kafka 作为一个以高性能著称的分布式消息队列,它会将性能优化到极致。因此在真正发送生产请求(Produce Request)到目标 Broker 之前会对这些字节数据进行压缩以减小传输的批次大小。默认情况下Kafka 生产者的压缩功能是禁用的,但是Kafka 提供了几种开箱即用的压缩选项,你可以通过配置参数 compression.type 来启用压缩并选择压缩算法。

5. 最终,我们获得了一批准备好的可以被发送的字节数据。 每个生产者都会与若干目标 Kafka Broker 保持套接字连接(socket connections),并通过 TCP 传输使用二进制协议(binary protocol)发送请求。到这里字节数据的奇妙旅行才真正开始。

下图展示了一个生产者请求的结构:

在这个示例中,生产者请求包含了多个主题和分区的数据批次,这些批次都属于同一个目标 Broker。一定要注意的是:

  • 每个生产请求(Produce Request)只会被发送到 Kafka 集群中的单个 Broker,也就是生产者请求和目标 Broker 是 1 对 1 的关系。
  • 同时一个生产者请求有可能包含多个主题分区(Topic Partition)的数据,这具体取决于生产者的工作方式以及当前正在发送的数据类型。

6. 每一条消息飞向 Broker 时,都会先降落在一个名为 Broker 的套接字接收缓冲区(socket receive buffer)。 这可以看作是进入数据的“着陆区”或者“缓冲区”,请求会在这里等待被网络线程(network threads)拾取并进行处理。

7.请求在套接字接收缓冲区经过短暂的停留后,一个空闲的网络线程(network thread)会从套接字接收缓冲区读取数据,并组装形成一个 Broker 端的请求对象,这个请求对象最终会被加入到另外一个请求队列(Request Queue)。

一旦某个网络线程接手这个请求后,它就负责跟踪和处理这个生产者请求的完整生命周期,即如果一个生产者向一个 Kafka 主题发送消息后:

  1. 该网络线程从生产者接收请求
  2. Broker 端处理该请求 (将消息写入 Kafka 提交日志并被复制到其他副本)
  3. 处理完成后,该网络线程发送响应给客户端,确认消息已成功被接收。

8. 接下来组装后的生产者请求在请求队列(request queue)短暂停留后,Broker 端的一个空闲 I/O 线程拾取并处理该请求。

9. I/O 线程拾取该请求后会先执行一些验证,例如对请求数据的 CRC 校验等。验证完成后,数据会被追加到分区的物理数据结构中,即提交日志(Commit Log)。(我将在下一篇文章中系统性地向你介绍 Kafka 存储结构相关的内容,请持续关注这个专栏)。

写入磁盘的操作非常昂贵,为了提高效率,Kafka 的 I/O 线程并不会直接将数据写入磁盘,而是先将消息写入页面缓存(page cache)。页面缓存是操作系统的一部分,负责管理内存与磁盘之间的数据流。

数据被写入页面缓存后,会在稍后通过后台进程刷新到磁盘。这种方式虽然不会立即触发磁盘写操作,但 Kafka 的数据复制协议确保了即使数据暂时停留在页面缓存中,系统依然是安全的。如果你对这个议题感兴趣,你可以参考阅读 jack vanlightly 大神的 jack-vanlightly.com/blog/2023/4… 这篇博客,在这里他对 Kafka 的刷盘机制和安全性进行了全面权威的分析,Kafka 的“安全”不在磁盘,而在“恢复协议的正确性”,这是一种大胆又优雅的分布式设计哲学。

10. 在第(9)步中我们分析了日志数据不会同步从页面缓存刷写到磁盘,Kafka 依赖于数据复制到多个 broker 节点来提供持久性和容错能力。 默认情况下,broker 不会在数据被复制到其他 broker 之前确认生产请求。为了避免 I/O 线程在等待复制的时候被阻塞,请求对象会被存储到一个称为炼狱(Purgatory)的数据结构中。

“炼狱”这个词十分形象。正如意大利诗人但丁在《神曲》中所描绘的那样,炼狱是介于地狱与天堂之间的过渡地带,灵魂在此经历净化,最终走向光明。Kafka 借用了这一意象,将请求的状态划分为三个层次:

  1. 天堂:可以立刻完成的请求(如acks=1的PRODUCE)。
  2. 炼狱:既无法立即完成,也未立即失败的请求(例如 acks=all 的 PRODUCE 请求,需等待所有 ISR 副本确认)。
  3. 地狱:已确定失败的请求。

生产请求会被保存在这里,直到复制过程完成。而对应的 I/O 线程则被释放出来去处理下一批请求。一旦待处理的生产者请求的数据被复制到其他 Broker 实现数据冗余,该请求就会被移出炼狱生成对应的响应对象(Response Object),并将其放入到另外一个响应队列(Response Queue)中。

11. 一旦响应被准备好,在第(7)步中的网络线程(network thread)就会继续接管整个流程,拾取对应的响应对象,并将其发送回对应的生产者客户端。

消费者的逆向追踪 —— 从磁盘到网络的 零拷贝 之旅

当消息在Kafka Broker端完成存储与复制后,便开启了它旅程的下半场。此时,消费者客户端将主动从Broker拉取消息进行处理。一个消费者可以同时订阅多个主题,并能并行地从多个分区中读取数据,从而实现高吞吐量的数据处理。

1. 为了消费消息,消费者客户端会首先构建并发送拉取请求(fetch request),消费者首先会组装一个拉取请求(fetch request),指定想要消费的主题、分区以及起始偏移量(offset)。 这个偏移量通常是根据其消费组ID,从 Kafka 内部的主题 __consumer_offsets 中获取上次成功消费后提交的位置,从而实现断点续传。

2. 正如在生产者流程中提到的,消费者会和一组Kafka Broker(包括其分配的分区所在节点及组协调器)建立并维护TCP连接,并通过该连接使用二进制协议发送拉取请求。 拉取请求的数据结构如下图所示:

3. 和生产者请求一样,拉取请求会先到达 Broker 的套接字接收缓冲区(socket receive buffer)。

4 . 拉取请求被一个空闲的网络线程(network thread)拾取并处理组装成一个 Broker 端的请求对象,这个请求对象会被加入到另外一个请求队列(Request Queue)。

5. 组装好的拉取请求在请求队列(request queue)短暂停留后,Broker 端的一个空闲 I/O 线程拾取并处理该请求。到这里 I/O 线程才开始真正从磁盘上读取拉取请求下想要的数据。

6. I/O 线程会取出请求,并使用请求中包含的偏移量与分区段中的 .index 文件进行比较。索引会精确告诉 I/O 线程需要从哪个对应的 .log 文件读取哪些字节范围的数据(这是另外一个有趣的议题,请关注我的专栏,在下一篇文章我将向你系统分析 Kafka 的存储结构)。

同样为了防止在 Broker 端没有足够的新数据写入时消费者无效的请求空轮询,Kafka 在消费者请求中引入了如下的数据读取设计:

  • 最小数据量:等待数据量达到一定字节数后再返回响应。
  • 最大等待时间:等待一定时间后再返回响应。

和生产者请求一样,如果 Broker 无法立即满足请求,这个请求会被放置到炼狱中等待。另一方面,如果请求可以立即满足,它会直接跳过炼狱,生成对应的响应对象(Response Object),并将其放入到另外一个响应队列(Response Queue)中。

7. 一旦响应被准备好,在第(4)步中的网络线程(network thread)就会继续接管整个流程,拾取对应的响应对象,并将其发送回消费者客户端。 值得注意的是Kafka 在网络中使用了零拷贝(zero-copy)传输,这意味着数据直接从操作系统的 页面缓存( Page Cache 被复制到网卡的 套接字缓冲区(Socket Buffer) ,传输过程完全在内核态完成,完全绕过了应用程序的用户空间内存。这避免了传统读写方式中多次不必要的 CPU 数据拷贝,从而极大地提升了数据传输效率。”

8. 现在数据终于回到了消费者客户端,让我们回忆一下,在 Kafka 中流动的始终是字节流(bytes)数据,因此为了让数据被业务端可用,消费者客户端做的最后一项工作是使用配置的反序列化器将原始字节流转换为可用的数据对象。

走进 Apache Kafka Broker 内部

这是一个漫长的旅程,但这不是故事的终点。Kafka 是一个充满复杂性的项目,我不会也无法在一篇文章里能覆盖 Kafka 的所有细节,让你充分感受它的强大和魅力。因此在这个专栏的后续文章中,我将一步一步地庖丁解牛式地讲解 Kafka 的核心流程和组件。这个专栏的后续章节将包含以下内容:

  1. Kafka 内核探秘(一):生产者的奇幻漂流——从消息创建到批量发送
  2. Kafka 内核探秘(二):Broker 请求处理全流程——网络线程、IO线程与炼狱机制
  3. Kafka 内核探秘(三):消费者的逆向追踪——从拉取请求到业务处理
  4. Kafka 内核探秘(四):存储引擎深度剖析——日志结构、索引设计与压实策略
  5. Kafka 内核探秘(五):复制协议解析——ISR机制与数据安全
  6. Kafka 内核探秘(六):消费者组协调机制——重平衡的演进与优化
  7. Kafka 内核探秘(七):控制平面演进——从ZooKeeper到KRaft架构