kafka07-深入服务端

1、协议设计

  • 在目前的Kafka 2.0.0中,一共包含了43种协议类型,每种协议类型都有对应的请求(Request)和响应(Response),它们都遵守特定的协议模式。

1.1、请求头和响应头

1.1.1、请求头

  • 每种类型的Request都包含相同结构的协议请求头(RequestHeader)和不同结构的协议请求体(RequestBody),如图6-1所示。

  • 协议请求头中包含4个域(Field):api_key、api_version、correlation_id和client_id。

1.1.2、响应头

  • 每种类型的Response也包含相同结构的协议响应头(ResponseHeader)和不同结构的响应体(ResponseBody),如图6-2所示。
  • 协议响应头中只有一个correlation_id。

1.1.3、数据类型

  • 细心的读者会发现不管是在图6-1中还是在图6-2中都有类似int32、int16,string的字样,它们用来表示当前域的数据类型。Kafka中所有协议类型的Request和Response的结构都是具备固定格式的,并且它们都构建于多种基本数据类型之上

  • 下面就以最常见的消息发送和消息拉取的两种协议类型做细致的讲解。

1.2、消息发送协议类型

  • 最常见的两种协议类型:消息发送消息拉取协议。
  • 消息发送的协议类型,即ProduceRequest/ProduceResponse,对应的api_key=0,表示PRODUCE
  • 从Kafka建立之初,其所支持的协议类型就一直在增加,并且对特定的协议类型而言,内部的组织结构也并非一成不变。以ProduceRequest/ProduceResponse为例,截至目前就经历了7个版本(VO~V6)的变迁。

1.2.1、消息发送的请求协议

  • 最新版本ProduceRequest(V6,即api_version=6)的组织结构如图6-3所示:

  • ProduceRequest请求体中各个域的含义:

  • 消息累加器RecordAccumulator中的消息是以<分区,Deque<ProducerBatch>>的形式进行缓存的,之后由Sender线程将其转变成<Node,List<ProducerBatch>>的形式。
    • 针对每个Node,Sender线程在发送消息前会将对应的List<ProducerBatch>形式的内容转变成ProduceRequest的具体结构。
    • List<ProducerBatch>中的内容首先会按照主题名称进行分类(对应ProduceRequest中的topic域),然后按照分区编号进行分类(对应ProduceRequest中的partition域),分类之后的ProducerBatch集合就对应ProduceRequest中的record_set域。
  • 从另一个角度来讲,每个分区中的消息是顺序追加的,那么在客户端中按照分区归纳好之后就可以省去在服务端中转换的操作了,这样将负载的压力分摊给了客户端,从而使服务端可以专注于它的分内之事,如此也可以提升整体的性能。
  • 如果参数acks设置非0值,那么生产者客户端在发送ProduceRequest请求之后就需要(异步)等待服务端的响应ProduceResponse。

1.2.2、消息发送的响应协议

  • 最新版本ProduceResponse(V6)的组织结构如图6-4所示:

  • ProduceResponse响应体中各个域的含义:

  • 消息追加是针对单个分区而言的,那么响应也是针对分区粒度来进行划分的,这样ProduceRequest和ProduceResponse做到了一一对应。 

1.3、消息拉取协议类型

  • 拉取消息的协议类型,即FetchRequest/FechResponse,对应的api_key=1,表示FETCH
  • 截至目前,FetchRequest/FetchResponse共历经了9个版本(V0~V8)的变迁。

1.3.1、消息拉取的请求协议

  • 最新版本FetchRequest(V8,即api_version=8)的组织结构如图6-5所示:

  • FetchRequest请求体中各个域的含义:

  • 不管是follower副本还是普通的消费者客户端,如果要拉取某个分区中的消息,就需要指定详细的拉取信息,也就是需要设定partition、fetch_offset,log_start_offset和max_bytes这4个域的具体值。那么对每个分区而言,就需要占用4B+8B+8B+4B=24B的空间。
  • 一般情况下,不管是follower副本还是普通的消费者,它们的订阅信息是长期固定的。也就是说,FetchRequest中的topics域的内容是长期固定的,只有在拉取开始时或发生某些异常时才会有所变动
  • FetchRequest请求是一个非常频繁的请求,如果要拉取的分区数有很多,比如有1000个分区,那么在网络上频繁交互FetchReuest时就会有固定的1000 x 24B=24KB的字节的内容在传动,如果可以将这24KB的状态保存起来,那么就可以节省这部分所占用的带宽。Kafka从1.1.0版本开始针对FetchRequest引入了session_id、epoch和forgotten_topics_data等域,session_id和epoch确定一条拉取链路的fetchsession。
    • 当session建立或变更时会发送全量式的FetchRequest,所谓的全量式就是指请求体中包含所有需要拉取的分区信息。
    • 当session稳定时则会发送增量式的FetchRequest,即topics域为空,因为topics域的内容已经被缓存在了session链路的两侧。
    • 如果需要从当前fetchsession中取消对某些分区的拉取订阅,则可以使用forgotten_topics_data字段来实现。
  • 这个改进在大规模(有大量的分区副本需要及时同步)的Kafka集群中非常有用,它可以提升集群间的网络带宽的有效使用率。不过对客户端而言效果不是那么明显,一般情况下单个客户端不会订阅太多的分区,不过总体上这也是一个很好的优化改进。

