Kafka 里的重要功能——复制

复制

复制功能是 Kafka 架构的核心。在 Kafka 的文档里,Kafka 把自己描述成“一个分布式的、可分区的、可复制的提交日志服务”。复制之所以这么关键,是因为它可以在个别节点失效时仍能保证Kafka的可用性和持久性。
在这里插入图片描述
Kafka 使用主题来组织数据,每个主题被分为若干个分区,每个分区有多个副本。那些副本被保存在 broker 上,每个 broker 可以保存成百上千个属于不同主题和分区的副本。

replication-factor

用来设置主题的副本数。每个主题可以有多个副本,副本位于集群中不同的broker上。也就是说副本的数量不能超过broker的数量,否则创建主题时会失败。

副本类型

首领副本

每个分区都有一个首领副本。为了保证一致性,所有生产者请求和消费者请求都会经过这个副本。

跟随副本

首领以外的副本都是跟随者副本。跟随者副本不处理来自客户端的请求,它们唯一一的任务就是从首领那里复制消息,保持与首领一致的状态。如果首领发生崩溃,其中的一个跟随者会被提升为新首领。

优先副本

除了当前首领之外,每个分区都有一个优先副本(首选首领),创建主题时选定的首领分区就是分区的优先副本。之所以把它叫作优先副本,是因为在创建分区时,需要在 broker 之间均衡首领副本。因此,我们希望首选首领在成为真正的首领时,broker间的负载最终会得到均衡。

默认情况下,Kafka 的 auto.leader.rebalance.enable 被设为true,它会检查优先副本是不是当前首领,如果不是,并且该副本是同步的,那么就会触发首领选举,让优先副本成为当前首领。

工作机制

首领的另一个任务是搞清楚哪个跟随者的状态与自己是一致的。跟随者为了保持与首领的状态一致,在有新消息到达时尝试从首领那里复制消息,不过有各种原因会导致同步失败。例如,网络拥塞导致复制变慢,broker 发生崩演导致复制滞后,直到重启broke r后复制才会继续。

为了与首领保持同步,跟随者向首领发送获取数据的请求,这种请求与消费者为了读取消息而发送的请求是一样的。首领将响应消息发给跟随者。请求消息里包含了跟随者想要获取消息的偏移量,而且这些偏移量总是有序的。

一个跟随者副本先请求消息1,接着请求消息2,然后请求消息3,在收到这3个请求的响应之前,它是不会发送第4个请求消息的。如果跟随者发送了请求消息4,那么首领就知道它已经收到了前面3个请求的响应。通过查看每个跟随者请求的最新偏移量,首领就会知道每个跟随者复制的进度。如果跟随者在10S内没有请求任何消息,或者虽然在请求消息,但在10S内没有请求最新的数据,那么它就会被认为是不同步的。如果一个副本无法与首领保持一致,在首领发生失效时,它就不可能成为新首领,因为它没有包含全部的消息。
相反,持续请求得到的最新消息副本被称为同步副本。在首领发生失效时,只有同步副本才有可能被选为新首领。

处理请求的内部机制

broker的大部分工作是处理客户端、分区副本和控制器发送给分区首领的请求。Kafka提供了一个二进制协议(基于TCP),指定了请求消息的格式以及broker 如何对请求作出响应——包括成功处理请求或在处理请求过程中遇到错误。

客户端发起连接并发送请求,broker处理请求并作出响应。broker按照请求到达的顺序来处理它们这种顺序保证让Kaka具有了消息队列的特性,同时保证保存的消息也是有序的。

所有的请求消息都包含一个标准消息头:
Request type(也就是 API key)
Request version(broker 可以处理不同版本的客户端请求,并根据客户端版本作出不同的响应)
Correlation id 一个具有唯一性的数字,用于标识请求消息,同时也会出现在响应消息和错误日志里(用于诊断问题)
client id 用于标识发送请求的客户端

broker 会在它所监听的每一个端口上运行一个 Acceptor 线程,这个线程会创建一个连接并把它交给 Processor 线程去处理。Processor线程(也被叫作“网络线程”)的数量是可配置的。网络线程负责从客户端获取请求消息,把它们放进请求队列,然后从响应队列获取响应消息,把它们发送给客户端。

