大多数Kafka解释止步于“这是一个分布式提交日志”和“它快是因为顺序磁盘I/O”。这话没错,但只是冰山一角。本文打算完整穿过Kafka的内部——拓扑结构、存储、复制、集群元数据、生产者路径、消费者路径以及线路协议——写给那些已经知道什么是broker,并且想知道真正令人困惑的部分的人。

有一个念头几乎贯穿下面所有内容:Kafka拒绝随机访问。消费者从不请求某个键,只要求“偏移量N之后的所有内容”。这条单一约束解释了存储引擎为什么长成那样,持久性为什么是一个复制故事而非磁盘刷盘故事,以及生产者与消费者的那么多机制为什么纯粹是为了处理这个约束所制造的边缘情况。

打开网易新闻 查看精彩图片

如果你已经走过“生产一条消息,消费一条消息”的教程,你手上已经有了这些词汇——只是还没看见它们底下到底是什么。本文按五层组织,每一层都建立在前一层之上:第一部分——物理模型,一个broker、主题和分区到底是什么,一个分区在磁盘上到底放了什么;第二部分——速度与安全,读如何变快(页缓存、零拷贝),写如何实现持久(靠复制,而不是fsync);第三部分——协调,broker们如何就上面所有这些达成一致——谁还活着、谁是领导者、配置是什么;第四部分——客户端契约,生产者的acks设置和消费者组的重平衡在机械层面到底干了什么;第五部分——底层,将这一切绑在一起的线路协议。

每一节都先从教程级别的概念说起,然后走得更远——所以,如果你看到某一节开头出现你已经了如指掌的内容,那是一个信号,你可以直接掠过,从“但是”那里接着往下读。

下面我们先看一张总图,然后一个部分一个部分地走:一条单一记录从生产者到已提交偏移的整个路径,KRaft控制器法定人数在一旁协调领导者关系。

第一部分:物理模型

1. 基本形状:broker、主题、分区。简单说,“在一个broker内部,你有主题。主题是消息流,为了可伸缩性,它被切分成多个分区。”没错,但是这三个词,到底哪个指一台实在的机器?因为本文的其余部分全系于这一点:Broker——一台物理或虚拟服务器。它拥有CPU、内存、磁盘和网卡——这是集群里真实的硬件。主题——一个逻辑名称,比如“user-signups”。它不是任何地方的文件,也不存在于任何一个单独的broker上。分区——那个真正的东西。一个主题被分成若干分区,每个分区都是一个有序的、只能追加的记录序列,它作为一个真实的目录,坐落在某一台特定broker的磁盘上,并且被复制到少数其他broker上以确保安全。

Kafka伸缩方式的一切,都源自分区作为物理存储的真正单元这一事实。一个只有一个分区的主题,永远只能在一个broker上伸展。而一个拥有上百个分区的主题,可以把工作铺开到上百台broker上去,因为每一个分区都是一个独立的日志,独立地被复制,独立地由某个broker来领导。当你创建一个主题并指定分区数为12,你其实就是在告诉Kafka:“为我制造12个独立的日志,将它们分散到我的broker上,并确保每个日志都有几份副本。”

2. 分区在磁盘上是什么样。如果你ssh到一台broker并打开数据目录,你会看到一堆以主题名加连字符加分数字命名的文件夹——“user-signups-0”、“user-signups-1”等等。进到里面,有三种文件:.log文件存着真正的消息;.index文件将偏移量映射到.log文件的字节位置,这样就不用扫描整个文件;.timeindex文件将时间戳映射到偏移量。这些文件被分成段,当.log文件达到一定大小,就会滚动生成一个新的,旧的段可以被压缩或删除。你永远看不到一个主题的“全局”文件,只有分区的本地文件。

第二部分:速度与安全

Kafka的快,不是因为它自己做磁盘I/O做得有多高明,而是因为它让操作系统来替它做。生产者追加记录到一个分区的leader,broker将这些记录写到一个内存映射的页缓存区域,然后操作系统决定何时将它们刷到磁盘。读这边,消费者请求偏移量N之后的所有数据,broker直接从页缓存里把数据复制到网络套接字——如果数据还没被清出缓存的话,这里根本不会发生磁盘读。而零拷贝,借助sendfile()系统调用,让数据从文件描述符直接流到套接字描述符,连用户态内存都不用经过,这就把CPU拷贝量砍掉了一大截。