1.3.2、消息拉取的响应协议

  • 最新版本FetchResponse(V8)的组织结构如图6-6所示:

  • FetchResponse结构中的也很多域,它主要分为4层。
    • 第1层包含throttie_time_ms、error_code、session_id和responses,前面3个域都见过,其中session_id和FetchRequest中Session_id对应。responses是一个数组类型,表示响应的具体内容,也就是FetchResponse结构中的第2层。
    • 第2层具体地细化到每个分区的响应。
    • 第3层中包含分区的元数据信息(partition、error_code等)及具体的消息内容(recordset),aborted_transactions和事务相关。
  • 除了Kafka客户端开发人员,绝大多数的其他开发人员基本接触不到或不需要接触具体的协议,那么我们为什么还要了解它们呢?其实,协议的具体定义可以让我们从另一个角度来了解Kafka的本质。以PRODUCE和FETCH为例,从协议结构中就可以看出消息的写入和拉取消费都是细化到每一个分区层级的。并且,通过了解各个协议版本变迁的细节也能够从侧面了解Kafka变迁的历史,在变迁的过程中遇到过哪方面的瓶颈,又采取哪种优化手段,比如FetchRequest的session_id的引入。

2、时间轮

  • Kafka中存在大量的延时操作,比如延时生产、延时拉取和延时删除等。Kafka并没有使用JDK自带的Timer或DelayQueue来实现延时的功能,而是基于时间轮的概念自定义实现了一个用于延时功能的定时器(SystemTimer)。JDK中Timer和DelayQueue的插入和删除操作的平均时间复杂度为O(nlogn)并不能满足Kafka的高性能要求,而基于时间轮可以将插入和删除操作的时间复杂度都降为O(1)。时间轮的应用并非Kafka独有,其应用场景还有很多,在Netty、Akka、Quartz、ZooKeeper等组件中都存在时间轮的踪影。

2.1、单层时间轮

  • 如图6-7所示,Kafka中的时间轮(TimingWheel)是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(TimerTaskList)TimerTaskList是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),其中封装了真正的定时任务(TimerTask)。

  • 时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickMs)。时间轮的时间格个数是固定的,可用wheelSize来表示,那么整个时间轮的总体时间跨度(interval)可以通过公式tickMs x wheelSize计算得出。时间轮还有一个表盘指针(currentTime),用来表示时间轮当前所处的时间,currentTime是tickMs的整数倍currentTime可以将整个时间轮划分为到期部分和未到期部分,currentTime当前指向的时间格也属于到期部分,表示刚好到期,需要处理此时间格所对应的TimerTaskList中的所有任务
  • 若时间轮的tickMs为1ms且wheelSize等于20,那么可以计算得出总体时间跨度interval为20ms。初始情况下表盘指针currentTime指向时间格0,此时有一个定时为2ms的任务插进来会存放到时间格为2的TimerTaskList中。随着时间的不断推移,指针currentTime不断向前推进,过了2ms之后,当到达时间格2时,就需要将时间格2对应的TimeTaskList中的任务进行相应的到期操作。此时若又有一个定时为8ms的任务插进来,则会存放到时间格10中,currentTime再过8ms后会指向时间格10。如果同时有一个定时为19ms的任务插进来怎么办?新来的TimerTaskEntry会复用原来的TimerTaskList,所以它会插入原本已经到期的时间格1。总之,整个时间轮的总体跨度是不变的,随着指针currentTime的不断推进,当前时间轮所能处理的时间段也在不断后移,总体时间范围在currentTime和currenffimetinterval之间

