Kafka设计原理

Kafka设计原理

broker端设计架构

broker是Apache Kafka最重要的组件,本质上它是一个功能载体,承载了绝大多数的Kafka服务。一个broker通常是以服务器形式出现,broker的主要功能就是持久化消息以及将消息队列中的消息从发送端传输到消费端。Kafka broker负责持久化producer端发送消息,同时为consumer端提供消息。我们将从以下8个方面讨论Kafka broker端设计

  • 消息设计
  • 集群管理
  • 副本与ISR机制
  • 日志存储
  • 请求处理协议
  • controller设计
  • broker状态机
  • broker通信原理

消费设计

1.消息格式
使用什么数据结构来保存消息和消息队列是消息引擎第一个要解决的问题,即定义消息格式。如果使用Java类定义消息格式,可能会得到如下的代码

        package com.aim.kafka.client.message;

        import java.io.Serializable;
        import java.util.zip.CRC32;

        public class Message implements Serializable {
            private CRC32 crc;
            private short magic;
            private boolean codecEnabled;
            private short codecClassOrdinal;
            private String key;
            private String value;
        }

Message类由消息键和消息体再加上一组元数据字段组成,使用Java类来定义消息,会面临Java对象开销问题。在Java内存模型中保存对象开销其实相当大,有可能花费比消息大小大2倍空间来保存数据。为了降低这种开销,JMM通常会对用户自定义类进行重排,以减少内存占用,随着Java堆的数据越来越多,垃圾回收的性能下降得很快,从而整体上拖累应用程序的吞吐量

即使这样,这个Java类依然要占用40字节空间:16字节对象头部(64位JVM对象头部通常由两个8字节的word组成) + 2字节magic + 2字节code + 1字节boolean + 4字节CRC + 4字节String + 4字节String + 7字节补齐 = 40字节。其中7字节完全被浪费掉,这种朴素的实现方案效率十分低下,Kafka的消息格式不是这样实现,Kafka实现方式本质上是使用Java NIO的ByteBuffer来保存信息,同时依赖文件系统的页缓存机制,而非依赖Java的堆缓存。毕竟大部分情况下,我们在堆上保存的对象在写入文件系统后很可能在操作系统的页缓存中仍然保存着,从而造成资源浪费

ByteBuffer是二进制字节结构,不需要重排补齐字节,因此省去了很多不必要的对象开销,根据Kafka官网的测试数据,一台32G内存的机器上,kafka几乎可以用到28~30GB的物理内存而不用担心Java GC的糟糕性能

2.版本变迁

Kafka消息格式有3个版本:V0版本、V1版本和V2版本

(1)V0版本
V0版本主要指Kafka 0.10.0.0之前的版本,是Kafka最早的消息版本,消息格式如下

各字段含义如下:

  • CRC校验码: 4字节CRC校验码,用于确保消息在传输过程中不会被篡改
  • magic:单字节版本号,V0版本magic = 0, V1版本magic = 1, V2版本magic = 2
  • attribute:单字节属性字段,目前只使用低三位表示消息的压缩类型
  • key长度字段:4字节消息key长度信息,若未指定key,该字段赋值-1
  • key值:消息key,长度由上面的"key长度字段"指定,如果"key长度字段"是-1,则无key,消息没有该字段
  • value长度字段:4字节消息长度,若未指定value,则给该字段赋值-1
  • value值:消息value,长度由"value长度字段"指定,如果"value长度字段"为-1,则没有该消息字段

除去key值和value值之外的所有字段被统称为消息头部信息,总共占用14字节,也就是说,一条Kafka V0版本的消息无法小于14字节,否则被认为非法消息

attribute字段占用1字节即8位,目前V0版本只使用低3位来指定压缩类型,其他5位作为扩展,支持的压缩类型如下

  • 0x00:未启用压缩
  • 0x01:GZIP
  • 0x02:Snappy
  • 0x03:LZ4

(2)V1版本
随着Kafka功能的不断演进,V0版本消息实际使用过程中存在存在的一些显著弊端也逐渐显现出来

  • 由于没有消息的时间信息,Kafka定期删除过期日志只能依靠日志段文件的"最近修改时间",但这个时间易受外部操作的干扰,比如若将日志段文件执行UNIX的touch命令,该日志文件的最近修改时间就被更新,一旦这个时间被破坏或更改,Kafka将无法对哪些消息过期做出正确判断

  • 很多流式处理框架都需要消息保存时间信息以便对消息执行时间窗口等聚合操作

鉴于这些原因,社区于Kafka 0.10.0.0中改进了V0版本的消息格式,推出V1版本的格式,主要变化就是在消息中加入了时间戳字段。V1版本于V0版本的第一个差别就是引入8字节的时间戳字段,而其他字段的含义与V0版本相同。加入8字节的时间戳后,V1版本的消息头部膨胀到22字节。

V1版本与V0版本的第2个差别在于attribute字段使用,由于整加了时间戳信息,V1版本attribute字段的第4位被用于指定时间戳类型,当前支持两种时间戳类型:CREATE_TIME 和LOG_APPEND_TIME,前者表示在消息创建时由producer指定时间戳,后者表示消息被发送到broker端时由broker指定时间戳

Kafka消息层次分为两层:消息集合和消息。一个消息集合包含若干个日志项,而每个日志项都封装了实际的消息和一组元数据信息。Kafka日志文件就是由一系列消息集合日志构成。V2之前版本更多使用日志项,V2版本则使用消息批次。每个消息集合中日志项由一条浅层消息和日志项头部组成

  • 浅层消息:如果没有启用消息压缩,那么这条浅层消息就是消息本身,否则Kafka会将多条消息压缩到一起封装进这条浅层消息的value字段,此时浅层消息被称为包装消息,而value字段中包含的消息则被称为内部消息,V0、V1版本中的日志项只能包含一条浅层消息

  • 日志项头部:头部由8字节位移字段加4字节长度字段构成,这里的offset是指该消息在Kafka分区日志中的offset,如果未压缩消息,该offset就是消息的offset,否则该字段表示浅层消息中最后一条inner消息的offset,因此V0、V1版本消息集合日志项中搜寻日志起始位移是非常困难的,需要深度遍历所有inner消息,这也就意味着broker端需要解压缩操作

V0、V1版本消息集合在设计上的缺陷:

  • 空间利用率不高:不论key和value长度,总是使用4字节固定长度保存这部分信息,例如保存100或1000都使用4字节,但其实只需7位就足以,另外3字节则浪费掉了,如果每条发送几十亿条消息这将是一笔不小但开销

  • 只保存最新消息位移:启用压缩,offset是消息集合最后一条消息的offset,如果想要获取第一条消息的位移,则需要将所有消息解压缩装入内存,然后反向遍历才能获取

  • 冗余的消息级CRC校验:为每条消息执行CRC校验有些鸡肋,即使网络传输过程中没有恶意篡改数据,CRC值也可能发生变化,例如若用户指定时间戳类型为LOG_APPEND_TIME,broker将使用当前时间戳覆盖掉消息已有时间戳,当broker端对消息更新时间戳后,其CRC也需要重新计算发生变化。因此对CRC校验实际上没有必要,不仅浪费空间,还占用了宝贵的CPU时间片

  • 未保存消息长度:每次需要单条消息的总字节数信息时都需要计算出来,没有使用单独字段保存,每次计算时为了避免对现有数据结构的破坏,都需要大量的对象副本,解序列化效率很低

(3)V2版本
V2版本针对之前版本的问题,重构了消息和消息集合格式的定义。V2版本依然分为消息和消息集合两个维度,但消息集合的概念换成了消息批次
在这里插入图片描述
V2版本变化:

  • 可变长度: "可变长度"表示Kafka会根据具体的值来确定到底需要几字节保存,为降低序列化时使用的字节数,V2版本借鉴了Google ProtoBuffer中的Zig-zag编码方式,使得绝对值较小的整数占用比较少的字节。在生产环境中,key或value非常大的情况并不多见,尤其是key。用户一般使用一个具有业务含义的字符串来标识key,那么该字符串长度一般不会太长,这样在大部分情况下只用1字节就能保存,相比V0、V1版本中无差别使用4字节来保存要节省3字节

  • 增加消息总长度:在消息格式的头部增加该字段,一次性算出消息总字节数,保存在该字段,而不是每次重新计算,Kafka操作消息时可直接获取总字节数,创建等大小等ByteBuffer,总字节数引入还实现了消息遍历时的快速跳跃和过滤,省去了很多空间拷贝的开销

  • 保存时间戳增量:不再使用8字节来保存时间戳信息,而是使用一个可变长度保存与batch起始时间戳的差值,差值通常都很小,需要的字节数也很少,从而节省时间

  • 保存位移增量:保存消息位移与外层batch起始位移的差值,而不是固定8字节的位移值,进一步节省消息总字节数

  • 增加消息头部:V2版本中每条消息都必须有一个头部数组,里面的每个头部信息只包含两个字段:头部key和头部value,类型分别是String和byte[]。增加头部信息主要是为满足用户的一些定制化需求,比如集群间的消息路由或承载消息的一些特定元数据信息

  • 去除消息级CRC校验:V2版本不再对每条消息计算CRC32值,而是针对batch进行CRC校验

  • 废弃attribute字段:V2版本的消息正式废除这个字段,原本保存在attribute字段中的压缩类型、时间戳等信息都统一保存在外层的batch格式字段中,但V2版本依然保留单字节的attribute字段留作以后扩展使用

V2版本的消息batch格式
在这里插入图片描述
相较于V0、V1版本的日志项,V2版本的消息batch格式要复杂的多:

  • CRC值从消息层面被移除,被放入batch层

  • batch层面上增加一个双字节attribute字段,同时废除消息级别的attribute字段,在这个双字节attribute字段中,最低的3位依然保存压缩类型,第4位依然保存时间戳类型,而5、6位分别表存0.11.0.0版本新引入的事务类型和控制类型

  • PID、producer epoch和序列号等信息是0.11.0.0版本为实现幂等性producer和支持事务而引入,PID表示一个幂等性producer的ID值,
    producer epoch表示某个PID携带的当前版本号,broker使用PID和epoch来确定当前合法的producer实例,并以此阻止过期producer向broker生产消息。序列号的引入主要是为实现消息生产的幂等性,Kafka依靠它来辨别消息是否已成功提交,从而防止出现重复生产消息

  • V2版本的消息batch开销增加到61字节,看上去似乎比V0、V1版本的增加不少,但V2版的batch允许包含多条Kafka消息,因此这些新字段的开销实际上摊到底层的每条消息上,故而总体上反而节省了空间

在0.11.0.0版本及之后的Kafka消息在支持事务、幂等性producer的同时还一定程度上减少了网络I/O和磁盘I/O开销

集群管理

1.成员管理
Kafka是分布式的消息引擎集群环境,支持自动化的服务发现与成员管理,Kafka是依赖Apache ZooKeeper实现的,每当一个broker启动时,会将自己注册到ZooKeeper下的一个节点

每个broker在ZooKeeper下注册节点的路径是chroot/brokers/ids/<broker.id>,如未配置chroot,则路径为/brokers/ids/<broker.id>。是否配置了chroot取决于server.properties中zookeeper.connect参数是否设置了chroot。broker向ZooKeeper中注册的信息以JSON格式保存,

其中包括的信息如下:

  • listener_security_protocol_map: 此值指定了该broker与外界通信所用的安全协议类型。Kafka当前支持broker使用不同的安全协议类型与clients和其他broker通信,用户可以配置成与公网clients通信使用SSL传输数据,与内网broker通信通信使用PLANTEXT

  • endpoints:指定broker的服务endpoint列表,每个endpoint指明了传输协议类型、broker主机名和端口信息,endpoint可以配置多个,每种协议类型都可以配置一个对应的endpoint,只是端口号不能冲突

  • rack:指定broker机架信息。和hadoop机架感知原理类似,若设置了机架信息,Kafka在分配分区副本时会考虑把某个分区的多个副本分配在多个机架上。这样即使某个机架上的broker全部奔溃,也能保证其他机架上的副本可以正常工作

  • jmx_port:broker的JMX监控端口,需要在启动broker前设置JMX_PORT环境变量。设置JMX端口后各种支持JMX的监控框架,如Zabbix等才可以实现获取Kafka提供的各种broker端监控指标

  • host:broker主机名或IP地址

  • port:broker服务端口号

  • timestamp:broker启动时间

  • version:broker当前版本号,broker注册信息的版本