在这里插入图片描述
请求消息被放到请求队列后,IO 线程会负责处理它们。比较常见的请求类型有:
生产请求:生产者发送的请求,它包含客户端要写入broker的消息。
获取请求:在消费者和跟随者副本需要从broker读取消息时发送的请求。
在这里插入图片描述
生产请求和获取请求都必须发送给分区的首领副本。如果broker收到一个针对特定分区的请求,而该分区的首领在另一个broker上,那么发送请求的客户端会收到一个“非分区首领”的错误响应。当针对特定分区的获取请求被发送到一个不含有该分区首领的broker上,也会出现同样的错误。Kafka客户端要自己负责把生产请求和获取请求发送到正确的broker上。

那么客户端怎么知道该往哪里发送请求呢?客户端使用了另一种请求类型,也就是元数据请求。这种请求包含了客户端感兴趣的主题列表。服务器端的响应消息里指明了这些主题所包含的分区、每个分区都有哪些副本,以及哪个副本是首领。元数据请求可以发送给任意一个broker,因为所有broker都缓存了这些信息。

一般情况下,客户端会把这些信息缓存起来,并直接往目标broker上发送生产请求和获取请求。它们需要时不时地通过发送元数据请求来刷新这些信息(刷新的时间间隔通过metadata.max.age.ms参数来配置,2.1.3的客户端默认参数30s),从而知道元数据是否发生了变更,比如,在新broker加入集群时,部分副本会被移动到新的broker上。另外,如果客户端收到“非首领”错误,它会在尝试重发请求之前先刷新元数据,因为这个错误说明了客户端正在使用过期的元数据信息,之前的请求被发到了错误的broker上。

生产请求

包含首领副本的broker在收到生产请求时,会对请求做一些验证。
发送数据的用户是否有主题写入权限?
请求里包含的acks值是否有效(只允许出现0、1、all)?
如果acks=all,是否有足够多的同步副本保证消息已经被安全写入?
之后,消息被写入本地磁盘。在Linux系统上,消息会被写到文件系统缓存里,并不保证它们何时会被刷新到磁盘上。Kafka不会一直等待数据被写到磁盘上,它依赖复制功能来保证消息的持久性。
在消息被写入分区的首领之后,broker 开始检查acks配置参数。如果acks 被设为0或1,那么broker立即返回响应;如果acks被设为all,那么请求会被保存在一个叫作炼狱(purgatory,受难的处所)的缓冲区里,直到首领发现所有跟随者副本都复制了消息,响应才会被返回给客户端。

获取请求

broker处理获取请求的方式与处理生产请求的方式很相似。客户端可以指定broker最多可以从一个分区里返回多少数据。这个限制是非常重要的,因为客户端需要为broker返回的数据分配足够的内存。如果没有这个限制,broker返回的大量数据有可能耗尽客户端的内存。

前面说过,请求需要先到达指定的分区首领上,然后客户端通过查询元数据来确保请求的路由是正确的。首领在收到请求时,它会先检查请求是否有效,比如,指定的偏移量在分区上是否存在?如果客户端请求的是已经被删除的数据,或者请求的偏移量不存在,那么 broker 将返回一个错误。

如果请求的偏移量存在,broker 将按照客户端指定的数量上限从分区里读取消息,再把消息返回给客户端。Kafka使用零复制技术向客户端发送消息。也就是说,Kafka直接把消息从文件(或者更确切地说是Linux文件系统缓存)里发送到网络通道,而不需要经过任何中间缓冲区。这是Kafka与其他大部分数据库系统不一样的地方,其他数据库在将数据发送给客户端之前会先把它们保存在本地缓存里。这项技术避免了字节复制,也不需要管理内存缓冲区,从而获得更好的性能。

客户端除了可以设置broker 返回数据的上限,也可以设置下限。在主题消息流量不是很大的情况下,这样可以减少cpu和网络开销。客户端发送一个请求,broker等到有足够的数据时才把它们返回给客户端,然后客户端再发出情求,而不是让客户端每隔几毫秒就发送一次请求,每次只能得到很少的数据甚至没有数据。对比这两种情况,它们最终读取的数据总量是一样的,但前者的来回传送次数更少,因此开销也更小。
在这里插入图片描述
当然,我们不会让客户端一直等待broker累积数据。在等待了一段时间之后,就可以把可用的数据拿回处理,而不是一直等待下去。所以,客户端可以定义一个超时时间。

ISR