2.2、多层时间轮

  • 如果此时有一个定时为350ms的任务该如何处理?直接扩充wheelSize的大小?Kafka中不乏几万甚至几十万毫秒的定时任务,这个wheelSize的扩充没有底线,就算将所有的定时任务的到期时间都设定一个上限,比如100万毫秒,那么这个wheelSize为100万毫秒的时间轮不仅占用很大的内存空间,而且也会拉低效率。Kafka为此引入了层级时间轮的概念,当任务的到期时间超过了当前时间轮所表示的时间范围时,就会尝试添加到上层时间轮中。
  • 如图6-8所示,复用之前的案例,第一层的时间轮tickMs=1ms,wheelSize=20、interval=20ms。第二层的时间轮的tickMs为第一层时间轮的interval,即20ms。每一层时间轮的wheelSize是固定的,都是20,那么第二层的时间轮的总体时间跨度interval为400ms。以此类推,这个400ms也是第三层的tickMs的大小,第三层的时间轮的总体时间跨度为8000ms

  • 对于之前所说的350ms的定时任务,显然第一层时间轮不能满足条件,所以就升级到第二层时间轮中,最终被插入第二层时间轮中时间格17所对应的TimerTaskList。如果此时又有一个定时为450ms的任务,那么显然第二层时间轮也无法满足条件,所以又升级到第三层时间轮中,最终被插入第三层时间轮中时间格1的TimerTaskList。注意到在到期时间为[400ms,800ms)区间内的多个任务(比如446ms、455ms和473ms的定时任务)都会被放入第三层时间轮的时间格1,时间格1对应的TimerTaskList的超时时间为400ms。随着时间的流逝,当此TimerTaskList到期之时,原本定时为450ms的任务还剩下50ms的时间,还不能执行这个任务的到期操作。这里就有一个时间轮降级的操作,会将这个剩余时间为50ms的定时任务重新提交到层级时间轮中,此时第一层时间轮的总体时间跨度不够,而第二层足够,所以该任务被放到第二层时间轮到期时间为[40ms,60ms)的时间格中再经历40ms之后,此时这个任务又被“察觉”,不过还剩余10ms,还是不能立即执行到期操作。所以还要再有一次时间轮的降级,此任务被添加到第一层时间轮到期时间为[10ms,11ms)的时间格中,之后再经历10ms后,此任务真正到期,最终执行相应的到期操作。

2.3、关于时间轮

  • Kafka在具体实现时间轮TimingWheel时还有一些小细节:
    • TimingWheel在创建的时候以当前系统时间为第一层时间轮的起始时间(startMs),这里的当前系统时间并没有简单地调用System.currentTimeMillis(),而是调用了Time.SYSTEM.hiResClockMs,这是因为currentTimeMillis()方法的时间精度依赖于操作系统的具体实现,有些操作系统下并不能达到毫秒级的精度,而Time.SYSTEM.hiResClockMs实质上采用了System.nanoTime()/1000000来将精度调整到毫秒级。
    • TimingWheel中的每个双向环形链表TimerTaskList都会有一个哨兵节点(sentinel),引入哨兵节点可以简化边界条件。哨兵节点也称为哑元节点(dummynode),它是个附加的链表节点,该节点作为第一个节点,它的值域中并不存储任何东西,只是为了方便操作而引入的。如果一个链表有哨兵节点,那么线性表的第一个元素应该是链表的第二个节点。
    • 除了第一层时间轮,其余高层时间轮的起始时间(startMs)都设置为创建此层时间轮时前面第一轮的currentTime。每一层的currentTime都必须是tickMs的整数倍,如果不满足则会将currentTime修剪为tickMs的整数倍,以此与时间轮中的时间格的到期时间范围对应起来。修剪方法为:currentTime=startMs-(startMs%tickMs)。currentTime会随着时间推移而推进,但不会改变为tickMs的整数倍的既定事实。若某一时刻的时间为timeMs,那么此时时间轮的currentTime=timeMs-(timeMs%tickMs),时间每推进一次,每个层级的时间轮的currentTime都会依据此公式执行推进。
    • Kafka中的定时器只需持有TimingWheel的第一层时间轮的引用,并不会直接持有其他高层的时间轮,但每一层时间轮都会有一个引用(overflowWheel)指向更高一层的应用,以此层级调用可以实现定时器间接持有各个层级时间轮的引用。
  • 关于时间轮的细节就描述到这里,各个组件中对时间轮的实现大同小异。读者读到这里是否会好奇文中一直描述的一个情景——“随着时间的流逝”或“随着时间的推移”,那么在Kafka中到底是怎么推进时间的呢?类似采用JDK中的scheduleAtFixedRate来每秒推进时间轮?显然这样并不合理,TimingWheel也失去了大部分意义。
  • Kafka中的定时器借了JDK中的DelayQueue来协助推进时间轮。具体做法是对于每个使用到的TimerTaskList都加入DelayQueue,“每个用到的TimerTaskList”特指非哨兵节点的定时任务项TimerTaskEntry对应的TimerTaskList.DelayQueueA根据TimerTaskList对应的超时时间expiration来排序,最短expiration的TimerTaskList会被排在DelayQueue的队头。Kafka中会有个线程来获取DelayQueue中到期的任务列表,有意思的是这个线程所对应的名称叫作"ExpiredOperationReaper",可以直译为“过期操作收割机”。当“收割机”线程获取DelayQueue中超时的任务列表TimerTaskList之后,既可以根据TimerTaskList的expiration来推进时间轮的时间,也可以就获取的TimerTaskList执行相应的操作,对里面的TimerTaskEntry该执行过期操作的就执行过期操作,该降级时间轮的就降级时间轮
  • 读到这里或许会感到困惑,开头明确指明的DelayQueue不适合Kafka这种高性能要求的定时任务,为何这里还要引入DelayQueue呢?注意对定时任务项TimerTaskEntry的插入和删除操作而言,TimingWheel时间复杂度为O(1),性能高出DelayQueue很多,如果直接将TimerTaskEntry插入DelayQueue,那么性能显然难以支撑。就算我们根据一定的规则将若干TimerTaskEntry划分到TimerTaskList这个组中,然后将TimerTaskList插入DelayQueue,如果在TimerTaskList中又要多添加一个TimerTaskEntry时该如何处理呢?对DelayQueue而言,这类操作显然变得力不从心。
  • 分析到这里可以发现,Kafka中的TimingWheel专门用来执行插入和删除TimerTaskEntry的操作,而DelayQueue专门负责时间推进的任务。试想一下,DelayQueue中的第一个超时任务列表的expiration为200ms,第二个超时任务为840ms,这里获取DelayQueue的队头只需要O(1)的时间复杂度(获取之后DelayQueue内部才会再次切换出新的队头)。如果采用每秒定时推进,那么获取第一个超时的任务列表时执行的200次推进中有199次属于“空推进”,而获取第二个超时任务时又需要执行639次“空推进”,这样会无故空耗机器的性能资源,这里采用DelayQueue来辅助以少量空间换时间,从而做到了“精准推进”,Kafka中的定时器真可谓“知人善用”,用TimingWheel做最擅长的任务添加和删除操作,而用DelayQueue做最擅长的时间推进工作,两者相辅相成

