Kafka基础-内部原理

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/gangchengzhong/article/details/81909759

理解Kafka的内部原理可以有助于故障的排除,因此本文会着重介绍以下三个部分:

  • Kafka复制的工作原理
  • Kafka是怎样处理生产者和消费者的请求
  • Kafka是怎样储存数据,例如文件格式和索引

1. 集群成员

Kafka使用Apache Zookeeper来维护当前集群的成员列表,每个broker都有一个唯一的标识,该标识可以在broker的配置文件设置或者自动生成。每次broker进程启动时,它都会通过创建一个临时节点在Zookeeper注册自己的ID,在Zookeeper对应的路径是/brokers/ids。

当broker与Zookeeper断开连接时(通常是由于停止broker,但也可能是由于网络故障),broker在启动时创建的临时节点将会自动从Zookeeper删除。监控集群broker列表的Kafka组件将被通知该broker已经离线。当停止broker时,即使代表该broker的临时节点已经被删除,该broker的ID仍然会存在于其它数据结构中。例如,每个topic的副本列表都会包含副本的broker IDs。如果一个broker已经完全离线,然后使用相同的ID启动一个新的broker,那么这个broker会立即加入到离线broker原来的集群位置,并且为其分配相同的topics和分区。

2. 控制器(Controller)

控制器是由其中一个Kafka broker充当,除了负责通常的broker功能外,还负责选举分区leaders。集群中第一个启动的broker将成为控制器,它会在ZooKeeper创建路径/controller的临时节点。当其它brokers启动时,它们也会尝试创建此临时节点,但会收到“node already exists”的异常。每个broker会创建一个监视器,用于监视控制器的状态,以便它们收到控制器更改的通知,从而保证集群只有一个控制器。

当控制器停止或者与Zookeeper断开连接时,之前创建的临时节点会被删除,其它brokers会通过监视器知道控制器已经下线,然后会尝试创建路径/controller的临时节点,第一个创建成功的broker将成为新的控制器,其余的brokers同样会收到“node already exists”的异常并将重新创建针对新控制器的监视器。每次新控制器的选举,它都会通过Zookeeper的条件递增操作接收到一个比原来更大的epoch号。如果brokers从一个具有旧的epoch号的控制器接收到消息,它们会知道忽略它。

当控制器发现一个broker离开集群时(通过监视相关的Zookeeper路径),如果这个broker是一个leader,那么控制器会遍历这个broker对应的所有followers,从而决定新的leader(简单地从对应分区的副本列表里面选择下一个副本),并向新的leader和那些followers发送一个包含新leader和分区followers列表的信息。新的leader会继续处理生产者和消费者的请求,而那些followers会从新的leader复制消息。当控制器发现一个broker加入集群时,它会使用这个broker的ID来检查此broker是否存在副本。如果有,控制器会通知新的和现有的brokers做相应的信息更新,新的broker会开始从现有的leader复制消息。

总之,Kafka使用Zookeeper的临时节点功能来选举控制器和在brokers加入或离开集群时通知控制器。每当控制器发现brokers加入或离开集群时,它就负责在分区和副本中选举新的leaders。控制器使用epoch号来防止多个节点都认为自己是当前控制器,也就是所谓的“split brain”场景。

3. 复制(Replication)

复制是Kafka架构的核心,因为它是在单个节点不可避免地发生故障时保证可用性和持久性的方法。正如已经提到过的,Kafka的消息是按topics来组织。每个topic都是分区的,每个分区可以有多个副本。副本也是保存在brokers里,每个broker通常可以保存成百上千个属于不同topics和分区的副本。

Kafka有两种类型的副本:

  • Leader,每个分区都有一个设计为leader的副本,该副本称为leader。为了保证一致性,所有生产者和消费者的请求都会发送给这个leader。
  • Follower,非leader分区的所有副本称为followers。此类副本不负责处理客户的请求,它们唯一的工作是从leader复制消息,与之保持同步最新的消息。在一个分区leader故障时,其中一个follower将会被选举为新的分区leader。