并不是所有保存在分区首领上的数据都可以被客户端读取。大部分客户端只能读取已经被写入所有同步副本的消息。分区首领知道每个消息会被复制到哪个副本上,在消息还没有被写入所有同步副本之前,是不会发送给消费者的,尝试获取这些消息的请求会得到空的响应而不是错误。
在这里插入图片描述
因为还没有被足够多副本复制的消息被认为是“不安全”的,如果首领发生崩溃,另一个副本成为新首领,那么这些消息就丢失了。如果我们允许消费者读取这些消息,可能就会破坏一致性。这也意味着,如果broker间的消息复制因为某些原因变慢,那么消息到达消费者的时间也会随之变长(因为我们会先等待消息复制完毕)。延迟时间可以通过参数replica.lag.time.max.ms 来配置,它指定了副本在复制消息时可被允许的最大延迟时间。

Kafka的数据复制是以Partition为单位的。而多个备份间的数据复制,通过Follower向Leader拉取数据完成。从一这点来讲,有点像Master-Slave方案。不同的是,Kafka既不是完全的同步复制,也不是完全的异步复制,而是基于ISR的动态复制方案。

ISR,也即In-sync Replica,副本同步队列。每个Partition的首领都会维护这样一个列表,该列表中,包含了所有与之同步的Replica(包含首领自己)。每次数据写入时,只有ISR中的所有Replica都复制完,首领才会将其置为commit,它才能被consumer所消费。

这种方案,与同步复制非常接近。但不同的是,这个ISR是由首领动态维护的。如果从属不能紧“跟上"首领,它将被首领从ISR中移除,待它又重新“跟上"Leader后,会被Leader再次加加ISR中。每次改变ISR后,Leader都会将最新的ISR持久化到Zookeeper中。

至于如何判断某个从属是否“跟上"首领,不同版本的Kafka的策略稍微有些区别。

从0.9.0.0版本开始,replica.lag.max.messages被移除,故首领不再考虑从属落后的消息条数。另外,首领不仅会判断从属是否在replica.lag.time.max.ms时间内向其发送Fetch 请求,同时还会考虑从属是否在该时间内与之保持同步。

示例:
在这里插入图片描述
在第一步中,LeaderA总共收到3条消息,但由于IsR中的Follower只同步了第1条消息(m1),故只有m1被commit,也即只有m1可被consumer消费。此时FollowerB与LeaderA的差距是1,而FollowerC与LeaderA的差距是2,虽然有消息的差距,但是满足同步副本的要求保留在ISR中。
在第二步中,由于旧的LeaderA宕机,新的Leader B在replica.lag.time.max.ms时间内未收到来自A的Fetch请求,故将A从ISR中移除,此时ISR={B,c}。同时,由于此时新的LeaderB中只有2条消息,并未包含m3(m3从未被任何Leader所commit),所以m3无法被consumer消费。
(上图中就是因为acks不为all或者-1,不全部复制,就会导致单台服务器宕机时的数据丢失m3丢失了)

使用ISR的原因

由于首领可移除不能及时与之同步的从属,故与同步复制相比可避免最慢的从属拖慢整体速度,也即ISR提高了系统可用性。
ISR中的所有从属都包含了所有commit过的消息,而只有commit过的消息才会被consumer消费,故从consumer的角度而言,ISR中的所有Replica都始终处于同步状态,从而与异步复制方案相比提高了数据一致性。

ISR相关配置

Broker的min.insync.replicas参数指定了Broker所要求的ISR最小长度,默认值为1。也即极限情况下ISR可以只包含Leader。但此时如果首领所在broker宕机,则该分区不可用,可用性得不到保证。

只有被ISR中所有Replica同步的消息才被Commit,但Producer发布数据时,首领并不需要ISR中的所有Replica同步该数据才确认收到数据。Producer可以通过acks参数指定最少需要多少个Replica 确认收到该消息才视为该消息发送成功。acks的默认值是1,即首领收到该消息后立即告诉Producer收到该消息,此时如果在ISR中的消息复制完该消息前首领宕机,那该条消息会丢失。而如果将该值设置为0,则Producer 发送完数据后,立即认为该数据发送成功,不作任何等待,而实际上该数据可能发送失败,并且Producer的Retry机制将不生效。更推荐的做法是,将acks设置为all或者-1,此时只有ISR中的所有Replica都收到该数据(也即该消息被Commit),首领才会告诉Producer该消息发送成功,从而保证不会有未知的数据丢失。

参考:King——笔记-Kafka

发布了130 篇原创文章 · 获赞 233 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_44367006/article/details/103178309