3、延时操作

3.1、延时生产

  • 如果在使用生产者客户端发送消息的时候将acks参数设置为-1,那么就意味着需要等待ISR集合中的所有副本都确认收到消息之后才能正确地收到响应的结果,或者捕获超时异常。
  • 如图6-9、图6-10和图6-11所示,假设某个分区有3个副本:leader、followerl和follower2,它们都在分区的ISR集合中。为了简化说明,这里我们不考虑ISR集合伸缩的情况。Kafka在收到客户端的生产请求(ProduceRequest)后,将消息3和消息4写入leader副本的本地日志文件。由于客户端设置了acks为-1,那么需要等到followerl和follower2两个副本都收到消息3和消息4后才能告知客户端正确地接收了所发送的消息。如果在一定的时间内,followerl副本或follower2副本没能够完全拉取到消息3和消息4,那么就需要返回超时异常给客户端。生产请求的超时时间由参数request.timeout.ms配置,默认值为30000,即30s

  • 那么这里等待消息3和消息4写入followerl副本和follower2副本,并返回相应的响应结果给客户端的动作是由谁来执行的呢?在将消息写入leader副本的本地日志文件之后,Kafka会创建一个延时的生产操作(DelayedProduce),用来处理消息正常写入所有副本或超时的情况,以返回相应的响应结果给客户端
  • 在Kafka中有多种延时操作,比如前面提及的延时生产,还有延时拉取(DelayedFetch)延时数据删除(DelayedDeleteRecords)等。
    • 延时操作需要延时返回响应的结果,首先它必须有个超时时间(delayMs),如果在这个超时时间内没有完成既定的任务,那么就需要强制完成以返回响应结果给客户端。其次,延时操作不同于定时操作,定时操作是指在特定时间之后执行的操作,而延时操作可以在所设定的超时时间之前完成,所以延时操作能够支持外部事件的触发
    • 就延时生产操作而言,它的外部事件是所要写入消息的某个分区的HW(高水位)发生增长。也就是说,随着follower副本不断地与leader副本进行消息同步,进而促使HW进一步增长,HW每增长一次都会检测是否能够完成此次延时生产操作,如果可以就执行以此返回响应结果给客户端;如果在超时时间内始终无法完成,则强制执行。
  • 延时操作创建之后会被加入延时操作管理器(DelayedOperationPurgatory)来做专门的处理。延时操作有可能会超时,每个延时操作管理器都会配备一个定时器(SystemTimer)来做超时管理定时器的底层就是采用时间轮(TimingWheel)实现的时间轮的轮转是靠“收割机”线程ExpiredOperationReaper来驱动的,这里的“收割机”线程就是由延时操作管理器启动的。也就是说,定时器、“收割机”线程和延时操作管理器都是一一对应的。延时操作需要支持外部事件的触发,所以还要配备一个监听池来负责监听每个分区的外部事件——查看是否有分区的HW发生了增长。另外需要补充的是,ExpiredOperationReaper不仅可以推进时间轮,还会定期清理监听池中已完成的延时操作
  • 图6-12描绘了客户端在请求写入消息到收到响应结果的过程中与延时生产操作相关的细节,在了解相关的概念之后应该比较容易理解:如果客户端设置的acks参数不为-1,或者没有成功的消息写入,那么就直接返回结果给客户端,否则就需要创建延时生产操作并存入延时操作管理器,最终要么由外部事件触发,要么由超时触发而执行。