Leader负责的另一项任务是了解有哪些followers与之保持同步最新的消息。在消息发送到leader时,followers会通过从leader复制所有信息来尝试保持最新的消息,但由于各种原因可能会失败,例如网络阻塞导致复制减慢或者当一个broker故障导致它所有followers无法同步消息,复制会滞后,直到这个broker恢复为止。

为了与leader保持同步,副本会向leader发送Fetch的请求,这类请求与消费者向broker发送的读取消息请求的类型是一样的,leader会向副本发送需要同步的消息作为那些请求的响应。那些Fetch请求包含副本希望接下来要读取消息的offset,并且将始终按顺序排列。

副本会先请求消息1,然后消息2,然后消息3,它在读取所有先前的消息之前不会请求消息4。这意味着当副本请求消息4时,leader可以知道这个副本已经读取消息3及之前的所有消息。根据每个副本请求的最后offset,leader可以知道每个副本消息同步的落后情况。一个follower在被认为脱离同步之前可以处于非活动或落后状态的时间由replica.lag.time.max.ms配置设置,默认为10000ms=10秒,这允许的滞后会对消费者和在选举期间的数据保存有影响(会在后续介绍)。如果一个副本超过10秒都没有请求同步消息或者它已请求同步消息但超过10秒都没有同步到最新的消息,那么这个副本将被认为“脱离同步”(out of sync)。如果一个副本不能跟上同步leader的消息,那么这个副本在leader故障的时候不能被选举为新的leader,毕竟这个副本没有包含完整的消息,该副本会被leader移除。与此相反的是,一直请求同步最新消息的副本被称为“in-sync”副本,简称ISR。在leader故障的时候,只有该类副本才有资格被选举为新的leader。

每个分区都有一个首选的leader-就是在最初创建topic时的leader。之所以是首选是因为当首次创建分区时,leaders在brokers之间是负载均衡的。因此,当首选leader确实是当前的leader时,负载将会均分到各个broker。默认地,Kafka设置了auto.leader.rebalance.enable=true,它会启动一个background的线程检查首选的leader是不是当前的leader,如果不是,而且首选leader处于“in-sync”状态,则会触发leader选举把首选leader选举为当前leader。

4. 处理请求

Kafka broker的工作大多数是处理从客户端、分区副本和控制器发送到分区leader的请求。Kafka有一个指定请求和返回格式的二进制协议,标准的header包括:

  • 请求类型(也称为API key)
  • 请求版本(brokers可以处理不同版本的客户请求并作出相应的返回)
  • 相关ID:请求的唯一标识,也出现在返回和错误日志中(ID用于故障排除)
  • 客户端ID: 用于区分发送请求的应用

对于broker监听的每个端口,broker启动一个创建一个连接的acceptor线程,并将其交给processor线程进行处理。processor线程(也称为network线程)数量是可配置的。processor线程负责接收客户连接请求,把它们放到一个请求队列中,和从响应队列中读取返回并将它们发送回客户端。如下图所示:

produce请求和fetch请求都必须发送到分区leader。如果一个broker接收到指定分区的produce/fetch请求,而那个分区的leader在不同的broker里,发送请求的客户端将收到“Not a Leader for Partition.”(不是分区leader)的错误返回。Kafka的客户端负责向包含请求相关分区的leader发送produce/fetch请求。客户端如何知道向哪个broker发送请求呢?Kafka客户端使用另一种类型称为metadata的请求,该类请求包含客户端感兴趣的一个topic列表。服务器返回包含topics中的分区信息,每个分区的副本信息和leader的信息。Metadata请求可以发送到任意的broker,因为所有的brokers都有包含这些信息的metadata缓存。

