了解 Kafka 的内部工作原理有助于理解 Kafka 的行为,也有助于诊断问题。
现在要讨论的是 3 个有意思的话题:
- Kafka 如何进行复制(复制);
- Kafka 如何处理来自生产者和消费者的请求(请求处理);
- Kafka 的存储细节,比如文件格式和索引(存储)。
对 Kafka 进行调优时,深入理解这些问题很有必要。
了解内部机制,可以更有目的性地进行深入的调优,而不只是停留在表面。
集群成员关系
Kafka 使用 Zookeeper 来维护集群成员的信息。
每个 broker 都有一个唯一的标识符,这个标识符可以在配置文件里指定,也可以自动生成。
在 broker 启动的时候,它通过创建临时节点把自己的 ID 注册到 Zookeeper。
Kafka 组件订阅 Zookeeper 的 /brokers/ids 路径(broker 在 Zookeeper 上的注册路径),当有 broker 加入集群或退出集群时,这些组件就可以获得通知。
如果你要启动另一个具有相同 ID 的 broker,会得到一个错误——新 broker 会试着进行注册,但不会成功,因为 Zookeeper 里已经有一个具有相同 ID 的broker。
在 broker 停机、出现网络分区或长时间垃圾回收停顿时,broker 会从 Zookeeper 上断开连接,此时 broker 在启动时创建的临时节点会自动从 Zookeeper 上移除。
监听 broker 列表的 Kafka 组件会被告知该 broker 已移除。
关闭 broker 时,它对应的节点也会消失,不过它的 ID 会继续存在于其他数据结构中。
在完全关闭一个 broker 之后,如果使用相同的 ID 启动另一个全新的 broker,它会立即加入集群,并拥有与旧 broker 相同的分区的主题。
// zookeeper 客户端连接到服务器上 # zkCli.sh // 查看根节点 # ls / // 可以看到下面挂了不少节点,因为这个 zookeeper 是共用的 [cluster, controller, storm, brokers, zookeeper, admin, isr_change_notification, log_dir_event_notification, controller_epoch, consumers, latest_producer_id_block, config, hbase] // 查看 brokers # ls /brokers [ids, topics, seqid] // 查看 ids,可以看到挂了三个节点 # ls /brokers/ids [1, 2, 3] // 随便找一个 ls 一下,发现节点下面已经没了 # ls /brokers/ids/1 // 获取一下这个节点的信息 # get /brokers/ids/1 {"listener_security_protocol_map":{"PLAINTEXT":"PLAINTEXT"},"endpoints":["PLAINTEXT://172.16.3.82:9092"],"jmx_port":-1,"host":"172.16.3.82","timestamp":"1564384290387","port":9092,"version":4} cZxid = 0x100011286 ctime = Mon Jul 29 15:11:30 CST 2019 mZxid = 0x100011286 mtime = Mon Jul 29 15:11:30 CST 2019 pZxid = 0x100011286 cversion = 0 dataVersion = 1 aclVersion = 0 ephemeralOwner = 0x3001e3e20841802 dataLength = 192 numChildren = 0 // 看一下主题有哪些 # ls /brokers/topics [test-topic, __consumer_offsets] // __consumer_offsets 这个是特殊的主题,用于提交偏移量的
控制器
控制器其实就是一个 broker,只不过它除了具有一般的 broker 的功能之外,还负责分区首领的选举。
集群里第一个启动的 broker 通过在 Zookeeper 里创建一个临时节点 /controller 让自己成为控制器。
其他 broker 在启动时也会尝试创建这个节点,不过它们会会收到一个“节点已经存在”的异常,然后“意识”到控制器节点已存在,也就是集群中已经有一个控制器了。
其他 broker 在控制器节点上创建 Zookeeper watch 对象,这样它们就可以收到这个节点的变更的通知。
这种方式可以确保集群里一次只有一个控制器存在。
// 查看 /controller 节点,发现节点下面没有子节点 [zk: localhost:2181(CONNECTED) 9] ls /controller [] // 查看节点上的数据 [zk: localhost:2181(CONNECTED) 10] get /controller {"version":1,"brokerid":1,"timestamp":"1564384290542"} cZxid = 0x100011288 ctime = Mon Jul 29 15:11:30 CST 2019 mZxid = 0x100011288 mtime = Mon Jul 29 15:11:30 CST 2019 pZxid = 0x100011288 cversion = 0 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x3001e3e20841802 dataLength = 54 numChildren = 0
如果控制器被关闭或者与 Zookeeper 断开连接,Zookeeper 上的临时节点就会消失。
集群里的其他 broker 通过 watch 对象得到控制器节点消失的通知,它们会尝试让自己成为新的控制器。
第一个在 Zookeeper 里成功创建控制器节点的 broker 就会成为新的控制器,其他节点会收到“节点已存在”的异常,然后在新的控制器节点上再次创建 watch 对象。
每个新选出的控制器通过 Zookeeper 的条件递增操作获得一个全新的、数值更大的 controller epoch。
其他 broker 在知道当前 controller epoch 后,如果收到由控制器发出的包含交旧的 epoch 消息,就会忽略它们。
当控制器发现一个 broker 已经离开集群(通过观察相关的 Zookeeper 路径),它就知道,那些失去首领的分区需要一个新首领(这些分区的首领刚好在这个 broker 上)。
控制器遍历这些分区,并确定谁应该成为新的首领(就是分区副本列表里面的下一个副本),然后向所有包含新首领或现有跟随者的 broker 发送请求。
该请求消息包含了谁是新首领以及谁是分区跟随者的信息。
随后,新首领开始处理来自生产者和消费者的请求,而跟随者开始从新首领那里复制消息。
当控制器发现一个 broker 加入集群时,它会使用 broker ID 来检查新加入的 broker 是否包含现有分区的副本。
如果有,控制器就把变更通知发送给新加入的 broker 和其他 broker,新 broker 上的副本开始从首领那里复制消息。
简而言之,Kafka 使用 Zookeeper 的临时节点来选举控制器,并在节点加入集群或退出集群时通知控制器。控制器负责在节点加入或离开集群时进行分区首领选举。
控制器使用 epoch 来避免“脑裂”。“脑裂” 是指两个节点同时认为自己是当前节点的控制器。
复制
复制是 Kafka 架构的核心。
复制之所以这么关键,是因为它可以在个别节点失效时仍能保证 Kafka 的可用性和持久性。
Kafka 使用主题来组织数据,每个主题被分为若干个分区,每个分区多个副本,那些副本被保存在 broker 上,每个 broker 可以保存成百上千个属于不同主题和分区的副本。
副本的两种类型:
- 首领副本:每个分区都有一个首领副本。为了保持一致性,所有生产者请求和消费者请求都会经过这个副本。
- 跟随者副本:首领副本以外的副本都是跟随者副本。跟随者副本不处理来自客户端的请求,它们唯一的任务就是从首领那里复制消息,保持与首领一致的状态。如果首领发生崩溃,其中的一个跟随者会被提升为新首领。
首领的另一个任务是搞清楚哪个跟随者的状态与自己是一致的。
跟随者为了保持与首领的状态一致,在有新消息到达时尝试从首领那里复制消息,不过有各种原因会导致同步失败。
例如,网络拥塞导致复制变慢,broker 发生崩溃导致复制滞后,直到重启broker 后复制才会继续。
为了与首领保持同步,跟随者向首领发送获取数据的请求,这种请求与消费者为了读取消息而发送的请求是一样的。
首领将相应消息发送给跟随者。
请求消息里包含了跟随者想要获取消息的偏移量,而且这些偏移量总是有序的。
一个跟随者副本先请求消息1,接着请求消息2,然后请求消息3,在收到这 3 个请求的响应之前,它是不会发送第 4 个请求消息的。
如果跟随者发送了请求消息4,那么首领就知道它已经收到了前面3个请求的响应。
通过查看每个跟随者请求的最新偏移量,首领就会知道每个跟随者复制的进度。
如果跟随者在 10s 内没有请求任何消息,或者虽然在请求消息,但在 10s 内没有请求最新的数据,那么它就会被认为是不同步的。
如果一个副本无法与首领保持一致,在首领发生失效时,它就不可能成为新首领——毕竟它没有包含全部的消息。
相反,持续请求得到的最新消息副本被称为同步的副本。在首领发生失效时,只有同步副本才可能被选为新首领。
跟随者的正常不活跃时间或在成为不同步副本之前的时间是通过 replica.lag.time.max.ms 参数来配置的。
这个时间间隔直接影响到首领选举期间的客户端行为和数据保留机制。
除了当前首领外,每个分区都有一个首选首领——创建主题时选定的首领就是分区的首选首领。
之所以把它叫做首选首领,是因为在创建分区时,需要在 broker 之间均衡首领(涉及到 broker 间分布副本和首领的算法)。
因此,我们希望首选首领在成为真正的首领时,broker 间的负载最终会得到均衡。默认情况下,Kafka 的 auto.leader.rebalance.enable 被设为 true,它会检查首选首领是不是当前首领,如果不是,并且该副本是同步的,那么就会触发首领选举,让首选首领成为当前首领。
找到首选首领
从分区的副本清单中很容找到首选。
清单里的第一个副本一般就是首选首领。
不管当前首领是哪一个副本,都不会改变这个事实,即使使用副本分配工具重新分配给其他 broker。如果手动进行副本分配,第一个指定的副本就是首选首领,所以要确保首选首领被传播到其他 broker 上,避免让包含了首领的 broker 负载过重,而且他 broker 却无法为它们分担负载。
处理请求
broker 的大部分工作就是处理客户端、分区副本和控制器发送给分区首领的请求。
Kafka 提供了一个二进制协议(基于 TCP),指定了请求消息的格式以及 broker 如何对请求作出响应——包括成功处理请求或在处理请求过程中遇到了错误。
客户端发起连接并发送请求,broker 处理请求并作出响应。
broker 按照请求到达的顺序来处理它们——这种顺序保证让 Kafka 具有了消息队列的特性,同时保证保存的消息也是有序的。
所有请求消息的标准头:
- Request type(也就是 API key)
- Request version(broker 可以处理不同版本的客户端请求,并根据客户端版本作出不同的响应)
- Correlation ID——一个具有唯一性的数字,用于标识请求消息,同时也会出现在响应消息和错误日志里(用于诊断问题)
- Client ID——用于标识发送请求的客户端
broker 会在它所监听的每一个端口上运行一个 Acceptor 线程,这个线程会创建一个连接,并把它交给 Processor 线程去处理,Process 线程的数量是可配置的。
网络线程负责从客户端请求消息,把它们放进请求队列,然后从响应队列获取响应消息,把它们发送给客户端。
生产请求和获取请求都必须发送给分区的首领副本。
如果 broker 收到一个针对特定分区的请求,而该分区的首领在另一个 broker 上,那么发送请求的客户端会收到一个“非分区首领”的错误响应。
当针对特定分区的获取请求被发送到一个不含有该分区首领的 broker 上,也会出现同样的错误。Kafka 客户端要自己负责把生产请求和获取请求发送到正确的 broker 上。
客户端使用了另一种请求,也就是元数据请求。
这种请求包含了客户端感兴趣的主题列表。
服务器端的响应消息里指明了这些主题所包含的分区、每个分区都有哪些副本,以及哪个副本是首领。
元数据请求可以发送给任意一个 broker,因为所有的 broker 都缓存了这些信息。
一般情况下,客户端会把这些信息缓存起来,并直接往目标 broker 上发送生产请求和获取请求。
它们需要时不时地通过发送元数据请求来刷新这些信息(刷新的时间间隔通过 metadata.max.age.ms 参数来配置),从而知道元数据是否发生了变更——比如,在新 broker 加入集群时,部分副本会被移动到新的 broker 上。
另外,如果客户端收到“非首领”错误,它会在尝试重发请求之前先刷新元数据,因为这个错误说明了客户端正在使用过期的元数据,之前的请求发送到了错误的 broker 上。
to be continued ......