3.2、延时拉取

  • 以图6-13为例,两个follower副本都已经拉取到了leader副本的最新位置,此时又向leader副本发送拉取请求,而leader副本并没有新的消息写入,那么此时leader副本该如何处理呢?可以直接返回空的拉取结果给follower副本,不过在leader副本一直没有新消息写入的情况下,follower副本会一直发送拉取请求,并且总收到空的拉取结果,这样徒耗资源,显然不太合理。

  • Kafka选择了延时操作来处理这种情况。Kafka在处理拉取请求时,会先读取一次日志文件,如果收集不到足够多(fetchMinBytes,由参数fetch.min.bytes配置,默认值为1)的消息,那么就会创建一个延时拉取操作(DelayedFetch)以等待拉取到足够数量的消息。当延时拉取操作执行时,会再读取一次日志文件,然后将拉取结果返回给follower副本。延时拉取操作也会有一个专门的延时操作管理器负责管理,大体的脉络与延时生产操作相同,不再赘述。如果拉取进度一直没有追赶上leader副本,那么在拉取leader副本的消息时一般拉取的消息大小都会不小于fetchMinBytes,这样Kafka也就不会创建相应的延时拉取操作,而是立即返回拉取结果。
  • 时拉取操作同样是由超时触发或外部事件触发而被执行的。超时触发很好理解,就是等到超时时间之后触发第二次读取日志文件的操作。外部事件触发就稍复杂了一些,因为拉取请求不单单由follower副本发起,也可以由消费者客户端发起,两种情况所对应的外部事件也是不同的。如果是follower副本的延时拉取,它的外部事件就是消息追加到了leader副本的本地日志文件中;如果是消费者客户端的延时拉取,它的外部事件可以简单地理解为HW的增长。
  • 目前版本的Kafka还引入了事务的概念,对于消费者或follower副本而言,其默认的事务隔离级别为"read_uncommitted”。不过消费者可以通过客户端参数isolation.level将事务隔离级别设置为"read_committed”(注意:follower副本不可以将事务隔离级别修改为这个值),这样消费者拉取不到生产者已经写入却尚未提交的消息。对应的消费者的延时拉取,它的外部事件实际上会切换为由LSO(LastStableOffset)的增长来触发。LSO是HW之前除去未提交的事务消息的最大偏移量,LSO<=HW。

4、控制器

  • 在Kafka集群中会有一个或多个broker,其中有一个broker会被选举为控制器(Kafka Controller),它负责管理整个集群中所有分区和副本的状态。当某个分区的leader副本出现故障时,由控制器负责为该分区选举新的1eader本。当检测到某个分区的ISR集合发生变化时,由控制器负责通知所有broker更新其元数据信息。当使用kafka-topics.sh脚本为某个topic增加分区数量时,同样还是由控制器负责分区的重新分配。

4.1、控制器的选举及异常恢复