客户端通常也会缓存这些信息并使用它来主导发送produce/fetch请求到每个分区的相应broker。它们还需要偶尔更新这些信息(更新间隔由metadata.max.age.ms配置设置,默认为300000ms=5分钟),方法是发送另外一个metadata请求,以便它们知道topic的metadata信息是否有更新。此外,如果客户端收到“Not a Leader for Partition.”错误,它会在再次发送请求之前更新其metadata缓存。

4.1 Produce请求

当一个包含分区leader的broker接收到一个produce请求时,它先会执行以下几个验证:

  • 发送请求的用户是否有该topic的写权限?
  • 请求指定的acks设置是否有效?(允许的值仅为0、1和all)
  • 如果acks设置为all,是否有足够的“in-sync”副本用于安全地写入消息?(如果“in-sync”副本的数量低于设置的数量,brokers可以配置为拒绝写入新消息)

然后broker会把新消息写入到本地磁盘。在Linux系统,消息会先被写入到文件系统的缓存,并且无法保证何时被写入磁盘。Kafka不会等待消息持久化到磁盘,它依赖于复制来保持消息的持久性。

一旦消息被写入到分区leader,这个broker就会检查acks的配置,如果acks设置为0或1,这个broker会马上向客户端返回信息;如果acks设置为all,请求会被保存在一个被称为purgatory的缓冲区,直到这个leader知道followers已经复制了消息,到那个时候才会向客户端发送返回信息。

4.2 Fetch请求

Brokers处理fetch请求的方式和处理produce请求的方式是非常相似的。客户端发送一个请求,要求broker从某个topic的分区发送指定offset的消息,例如“请将topic Test的分区0中的offset为53开始的消息和分区3中的offset为64开始的消息发送给我”。客户端还可以限制broker每个分区返回的数据量(max.partition.fetch.bytes=1048576=1MB),这个限制很重要,因为客户端需要分配保存从broker返回信息的内存大小。如果没有这个限制,brokers可以返回过大的信息导致客户端内存不足。

正如之前提到的,请求必须发送到指定的分区leader,并且客户端需要发送必要的metadata请求以确保fetch请求能被正确地路由。当leader接收到请求时,首先会检查请求是否有效 - 分区是否存在指定的offset?如果客户端请求的消息太旧而且已经从该分区删除,或者指定的offset不存在,这个broker会返回错误。

如果指定的offset存在,这个broker会从分区相应的offset开始读取消息,直到大小达到客户端在请求设置的限制,然后把消息发送给客户端。Kafka使用了zero-copy的方法来发送消息到客户端,这意味着Kafka从文件(Linux文件系统缓存)读取消息后直接发送到网络通道,而没有使用任何中间缓冲区。这与大多数数据库不同,在大多数数据库中,数据在发送到客户端之前是先保存在本地缓存中。这种技术消除了复制字节和管理内存缓冲区的开销,从而大大提高了性能。

除了可以设置broker返回数据量的上限之外,客户端还可以设置返回数据量的下限(fetch.min.bytes=1)。例如,把下限设置为10KB,意思是“一旦broker有10KB数据才返回”。这是当客户端从流量不多的topic读取消息时减少CPU和网络使用率的好方法。该方法是客户端发送一个请求,broker会先等待,直到有足够多的数据才返回(如下图所示),而不是客户端每隔几毫秒就向brokers发送请求,然后返回非常少的数据或者没有数据。两种方法读取的数据量总体上是相同的,但前者来回的次数少很多,因此开销较少。

当然,我们不希望客户端一直等待broker有足够多的数据。因此,客户端还可以定义一个超时时间(fetch.max.wait.ms=500)来告诉broker“如果在500毫秒内没有达到最小的数据量,则只需要发送现有的数据”。