Kafka开源以来总共提供4个版本的broker信息,当前最新版本号为4。ephemeralOwner值不为0,表示该broker是ZooKeeper中的临时节点,ZooKeeper临时节点生命周期和客户端会话绑定,如果客户端会话失效,该临时节点会自动被清除掉,Kafka正是利用ZooKeeper临时节点来管理broker生命周期,broker启动时在ZooKeeper中创建对应的临时节点,同时还会创建一个监听器监听该临时节点的状态;一旦broker启动后,监听器会自动同步整个集群信息到broker上;一旦broker崩溃,与ZooKeeper的会话就会失效,导致临时节点被删除,监听器被触发,处理broker奔溃后续事宜,这就是Kafka管理集群及其成员的主要流程

ZooKeeper路径

新版本producer和consumer都已经不再需要连接ZooKeeper,但Kafka还是重度依赖于ZooKeeper。一旦ZooKeeper服务挂掉,Kafka集群的很多组件就无法正常工作,掌握Kafka使用到的各个ZooKeeper路径对于深入理解Kafka有很大好处
在这里插入图片描述

  • /brokers:里面保存了Kafka集群的所有信息,包括每台broker的注册信息,集群上所有topic的信息等

  • /controller:保存Kafka controller组件(controller负责集群的领导者选举)的注册信息,同时也负责controller的动态选举

  • /admin:保存管理脚本的输出结果,比如删除topic,对分区进行重分配等操作

  • /isr_change_notification:保存ISR列表发生变化的分区列表。controller会注册一个监听器实时监控该节点下子节点的变更

  • /config:保存了Kafka集群下各种资源的定制化配置信息,比如每个topic可能有自己专属的一组配置,就保存在/config/topics/下

  • /cluster:保存Kafka集群的简要信息,包括集群的ID信息和集群版本号

  • /controller_epoch:保存了controller组件的版本号。Kafka使用该版本号来隔离无效的controller请求

副本与ISR设计

一个Kafka分区本质上是一个备份日志,利用多份相同的备份共同提供冗余机制来保持系统高可用性,这些备份在Kafka中被称为副本。Kafka把分区的所有副本均匀地分配到所有broker上,并从这些副本中挑选一个作为leader副本对外提供服务,而其他副本被称为follower副本,只能被动地向leader副本请求数据,从而保持与leader副本的同步

一旦Kafka leader副本所在的broker发生宕机,follower副本会竞相争夺成为新leader的权力,但不是所有的follower都有资格去竞选leader。follower被动地向leader请求数据,对于那些落后leader进度太多的follower而言,没有资格竞选leader,因为这样的follower副本成为leader,会导致数据丢失,这对于clients是灾难性的,为此Kafka引入ISR的概念

所谓ISR,就是Kafka集群动态维护一组同步副本集合,每个topic分区都有自己的ISR列表,ISR中的所有副本都与leader保持同步状态,leader副本总是包含在ISR中,只有ISR中的副本才有资格被选举为leader,而producer写入的一小Kafka消息只有被ISR中所有副本都接收到,才能视为"已提交",若ISR有N个副本,那么该分区最多可以容忍N-1个副本奔溃而不丢失已提交信息

1.follower副本同步
在这里插入图片描述
follower副本只做一件事情就是向leader副本请求数据:

  • 起始位移(base offset):表示该副本当前所含第一条信息的offset

  • 高水印值(high watermark,HW):副本高水印值,保存该副本最新一条已提交消息的位移,leader分区的HW值决定来副本中已提交消息的范围,也确定了consumer能够获取的消息上限,超过HW值的所有消息都被视为"未提交成功的",因而consumer无法消费。并不是只有leader副本才有HW值。实际上每个follower副本都有HW值,只不过只有leader副本的HW值才能决定clients能看到的消息数量

  • 日志末端位移(log end offset,LEO):副本日志中下一条待写入消息的offset。所有副本都需要维护自己的LEO信息,每当leader副本接收到producer端推送的消息,会更新自己的LEO(通常加1)。follower副本向leader副本请求到数据后也会增加自己的LEO,事实上只有ISR中的所有副本都更新了对应的LEO之后,leader副本才会向右移动HW值表明消息写入成功
    在这里插入图片描述
    假设Kafka集群当前只有一个topic,该topic只有一个分区,分区共有3个副本,因此ISR也是这3个副本,该topic没有任何数据,因此3个副本的LEO都是0,HW值是0。现在一个producer向broker1所在leader副本发送一条消息,会触发以下流程

  • broker1上的leader副本接收到消息,把自己的LEO值更新为1

  • broker2和broker3上的follower副本各自发送请求给broker1

  • broker分别把消息推送给follower副本

  • follower副本接收到消息后各自更新自己的LEO为1

  • leader副本接收到其他follower副本的数据请求响应后,更新HW值为1,此时位移为0的这条消息可以被consumer消费

对于acks=-1的producer,只有完整地做完上面所有5步操作,producer才能正常返回,也标志这条消息发送成功
2. ISR设计
不同Kafka版本对ISR的界定方法不同,大体上可分为0.9.0.0版本之前和0.9.0.0版本之后两种方法。

(1)0.9.0.0版本之前
0.9.0.0版本之前,Kafka提供一个参数replica.lag.max.messages,用于控制follower副本落后leader副本的消息数,一旦超过这个消息数,则视为该follower为不同步状态,从而需要被Kafka踢出ISR

导致follower与leader不同步的原因有以下3种:

  • 请求速度追不上:follower副本在一段时间内都无法追上leader副本端的消息接收速度。比如follower副本所在broker的网络I/O开销过大导致备份消息的速度持续慢于从leader获取消息的速度

  • 进程卡住:follower在一段时间内无法向leader请求数据,比如频繁GC或程序bug等

  • 新创建的副本:如果用户增加了副本数,那么新创建的follower副本在启动后全力追赶leader进度。在追赶进度这段时间内通常都是与leader不同步

在0.9.0.0版本之前除了replica.lag.max.message参数从落后消息数量上控制是否踢出ISR外还有另一个参数replica.lag.time.max.ms用于检测follower副本在规定时间内如果不能向leader请求数据则认为不同步。0.9.0.0版本之前的这种ISR方案在设计上存在一些固有缺陷,对于replica.lag.max.message如果要正确设置值,需要用户结合具体使用场景评估,没有统一的设置方法。对于一个参数的设置,有一点很重要:用户应该对他们知道的参数进行设置,而不是对他们需要进行猜测的参数进行设置。假设replica.lag.max.messages=4,当producer程序突然发起一波消息生产的瞬时高峰流量,比如一次性发送4条消息,消息数与
replica.lag.max.message值相等,此时follower副本会被认为与leader副本不同步,从而被踢出ISR,但follower副本其实处于存活状态且没有任何性能问题,下次FetchRequest就能追上leader的LEO,重新加入ISR。并且这个状态会不断的重复。由于replica.lag.max.message是全局设置,所有topic都受到这个参数影响。假设集群中有两个topic:t1和t2,若他们的流量差距非常大,t1的消息生产者一次性生产5000条消息;而另一个topic t2,它的生产者一次生产10条消息,那么Kafka就需要相当长的时间才能辨别t2各个分区中那些滞后的副本,一旦出现有broker崩溃的情况,极易造成状态的不一致

(2)0.9.0.0之后
0.9.0.0版本之后,Kafka去掉replica.lag.max.messages参数,只通过replica.lag.time.max.ms参数控制follower是否不同步。同时检测机制也发生了变化,如果一个follower副本落后leader的时间持续性地超过这个参数值,那么该follower就是不同步的

水印和leader epoch

水印也被称为高水印或高水位,通常被用在流式处理领域,以表征元素或事件在基于时间层面上的进度,流式系统保证在高水印t时刻,创建时间=t’且t’ <= t的所有事件都已经到达或被观测到。Kafka中水印概念与时间无关,与位置信息相关,表示的是位置信息,即位移,源码中表述为highwatermark

一个Kafka分区通常存在多个副本用于实现数据冗余,进一步实现高可用性。副本根据角色不同分为如下3类

  • leader副本:响应clients端读/写请求的副本

  • follower副本:被动备份leader副本上的数据,不能响应clients端的读/写请求

  • ISR副本集合:包含leader副本和所有与leader副本保持同步的follower副本