4.1.1、控制器的选举

  • Kafka中的控制器选举工作依赖于ZooKeeper,成功竞选为控制器的broker会在ZooKeeper中创建/controller这个临时节点,此临时节点的内容参考如下:
    • {"version": 1, "broker id": 0, "timestamp":"1529210278988"}
    • 其中version在目前版本中固定为1,brokerid表示成为控制器的broker的id编号,timestamp表示竞选成为控制器时的时间戮。
  • 在任意时刻,集群中有且仅有一个控制器
    • 每个broker启动的时候会去尝试读取/controller节点的brokerid的值,如果读取到brokerid的值不为-1,则表示已经有其他broker节点成功竞选为控制器,所以当前broker就会放弃竞选;
    • 如果ZooKeeper中不存在/controller节点,或者这个节点中的数据异常,那么就会尝试去创建/controller节点。当前broker去创建节点的时候,也有可能其他broker同时去尝试创建这个节点,只有创建成功的那个broker才会成为控制器,而创建失败的broker竞选失败。
    • 每个broker都会在内存中保存当前控制器的brokerid值,这个值可以标识为activeControllerId。
  • ZooKeeper中还有一个与控制器有关的/controller_epoch持久节点,节点中存放的是一个整型的controller_epoch值。controller_epoch用于记录控制器发生变更的次数,即记录当前的控制器是第几代控制器,我们也可以称之为“控制器的纪元”。
  • controller_epoch的初始值为1,即集群中第一个控制器的纪元为1,当控制器发生变更时,每选出一个新的控制器就将该字段值加1。每个和控制器交互的请求都会携带controller_epoch这个字段,如果请求的controlle_epoch值小于内存中的controller_epoch值,则认为这个请求是向已经过期的控制器所发送的请求,那么这个请求会被认定为无效的请求。如果请求的controller_epoch值大于内存中的controller_eph值,那么说明己经有新的控制器当选了。由此可见,Kafka通过controller_epoch来保证控制器的唯一性,进而保证相关操作的一致性

4.1.2、控制器的职责

  • 控制器的broker比其他普通的broker有更多的职责。
  • (1)监听分区相关的变化。
    • 为ZooKeeper中的/admin/reassign_partitions节点注册PartitionReassignmentHandler,用来处理分区重分配的动作。
    • 为ZooKeeper中的/admin/preferred-replica-election节点添加PreferredReplicaElectionHandler,用来处理优先副本的选举动作。
    • 为ZooKeeper中的lisr_change_notification点注册IsrChangeNotificetionHandler,用来处理ISR集合变更的动作。
  • (2)监听主题相关的变化。
    • 为ZooKeeper中的/brokers/topics节点添加TopicChangeHandler,用来处理主题增减的变化。
    • 为ZooKeeper中的/admin/delete_topics节点添加TopicDeletionHandler,用来处理删除主题的动作。
  • (3)监听broker相关的变化。
    • 为ZooKeeper中的/brokers/ids节点添加BrokerChangeHandler,用来处理broker增减的变化。
  • (4)从ZooKeeper中读取获取当前所有与主题、分区及broker有关的信息并进行相应的管理。
    • 对所有主题对应的ZooKeeper中的/brokers/topics/<topic>节点添加PartitionModificationsHandler,用来监听主题中的分区分配变化。
  • (5)启动并管理分区状态机和副本状态机。
  • (6)更新集群的元数据信息
  • (7)如果参数auto.leader.rebalance.enable设置为true,则还会开启一个名为auto-leader-rebalance-task”的定时任务来负责维护分区的优先副本的均衡。

4.1.3、控制器的原理

  • 控制器在选举成功之后会读取ZooKeeper中各个节点的数据来初始化上下文信息(ControllerContext),并且需要管理这些上下文信息。比如为某个主题增加了若干分区,控制器在负责创建这些分区的同时要更新上下文信息,并且需要将这些变更信息同步到其他普通的broker节点中。
  • 不管是监听器触发的事件,还是定时任务触发的事件,或者是其他事件(比如ControlledShutdown)都会读取或更新控制器中的上下文信息,那么这样就会涉及多线程间的同步。如果单纯使用锁机制来实现,那么整体的性能会大打折扣。针对这一现象,Kafka的控制器使用单线程基于事件队列的模型,将每个事件都做一层封装,然后按照事件发生的先后顺序暂存到LinkedBlockingQueue中,最后使用一个专用的线程(ControllerEventThread)按照FIFO(FirstInputFirstOutput,先入先出)的原则顺序处理各个事件,这样不需要锁机制就可以在多线程间维护线程安全,具体可以参考图6-14。

  • 在Kafka的早期版本中,并没有采用Kafka Controller这样一个概念来对分区和副本的状态进行管理,而是依赖于ZooKeeper,每个broker都会在ZooKeeper上为分区和副本注册大量的监听器(Watcher)。当分区或副本状态变化时,会唤醒很多不必要的监听器,这种严重依赖ZooKeeper的设计会有脑裂、羊群效应,以及造成ZooKeeper过载的隐患(旧版的消费者客户端存在同样的问题)。在目前的新版本的设计中,只有Kafka Controller在ZooKeeper上注册相应的监听器其他的broker极少需要再监听ZooKeeper中的数据变化,这样省去了很多不必要的麻烦。不过每个broker还是会对/controller节点添加监听器,以此来监听此节点的数据变化(ControllerChangeHandler)。