值得注意的是,并不是所有存在于该分区leader的数据都可供客户端读取,大多数客户端只能读取写入所有“in-sync”副本的消息。上述已经介绍过分区leader知道哪些消息被复制到哪些副本,在一个消息被写入到所有“in-sync”副本之前,该消息将不会被发送给消费者。尝试读取那些消息会返回空而不是错误。这种设计的原因是,没有被复制到足够的副本的消息是“不安全的”。例如,一个leader崩溃,另外一个副本被选举为新的leader,但这个新的leader没有完成消息的同步,那么消息会丢失。因此,客户端需要等待消息被写入到所有“in-sync”副本才能读取(如下图所示)。如果由于某些原因导致复制缓慢,消费者读取消息的时间会变长。允许复制延时的时间由replica.lag.time.max.ms设置,默认是10000ms=10秒,在此范围内,副本仍会被认为是“in-sync”的。

4.3 其它请求

除了已经介绍过的Produce/Fetch/Metadata请求之外,Kafka还可以处理其它类型的请求,当前支持20种类型。例如,当控制器宣布分区有一个新的leader时,它会向这个新的leader和所有followers发送一个LeaderAndIsr请求,因此新的leader就会知道开始处理客户端的请求,followers知道去同步新的leader。

在新版本的实现中,Kafka不再使用Zookeeper来保存offsets信息,而是使用一个特别的topic。因此,Kafka新增了OffsetCommitRequest、OffsetFetchRequest和ListOffsetsRequest请求。当调用客户端commitOffset()的API时,不再把offsets写入到Zookeeper,而是向Kafka发送OffsetCommitRequest请求把offsets写入到一个特别的topic。

Topic的创建现在还是由命令行工具完成,命令行工具直接更新Zookeeper中的topics列表,brokers会监控这个列表得知新的topics被创建。在新版本的实现中,会新增CreateTopicRequest允许所有客户端直接向brokers发送请求创建topics。

除了新增支持新的请求类型,有时还会修改现有的请求添加一些功能。例如,在Kafka 0.9.0和0.10.0版本期间,决定通过增加信息到Metadata的返回让客户端知道当前控制器的信息。在发布的0.10.0版本中,新增ApiVersionRequest请求,允许客户端询问broker支持每个请求的版本并相应地使用正确的版本。

5. 物理存储

Kafka的基本存储单元是分区副本。分区不能在多个brokers间分割,甚至不能在同一个broker的多个磁盘间分割。因此,分区的大小是由单个挂载点(如果使用JBOD配置,则是单个磁盘,如果使用RAID配置,则是多个磁盘)上的可用空间限制。当配置Kafka时,管理员需要定义存储分区的目录列表,使用的配置是log.dirs,如果没有设置会使用log.dir,默认路径是/tmp/kafka-logs。

5.1 分配分区

当创建一个topic时,Kafka首先会决定怎样在brokers之间分配分区。假设有6个brokers,并且决定创建一个包含10个分区且复制因子为3的topic。所以现在要把30个分区副本分配给6个brokers。当进行分配时,我们的目标是:

  • 平均分配,本例就是每个broker分配5个副本。
  • 确保每个分区的每个副本都在不同的broker。如果分区0的leader被分配到broker2,那么可以把2个followers分配到broker 3和4,但不能再分配到broker2,也不能都分配到broker3。
  • 如果brokers有机架信息(在Kafka0.10.0及更高版本可用),尽可能把每个分区的副本分配到不同的机架上。这样可以确保即使整个机架都故障的情况下都不会导致分区完全不可用。

为了达到此目标,从随机一个broker(例如是broker4)开始,并以循环的方式把分区分配给每个broker,以确定leaders的位置。因此分区0的leader将分配到broker4,分区1的leader将分配到broker5,分区2的leader将分配到broker0,依此类推。然后,对每个分区,依次把副本分配到leader所在的broker的下一个。例如,分区0的leader在broker4,它的第一个follower将在broker5,第二个follower在broker0。分区1的leader在broker5,它的第一个follower将在broker0,第二个follower在broker1,依此类推。