每个Kafka副本对象都持有两个重要属性:日志末端位移(LEO)和高水印(HW),并不是只有leader副本才有

  • LEO:日志末端位移,记录该副本对象底层日志文件中下一条位移值,若LEO=10,表示该副本上已经保存10条消息,位移范围是[0~9],Kafka对leader副本和follower副本的LEO更新机制不同

  • HW:任何副本对象的HW值一定不大于其LEO值,小于等于HW值的所有消息都被认为是"已提交"或"已备份",kafka对leader副本和follower副本的HW值更新机制是不同的

  1. LEO更新机制
    follower副本只是被动地向leader副本请求数据,具体表现为follower副本不停的向leader副本所在broker发送FETCH,一旦获取信息,便写入自己的日志中进行备份。Kafka设计了两套follower副本LEO属性:一套LEO值保存在follower副本所在broker的缓存上;另一套LEO值保存在leader副本所在broker缓存上,因此leader副本所在机器的缓存上保存了该分区下所有follower副本的LEO属性值

    1. follower副本端的follower副本LEO更新

      follower副本端的follower副本LEO值就是指该副本对象底层日志的LEO值,每当新写入一条消息,其LEO值会加1,在follower发送FETCH请求后,leader将数据返回给follower,此时follower开始向底层log写数据,从而自动更新其LEO值

    2. leader副本端的follower副本LEO更新

      leader副本端的follower副本LEO更新发生在leader处理follower FETCH请求时,一旦leader接收到follower发送的FETCH请求,首先会从自己的 log中读取相应的数据,但是在给follower副本返回数据之前会先更新follower的LEO

    和follower更新LEO道理相同,leader写log时会自动更新自己的LEO值

  2. HW更新机制
    follower更新HW发生在更新LEO之后,一旦follower向log写完数据,就会尝试更新HW值,具体算法就是比较当前LEO值与FETCH响应中leader的HW值,取两者的小者作为新的HW值。如果follower的LEO值超过了leader的HW值,那么follower HW值是不会越过leader HW值

    以下4种情况leader会尝试更新分区HW值,但有可能因为不满足条件而不做任何更新

  • 副本成为leader副本时:当某个副本成为分区leader副本,Kafka会尝试更新分区HW,毕竟leader发生变更,这个副本状态是一定要检查的

  • broker出现奔溃导致副本被踢出ISR时:若有broker崩溃,则必须查看是否会波及此分区,因此检查分区HW值是否需要更新是必要的

  • producer向leader副本写入消息时:因为写入消息会更新leader的LEO,故有必要再查看HW值是否需要更新

  • leader处理follower FETCH请求时:当leader处理follower的FETCH请求时,首先从底层log读取数据,之后尝试更新分区HW值当Kafka broker都正常工作时,分区HW值的更新时机有两个:leader处理PRODUCE请求时和leader处理FETCH请求时。leader broker上保存了一套follower副本的LEO以及它自己的LEO,当尝试确定分区HW时,会选出所有满足条件的副本,比较他们的LEO,也包括leader自己的LEO,并选择最小的LEO值作为HW值,这里的满足条件指以下两个条件之一

  • 处于ISR中

  • 副本LEO落后于leader LEO的时长不大于replica.lag.time.max.ms参数值,默认值为10秒某些情况下Kafka可能出现副本已经追上leader的进度,但却不在ISR中的情况,比如某个从failure中恢复的副本。如果Kafka只判断第一个条件。确定分区HW值时就不会考虑这些未在ISR中的副本,但这些副本已经具备立刻进入ISR的资格,因此可能出现分区HW值越过ISR中副本LEO的情况,这肯定是不允许的,因为分区HW实际上就是ISR中所有副本LEO的最小值

  1. 图解Kafka备份原理
    假设有一个测试主题,单分区,副本因子是2,即一个leader副本和一个follower副本,当生产者发送一条消息时,broker端的副本到底会发生什么事情以及分区HW如何被更新
    在这里插入图片描述
    初始时leader以及follower的HW和LEO都是-1。leader中的remote LEO指leader端保存的follower LEO,也被初始化为-1,producer没有发送任何消息给leader,而follower已经开始不断地给leader发送FETCH请求,但因为没有数据,因此什么都不会发生。follower发送过来的FETCH请求因为无数据而暂时被寄存到leader端的purgatory中,等待500毫秒(replica.fetch.wait.max.ms参数)超时后会强制完成,若在寄存期间producer端发送过来数据,kafka会自动唤醒该FETCH请求,让leader继续处理

    purgatory是Kafka暂存请求对象的地方,有些请求由于各种各样的原因无法立即被处理,会被Kafka放入purgatory中。purgatory中暂存的FETCH和PRODUCE
    请求的处理时机会影响HW值的更新

    (1)情况1:leader副本写入消息后follower副本发送FETCH请求
    在这里插入图片描述
    leader接收到生产消息的请求后,主要做如下两件事情

    1)写入消息到底层日志,同时更新leader副本的LEO属性

    2)尝试更新leader副本的HW值。而此时follower尚未发送FETCH请求,那么leader端保存的remote LEO依然是-1,因此leader会比较它自己的LEO值和remote LEO值,发现最小值是0,与当前HW值相同,故不会更新分区HW值

    写入消息成功后,leader端HW值依然是0,而LEO是1,remote LEO是1。假设此时follower发送了FETCH请求或者follower早已发送了FETCH请求,只不过在broker的请求队列中排队。leader端的处理逻辑依次如下

    1)读取底层log数据

    2)更新remote LEO = 0:因为follower还没有写入这条消息,因此remote LEO为0。leader通过follower发送的FETCH请求中的fetch offset来确定

    3)尝试更新分区HW:此时leader LEO = 1, remote LEO = 0,故分区HW值 = min(leader LEO, follower LEO) = 0

    4)把数据和当前分区HW值(依然为0)发送给follower副本

    follower副本接收到FETCH response后依次执行下列操作

    1)写入本地log(同时更新follower LEO)

    2)更新follower HW:比较本地LEO和当前leader HW后取较小值,故follower HW = 0

    此时,第一轮FETCH请求处理结束,虽然leader和follower都已经在log中保存这条消息,但分区HW值尚未被更新,实际这是在第二轮 RPC中被更新
    在这里插入图片描述
    follower发来第二轮FETCH请求,leader端接收到后仍然会执行下列操作

    1)读取底层log数据

    2)更新remote LEO = 1

    3)尝试更新分区HW:此时leader LEO = 1,remote LEO = 1,故分区HW值 = min(leader LEO,follower remote LEO) = 1

    4)把数据(实际上没有数据)和分区HW值发送给follower副本

    follower副本接收到FETCH response后依次执行下列操作

    1)写入本地log,但没有任何数据可写,follower LEO仍然为1

    2)更新follower HW:比较本地LEO和当前leader HW值取最小值,故更新HW值为1

    此时消息已经被成功拷贝到leader和follower的log中且分区HW是1,表明consumer能够消费offset = 0这条消息,这便是kafka处理消息写入及消息备份的流程

    (2)情况2:FETCH请求保存在purgatory中时生产者发送消息

    当leader无法立即满足FETCH返回要求时,比如没有数据返回,该FETCH会被暂存到leader端的purgatory中,待时机成熟时尝试再次处理,不过 Kafka不会无限期缓存着,默认会有一个超时时间(500毫秒),一旦超过时间,则请求会被强制完成。我们要讨论的场景是在寄存期间,producer发送 PRODUCE请求,从而使之满足条件而被唤醒,此时leader端处理流程如下:

    1)leader写入本地log,同时自动更新leader LEO

    2)尝试唤醒在purgatory中寄存的FETCH请求

    3)尝试更新分区HW

    唤醒后FETCH请求处理与第一种情况一致。以上分析说明一个事实:Kafka使用HW值来决定副本备份的进度,而HW值的更新通常需要另一轮FETCH请求才能完成,但这种设计是有缺陷的,可能造成如下问题:

    1)备份数据丢失

    2)备份数据不一致

  2. 基于水印备份机制的缺陷
    在0.11.0.0版本之前,Kafka一直使用基于水印的备份机制,但经过上面分析这种机制可能会引起两方面问题

    (1) 数据丢失
    使用HW值来确定备份进度时其值的更新是在下一轮RPC中完成,设想一下这样的场景:某follower发送了第二轮的FETCH请求给leader,在接收到响应之后,会首先写入本地日志,假设没有数据可写,故follower LEO不会发生变化,之后follower副本准备更新其HW值,此时发生故障。follower副本发生崩溃,这时可能造成数据丢失

    假设有两个副本A和B,开始状态为A是leader,生产端参数min.insync.replicas设置为1,当生产者发送两条消息给A后,A写入底层log,此时Kafka会通知生产者消息已成功写入。但broker端,leader和follower底层log虽都写入了2条消息且分区HW已经被更新到2,但follower HW尚未被更新。若此时副本B所在broker宕机,那么重启B会自动把LEO调整到之前的HW值,故副本B会做日志截断,将offset=1的那条消息从log中删除,并调整LEO=1,此时follower副本底层log中只有1条消息,即offset=0的消息

    B重启之后需要给A发FETCH请求,但若A所在broker机器宕机,那么Kafka会令B成为新的leader,而当A重启回来后也会执行日志截断,将HW调整为1,这样位移为1的消息就从两个副本log中被删除,永远消息

    该场景丢失数据前提是min.insync.replicas=1,一旦消息被写入leader端,log即被认为已提交,而延迟一轮FETCH请求更新HW值的设计使得follower HW值是异步延迟更新,若在这个过程中leader发生变更,那么成为新leader的follower的HW值就有可能过期。使得clients端认为成功提交的消息被删除

    (2)数据不一致/数据离散
    除数据丢失外,还可能造成leader端日志和follower端log的数据不一致,即数据离散问题。假设leader端保存的消息顺序是r1、r2、r3、r4、r5…,而follower端保存消息顺序可能是r1、r3、r4、r5、r6…。

    这种情况的初始状态与数据丢失场景有些许不同:A依然是leader,A的log写入了2条消息,但B的log只能写入1条消息。分区HW更新到2,但B的HW还是1,同时生产端的min.insync.replicas=1

    如果A和B所在机器同时挂掉,假设B先重启回来,B将成为leader,分区HW = 1,假设此时producer发送了第三条消息给B,于是B的log中offset = 1的消息变成了第三条消息,同时分区HW更新到2,因为A还没有回来,只有B一个副本,故可以直接更新HW而不用理会A,之后A重启回来,需要执行日志截断,但发现此时分区HW = 2,而A之前的HW值也是2,故不做调整,此后A和B将以这种状态继续正常工作

    显然,在这种场景下,A和B底层log中保存在offset = 1的消息是不同的记录,从而引发不一致的情形出现

  3. 0.11.0.0版本解决之道

    Kafka社区于0.11.0.0版本中正式引入leader epoch值彻底解决了基于水印备份机制的这两个弊端

    对于0.11.0.0版本之前的两个问题,根本原因是HW值被用于衡量副本备份的成功与否,以及在出现崩溃时作为日志截断的依据,但HW值的更新是异步延迟的,特别是需要额外的FETCH请求处理流程才能更新,故这中间发生的任何奔溃都可能导致HW值的过期。鉴于这些原因,Kafka 0.11.0.0引入了leader epoch来取代HW值。leader端多开辟一段内存区域专门保存leader的epoch信息。这样即使出现上面的场景,Kafka也能很好地规避这些问题

    领导者epoch即leader epoch,实际上是一对值(epoch, offset)。epoch表示leader的版本号,从0开始,当leader变更过1次时,epoch就会加1,而offset则对应于该epoch版本的leader写入第一条消息的位移。假设存在两个值(0,0)和(1,120),那么表示第一个leader从位移0开始写入消息,共写入120条,即[0, 119];而第二个leader版本号为1,从位移120处开始写入消息

    每个leader broker会保存这样一个缓存,并定期写入一个检查点文件中,当leader写底层log时,它会尝试更新整个缓存,如果这个leader首次写消息,则会在缓存中增加一个条目,否则就不做更新。每次副本重新成为leader时会查询这部分缓存,获取对应leader版本的位移,这就不会发生数据不一致和丢失的情况

规避数据不一致
在这里插入图片描述
规避数据不一致
在这里插入图片描述

日志存储设计

Kafka日志

Kafka日志只能按照时间顺序在日志尾部追加写入记录,Kafka并不是直接把原始消息写入日志文件,而是将消息和一些必要元数据信息打包在一起封装成一个record写入日志,这里的record就是之前提到的消息集合或消息batch。日志记录按照被写入顺序保存,读取日志按时间先后读取,每条记录都被分配一个唯一且递增的记录号,即位移信息,Kafka定义了自己的消息格式并且在写入日志前序列化成紧凑的二进制字节数组来保存日志

日志中记录的排序通常时间顺序,位于日志左边的记录发生时间通常要小于右边部分的记录。Kafka自0.10.0.0版本开始在消息体中增加了时间戳信息,默认情况下消息创建时间会被封装进消息中,Kafka记录大部分遵循按时间顺序这一规则。但Kafka的Java版本producer支持用户为消息指定时间戳,用户可以打乱这种时间顺序,不过这样时间戳索引文件可能会失效,实际中没有太多的使用场景

Kafka日志设计是以分区为单位的,即每个分区都有它自己的日志,该日志被称为分区日志。producer生产Kafka消息时需要确定该消息被发送到的分区,然后Kafka broker把该消息写入该分区对应的日志中
在这里插入图片描述
具体每个日志而言,Kafka又将进一步细分成日志段文件以及日志段索引文件,每个分区日志都是由若干组日志段文件+索引文件构成
在这里插入图片描述
该分区下有1组日志段,.log后缀的文件就是日志段文件,而.index和.timeindex文件都是与日志段对应的索引文件

底层文件系统

创建topic时,Kafka为该topic的每个分区在文件系统中创建了一个对应的子目录,名字就是-<分区号>,比如有一个topic为test,分区数为2,那么在文件系统中Kafka会创建两个子目录:test-0和test-1,每个子目录文件构成都是若干组日志段+索引文件

日志段文件,即后缀是.log的文件保存着真实的Kafka记录。每个.log文件都包含一段位移范围的Kafka记录,Kafka使用该文件第一条记录对应的offset来命名此.log文件,因此每个新创建的topic分区一定有offset是0的.log文件,即00000000000000000000.log。在Kafka内部offset是用64位来保存,但对于日志段文件而言,Kafka只使用20位数字来标识offset

Kafka每个日志段文件是有上限大小的,由broker端参数log.segment.bytes控制,默认是1GB大小。当日志段文件填满记录后,Kafka会自动创建一组新的日志段文件和索引文件,这个过程被称为日志切分,日志切分后,新的日志文件将被创建并开始担任保存记录的角色

一旦日志段文件被填满,就不能再追加写入新消息,而Kafka正在写入的分区日志段文件被称为当前激活日志段或简称为当前日志段。当前日志段非常特殊,不受任何Kafka后台任务的影响,比如定期日志清楚任务和定期日志compaction任务

索引文件

除了.log文件,Kafka分区日志还包含两个特殊的文件.index和.timeindex,他们都是索引文件,分别被称为位移索引文件和时间戳索引文件。位移索引文件可以帮助broker更快地定位记录所在的物理文件位置,而时间戳索引文件则是根据给定的时间戳查找对应的位移信息.

.index和.timeindex文件都属于稀疏索引文件,每个索引文件都由若干条索引项组成。Kafka不会为每条消息都保存对应的索引项,而是待若干条记录后才增加一个索引项。broker端参数log.index.interval.bytes设置了这个间隔到底多大,默认值是4KB,即Kafak分区至少写入4KB数据后才会在索引文件中增加一个索引项,故本质上他们是稀疏的