4.1.4、异常恢复

  • 当/controller节点的数据发生变化时,每个broker都会更新自身内存中保存的activeControllerId。如果broker在数据变更前是控制器,在数据变更后自身的brokerid值与新的activeControllerld值不一致,那么就需要“退位”,关闭相应的资源,比如关闭状态机、注销相应的监听器等。
    • 有可能控制器由于异常而下线,造成/controller这个临时节点被自动删除;
    • 也有可能是其他原因将此节点删除了。
  • 当/controller节点被删除时,每个broker都会进行选举,如果broker在节点被删除前是控制器,那么在选举前还需要有一个“退位”的动作。
    • 如果有特殊需要,则可以手动删除/controller节点来触发新一轮的选举。
    • 当然关闭控制器所对应的broker,以及手动向/controller节点写入新的brokerid的所对应的数据,同样可以触发新一轮的选举

4.2、优雅关闭

  • 如何优雅地关闭Kafka?笔者在做测试的时候经常性使用jps(或者ps ax)配合kil1-9的方式来快速关闭Kafka broker的服务进程,显然kill-9这种“强杀”的方式并不够优雅,它并不会等待Kafka进程合理关闭一些资源及保存一些运行数据之后再实施关闭动作。在有些场景中,用户希望主动关闭正常运行的服务,比如更换硬件、操作系统升级、修改Kafka配置等。
  • kafka自带的kafka-server-stop.sh个脚本在很多时候并不奏效, 这与其中的ps命令有关。
  • 优雅地关闭Kafka的服务进程只需要按照以下两个步骤就可以:
    • (1)获取Kafka的服务进程号PIDS。可以使用Java中的jps命令或使用Linux系统中的ps命令来查看。
    • (2)使用kill -s TERM PIDS 或kill -15 PIDS的方式来关闭进程,注意千万不要使用kill -9的方式。
  • 为什么这样关闭的方式会是优雅的?
    • Kafka服务入口程序中有一个名为"kafka-shutdownhock”的关闭钩子,待Kafka进程捕获终止信号的时候会执行这个关闭钩子中的内容,其中除了正常关闭一些必要的资源,还会执行一个控制关闭(ControlledShutdown)的动作。
  • 使用ControlledShutdown 的方式关闭Kafka有两个优点:
    • 一是可以让消息完全同步到磁盘上,在服务下次重新上线时不需要进行日志的恢复操作;
    • 二是ControllerShutdown在关闭服务之前,会对其上的leader副本进行迁移,这样就可以减少分区的不可用时间。
  • 若要成功执行ControlledShutdown动作还需要有一个先决条件,就是参数controlled.shutdown.enable的值需要设置为true,不过这个参数的默认值就为true,即默认开始此项功能。ControlledShutdown动作如果执行不成功还会重试执行,这个重试的动作由参数controlled.shutdown.max.retries配置,默认为3次,每次重试的间隔由参数controlled.shutdown.retry.backoff.ms设置,默认为5000ms。
  • 下面我们具体探讨ControlledShutdown的整个执行过程。
  • 参考图6-16,假设此时有两个broker,其中待关闭的broker的id为x,Kafka控制器所对应的broker的id为y。待关闭的broker在执行ControlledShutdown动作时,首先与Kafka控制器建立专用连接(对应图6-16中的步骤①),然后发送ControlledShutdownRequest请求,ControlledShutdownRequest请求中只有一个brokerid字段,这个brokerid字段的值设置为自身的brokerid的值,即x(对应图6-16中的步骤②)
  • Kafka控制器在收到ControlledShutdownRequest请求之后,将会对与待关闭broker有关联的所有分区进行专门的处理,这里的“有关联”是指分区中有副本位于这个待关闭的broker之上(这里会涉及Kafka控制器与待关闭broker之间的多次交互动作,涉及leader副本的迁移和副本的关闭动作,对应图6-16中的步骤③)

  • 如果这些分区的副本数大于1且leader副本位于待关闭broker上,那么需要实施leader副本的迁移及新的ISR的变更。具体的选举分配的方案由专用的选举器ControlledShutdownLeaderSelector提供。
  • 如果这些分区的副本数只是大于1,leader副本并不位于待关闭broker上,那么就由Kafka控制器来指导这些副本的关闭。如果这些分区的副本数只是为1,那么这个副本的关闭动作会在整个ControlledShutdown动作执行之后由副本管理器来具体实施。
  • 对于分区的副本数大于1且leader副本位于待关闭broker上的这种情况,如果在Kafka控制器处理之后leader副本还没有成功迁移,那么会将这些没有成功迁移leader副本的分区记录下来,并且写入ControlledShutdownResponse响应(对应图6-16中的步骤④,整个ControlledShutdown动作是一个同步阻塞的过程)。
  • 待关闭的broker在收到ControlledShutdownResponse响应之后,需要判断整个ControlledShutdown动作是否执行成功,以此来进行可能的重试或继续执行接下来的关闭资源的动作。行成功的标准是ControlledShutdownResponse中error_code字段值为0,并且partitions_remaining数组字段为空
  • 注意要点:图6-16中也有可能x=y,即待关闭的broker同时是Kafka控制器,这也就意味着自己可以给自己发送ControlledShutdownRequest请求,以及等待自身的处理并接收ControlledShutdownResponse的响应,具体的执行细节和x!=y的场景相同。
  • ControlledShutdownRequest的结构如图6-17所示。

  • ControlledShutdownResponse的结构如图6-18所示。