当考虑机架感知时,不是按数字顺序选择brokers,而是准备一个机架交替的broker列表。假设broker0,1和2在同一个机架上,而broker3,4和5在另外一个机架上。不按0到5的顺序选择brokers,而是按0,3,1,4,2,5交替的顺序,每个broker后面是另一个机架的broker。在这种情况下,如果分区0的leader在broker4,它的第一个follower将在不同机架的broker2。如果第一个机架脱机,还有副本在另外一个机架,因此,分区仍然可用。这适用于所有分区副本,因此可以保证在机架故障情况下的分区可用性。

一旦为每个分区和副本选择了正确的brokers,就可以决定将哪个目录用于新的分区。使用的规则非常简单:计算每个目录上的分区数量,把新的分区保存到具有最少分区的目录中。这意味着如果增加新的磁盘,所有新的分区都会保存在新的磁盘。这是因为,在目录分区数均衡前,新的磁盘始终具有最少的分区。

需要注意的是,分配分区的规则只会考虑目录的分区数量,而不会考虑磁盘的可用空间或现有负载,也不会考虑分区的大小。这意味着如果某些brokers具有比其它brokers更多的磁盘空间(可能是因为群集是旧服务器和新服务器的混合),某些分区非常大,或者在同一个broker有不同大小的磁盘,则在分配分区的时候需要非常小心。

5.2 文件管理

Retention是Kafka的一个重要概念,Kafka不会永久保存数据,也不会等待所有消费者读取完消息后才删除数据。Kafka管理员需要为每个topic配置数据的保留期,可以是按时间(log.retention.hours=168=7天),也可以是按大小(log.retention.bytes=-1)。

因为在一个大文件里面查找和删除消息是既耗时又容易出错,所以会把每个分区分割成segments。默认地,每个segment包含1GB(log.segment.bytes)的数据或1周(log.roll.hours=168=7天)的数据,以较小者为准。当broker正在写数据到分区时,如果达到segment的上限,就会新创建一个segment。

正在写入数据的segment称为活动的segment,它永远不会被删除。因此如果设置retention为只保留1天数据但每个segment按5天数据分割,那么数据会被保留5天,因为segment在关闭前是无法被删除的。如果配置保留1周数据和每个segment按1天数据分割,那么每天删除最旧的segment的同时会创建一个新的segment,因为大部分时间分区会有7个segments。

5.3 文件格式

每个segment都是保存在单个数据文件中,每个文件保存了消息及其offsets。磁盘上的数据格式和从生产者发送到broker以及稍后从broker发送给消费者的格式是一样的。使用相同的消息格式允许Kafka在向消费者发送消息时使用zero-copy优化,而且还避免了解压缩和重新压缩消息。

每个消息除了包含key、value和offset,还包含其它信息,例如是消息的大小,用于检测损坏的checksum code,表示消息格式版本的magic byte,压缩编解码器(Snappy,GZip或LZ4),和timestamp(在0.10.0版本添加)。这个timestamp由生产者在发送消息或者broker在接收到消息时提供,具体取决于配置。

如果生产者发送的消息是压缩的,则在同一批处理的所有消息都会压缩在一起并作为一个“wrapper message”的值来发送(如下图所示)。因此,broker只会收到单个消息,消费者也相应会收到单个消息。但当消费者解压这个消息时会得到同一批的所有消息,每个消息都有各自的timestamps和offsets。

Kafka brokers内置DumpLogSegment工具,允许查看文件系统的分区segment和检查其内容-每个消息的offset,checksum,magic byte,大小和压缩编解码器。命令如下:

bin/kafka-run-class.sh kafka.tools.DumpLogSegments

如果指定参数--deep-iteration,可以显示上述提到的压缩在消息里面的“wrapper message”。

5.4 索引

Kafka允许消费者从任何可用的offset开始读取消息。这意味着如果消费者需要从offset 100开始读取1MB消息,broker必须能够快速地定位offset为100的消息(可以在分区的任何segments中)并开始读取消息。为了帮助brokers快速定位指定offset的消息,Kafka维护每个分区的索引。这个索引保存了offsets和segment文件、位置的映射关系。