不论是位移索引文件还是时间戳索引文件,它们中的索引项都按照某种规律进行升序排列。对于位移索引文件而言,它是按照位移顺序保存的;而时间戳索引文件则严格按照时间戳顺序保存。有了这种升序规律,Kafka可以利用二分查找算法来搜寻目标索引项,从而降低整体时间复杂度到O(lgN)。若没有索引文件,Kafka搜寻记录的方式只能是从每个日志段文件的头部顺序扫描,因此这种方案的时间复杂度是O(N),显然引入索引文件可以极大地减少查找时间,减少broker端的CPU开销

索引文件支持两种打开方式:只读模式和读/写模式。对于非当前日志段而言,其对应的索引文件通常以只读方式打开,即只能读取索引文件中的内容而不能修改它。当前日志段的索引文件必须要能被修改,因此总是以读/写模式打开。当日志进行切分时,索引文件也需要进行切分。因此Kafka会关闭当前正在写入的索引文件,同时以读/写模式创建一个新的索引文件。broker端参数log.index.size.max.bytes设置索引文件的最大文件大小,默认值是10MB。由于索引文件的空间默认是预先分配好的,当索引文件切分时,Kafka会把该文件大小"裁剪"到真实的数据大小

位移索引文件

在这里插入图片描述
每个索引项固定地占用8字节物理空间,同时Kafka强制要求索引文件必须是索引项大小的整数倍,即8的整数倍,假设用户设置参数log.index.size.max.bytes为300,那么Kafka内部会将该文件大小调整为296,即不大于300的最大的8的倍数。

其中相对位移是与索引文件起始位移的差值。索引文件名中的位移就是该索引文件的起始位移。通过保存差值,我们只需要4字节而不用保存整个位移的8字节。假设索引文件名为0000000000000000050.index,起始位移则为50,位于55位移的消息在索引项中的相对位移就是5。因此只保存相对位移可以节省很多磁盘空间,之后在获取索引项时还需要把Kafka相对位移还原成绝对位移

位移索引文件会强制保证索引项中的位移都是升序排列,这种顺序性提升了查找的性能。有了位移索引文件,broker可以根据指定位移快速定位到记录的物理文件位置,或至少定位出离目标记录最近的低位文件位置。即使从该位置处扫描日志段文件,也要必从头扫描代价小得多
在这里插入图片描述
假设broker端要查找位移为7000的消息,首先在位移索引文件中根据二分查找算法找到小于7000的最大索引项:2650,1150100,然后Kafka在对应的.log文件中找到第1150100字段处开始顺序搜寻,直至找到位移为7000的消息记录。如果想增加索引项的密度,可以减少broker端参数log.index.interval.bytes的值

时间戳索引文档

在这里插入图片描述
自Kafka 0.10.0.0版本在消息中加入时间戳信息后,很多用户想要查找某段时间内的消息记录,为此Kafka引入时间戳索引文件,每个索引项固定占用12字节物理空间,Kafka强制要求索引文件必须是索引项大小的整数倍,即12的整数倍。当log.index.size.max.bytes设置大小不为12的整数倍时,Kafka会强制文件大小为小于设置参数值的12的最大倍数。时间戳索引项保存的也是相对位移

时间戳索引项保存的是时间戳与位移的映射关系,给定时间戳后根据时间戳索引文件只能找到不大于该时间戳的最大位移,之后Kafka还需要根据时间戳返回的位移去索引文件中定位真实的物理文件位置。时间戳索引文件的时间戳一定是按照升序排列。若消息R2在日志段中位于R1之前,但R2的时间戳小于R1(因为producer允许用户指定时间戳),那么R2这条消息是不会被记录在时间戳索引项中,因为会造成时间乱序,Kafka目前无法调整这种时间错乱的情况,而缺乏对应的索引项也是的clients根据时间戳查找消息的结果不能完全准确,因此在实际场景中不推荐producer端直接手动指定时间戳

日志留存

Kafka会定期清除日志文件,清除的单位是日志段文件,即删除符合清除策略的日志段文件和对应的两个索引文件。当前留存策略如下:

  • 基于时间的留存策略:Kafka默认会清除7天的日志数据包括索引文件,Kafka提供3个broker端参数,其中log.retention.{hours|minutes|ms}用于配置清除日志的时间间隔,其中ms优先级最高,minutes次之,hours优先级最低

  • 基于大小的留存策略:Kafka默认只会为每个log保存log.retention.bytes参数值大小的字节数。默认值是-1,表示Kafka不会对log进行大小方面的限制

日志清除是一个异步过程,Kafka broker启动后会创建单独的线程处理日志清除事宜。日志清除对于当前日志段是不生效的,Kafka永远不会清除当前日志段。若用户把日志段文件最大文件的大小设置得过大而导致没有出现日志划分,那么日志段清除也就永远无法执行

在基于时间的清除策略中,0.10.0.0版本之前Kafka使用日志段文件的最近修改时间来衡量日志段文件是否依然在留存时间窗口中,但文件的最近修改时间属性经常可能无意修改,比如执行touch操作,因此在0.10.0.0版本引入时间戳字段之后,该策略会计算当前时间戳与日志段首条消息的时间戳之差来衡量日志段是否留存的依据。如果第一条消息没有时间戳信息,Kafka才会使用最近修改时间属性

日志compaction

前面所说的所有topic都有这样一个共性:clients端需要访问和处理这种topic下的所有消息,考虑这样一种场景,某个Kafka topic保存的是用户的邮箱地址,每次用户更新邮箱地址都会发送一条Kafka消息。该消息的key就是用户ID,而value保存邮箱地址信息。假设用户ID为user123的用户连续修改了3次邮箱地址,那么就会产生3条对应的Kafka消息

(1)user123 => user123@kafka1.com

(2)user123 => user123@kafka2.com

(3)user123 => user123@kafka3.com

这种情况下用户只关心最近修改的邮件地址,即value是user123@kafka3.com的信息,之前的其他消息都是过期的,可以删除,但前面提到的清除策略都无法实现这样的处理逻辑,为此Kafka社区引入了log compaction

log compaction确保Kafka topic每个分区下的每条具备相同key的消息都至少保存最新value消息,它提供了更细粒度化的留存策略,这也说明了如果使用log compaction,Kafka消息必须设置key,无key消息是无法为其进行压实操作,典型的log compaction场景如下

  • 数据库变更订阅:用户通常在多个数据系统存有数据,如数据库、缓存、查询集群和Hadoop集群等,对数据库的所有变更都需要同步到其它数据系统中。同步的过程中用户没必要同步所有数据,只需同步最近的变更或增量变更

  • 时间溯源:编织查询处理逻辑到应用设计中并使用变更日志保存应用状态

  • 高可用日志化:将本地计算进程的变更实时记录到本地状态中,以便在出现崩溃时其它进程可以加载该状态,从而实现整体上的高可用
    在这里插入图片描述
    为实现log compaction,Kafka在逻辑上将每个log划分成log tail和log head。log head和普通的Kafka log没有区别,事实上其就是Kafka log的一部分在log head中所有offset都是连续递增的,log tail中消息的位移则是不连续的,它已经是压实之后的消息集合。图中log tail部分位移为17和22的消息彼此相邻,这表明是已经compaction过,位移18、19、20、21的4条消息都被移除了。log compaction只会根据某种策略有选择性地移除log中的消息,而不会变更消息的offset值

Kafka有一个组件叫Cleaner,它负责执行compaction操作,Cleaner负责从log中移除已废弃的消息。再次强调一下,如果一条消息key是K,位移是o,只要日志中存在另外一条消息,key也是K,但位移是o’其o<o’,即认为前面那条消息已被废除

log compaction是topic级别的设置,一旦为某个topic启用log compaction,Kafka会将该topic的日志在逻辑上划分成两部分:"已清理"部分和"未清理"部分,后者又可划分为"可清理"部分和"不可清理"部分

"不可清理"部分无法被Kafka Cleaner清理,当前日志段永远属于"不可清理"部分,当前Kafka使用一些后台线程定期执行正在的清除任务,每个线程会挑选出"最脏"的日志段执行清理,衡量一个日志段"脏"的程度使用"脏"日志部分与总日志部分大小比率

在内部,Kafka会构造一个哈希表来保存key与最新位移的映射关系,当执行compaction时,Clean不断拷贝日志段中的数据,只不过他无视哪些key存在于
哈希表中但具有较大位移值的消息
在这里插入图片描述
key为K1的消息总共有3条,经过compaction之后log只保存Value=v4的那条消息,因为该消息位移最高,其他消息被视为"可清理"

当前与compaction相关的Kafka参数如下

  • log.cleanup.policy: 是否启用log compaction。0.10.1.0版本之前只有两种取值,即delete和compact。其中delete是默认值,表示采用之前所说的留存策略;设置compact则表示启用log compaction。自0.10.1.0版本开始,该参数支持同时指定两种策略,如log.cleanup.policy=delete,compact,表示既为该topic执行普通留存策略,也对其进行log compaction

  • log.cleaner.enable: 是否启用log Cleaner,在0.9.0.0之前的版本中该参数默认是false,即不启用compaction,自0.9.0.1版本之后该参数默认为true。如果要使用log compaction,需要设置此参数为true,并且同时设置log.cleanup.policy=compact

  • log.cleaner.min.compaction.lag.ms: 默认值是0,表示除当前日志段,理论上所有的日志段都属于"可清理"部分,用户可以设置此参数来保护那些比某个时间新的日志段不被清理。假设设置此参数为10分钟,当前时间下午1点,那么所有最大时间戳(通常最后几条消息的时间戳)在12:50之后的日志段都不可清理

Kafka新版本consumer使用_consumer_offsets内部topic来保存位移信息,这个topic就是采用log compaction留存策略,对于每一个key(通常是groupId + topic + 分区号),我们最关心的就是位移值

通信协议

协议设计

通信协议,就是实现client-server间或server-server间数据传输的一套规范。Kafka通信协议是基于TCP之上的二进制协议,这套协议提供的API表现为服务于不同功能的多种请求类型以及对应的响应。所有类型的请求和响应都是结构化的,由不同的初始类型构成,Kafka使用这组协议完成各个功能的实现

Kafka客户端与broker传输数据时,首先需要创建一个连向特定broker的socket连接,按照待发送请求类型要求的结构构造响应的请求二进制字节数数组,之后发送给broker并等待从broker处接收响应。假如是像发送消息和消费消息这样的请求,clients通常会一直维持与某些broker的长连接,从而创建TCP连接的开销不断地摊薄给每条具体请求

实际使用过程中,单个Kafka clients通常需要同时连接多个broker服务器进行数据交互,但在每个broker之上只需要维护一个Socket连接用于数据传输。clients可能会创建额外的Socket连接用于其他任务,如元数据获取及组rebalance等,Kafka自带的Java clients使用类似于epoll的方式在单个连接上不停的轮询以传输数据

broker端需要确保在单个Socket连接上按照发送顺序对请求进行一一处理,然后依次返回对应的响应结果。单个TCP连接上某一时刻只能处理一条请求的做法正是为了保证不会出现请求乱序。clients端在实现时需要自行保证请求发送顺序。比如Java版本producer默认情况下会对PRODUCE请求进行流水化处理,在内存中它允许多条未处理完成的请求同时排队等候发送,这样做的好处是提升了producer端的吞吐量,潜在风险是PRODUCER请求发送乱序所导致的消息生产乱序,在实际应用中,用户可以通过设置参数max.in.flight.request.per.connection=1来关闭这种流水化作业

Kafka通信协议中规定的请求发送流向有3种

  • clinets给broker发送请求

  • controller给其他broker发送请求

  • follower副本所在broker向leader副本所在broker发送请求,不过只能是固定的FETCH请求

Kafka的broker端提供了一个可配置的参数用于限制broker端能够处理的最大字节数,一旦超过该预值,发生此请求的Socket连接就会强制关闭,clients观察到连接关闭后只能执行连接重建和请求重试等逻辑

请求/响应结构