但速度归速度,持久性怎么办?如果你内存里有一大堆还没写入磁盘的数据,服务器断电了怎么办?Kafka的答案是:不是靠刷磁盘,而是靠复制。一个分区有一个leader和多个follower。生产者把消息发给leader,leader把这批消息写进自己的页缓存,并且同时把同一批数据推送给所有的follower,follower收到后也写进自己的页缓存。当leader看到足够的follower已经确认收到(在acks=all模式下是所有同步副本),它就向生产者报告这批消息已提交。如果leader挂了,一个同步副本会被选为新leader,而不会丢失已被确认的消息。fsync是昂贵的,它把连续性变成了随机波动。Kafka避开了这条路,把持久性问题转化成了一个复制问题,于是它就能在保持顺序写入的同时,继续跑得飞快。

第三部分:协调

前面的一切都依赖一个前提:集群自己知道谁是leader,谁还活着,配置是什么。这就是控制器和KRaft的用武之地。过去有一个单独的ZooKeeper集合负责维护这些元数据,但如今的Kafka已经转向了KRaft——KRaft将集群元数据作为一个内部主题来管理,由一个控制器法定人数通过共识来维护。当一个新的broker加入,它向控制器注册自己。当一个broker失联,控制器会重新分配它之前负责领导的分区,并告诉其他follower开始从新leader那里复制。当你想创建一个主题或改变配置,命令会发给控制器,控制器将它写进元数据日志,然后推送给集群里的所有broker。一切关于状态的决定,都是通过这个元数据日志按顺序广播的,这样每个broker都能独立得到同一个世界观。

第四部分:客户端契约

从生产者的视角看,acks设置就是那个决定你愿不愿意为速度牺牲持久度的旋钮。acks=0,生产者把消息发出去,不等任何确认,最快,但消息可能就在网络里丢了。acks=1,leader收到消息并写入本地日志后就确认,这快且相对安全,但如果leader在将数据复制出去之前挂掉,消息就没了。acks=all(或acks=-1),leader必须等所有同步副本都确认收到,才告诉生产者成功,这是最安全的,但延迟会增加,因为要等待最慢的那个follower跟上。

消费者这边,消费者组里的每个消费者负责一个或多个分区的消费。当一个消费者加入或离开组,就会触发一次重平衡:控制器命令整组暂停,重新为每个分区分配一个消费者,然后恢复。重平衡期间,不会有任何消息被处理。这就是为什么“静态成员”和增量协作式重平衡被引入,以减少服务中断。消费者通过提交偏移量到__consumer_offsets内部主题,来追踪自己的进度,下一次它醒来时,从上次提交的偏移量处接着读——这又回到了开篇那个根本约束:没有随机访问,只有从某个偏移量开始的顺序追赶。

第五部分:底层线路协议

最后,是连接所有这些组件的导线。Kafka使用一个二进制的、基于TCP的协议,它被设计成低开销且可流水线化。请求由一个API键、一个相关性ID和一个请求体组成。生产者用ProduceRequest向leader分区发送批量消息。消费者用FetchRequest拉取一批消息。控制器与broker之间用专门的API调用来交换元数据和状态。协议本身是可扩展的,新字段可以被追加到请求的尾部,老客户端只是忽略它们,这就让版本演进可以不中断服务。而请求的最前面总是有一个整数,告诉broker这一整块有多大,使得接收方能直接从网络缓冲区中切出完整的一个请求,而无需做任何复杂的解析。

所有这些决定——拒绝随机访问、用复制替代刷盘、将状态变化建模为不可变日志——最终都回溯到同一件事:Kafka在每一个层面上,都是一个日志优先的系统。生产者写日志,消费者读日志,broker复制日志,控制器也通过日志来协商集群状态。一旦你把一切都看作日志,你就不难理解它为什么能这样工作——以及为什么教程在讲完“分布式提交日志”以后,总有这么多东西还没说。