索引也分多个segments来保存,因此在删除消息时可以同时删除旧的索引条目。如果索引损坏,只需要重新读取消息,记录offsets和位置,就可以从匹配的日志segment重新生成。如果需要,管理员删除索引segments也是完全安全的,索引会被自动重新创建。

5.5 Compaction(这里我翻译为精炼)介绍

根据上述介绍,Kafka会根据设置的保留期来存储数据。但是,如果要保存类似客户的送货地址,保存最新的地址会更有意义,这样,你不必担心存在旧的地址。另一个用例是保存状态信息,每次状态改变时,应用程序会将新状态写入到Kafka。我们只需要关系最新的状态,而不是之前发生的所有状态。

Kafka支持此类用例,如果retention策略(log.cleanup.policy)设置为delete,会删除超过保留时间的消息;如果设置为compact,对具有相同key的消息,只保留最新的值。显然,把策略设置为compact只对包含key和value的消息有作用,对key为null的消息不起作用。

5.6 Compaction工作原理

Kafka的消息可以分为2部分(如下图所示):

  • 之前已精炼的消息,此部分的每个key都是唯一的,对应的值是上一次精炼时的最新值。可以称为“Clean”部分。
  • 在上一次精炼后写入的消息。可以称为“Dirty”部分。

当Kafka启动时,如果启用了compaction(log.cleaner.enable,默认为true),每个broker将启动一个compaction manager线程和多个cleaner线程,它们用于执行compaction的任务。每个线程会选择一个有较多dirty消息的分区,然后对其执行compaction任务。为了防止compaction过于频繁,Kafka使用了配置log.cleaner.min.cleanable.ratio(默认值为0.5)来限定可进行compaction的最小dirty消息比率。

要精炼一个分区,需要遍历两次日志文件,第一次遍历cleaner线程读取分区的dirty部分并创建一个内存映射。每个映射项由一个hash后的16字节key和8字节offset组成,这意味着每个映射项仅使用24字节。如果要查看一个1GB的segment并假设每个消息大小是1KB,那么该segment将包含1百万个消息,需要24MB内存来创建映射关系。如果有相同的key,会使用更少内存,因为可以重用相同的hash。这可以说是十分有效。

一旦cleaner线程创建offset的映射,会进行第二次遍历,它将从最旧的clean segments开始读取消息,如果消息的key已经存在于offset映射中,会保留较新的值(如下图所示)。较新的值会被复制到一个以“.clean”为后缀的临时文件中,处理完后会把后缀改为“.swap”,然后删除原来的日志文件,最后把后缀“.swap”去掉。

当配置Kafka时,管理员需要配置用于保存这个映射的可使用内存大小(log.cleaner.dedupe.buffer.size,默认是134217728=128MB)和cleaner线程数(log.cleaner.threads,默认是1)。每个线程都会创建一个映射,但这个配置是针对所有线程的总内存大小。如果配置了1GB并且有5个cleaner线程,那么每个线程会使用200MB内存。Kafka不要求分区的整个dirty部分都能够保存在分配的内存映射里面,但至少一个segment能够被保存在此内存映射里。如果不能,Kafka会记录一个错误日志,管理员需要分配更多的内存或者使用更少的cleaner线程。Kafka会先精炼能够保存在内存映射的最旧的segments,剩余的segments会等待下次的compaction。

5.7 删除事件

如果需要删除指定key的消息时,可以生成包含该key但把值设为null的消息,我们称此类消息为墓碑消息(tombstone)。当cleaner线程找到这样的消息时,它将首先进行正常的compaction并保留墓碑消息一段时间(log.cleaner.delete.retention.ms=86400000=24小时)。在此期间,消费者可以读取该消息并知道它已被删除。在此配置时间之后,cleaner线程将删除这些墓碑消息。

END O(∩_∩)O

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页