Kafka协议提供的所有请求及其响应的结构体都是由固定格式组成,他们统一构建于多种初始类型之上,初始类型如下

  • 固定长度初始类型:包括int8、int16、int32和int64,分别表示有符号的单字节整数、双字节整数、4字节整数和8字节整数

  • 可变长度初始类型:包括bytes和string,由一个有符号整数N加上后续的N字节组成。N表示他们的内容,若为-1,则表示内容为空。其中string使用int16来保存N;bytes使用int32保存N

  • 数组:用于处理结构体之类重复性数据结构。它们总是被编码成一个int32类型的整数N以及后续的N字节。N表示该数组的长度信息,而具体到里面的元素可以是其它初始化类型,在下面的请求结构体,我们使用[Array]的方式表示数组

所有的请求和响应都是具有统一格式,Size + Request/Response,其中Size是int32表示的整数,表征了该请求或响应的长度信息

请求又可划分为请求头部和请求体,请求体的格式因请求类型的不同而变化,但请求头部的结构是固定的,由一下4个字段构成

  • api_key:请求类型,以int16整数表示

  • api_version:请求版本号,以int16整数表示

  • correlation_id:与对应响应的关联号,实际中用于关联response与request,方便用户调试和排错。此字段以int32整数表示

  • client_id:表示发出此请求的client ID,实际场景中用于区分集群上不同clients发送请求,该字段是一个非空字符串

响应也可划分成响应头部和响应体,响应体的格式因其对应的请求类型的不同而变化,但响应头部的结构是固定的

  • correlation_id:该字段就是上面请求头部中的correlation_id。有了该字段,用户就能知道该响应对应于那个请求

Kafka推荐总是指定client_id和correlation_id,方便用户后续定位问题和DEBUG

常见请求类型

Kafka 1.0.0版本总共提供多达38个请求类型,2.7版本则多达57个请求,完整的请求列表参见http://kafka.apache.org/protocol.html#protocol_api_keys

PRODUCE请求

此请求为编号0的请求,即api_key = 0,是Kafka通信协议中第一个请求类型,其实现消息的生产,clients向broker发送PRODUCE请求并等待broker端返回响应表明生产是否成功

PRODUCER最新的版本共有9个版本,这里我们拿api_version=5的版本举一个栗子,其它版本可以参考:http://kafka.apache.org/protocol.html#The_Messages_Produce

版本号为5的PRODUCE请求格式为:事务Id + acks + timeout + [topic数据]

  • 事务Id:transaction Id是0.11.0.0版本为支持事务引入的字段,是string类型,如果不使用事务,该字段为null,使用0.11.0.0版本的用户如果不使用事务型producer,设置该字段为null即可

  • acks:int16类型,表明PRODUCE请求从broker处返回之前必须被应答的broker数,当前只有3个取值:0、1和-1(同all)

  • timeout:以毫秒计算的请求超时时间,默认值为30秒,若broker端30秒内未发送响应给clients,则clients端视该请求超时

  • topic数据:这是PRODUCE请求主要的数据载体,里面封装了要发送到broker端的消息数据,该字段可进一步划分为topic + [partition + 消息集合]

    • topic: 表示该PRODUCE请求要发送到哪些topic。topic数据是数组类型,故单个PRODUCE请求可同时生产多个topic的消息

    • partition:表征PRODUCE请求要发送消息到topic的那些分区

    • 消息集合:封装真正的消息数据

PRODUCE响应结构:[response] + throttle_time_ms

  • throttle_time_ms:表示因超过配额限制而延迟该请求处理的时间,以毫秒计。如果没有配置任何配额设置,则该字段恒为0

  • [responses]:每个topic都有对应的返回数据,即response,每个response结构如下

    • partition:该topic分区号

    • error_code:请求是否成功

    • base_offset:消息集合的起始位置

    • log_append_time:broker端写入消息的时间

    • log_start_offset:response被创建时该分区日志总的起始位移

FETCH请求

FETCH请求是编号为1的请求,api_key=1,它服务于消费消息,既包括clients向broker发送的FETCH请求,也包括分区follower副本发送给leader副本的FETCH请求

我们看一下版本号为6的版本的FETCH请求格式:replica_id + max_wait_time + min_bytes + max_bytes + isolation_level + [topics]

  • replica_id:int32整数表征的副本ID。该字段专门服务于follower给leader发送的FETCH请求。正常的clients端consumer程序,该字段为-1

  • max_wait_time:int32类型的整数,表示等待响应返回的最大时间

  • min_bytes:int32类型整数,表示响应中包含数据的最小字节数。默认是1字节,表示只要broker端累积了超过1字节数据,就可以返回响应。对于要提升TPS的用户来说,可以适当增加此值,让broker积攒更多数据之后再返回

  • max_bytes:int32类型整数,响应中包含数据的最大字节数。若FETCH请求中要获取第一个topic分区的单条消息已超过这个阈值,那么Kafka已经允许broker返回这条消息给clients

  • isolation_level:int8类型的整数,0.11.0.0版本新引入的字段,表示FETCH请求的隔离级别。当前只有两种隔离级别:READ_UNCOMMITTED(isolation_level = 0)
    和READ_COMMITTED(isolation_level = 1).对于非事务型的consumer而言,该字段固定为0

  • [topics]:数组类型,表征要请求的topic数据,该数组中的每个元素结构如下

    • topic

    • 若干分区信息,每个分区信息由partition、fetch_offset、log_start_offset和max_bytes构成。fetch_offset表明broker需要从指定分区的哪个位移开始读取信息,而log_start_offset则是为follower发送的FETCH请求专用,表明follower副本最早可用的分区。max_bytes与最外层max_bytes含义不同,前者限定的是单个分区所能获取到的最大字节数,后者限定整FETCH请求能获取到的最大字节数

FETCH请求对应的响应格式如下:throttle_time_me[responses]

  • throttle_time_ms:与PRODUCER响应结构中throttle_time_ms含义相同

  • [responses]:返回的消息数据,是一个数组类型,每个元素结构如下

    • topic:消息数据所属topic

    • partition_header:可进一步细分为分区号、error_code、高水印值等字段

    • 消息集合:真实的数据

METADATA请求

clients向brokers发送METADATA请求以获取指定topic的元数据信息,最新版本请求格式为[topics] + allow_auto_topic_creation + include_cluster_authorized_operations + include_topic_authorized_operations

  • allow_auto_topic_creation:是否运行自动创建topic

  • [topics]:数组类型,每个元素指定METADATA请求想要获取元数据的topic

  • include_cluster_authorized_operations:是否包含集群权限

  • include_topic_authorized_operations:是否包含topic权限

最新版本响应格式为:throttle_time_ms + [brokers] + cluster_id + controller_id + [topics] + cluster_authorized_operations

  • throttle_time_ms:与之前含义相同

  • [brokers]:集群broker列表,每个broker信息包括node_id、host、port和rack,即节点ID、主机名或IP地址、端口和机架信息

  • cluster_id:该请求被发送的集群ID

  • controller_id:该集群controller所在的broker ID

  • [topics]:topic元数据数组,每个元素表征一个topic的所有元数据信息

    • error_code:topci错误码,表示该元素是否有问题

    • name:topic名

    • is_internal:是否属于内部名

    • partitions:topic下所有分区的元数据,包括每个分区的错误代码、leader信息、副本信息、ISR信息等

请求处理流程
clients端

Kafka没有规定这些请求必须如何处理,只要clients端代码必须按符合的格式构造请求,然后发送给broker。每种语言的clients端代码必须自行实现对请求
和响应的完整生命周期管理
在这里插入图片描述
clients发送请求前需要首先确定目标broker,搞清楚请求是发送给哪个broker。大部分的请求都需要发送给特定的broker,比如PRODUCE和FETCH请求,clients端只发送给特定分区的leader broker。也有一类请求不需要强制发送给指定broker,METADATA请求可以发送给集群中任意一个broker,每个broker都保存了相同的元数据缓存

确认目标broker后,clients会创建与broker的连接并一直维持,下次向相同broker发送其他请求时就可以直接使用该连接不需要重建。一旦TCP连接建立clients端需要按照协议规定格式构造不同请求。真正的I/O操作是在轮询操作中执行。在底层把I/O操作完全托管给Java NIO的Selector,在轮询中会真正检查是否有真正的I/O事件发生,如发送请求和获取请求,甚至连接重建或断开

如不是连接断开这样的I/O事件,clients通常会对接收到的响应执行对应的回调逻辑,若是连接断开,则clients端需要支持自动重建连接

broker端

在这里插入图片描述
每个broker启动都会创建一个请求阻塞队列,专门用于接收从cliets端发送过来的请求。broker还会创建若干个请求处理线程专门获取并处理该阻塞队列中的请求。标识关闭该线程的一类特殊请求被称为AllDone请求,AllDone不属于通信协议的一部分,只作为broker端关闭的一个特殊标记被放入队列,请求处理线程一旦从队列中获取到AllDone,则意味着broker要发起关闭操作,退出请求处理循环逻辑

版本与兼容性

Kafka通信协议是区分版本的,每种请求类型都有多个版本信息,可以到官网查询。在Kafka 0.10.2.0之前,client端和broker端之间的兼容性是单向的,高版本Kafka的broker可以处理低版本clients请求,但低版本broker不能处理高版本client请求

Kafka 0.10.2.0版本开始,对于低版本clients+高版本(broker版本大于0.10.2.0)环境,Kafka可以通过命令查看broker支持的请求类型,自动选择broker支持的最高版本来构建请求。在安装目录运行bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092,即可查看broker集群,各broker支持各请求协议的版本
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dQOFmPeC-1617466377997)(./image/0.24.0支持协议版本范围.png)]
使用Java Clients客户端,其会自动判断连接的broker端所支持client请求的最高版本,并创建规范的请求

Java API构造请求实例

Kafka提供了大量脚本实现Kafka的管理工作,但在某些业务下,我们希望通过代码API实现Kafka的管理,以下为实现创建topic示例

添加依赖
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>1.0.2</version>
</dependency>
实现发送请求客户端
package com.aim.kafka.client.producer;

import org.apache.kafka.common.protocol.ApiKeys;
import org.apache.kafka.common.requests.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;

public class ProducerClient {

    /**
     * 发送请求
     * @param host          目标broker主机名
     * @param port          目标broker端口
     * @param request       请求对象
     * @param apiKey        请求类型
     * @return              序列化后的response
     * @throws IOException
     */
    public ByteBuffer send(String host, int port, AbstractRequest request, ApiKeys apiKey) throws IOException {
        Socket socket = connect(host, port);
        try {
            return send(request, apiKey, socket);
        } finally {
            socket.close();
        }
    }

    /**
     * 创建topic
     *
     * @param topicName                 topic名
     * @param partitions                分区数
     * @param replicationFactor         副本数
     * @throws IOException
     */
    public void createTopics(String topicName, int partitions, short replicationFactor) throws IOException {
        Map<String, CreateTopicsRequest.TopicDetails> topics = new HashMap<>();
        // 插入多个元素可同时创建多个topic
        topics.put(topicName, new CreateTopicsRequest.TopicDetails(partitions, replicationFactor));
        int creationTimeoutMs = 60000;
        CreateTopicsRequest request = new CreateTopicsRequest.Builder(topics, creationTimeoutMs).build();
        ByteBuffer response = send("localhost", 9092, request, ApiKeys.CREATE_TOPICS);
        CreateTopicsResponse.parse(response, request.version());
    }

    /**
     * 向给定Socket发送请求
     * @param request           请求对象
     * @param apiKey            请求类型,即哪种请求
     * @param socket            连接向目标broker的Socket
     * @return                  序列化后的response
     * @throws IOException
     */
    private ByteBuffer send(AbstractRequest request, ApiKeys apiKey, Socket socket) throws IOException {
        RequestHeader header = new RequestHeader(apiKey, request.version(), "client-id", 0);
        ByteBuffer buffer = request.serialize(header);
        byte[] serializedRequest = buffer.array();
        byte[] response = issueRequestAndWaitForResponse(socket, serializedRequest);
        ByteBuffer responseBuffer = ByteBuffer.wrap(response);
        ResponseHeader.parse(responseBuffer);
        return responseBuffer;
    }

