文章目录
深入Kafka
1. 集群成员关系
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会继续存在于其他数据结构中。例如,主题的副本列表里就可能包含这些ID。在完全关闭一个broker之后,如果使用相同的ID启动另一个全新的broker,它会立即加入集群,并拥有与旧broker相同的分区和主题。
2. 控制器
控制器其实就是一个broker,只不过它除了具有一般broker的功能之外,还负责分区首领的选举。集群里第一个启动的broker通过在Zookeeper里创建一个临时节点/controller
让自己成为控制器。其他broker在启动时也会尝试创建这个节点,不过它们会收到一个“节点已存在”的异常,然后“意识”到控制器节点已存在,也就是说集群里已经有一个控制器了。其他broker在控制器节点上创建Zookeeper watch对象,这样它们就可以收到这个节点的变更通知。这种方式可以确保集群里一次只有一个控制器存在。
如果控制器被关闭或与Zookeeper断开连接,Zookeeper上的临时节点就会消失。集群里的其他broker通过watch对象得到控制器节点消失的通知,它们会尝试让自己成为新的控制器。第一个在Zookeeper里成功创建控制器节点的broker就会成为新的控制器,其他节点会收到“节点已存在”的异常,然后在新的控制器节点上再次创建watch对象。每个新选出的控制器通过Zookeeper的条件递增操作获得一个全新的、数值更大的controller epoch。其他broker在知道当前controller epoch后,如果收到由控制器发出的包含较旧epoch的消息,就会忽略它们。
当控制器发现一个broker已经离开集群,他就知道,那些失去首领的分区需要一个新首领(这些分区的首领刚好是在这个broker上)。控制器遍历这些分区,并确定谁应该成为新首领(简单来说就是分区副本列表里的下一个副本),然后向所有包含新首领或现有跟随者的broker发送请求。该请求消息包含了谁是新首领以及谁是分区跟随者的信息。随后,新首领开始处理来自生产者和消费者的请求,而跟随者开始从新首领那里复制消息。
当控制器发现一个broker加入集群时,它会使用broker ID来检查新加入的broker是否包含现有分区的副本。如果有,控制器就会把变更通知发送给新加入的broker和其他broker,新broker上的副本开始从首领那里复制消息。
简而言之,Kafka使用Zookeeper的临时节点来选举控制器,并在节点加入集群或退出集群时通知控制器。控制器负责在节点加入或离开集群时进行分区首领选举。控制器使用epoch来避免“脑裂”。“脑裂”是指两个节点同时认为自己是当前的控制器。
3. 复制
Kafka把自己描述成“一个分布式的、可分区的、可复制的提交日志服务”。复制之所以这么关键,是因为它可以在个别节点失效时仍可以保证Kafka的可用性和持久性。
副本有以下两种类型:
- 首领副本
每个分区都有一个首领副本。为了保证一致性,所有生产者请求和消费者请求都会经过这个副本。 - 跟随者副本
首领以外的副本都是跟随者副本。跟随者副本不处理来自客户端的请求,它们唯一的任务就是从首领那里复制消息,保持与首领一致的状态。如果首领发生了崩溃,其中的一个跟随者会被提升为新首领。
首领的另一个任务是搞清楚哪个跟随者的状态与自己是一致的。跟随者为了与首领的状态一致,在有新消息到达时尝试从首领那里复制消息,不过有各种原因会导致同步失败。例如,网络拥塞导致复制变慢,broker发生崩溃导致复制滞后,直到重启broker后复制才会继续。
为了与首领保持同步,跟随者向首领发送获取数据的请求,这种请求与消费者为了读取消息而发送的请求是一样的。首领将响应消息发给跟随者。请求消息里包含了跟随者想要获取消息的偏移量,而且这些偏移量总是有序的。
一个跟随者副本先请求消息1,接着请求消息2,然后请求消息3,在收到这3个请求的响应之前,它是不会发送第4个请求消息的。如果跟随者发送了请求消息4,那么首领就知道它已经收到了前面3个请求的响应。通过查看每个跟随者请求的最新偏移量,首领就会知道每个跟随者复制的进度。如果跟随者在10s内没有请求任何消息,或者虽然在请求消息,但在10s内没有请求最新的数据,那么它就会被认为是不同步的,如果一个副本无法与首领保持一致,在首领发生失效时,它就不可能成为新首领—毕竟它没有包含全部的消息。
相反,持续请求得到的最新消息副本被称为同步的副本。在首领发生失效时,只有同步副本才有可能被选为新首领。
跟随者的正常不活跃时间或在成为不同步副本之前的时间是通过replica.lag.time.max.ms
参数来配置的。这个时间间隔直接影响着首领选举期间的客户端行为和数据保留机制。
除了当前首领之外,每个分区都有一个首选首领–创建主题时选定的首领就是分区的首选首领。之所以把它叫作首选首领,是因为在创建分区时,需要在broker之前均衡首领。因此,希望首选首领在成为真正的首领时,broker间的负载最终会得到均衡。默认情况下,Kafka的auto.leader.rebalance.enable
被设为true
,它会检查首选首领是不是当前首领,如果不是,并且该副本是同步的,那么就会触发首选首领,让首选首领成为当前首领。
找到首选首领
从分区的副本清单里可以很容易找到首选首领,清单里的第一个副本一般就是首选首领。如果手动进行副本分配,第一个指定的副本就是首选首领,所以要确保首选首领被传播到其他broker上,避免让包含了首领的broker负载过重,而其他broker却无法为它们分担负载。
4. 处理请求
broker的大部分工作是处理客户端、分区副本和控制器发送给分区首领的请求。Kafka提供了一个二进制协议(基于TCP),指定了请求消息的格式以及broker如何对请求作出响应—包括成功处理请求或在处理请求过程中遇到错误。客户端发起连接并发送请求,broker处理请求并作出响应。broker按照请求到达的顺序来处理它们—这种顺序保证让Kafka具有了消息队列的特性,同时保证保存的消息也是有序的。
所有的请求消息都包含一个标准消息头:
- Request type(也就是API key)
- Request version(broker可以处理不同版本的客户端请求,并根据客户端版本作出不同的相应)
- Correlation ID ---- 一个具有唯一性的数字,用于标识请求消息,同时也会出现在响应消息和错误日志里(用于诊断问题)
- Client ID ---- 用于标识发送请求的客户端
broker会在它所监听的每一个端口上运行一个Acceptor线程,这个线程会创建一个连接,并把它交给Processor线程去处理。Processor线程(也被叫作“网络线程”)的数量是可配置的。网络线程负责从客户端获取请求消息,把它们放进请求队列,然后从响应队列获取响应消息,把它们发送给客户端。
生产请求
生产者发送的请求,它包含客户端要写入broker的消息。
获取请求
在消费者和跟随者副本需要从broker读取消息时发送的请求。
生产请求和获取消息都必须发送给分区的首领副本。如果broker收到一个针对特定分区的请求,而该分区的首领在另一个broker上,那么发送请求的客户端会收到一个“非分区首领”的错误响应。当针对特定分区的获取请求也被发送到一个不含有该分区首领的broker上,也会出现同样的错误。Kafka客户端要自己负责把生产请求和获取请求发送到正确的broker上。
客户端使用了另一种请求类型,也就是元数据请求。这种请求包含了客户端感兴趣的主题列表。服务器端的响应消息里指明了这些主题所包含的分区、每个分区都有哪些副本,以及哪个副本是首领。元数据请求可以发送给任意一个broker,因为所有broker都缓存了这些信息。
一般情况下,客户端会把这些信息缓存起来,并直接往目标broker上发送生产请求和获取请求。它们需要时不时地通过发送元数据请求来刷新这些信息(刷新的时间间隔通过metadata.max.age.ms参数来配置),从而知道元数据是否发生了变更—比如,在新broker加入集群时,部分副本会被移动到新的broker上。另外,如果客户端收到“非首领”错误,它会在尝试重发请求之前先刷新元数据,因为这个错误说明了客户端正在使用过期的元数据信息,之前的请求被发到了错误的broker上。
4.1 生产请求
包含首领副本的broker在收到生产请求时,会对请求做一些验证。
- 发送数据的用户是否有主题写入权限?
- 请求里包含的acks值是否有效(只允许出现0,1或all)?
- 如果acks=all,是否有足够多的同步副本保证消息已经被安全写入?
之后,消息被写入本地磁盘。在Linux系统上,消息会被写到文件系统缓存里,并不保证它们何时会被刷新到磁盘上。Kafka不会一直等待数据被写到磁盘上—它依赖复制功能来保证消息的持久性。
在消息被写入分区的首领之后,broker开始检查acks配置参数—如果acks被设为0或1,那么broker立即返回响应;如果acks被设为all,那么请求会被保存在一个叫作炼狱的缓冲区里,直到首领发现所有跟随者副本都复制了消息,响应才会被返回给客户端。
4.2 获取请求
客户端发送请求,向broker请求主题分区里具有特定偏移量的消息。客户端还可以指定broker最多可以从一个分区里返回多少数据。
如果请求的偏移量存在,broker将按照客户端指定的数量上限从分区里读取数据,也可以设置下限,再把消息返回给客户端。Kafka使用零复制技术想客户端发送消息,也就是Kafka直接把消息从文件里发送到网络通道,而不需要经过任何中间缓冲区。
并不是保存在分区首领上的数据都可以被客户端读取,大部分客户端只能读取已经被写入所有同步副本的消息,分区首领知道每个消息会被复制到哪个副本上,在消息还没有被写入所有同步副本之前,是不会发送给消费者的,尝试获取这些消息的请求会得到空的响应而不是错误。通过设置replica.lag.time.max.ms
参数,可以指定副本在复制消息时可被允许的最大延迟时间。
4.3 其他请求
为了将偏移量保存在Kafka主题上,而不是Zookeeper中,需要在协议里增加几种请求类型:OffsetCommitRequest
、OffsetFetchRequest
与ListOffsetRequset
。现在,在应用程序中调用commitOffset()
方法时,客户端不再把偏移量写入Zookeeper,而是往Kafka发送OffsetCommitRequest
请求。
主题的创建仍然需要通过命令行工具来完成,命令行工具会直接更新Zookeeper里的主题列表,broker监听这些主题列表,在有新主题加入时,它们会收到通知。通过增加CreateTopicRequest
请求类型,这样客户端就可以直接向broker请求创建新主题。
除了往协议里添加新的请求类型外,可以通过修改已有的请求类型来给它们增加新功能。在升级客户端之前,先升级broker,因为新的broker知道如何处理旧的请求。
5. 物理存储
Kafka的基本存储单元是分区。分区无法在多个broker间进行再细分,也无法在同一个broker的多个磁盘上进行再细分。所以,分区的大小收到单个挂载点可用空间的限制(一个挂载点由单个磁盘或多个磁盘组成,如果配置了JBOD,就是单个磁盘,如果配置了RAID,就是多个磁盘)。
在配置Kafka的时候,管理员指定了一个用于存储分区的目录清单—也就是log.dirs
参数的值,该参数一般会包含每个挂载点的目录。
5.1 分区分配
在创建主题时,Kafka首先会决定如何在broker间分配分区。假设有6个broker,打算创建一个包含10个分区的主题,并且复制系数为3。那么Kafka就会有30个分区副本,它们可以被分配给6个broker。
- 在broker间平均地分布分区副本。
- 确保每个分区的每个副本分布在不同的broker上。
- 如果为broker指定了机架信息,那么尽可能把每个分区的副本分配到不同机架的broker上。
为分区和副本选好合适的broker之后,接下来要决定这些分区应该使用哪个目录。单独为每个分区分配目录,规则很简单:计算每个目录里的分区数量,新的分区总是被添加到数量最小的那个目录里。也就是说,如果添加了一个新磁盘,所有新的分区都会被创建到这个磁盘上。因为在完成分配工作之前,新磁盘的分区数量总是最少的。
5.2 文件管理
保留数据是Kafka的一个基本特性,Kafka不会一直保留数据,也不会等到所有消费者都读取了消息之后才删除消息。相反,Kafka管理员为每个主题配置了数据保留期限,规定数据被删除之前可以保留多长时间,或者清理数据之前可以保留的数据量大小。
因为在一个大文件里查找和删除消息是很费时的,也很容易出错,所有把分区分成若干个片段。默认情况下,每个片段包含1GB或一周数据,以较小的那个为准。在broker往分区写入数据时,如果达到片段上限,就关闭当前文件,并打开一个新文件。
当前正在写入数据的片段叫作活跃片段。活跃片段永远不会被删除,所以如果要保留数据1天,但片段里包含了5天的数据,那么这些数据会被保留5天,因为在片段被关闭之前只写数据无法被删除。
broker会为分区里的每个片段打开一个文件句柄,哪怕片段是不活跃的。这样会导致打开过多的文件句柄,所以操作系统必须根据实际情况做一些优化。
5.3 文件格式
Kafka附带了一个叫DumpLogSegment
的工具,可以用它查看片段的内容。它可以显示每个消息的偏移量、校验和、魔术数字节、消息大小和压缩算法。运行该工具的方法如下:
bin/kafka-run-class.sh kafka.tools.DumpLogSegments
如果使用了--deep-iteration
参数,可以显示被压缩到包装消息里的消息。
5.4 索引
为了帮助broker更快地定位到指定的偏移量,Kafka为每个分区维护了一个索引。索引把偏移量映射到片段文件和偏移量在文件里的位置。
索引也被分成片段,所以在删除消息时,也可以删除相应的索引。Kafka不维护索引的校验和。如果索引出现损坏,Kafka会通过重新读取消息并录制偏移量和位置来重新生成索引。如果有必要,管理员可以删除索引,这样做是绝对安全的,Kafka会自动重新生成这些索引。
5.5 清理
一般情况下,Kafka会根据设置的时间保留数据,把超时时效的旧数据删除掉。
5.6 清理的工作原理
每个日志片段可以分为以下两个部分。
干净的部分
这些消息之前被清理过,每个键只有一个对应的值,这个值是上一次清理时保留下来的。
污浊的部分
这些消息是在上一次清理之后写入的。
如果在Kafka启动时启用了清理功能(通过配置log.cleaner.enabled参数),每个broker会启动一个清理管理器线程和多个清理线程,它们负责执行清理任务。这些线程会选择污浊率较高的分区进行清理。
为了清理分区,清理线程会读取分区的污浊部分,并在内存里创建一个map。map里的每个元素包含了消息键的散列值和消息的偏移量,键的散列值是16B,加上偏移量总共是24B。如果要清理一个1GB的日志片段,并假设每个消息大小为1KB,那么这个片段就包含一百万个消息,所以就需要24MB的map清理这个片段。
管理员在配置Kafka时可以对map使用的内存大小进行配置。每个线程都有自己的map,而这个参数指的是所有线程可使用的内存总大小。如果为map分配了1GB内存,并使用了5个清理线程,那么每个线程可以使用200MB内存来创建自己的map。Kafka并不要求分区的整个污浊部分来适应这个map的大小,但要求至少有一个完整的片段必须符合。
清理线程在创建偏移量map后,开始从干净的片段处读取消息,从最旧的消息开始,把它们的内容与map里的内容进行对比。它会检查消息的键是否存在于map中,如果不存在,那么说明消息的值是最新的,就把消息复制到替换片段上。如果键已存在,消息会被忽略,因为在分区的后部已经有一个具有相同键的消息存在。在复制完所有的消息后,将替换片段和原始片段进行交换,然后开始清理下一个片段。完成整个清理过程之后,每个键对应一个不同的消息—这些消息的值都是最新的。
5.7 被删除的事件
为了彻底把一个键从系统里删除,应用程序必须发送一个包含该键且值为null的消息。清理线程发现该消息时,会先进行常规的清理,只保留值为null的消息。该消息(被称为墓碑消息)会被保留一段时间,时间长度是可配置的。在这期间,消费者可以看到这个墓碑消息,并且发现它的值已经被删除。于是,如果消费者往数据库是复制Kafka的数据,当它看到这个墓碑消息时,就知道应该把相关的用户信息从数据库里删除。在这个时间段过后,清理线程会移除这个墓碑消息,这个键也将从Kafka分区里消失。重要的是,要留给消费者足够多的时间,让他看到墓碑消息,因为如果消费者离线几个消失并错过了墓碑消息,就看不到这个键,也就不知道它已经从Kafka里删除,从而也就不会删除数据库里的相关数据了。
5.8 何时会清理主题
就像delete策略不会删除当前活跃的片段一样,compact策略也不会对当前片段进行清理。只有旧片段里的消息才会被清理。