4.3、分区leader的选举

  • 分区leader副本的选举由控制器负责具体实施。当创建分区(创建主题或增加分区都有创建分区的动作)或分区上线(比如分区中原先的leader副本下线,此时分区需要选举一个新的leader上线来对外提供服务)的时候都需要执行leader的选举动作,对应的选举策略为OfflinePartitionLeaderElectionStrategy。这种策略的基本思路是按照AR集合中副本的顺序查找第一个存活的副本,并且这个副本在ISR集合中一个分区的AR集合在分配的时候就被指定,并且只要不发生重分配的情况,AR集合中的副本顺序是保持不变的,而分区的ISR集合中副本的顺序可能会改变。
  • 注意这里是根据AR的顺序而不是ISR的顺序进行选举的。举个例子,集群中有3个节点:broker1、broker2和broker3,在某一时刻具有3个分区且副本因子为3的主题topic-leader的具体信息如下:
]# kafka-topics.sh --zookeeper localhost:2181 --describe --topic topic-leader
Topic: topic-leader	TopicId: EyMNc3H3Td-UFiWLfOml6g	PartitionCount: 3	ReplicationFactor: 3	Configs: 
	Topic: topic-leader	Partition: 0	Leader: 3	Replicas: 3,1,2	Isr: 3,1,2
	Topic: topic-leader	Partition: 1	Leader: 1	Replicas: 1,2,3	Isr: 1,3,2
	Topic: topic-leader	Partition: 2	Leader: 2	Replicas: 2,3,1	Isr: 3,1,2
  • 此时关闭broker1,那么对于分区1而言,存活的AR就变为[2,3],同时ISR变为[3,2]。此时查看主题topic-leader的具体信息(参考如下),分区1的leader就变为了2而不是3。
]# kafka-topics.sh --zookeeper localhost:2181 --describe --topic topic-leader
Topic: topic-leader	TopicId: EyMNc3H3Td-UFiWLfOml6g	PartitionCount: 3	ReplicationFactor: 3	Configs: 
	Topic: topic-leader	Partition: 0	Leader: 3	Replicas: 3,1,2	Isr: 3,2
	Topic: topic-leader	Partition: 1	Leader: 2	Replicas: 1,2,3	Isr: 3,2
	Topic: topic-leader	Partition: 2	Leader: 2	Replicas: 2,3,1	Isr: 3,2
  • 如果ISR集合中没有可用的副本,那么此时还要再检查一下所配置的unclean.leader.election.enable参数(默认值为false)。如果这个参数配置为true那么表示允许从非ISR列表中的选举leader,从AR列表中找到第一个存活的副本即为leader。
  • 当分区进行重分配的时候也需要执行leader的选举动作,对应的选举策略为ReassignPartitionLeaderElectionStrategy。这个选举策略的思路比较简单:从重分配的AR列表中找到第一个存活的副本,且这个副本在目前的ISR列表中。当发生优先副本的选举时,直接将优先副本设置为leader即可,AR集合中的第一个副本即为优先副本(PreferredReplicaPartitionLeaderElectionStrategy)。
  • 还有一种情况会发生leader的选举,当某节点被优雅地关闭(也就是执行ControlledShutdown)时,位于这个节点上的leader副本都会下线,所以与此对应的分区需要执行leader的选举。与此对应的选举策略(ControlledShutdownPartitionLeaderElectionStrategy)为:从AR列表中找到第一个存活的副本,且这个副本在目前的ISR列表中,与此同时还要确保这个副本不处于正在被关闭的节点上。

1

#                                                                                                                       #
posted @ 2022-02-14 18:03  麦恒  阅读(36)  评论(0编辑  收藏  举报