    /**
     * 发送序列化请求并等待response
     * @param socket                   连向目标broker的socket
     * @param request                  序列化后的请求
     * @return                         序列化后的response
     * @throws IOException
     */
    private byte[] issueRequestAndWaitForResponse(Socket socket, byte[] request) throws IOException {
        sendRequest(socket, request);
        return getResponse(socket);
    }

    /**
     * 从Socket获取response
     * @param socket            连向目标broker的Socket
     * @return                  获取到的序列化后的response
     * @throws IOException
     */
    private byte[] getResponse(Socket socket) throws IOException {
        DataInputStream dis = null;
        try {
            dis = new DataInputStream(socket.getInputStream());
            byte[] response = new byte[dis.readInt()];
            dis.readFully(response);
            return response;
        } finally {
            if (dis != null) {
                dis.close();
            }
        }

    }

    /**
     * 发送序列化请求给socket
     * @param socket            连向目标broker的Socket
     * @param request           序列化后的请求
     * @throws IOException
     */
    private void sendRequest(Socket socket, byte[] request) throws IOException {
        DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
        dos.writeInt(request.length);
        dos.write(request);
        dos.flush();
    }

    /**
     * 创建Socket连接
     * @param hostName          目标broker主机名
     * @param port              目标broker服务端口
     * @return                  创建的Socket连接
     * @throws IOException
     */
    private Socket connect(String hostName, int port) throws IOException {
        return new Socket(hostName, port);
    }
}

controller设计

当Kafka集群启动时,所有broker都会参与到controller的选举竞争中,最后只能由一个broker胜出,也就是说任意时刻Kafka集群只会有一个controller。controller主要用于协调和管理Kafka集群。一旦controller崩溃,剩余的broker会立即得到通知,然后
开启新一轮controller选举。选举出来的controller将继续担负上一个controller的工作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sVOpoeby-1617466377998)(./image/controller架构.png)]

controller状态

controller维护的状态机分为两类:每台broker上的分区副本和每个分区的leader副本信息。从维度上看又分为副本状态和分区状态。controller为维护这两个状态引入了两个状态机,分别管理副本状态和分区状态

副本状态机

在这里插入图片描述
Kafka为副本定义了7种状态及每个状态之间的流转规则

  • NewReplica:controller创建副本的最初状态,在这个状态的副本只能成为follower副本

  • OnlineReplica:启动副本后变更为该状态,在该状态下副本既可以成为follower副本也可以成为leader副本

  • OfflineReplica:一旦副本所在broker奔溃,该副本变更为此状态

  • ReplicaDeletionStarted:开启topic删除操作,topic下所有分区的所有副本都被删除,副本进入此状态

  • ReplicaDeletionSuccessful:若副本成功响应了删除副本请求,则进入该状态

  • ReplicaDeletionIneligible:若副本删除失败,则进入该状态

  • NonExistentReplica:若副本被成功删除则进入该状态

当创建某个topic后,topic下所有分区的所有副本都是NonExistent状态,此时cotroller加载ZooKeeper中该topic下每个分区的所有副本信息,同时将副本状态变更为New,之后controller选择该分区副本列表中第一个副本作为分区的leader副本,并将所有副本加入ISR,然后在ZooKeeper中持久化该决定

确定分区的leader和ISR后,controller将这些信息以请求方式发送给所有的副本,同时将副本状态同步到集群中所broker上。之后controller会将分区的所有副本状态设置为Online,副本进入正常工作状态

开启删除topic操作,controller尝试停止所有副本,此时副本停止向leader获取数据,若停止的副本是leader副本,则controller会设置该分区leader为NO_LEADER,之后副本进入Offline状态。当所有副本进入Offline状态,controller将副本进一步变更为ReplicaDeletionStarted状态,表明删除topic任务开启。在这一步状态流转中,controller会给这些副本所在broker发送请求,删除本机上的副本数据。删除成功,副本状态变更为ReplicaDeletionSuccessful,如果有失败副本,则失败副本状态变为ReplicaDeletionIneligible,表明暂时还无法删除该副本,等待controller重试。处于ReplicaDeletionSuccessful状态的副本之后会被自动变更为NonExistent终止状态。同时controller上下文缓存会清除这些副本信息

分区状态机

分区状态机负责集群下所有分区的状态管理,分区状态机定义了4个分区状态

  • NonExistent:表明不存在的分区或已删除的分区

  • NewPartition:一旦分区被创建便处于此状态,此时Kafka已经为分区确定了副本列表,但尚未选出leader和ISR

  • OnlinePartition:一旦分区的leader被选出,则进入此状态,也是分区正常工作的状态

  • OfflinePartition:成功选举出leader后,若leader所在的broker宕机,则分区将进入该状态,表明无法正常工作

当创建topic时,controller负责创建分区对象,首先会短暂将所有分区状态设置为NonExistent,之后马上从controller的上下文读取副本分配方案,然后令分区状态进入NewPartition。处于NewPartition状态的分区尚未有leader和ISR,controller会初始化leader和ISR信息并设置分区状态为OnlinePartition,此时分区开始正常工作。若用户发起删除topic操作或关闭broker操作,controller会令受影响的分区进入Offline状态。若为删除操作,controller会开启分区下副本的删除操作,并最终将分区状态设置为NonExistent。如果关闭broker操作,则controller会判断该broker是否是分区leader,如果是需开启新一轮leader选举并调整分区状态为OnlinePartition

controller职责

controller如果要能持续稳定的对外提供服务,必须妥善的保存上述的这些状态。但controller的状态不仅如此,controller的职责相当多

  • 更新集群元数据信息

  • 创建topic

  • 删除topic

  • 分区重分配

  • preferred leader副本选举

  • topic分区扩展

  • broker加入集群

  • broker奔溃

  • 受控关闭

  • controller leader选举

更新集群元数据

一个clients可以向集群中任意一台broker发送METADATA请求查询topic分区信息,比如topic有多少分区、每个分区leader在哪台broker,以及分区的副本列表。随着集群运行,某些信息可能会发生变化,因此需要controller提供一种机制,随时随地将变更后的分区信息广播出去,同步给集群上的所有broker。controller提供的方案是,当有分区信息发生变更时,controller将变更后的信息封装进UpdateMetadataRequests请求,然后发送给集群中的每个broker,当clients在请求数据时总能获取最新、最即时的分区信息

创建topic

controller启动时会创建一个ZooKeeper的监听器,该监听器监控ZooKeeper节点/brokers/topics下子节点的变更情况。一个clients或admin创建topic的方式有如下几种

  • 通过kafka-topics脚本的–create创建

  • 构造CreateTopicRequest请求创建

  • 配置broker端参数auto.create.topics.enable为true,然后发送MetadataRequest请求

以上三种方式其基本原理都是ZooKeeper的/brokers/topics下创建一个对应的znode,然后把这个topic的分区以及对应的副本列表写入这个znode。controller监听器一旦监控到该目录下有新增znode,则触发topic创建逻辑,controller会为新建topic的每个分区确定leader和ISR,然后更新集群的元数据信息。之后controller还会创建一个新的监听器用于监听ZooKeeper的/brokers/topics/<新增topic>节点内容的变更。当topic分区发生变化时controller也能在第一时间得到通知

删除topic

标准的Kafka删除topic方法有如下两种方式

  • 通过kafka-topics脚本的–delete来删除topic

  • 构造DeleteTopicRequest

这两种方式都是向ZooKeeper的/admin/delete_topics下创建一个znode。controller启动时会创建一个监听器专门监听该路径下的子节点变更情况,一旦发现有新增节点,则controller立即开启删除topic逻辑。该逻辑分为两个阶段:1。停止所有副本运行;2。删除所有副本日志数据。一旦这些操作完成,controller将移除/admin/delete_topics/<待删除topic>节点,表示topic删除操作正式完成

分区重分配

分区重分配操作通常由Kafka集群的管理员发起,旨在对topic的所有分区重新分配副本所在broker位置,实现更均匀的分配效果。在该操作中管理员需要手动制定分配方案并按照制定的格式写入ZooKeeper的/admin/reassign_partitions节点下

分区副本重分配的过程实际上是先扩展再收缩的过程。controller首先将分区副本集合进行扩展,旧副本集合与新副本集合的合集,等待全部与leader保持同步之后将leader设置为新分配方案中的副本,最后执行收缩阶段,将分区副本集合缩减成分配方案中的副本集合

preferred leader选举

为避免分区副本分配不均匀,Kafka引入preferred副本概念。如果一个分区的副本列表是[1,2,3],那么broker1就被称为该分区的preference leader,取分区副本的第一个副本。在集群运行过程中,分区leader会发生变化,从而导致leader不再是preferred leader,此时用户可以发起命令将这些分区的leader重新调整为preferred leader

  • 设置broker参数auto.leader.rebalance.enable为true,controller会定时自动调用preferred leader

  • 通过kafka-preferred-replica-election脚本手动触发

这两种方法都向ZooKeeper的/admin/preferred_replica_election节点写入数据,controller会注册监听该目录的监听器,一旦触发,controller会将对应分区的leader调整回副本列表中的第一个副本,之后将此便更广播

topic 分区扩展

在Kafka集群运行过程中,可能会因为topic现有分区数不足以支撑clients的业务量,会增加分区。增加分区主要使用kafka-topics脚本的–alter选项完成。和创建topic一样会向ZooKeeper的/brokers/topics/节点下写入新的分区目录。一旦增加topic分区,监听topic分区目录数据变化的监听器将触发,执行对应分区创建任务,之后更新集群元数据信息

broker加入集群

每个broker成功启动后都会在ZooKeeper的/broker/ids下创建一个znode,并写入broker的信息。如果要让kafka动态维护broker列表,就必须注册一个ZooKeeper监听器时刻监控该目录下的数据变化

每当有新的broker加入集群时,该监听器会感知到变化,执行对应的broker启动任务,之后更新集群元数据信息并广播出去

broker崩溃

当前broker在ZooKeeper中注册的znode是临时节点,一旦broker崩溃,broker与ZooKeeper的会话会失效并导致临时节点被删除,监控broker加入的那个监听器同样被用来监控崩溃而退出集群的broker列表。若有broker子目录消失,controller便立即可知道broker退出集群,开启broker退出逻辑,更新集群元数据并同步到其他broker

受控关闭

broker关闭方式有两种,一种通过kafka-server-stop脚本、kill -15受控的关闭broker,另一种则是broker崩溃或主机掉电、kill -9方式强制退出。第一种受控关闭能最大限度降低broker的不一致,受控关闭是由即将关闭的broker向controller发送请求,请求的名字是ControlledShutdownRequest。一旦发送完ControlledShutdownRequest,待关闭broker将一直处于阻塞状态,直到接收到broker端发送的ControlledShutDownResponse,表示关闭成功,或用完所有重试机会后强制退出

controller在处理完必要的leader重选举和ISR收缩调整后,会给broker发送ControlledShutdownResponse表明该broker现在可以正常退出。之前所有controller的功能都依托于ZooKeeper的监听器功能实现,但对于受控关闭,它依托于broker端的RPC来实现,即broker直接发送请求给controller,没有借助ZooKeeper

controller leader选举

作为Kafka集群的重要组件,controller支持故障转移,若当前controller发生故障或显示关闭,Kafka必须要能保证即时选出新的controller。一个Kafka集群中发生controller leader选举的场景共有4种

  • 关闭controller所在broker

  • 当前controller所在broker宕机或崩溃

  • 手动删除ZooKeeper的/controller节点

  • 手动向ZooKeeper的/controller节点写入新的broker id

controller会创建一个监听/controller节点的监听器,上面四种操作变更实际上都是/controller节点的内容。/controller本质上是一个临时节点,节点保存了当前controller所在broker id。集群首次启动所有broker节点都会抢着创建该节点,ZooKeeper保证最终只能有一个broker胜出,胜出的broker即成为controller

成为controller后,会立即更新/controller_epoch节点值,增加版本号。然后履行上面所有的职责。那些没有成为controller的broker会继续监听/controller节点的存活情况并随时准备竞选新的controller

controller与broker间的通信

controller启动时会为集群中所有broker创建一个专属Socket连接,也包括controller所在broker。如果Kafka集群有100台broker机器,controller会创建100个Socket连接,在较新的Kafka版本中会使用基于Java NIO selector的网络连接库实现,但controller依然会为每个TCP连接创建一个RequestSendThread线程。如果集群有100台broker则会创建100个Socket连接和100个I/O线程

这些连接和线程被用于controller给集群broker发送请求。controller只能给broker发送3种请求

  • UpdateMetadataRequest:更新集群元数据请求。该请求携带集群当前最新的元数据信息。broker接受到请求后会更新本地内存中缓存信息。从而保证返还给clients的信息总是最新、最及时的

  • leaderAndIsrRequest:用于创建分区、副本,同时完成作为leader和作为follower角色各自的逻辑

  • StopReplicaRequest:停止指定副本的数据请求操作,负责删除副本数据功能

controller通常都是发送请求给broker,但ControllerShutdownRequest请求是broker主动通过RPC直接发送controller

controller组件

controller组件构成图
在这里插入图片描述
controller组件按功能可以分为数据类、基础功能类、状态机类、选举器和监听器

数据类组件ControllerContext

ControllerContext被称为controller上下文或controller缓存,是controller最重要的数据组件。汇总了ZooKeeper中关于Kafka集群的所有元数据信息,是controller能够正确提供服务的基础。在0.11.0.0之前Kafka Controller设计是多线程的,为保护这个controller使用了大量的同步机制,修改优化困难
在这里插入图片描述

基础功能类

基础功能类如下

  • ZkClient:封装与ZooKeeper的各种交互API。controller与ZooKeeper的所有操作都交由该组件完成

  • ControllerChannelManager:controller通道管理器负责controller向其他broker发送请求

  • ControllerBrokerRequestBatch:controller将发往同一broker的各种请求按照类型分组,统一发送提升效率

  • RequestSendThread:负责给其他broker发送请求的I/O线程

  • ZookeeperLeaderElector:结合ZooKeeper负责controller的leader选举

状态机类
  • ReplicaStateMachine:副本状态机,负责定义副本状态及合法的副本状态流转

  • PartitionStateMachine:分区状态机,负责定义分区状态及合法的分区状态流转

  • TopicDeletionManager:topic删除状态机,处理删除topic的各种状态流程以及相应的状态变更

选举器
  • OfflinePartitionLeaderSelector:常规性的分区leader选举

  • ReassignPartitionLeaderSelector:用户发起分区重分配时的leader选举

  • PreferredReplicaPartitionLeaderSelector:用户发起preferred leader选举时的leader选举

  • ControlledShutdownLeaderSelector:负责broker在受控关闭后的leader选举

ZooKeeper监听器
  • PartitionsReassignedListener:监听ZooKeeper下分区重分配路径的数据变更情况

  • PreferredReplicaElectionListener:监听ZooKeeper下preferred leader选举路径的数据变更

  • IsrChangeNotificationListener:监听ZooKeeper下ISR列表变更通知路径下的数据变化

Kafka一旦发现topic分区的ISR发生变化,就会在ZooKeeper的/isr_change_notification节点下写入一个新的数据节点,里面封装了集群中那些topic的那些分区对应的ISR发生了变更。该监听器监控到节点变化后会发起更新元数据请求给集群中的所有broker

以上三个监听器是有controller自己维护,更多的监听器交由给各个状态机来分别维护

broker请求处理

Reactor模式

在这里插入图片描述
Kafka broker处理请求的模式是Reactor设计模式。Reactor设计模式是一种事件处理模式,旨在处理多个输入源同时发送过来的请求。Reactor模式中的服务处理器或分区器将入站请求按照多路复用的方式分发到对应的请求处理器中

Reactor模式看起来很像生产者-消费者模式。外部输入客户端类似生产者,将事件放入dispatcher中的队列上;Reactor通常会创建多个request handler线程专门dispatcher发送过来的事件。在Kafka中这里所说的事件就是对应于Socket连接通道,broker上每当有新Socket连接通道被创建,dispatcher会将连接发送给下面某个request handler来消费

Reactor模式最重要的两个组件就是acceptor线程和processor线程。acceptor线程实时监听外部数据源发送过来的事件,并执行分发任务;processor线程执行事件处理逻辑并将处理结果发送给client

kafka broker请求处理

Kafka broker请求处理实现了上面的Reactor模式。每个broker都有一个acceptor线程和若干个processor线程。processor线程的数量可以通过num.network.threads配置,该参数配置在broker端,默认值为3。broker会为用户配置的每组listener创建一组processor线程,每个broker可以同时设置多种通信安全协议,比如PLAINTEXT和SSL,因此一旦某个broker同时配置多套通信安全协议,Kafka会为每个协议都创建一组processor线程
在这里插入图片描述
在Kafka中对应于Reactor模式的"事件"实际上是连向broker的Socket连接通道,而不是clients端发送过来的真实请求。clients端通常会保存与broker的长连接,因此不需要频繁地重建Socket连接,故broker端固定使用一个acceptor线程来唯一地监听入站连接。acceptor只做新连接监听这一件事,acceptor的线程处理逻辑是很轻量的,实际使用过程中通常也不是系统瓶颈,这就是多路复用在broker端的一个应用

processor线程接收acceptor线程分配的新Socket连接通道,然后开始监听该通道上的数据传输。broker以线程组而非线程池的方式来实现这组processor。之后使用简单的数组索引轮询方式依次给每个processor线程分配任务,实现均匀化的负载均衡。processor并非处理请求的真正执行者,Kafka创建一个KafkaRequestHandler
线程池专门处理真正的请求。processor线程一个重要的任务就是将Socket连接上接收到的请求放入请求队列中。每个broker启动时会创建一个全局唯一的请求队列,大小由broker端参数queued.max.requests控制,默认值是500,表示每个broker最多只能保存500个未处理的请求。一旦超过,clients端发送给broker的请求将被"阻塞",直到空出空间

processor线程把请求放入请求队列,KafkaRequestHandler线程池分配具体的线程从该队列中获取请求并执行真正的请求处理逻辑,该线程池的大小是可以配置的,由broker端参数num.io.treads控制,默认8个线程

除了请求队列,每个broker还会创建与processor线程数等量的响应队列,即每个processor线程都创建一个对应的响应队列。processor线程的另外一个重要任务就是实时处理各自响应队列中的响应结果

如果某个broker上存在多个clients端连接过来的Socket,特别是clients数量远远大于processor线程数,每个processor线程都需要处理多个Socket连接通道上的数据。为应对这种情况,Kafka设计上使用Java NIO的Selector + Channel + Buffer的思想,在每个processor线程中维护一个Selector实例,并通过这个Selector来管理多个通道上的数据交互,这便是多路复用在processor上的应用

假设broker id为B1,client id是C1,broker端配置遵循默认配置,完整请求处理流程如下

  • 启动B1

    • 启动acceptor线程A

    • 启动3个processor线程P1、P2和P3

    • 创建KafkaRequestHandler线程池和8个请求处理线程H1~H8

  • B1启动后,线程A不断轮询是否存在连向该broker的新连接;P1~P3实时轮询线程A是否发送新的Socket连接通道以及请求队列和响应队列是否需求处理;H1~H8实时监控请求队列中的新请求

  • 此时,C1开始向B1发送数据,首先C1会创建与该broker的Socket连接

  • 线程A监听到该Socket请求,接收并将该连接发送给P1~P3中的一个,假设为P2

  • P2下次轮询开始时发现有A线程传过来的新连接,将其注册到Selector上并开始监听其上的入站请求

  • C1开始给B1发送PRODUCE请求

  • P2监听到有新的请求到来,获取后发送到请求队列中

  • H1~H8实时监听请求队列,必然有一个线程最先发现PRODUCE请求到来,假设是H5,H5线程将该请求队列中取出来并开始处理

  • H5线程处理请求完成,该处理结果以响应的方式放入P2的响应队列

  • P2监听到响应队列有新的响应到来,将响应从该队列中取出来通过对应的Socket连接发送给C1

  • C1接收到响应,标记本次PRODUCE请求过程结束

producer端设计

producer端基本数据结构

ProducerRecord

一个ProducerRecord封装一条待发送消息,ProducerRecord由5个字段构成

  • topic:消息所属topic

  • partition:消息所属分区

  • key:消息key

  • value:消息体

  • timestamp:消息时间戳

  • headers:消息头

ProducerRecord允许构建消息对象时直接指定要发送分区,这样将不会先通过Partitioner计算目标分区。也可以直接指定消息的时间戳,但需要谨慎使用,有可能会令时间戳索引机制失效

RecordMetaData

此数据结构表示Kafka服务器端返回给客户端的消息的元数据信息

  • offset:消息在分区日志中位移信息

  • timestamp:消息时间戳

  • topicPartition:所属topic的分区

  • checksum:消息CRC32码

  • serializedKeySize:序列化后的消息key字节数

  • serializedValueSize:序列化后的消息value字节数

工作流程

用户构建待发送的消息对象ProducerRecord,调用KafkaProducer#send方法发送消息。KafkaProducer接收到消息后进行序列化操作,然后结合本地缓存的元数据信息一起发送给partitioner去确认目标分区,最后追加写入内存中的消息缓冲池,KafkaProducer#send发送成功
在这里插入图片描述
KafkaProducer中有一个专门的Sender I/O线程负责将缓冲池中的消息分批次发送给对应的broker,完成正在的消息发送逻辑

KafkaProducer.send发送消息详细流程
序列化 + 计算目标分区

首先对待发送消息进行序列化并计算目标分区
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hjMW7Cmp-1617466378002)(./image/序列化+分区计算.png)]

追加写入消息缓冲区

producer创建时会创建一个默认32MB,由buffer.memory参数指定的accumulator缓冲区,专门保存待发送的消息。除之前提到的linger.ms和batch.size等参数之外,该数据结构还包含一个特别重要的集合信息:消息批次信息batches。该集合类型为ConcurrentMap,里面分别保存了每个topic分区下的batch队列,也就是前面说的批次是按照topic分区进行分组。发往不同分区的消息保存在对应的batch队列中。

单个topic分区下的batch队列中保存的是若干个消息批次,每个batch中最重要的3个组件如下

  • compressor:负责执行追加写入操作

  • batch缓冲区:由batch.size参数控制,消息被真正追加写入的地方

  • thunks:保存消息回调逻辑的集合

这一步的目的是将待发送消息写入缓冲池中。这行这一步结束后KafkaProducer.send方法就执行完了,用户主线程唤醒Sender线程,等待Sender线程发送消息并执行返回结果

Sender线程预处理及消息发送

Send线程自KafkaProducer创建后就一直运行着。工作流程如下

  • 不断轮询缓冲区寻找已做好发送准备的分区

  • 将轮询获得的各个batch按照目标分区所在的leader broker进行分组

  • 将分组后的batch通过底层创建的Socket连接发送给各个broker

  • 等待服务器发送response响应

Sender线程处理response

Sender线程会发送PRODUCE请求给对应的broker,broker处理完毕后发送对应的PRODUCE response。一旦Sender线程接收到response,将依次按照消息发送顺序调用batch中的回调方法。这样producer发送消息工作算完全完成了

consumer端设计

consumer group状态机

consumer依赖于broker端的组协调者coordinator来管理组内的所有consumer实例并负责把分配方案发送到每个consumer上。分配方案是由组内的leader consumer根据指定的分区分配策略指定。该分配策略必须是组内所有consumer都支持。如果所有consumer协调在一起都无法选出共同的分区策略,coordinator就会抛出异常。这样就保证了每个consumer group总有一个一致性的分配策略,同时也能确保每个consumer只能为它拥有的分区提交位移

Kafka分区分配操作是在consumer端执行而非在broker端,好处如下

  • 便于维护升级:如果在broker端实现,分配策略变动需要重启整个Kafka集群,代价高昂

  • 便于实现自定义策略:不同的策略由不同的逻辑实现。coordinator端代码不容易实现灵活可定制的策略

  • 解耦组管理与分区分配,coordinator负责组管理工作,consumer负责分区分配

Kafka为consumer group定义了5个状态

  • Empty:该状态表明group下没有任何active consumer,但可能包含位移信息。每个group创建时便处于Empty状态。如果group工作一段时间后所有consumer都离开组,group也会处于该状态。由于可能包含位移信息,处于该状态下的group依然可以响应OffsetFetch请求,返回clients端对应的位移信息

  • PreparingRebalance:该状态表明group正准备进行group rebalance。此时group已经接收了一部分成员发送过来的JoinGroup请求,同时等待其他成员发送JoinGroup请求,直到所有成员都成功加入组或超时。由于该状态下的group仍然可能存有位移信息,因此clients依然可以发送OffsetFetch请求去获取位移,甚至可以发起OffsetCommit请求去提交位移

  • AwaitingSync:该状态表明所有成员都已经加入组并等待leader consumer发送分区分配方案。此时依然可以获取位移,但提交位移coordinator会抛出REBALANCE_IN_PROGRESS异常来表明该group正在进行rebalance

  • Stable:表明group开始正常消费,group必须响应clients发送过来的任何请求,如位移提交请求、位移获取请求、心跳请求等

  • Dead:表明group已经彻底废弃,group内没有任何active成员并且group的所有元数据信息都已被删除。处于此状态的group不会响应任何请求。coordinator会返回UNKNOWN_MEMBER_ID异常
    在这里插入图片描述
    当group首次创建时,coordinator会设置该group状态为Empty,当有新成员加入组,组状态变更为PreparingRebalance。group会等待一段时间让更多的组成员加入,可以通过consumer端参数max.poll.interval.ms指定。所有成员都已经加入组,组状态变更为AwaitingSync,之后leader consumer开始分配消费方案

分配方案确定后,leader consumer将方案以SyncGroup请求的方式发送给coordinator,然后coordinator再将方案下发给所有成员,此时组状态进入Stable group开始正常消费数据

当group处于Stable,若所有成员都离组,group状态会首先调整为PreparingRebalance,然后变更为Empty,最后等待元数据过期被删除后变为Dead

消费组状态流转条件

  • Empty与PreparingRebalance:Empty状态group下没有任何active consumer。当有一个consumer加入时,Empty变为PreparingRebalance

  • Empty与Dead:Empty状态下的group依然可能保存group元数据信息甚至位移信息。Kafka默认会在1天后删除这些数据。可以通过broker端offsets.retention.minutes配置。一旦这些数据被删除,group进入Dead状态

  • PreparingRebalance与AwaitingSync:在PreparingRebalance状态时,若成员在规定时间内完成加入组操作,可通过max.poll.interval.ms控制,那么group进入AwaitingSync状态。若有的组成员很慢,没能在这段时间加入组,规定时间一过group依然会进入AwaitingSync,当慢consumer加入组,group又会重新变更为PreparingRebelance,在实际使用过程中要谨慎设置max.poll.interval.ms参数。对于处于AwaitingSync状态group,当已加入成员崩溃、主动离组或元数据信息发生变更,group会重新进入PreparingRebalance

  • AwaitingSync与Stable:在coordinator成功下发了leader consumer做的分配方案后,group进入到Stable正常工作

  • Stable与PreparingRebalance:group正常工作时,当有成员发生崩溃或主动离组,或leader consumer重新加入组,或成员元数据发生变更,group会进入PreparingRebalance开启新一轮rebalance。在实际使用过程中,常见的rebalance原因就是Stable状态的group下consumer处理逻辑过重且session超时,被踢出group导致

  • 其它状态与Dead:consumer group位移保存在内部topic __consumer__offsets下的某个分区,当这个分区leader所在broker发生崩溃时,就必须对分区进行迁移,导致coordinator变更。此时不论group处于那种状态,都必须直接将状态设置为Dead

group管理协议

coordinator的组管理协议由两个阶段构成

  • 组成员加入阶段:用于为group指定active成员并从中选举出leader consumer

  • 状态同步阶段:让leader consumer制定分配方案同步到其他组成员中

在consumer看来,第一个阶段是收集所有consumer的topic订阅信息;第二个阶段利用这些信息给每个consumer分配要消费的分区。每个group下的leader consumer
通常都是第一个加入group的consumer

阶段一:成员入组

用到的协议请求类型为JoinGroup。当确定group对应的coordinator之后,每个成员都要显示地发送JoinGroup请求给coordinator。请求封装了consumer各自的订阅信息、成员id等元素。coordinate会持有这些JoinGroup请求一段时间,直到所有组成员都发送JoinGroup请求。coordinator会选择其中一个consumer作为leader,然后给所有组成员发送对应的response

coordinator拿到所有成员的JoinGroup请求后去获取所有成员都支持的协议类型。如果有成员指定了一个与其他成员不兼容的协议类型,该成员会被拒绝加入组。这里的协议指consumer group端支持的分配策略。Kafka默认支持3种分配策略(range, round-robin、sticky),如果一个consumer指定了自定义策略而其他consumer都不支持,这个consumer将不被允许加入组

coordinator处理JoinGroup请求后会把所有consumer成员的元数据信息封装进一个数组,然后以JoinGroup response的方式发送给group的leader consumer,而其他成员只会发送一个空数组,以减少网络I/O开销

leander consumer通过JoinGroup response获取了group下所有成员的订阅信息,就可以开始指定分配方案

阶段2:同步组状态信息

group所有成员加入组后,leader consumer根据指定的分配策略进行分区的分配。Kafka支持3种分配策略,range、round-robin和sticky。sticky策略可以最大限度地实现分区负载的均匀分配以及rabalance之后最少的分配变动

在这个阶段,group所有成员需要显示给coordinator发送SyncGroup请求。leader consumer的SyncGroup请求会包含分配方案。coordinator接收到leader的SyncGroup请求后取出分配方案并单独抽取出每个consumer对应的分区,然后把分区封装进SyncGroup的response,发送给各个consumer。每个consumer只会得到属于自己的那一部分分区,而不会知晓其他consumer的分配方案

所有consumer成员都收到SyncGroup response后,coordinator将group状态设置为Stable,此时组开始正常工作,每个成员按照coordinator发送过来的方案开始消费指定分区

rebalance场景

场景1:新成员加入组

在这里插入图片描述

场景2:成员发生崩溃

组成员崩溃和组成员主动离开是两种不同场景,在崩溃时成员并不会主动告知coordinator此事,coordinator可能需要一个完整的session.timeout周期才检测到这一崩溃,这必定会造成consumer滞后。离开组则是主动发起rebalance,而奔溃则是被动发起rebalance
在这里插入图片描述

场景3:成员主动离组

在这里插入图片描述

场景4:成员提交位移

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3qEv0ykD-1617466378005)(./image/成员提交位移.png)]

实现精确一次处理语义

消息交付语义

3种交付语义

  • 最多一次:消息可能丢失也可能被处理,但最多只会被处理一次

  • 至少一次:消息不会丢失,但可能被处理多次

  • 精确一次:消息被处理且只会被处理一次

producer端消息交付语义

在producer中,Kafka引入已提交消息概念。一旦消息被成功提交到日志文件,只要至少存在一个可用的包含该消息的副本,这条消息就永远不会丢失

在0.11.0.0版本之前,Kafka producer默认提供at least once语言。当producer向broker发送新消息后,分区leader副本所在broker成功将该消息写入本地磁盘,然后发送响应给producer,此时网络出现故障导致该响应没有发送成功,未接到响应的producer会认为该消息请求失败而开启重试操作。若重试网络恢复正常,那么同一条消息被写入日志两次。

在Kafka 0.11.0.0版本通过对幂等性producer和事务的支持,完美解决了消息重复发送问题

consumer端消息交付语义

相同日志下所有的副本都因该有相同的内容以及相同的当前位移值。consumer通过consumer位移自行控制和标记日志读取进度。如果consumer崩溃,替代它的新程序会接管这个consumer位移,从崩溃时读取位置继续开始消费

一种方式是consumer首先获取若干消息,然后提交位移,之后再开始处理消息。若consumer在提交位移后处理消息前崩溃,实现的就是最多一次消费语义

另一种方式consumer获取了若干消息,处理到最后提交位移,consumer保证只有消息被处理完成后才提交位移,此实现就是至少一次消费语义

精确一次消费语义可以通过0.11.0.0版本引入的事务解决

幂等producer

幂等性producer是0.11.0.0版本用于实现精确一次引入的。若一个操作执行多次结果与只运行一次的结果相同,那么我们称该操作为幂等操作

0.11.0.0版本引入幂等性producer表示它的发送操作是幂等的,瞬时的发送错误可能导致producer重试,同一条消息被producer发送多次,但broker端这条消息只会被写入日志一次。对于单个topic分区,这种producer提供的幂等性消除了各种错误导致的重复消息。如果要启用幂等性producer以及获取其提供的精确一次语义,用户可以显示设置producer端enable.idempotence为true

幂等性producer设计思路类似于TCP工作方式。发送到broker端的每批消息都会被设置一个序列号用于消息去重。这个序号会被保存在底层日志中,即使分区leader副本挂掉,新选出的leader broker也能执行消息去重工作。保存序列号只需几个字节,因此开销不大

除序列号,Kafka还会为每个producer实例分配一个producer id(PID)。PID分配的过程对用户来说是完全透明的,消息要被发送到的每个分区都有对应的序列号值,总是从0开始并且严格单调递增。PID、分区和序列号关系类似一个Map,key由PID、分区号组成,value则是序列号。每对PID、分区号都有对应的序列号,若发送消息的序列号小于或等于broker端保存的序列号,那么broker会拒绝这条消息的写入

以上设计保证即使出现重试操作,每条消息也只会被保存在日志中一次。由于每个新的producer实例会被分配不同的PID,当前设计只能保证单个producer实例的精确一次语义,无法实现多个producer实例一起提供精确一次语义

事务

为实现精确一次语义,Kafka引入了第二个设计事务。引入事务是的客户端,无论是生产端还是消费端都能够将一组消息放入一个原子单元中统一处理

处于事务中的这组消息能够从多个分区中消费,也可以发送到多个分区中。重要的是不论发送还是消费,Kafka都能保证原子性,即所有写操作要么全部成功,要么全部失败

Kafka为实现事务要求应用程序必须提供一个唯一的id来表征事务。这个id被称为事务id,其必须在应用程序所有会话上唯一,当提供事务ID后,Kafka可以确保

  • 跨应用程序会话间的幂等发送语义

  • 支持跨会话间的事务恢复,如果某个producer实例挂掉,Kafka能够保证下一个实例首先完成之前未完成的事务,从而总是保证状态的一致性

对于consumer而言,事务支持要弱一些

  • 对于compacted的topic,事务中的消息可能已经被删除

  • 事务可能跨多个日志段,因此若老的日志段被删除,用户将丢失事务中的部分消息

  • consumer程序可能使用seek方法定位事务中的任意位置,也可能造成部分消息的丢失

  • consumer可能选择不消费事务中的所有消息,无法保证读取事务的全部消息

在这里插入图片描述

上图中C是事务控制消息,事务控制消息与普通Kafka消息一样,只是消息属性字段中专门使用1位表示是controller message。control message总共有两类:COMMIT和ABORT,分别表示事务提交和事务终止。将control message保存到kafka日志的目的是让consumer能够识别事务边界,从而整体读取某个事务下的所有消息

Producer producer5 = new KafkaProducer(properties);
// 事务初始化
producer5.initTransactions();
try {
    // 事务开始
    producer5.beginTransaction();
    producer5.send(new ProducerRecord<>("my-topic", "audit", "bbbb"), (metadata,
                                                                       exception) -> {
        if (exception == null) {
            //消息发送成功
        } else {
            //执行错误处理逻辑
            if (exception instanceof RetriableException) {
                //处理可重试瞬时异常
            } else {
                //处理不可重试异常
            }
        }
    });
    producer5.send(new ProducerRecord<>("my-topic1", "audit1", "bbbb1"), (metadata,
                                                                          exception) -> {
        if (exception == null) {
            //消息发送成功
        } else {
            //执行错误处理逻辑
            if (exception instanceof RetriableException) {
                //处理可重试瞬时异常
            } else {
                //处理不可重试异常
            }
        }
    });
    // 事务提交
    producer5.commitTransaction();
} catch (Exception e){
    // 回滚
    producer5.abortTransaction();
} finally {
    // 客户端关闭
    producer5.close();
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值