Kafka 副本同步

461 阅读5分钟

06_Kafka副本同步机制 (6).jpg

一、简介

在 Kafka 启动的时候,会初始化 ReplicaManager 这样的一个类,主要就是负责数据写入磁盘的,在构建 ReplicaManager 的时候,在其内部构建了一个 ReplicaFetcherManager 用来处理 leader , follow 之间的数据同步。 这个 ReplicaFetcherManager 会创建一个后台线程,构建线程自己的 Channel , Selector , NetWorkClient 进行网络通信,调用 dowork() 开始工作

 override def doWork() {

    val fetchRequest = inLock(partitionMapLock) {
       // 针对 leader 都在某个 broker 上的一批 follow 分区,构建了一个 fetchRequest
      // 然后发送给 Broker 
      val fetchRequest = buildFetchRequest(partitionMap)
      if (fetchRequest.isEmpty) {
        trace("There are no active partitions. Back off for %d ms before sending a fetch request".format(fetchBackOffMs))
        partitionMapCond.await(fetchBackOffMs, TimeUnit.MILLISECONDS)
      }
      fetchRequest
    }

    if (!fetchRequest.isEmpty)// 处理请求
      processFetchRequest(fetchRequest)
  }

二、同步流程

2.1、构建 Fetch 请求

首先会通过 buildFetchRequest() 方法,构建一个 fetch 请求,会将要拉取的 partition 和 partition 的信息封装一下

  • 需要从哪开始拉取
  • 最多拉取多少数据,默认为 1024 * 1024
  • 最少拉取多少数据,默认为 1 ,如果连 1 都没有的话,会默认等待 500ms ,如果等待 500ms 也是没有数据的话,就会直接返回
  • Topic Name
  • 最大等待时间
  • 需要拉取的分区
protected def buildFetchRequest(partitionMap: Map[TopicAndPartition, PartitionFetchState]): FetchRequest = {
    val requestMap = mutable.Map.empty[TopicPartition, JFetchRequest.PartitionData]

    partitionMap.foreach { case ((TopicAndPartition(topic, partition), partitionFetchState)) =>
      if (partitionFetchState.isActive)
        requestMap(new TopicPartition(topic, partition)) = new JFetchRequest.PartitionData(partitionFetchState.offset, fetchSize)
      // 从哪开始拉,拉取的数据量有多大,默认是 1M
    }
    // 一次 fetch 至少拉取1字节数据,如果一个字节数据都没有,会等待一段时间
    // 默认最多等待 500 ms ,如果 500ms 还没有的话,就会返回
    new FetchRequest(new JFetchRequest(replicaId, maxWait, minBytes, requestMap.asJava))
  }

2.2、发送 Fetch 请求

这里主要就是采用了一个同步的请求方式,必须要等到获取到响应才可以,其底层发送逻辑和之前服务端分析的是一样的,Kafka 基于 NIO 自己封装的那套组件。

val header = apiVersion.fold(networkClient.nextRequestHeader(apiKey))(networkClient.nextRequestHeader(apiKey, _))
    try {
      if (!networkClient.blockingReady(sourceNode, socketTimeout)(time))
        throw new SocketTimeoutException(s"Failed to connect within $socketTimeout ms"
#### 2.3、处理 Fetch 请求


##### 2.3.1、拉取数据
  (1)首先根据 topic 和 partition 确定文件。
(2)通过 startOffset 确定 log 文件的 Segment 日志段。
(3)读取 segment 数据,通过稀疏索引确定文件的 position 。
(4)位置确认后,底层通过 NIO 将数据读取为 ByteBuffer ,封装为 FileMessageSet,然后封装为 FetchDataInfo。




##### 2.3.2、更新元数据


- follower 更新 fetch 时间

follow 每次发送 fetch 请求过来之后,都会更新一下 fetch 请求的时间。


- follower 更新 LEOKafkaApis 处理 Fetch 请求,执行完拉取数据操作之后,会通过拉取到的数据更新 FollowLEO。


- ISR 列表更新

收缩:在 **ReplicaManager** 启动的时候,内部会初始化一个调度任务,默认是 **每隔 10秒 调度一次** , 它主要就是负责检查所有 follow 的 fetch 请求时间,follow 在发送 fetch 请求的时候,不仅会更新 LEO ,也会更新自己发送 fetch 请求的时候, **当前时间 - 上一次更新时间 > 最大等待时间 (maxLagMs , 默认是 10s) ** 就认为应该要踢出 ISR 列表,这时就会对 ISR 列表进行更新,但这里对 ISR 列表进行更新之后,需要对 LeaderHW 进行更新,就比如说此时 LeaderLEO = 90 ,follow1 的 LEO = 80 ,follow2 的 LEO = 90,那么此时 LeaderHW = 80,但如果此时剔除掉 follow1 ,那么此时就应该更新 LeaderHW = 90。

增加:在 处理 Fetch 请求拉取完数据之后,会判断当前 follow 的 LEO 是否 **大于等于 LeaderHW** ,如果满足条件就会重新加入到 ISR 集合当中,并且因为 ISR 集合的变动,会触发更新 LeaderHW 操作。


更新 HW :这里更新 HW 的逻辑比较简单,就是获取全部 follow 的 LEO ,找到 LEO 的最小值,作为 Leader 新的 HW ,如果新的 HW 小于旧的 HW  则不会进行更新。


```scala
private def maybeIncrementLeaderHW(leaderReplica: Replica): Boolean = {
    val allLogEndOffsets = inSyncReplicas.map(_.logEndOffset) // 获取所有副本的 LEO
    // 找到所有 LEO 的最小值
    val newHighWatermark = allLogEndOffsets.min(new LogOffsetMetadata.OffsetOrdering)
    val oldHighWatermark = leaderReplica.highWatermark
    // 如果新的比旧的大,就做一个变更
    if (oldHighWatermark.messageOffset < newHighWatermark.messageOffset || oldHighWatermark.onOlderSegment(newHighWatermark)) {
      leaderReplica.highWatermark = newHighWatermark
      debug("High watermark for partition [%s,%d] updated to %s".format(topic, partitionId, newHighWatermark))
      true
    } else {
      debug("Skipping update high watermark since Old hw %s is larger than new hw %s for partition [%s,%d]. All leo's are %s"
        .format(oldHighWatermark, newHighWatermark, topic, partitionId, allLogEndOffsets.mkString(",")))
      false
    }
  }
  • fetch 的时候如果刚好没有新的数据,如何延迟执行

当 fetch 请求没有拉取到数据的时候,会将 fetch 请求封装为一个 delayedFetch ,然后 放入延迟调度队列中 ,默认是 500ms ,如果 500ms 都没有新数据写入的话,500ms 一到,就会从延迟队列中出来,返回一个空的数据包回去。 如果 500ms 内有新数据加入,也就是说 leader 有数据写入,当 log.append() 执行完毕之后,会唤醒延迟调度队列中,这个 topic ,partition 对应的那个延迟任务,因为封装 delayFetch 是有一个回调函数的,这时就会通过这个回调函数返回一个空的数据包回去,因为返回的数据是空的,所以会再次发送一个 Fetch 请求,这时因为 leader 已经写完数据了,在进行 fetch 肯定是可以获取到的。

  • ack = all 的时候,如何延迟返回响应,等待副本拉取

这个是在追加消息的时候,对 ack 进行判断,如果是 -1 的话,会封装为一个 delayedProduce,设置好超时时间,回调函数,然后放入到 延迟队列里去,等待所有 ISR 集合中的 follow 拉取完毕,当 follow 拉取完数据之后,会对这个 topic,partition 对应的延迟任务从队列中移除,然后 delayedProducer 就会通过回调函数,将响应信息返回。

2.4、返回 Fetch 结果

当请求处理完毕之后,就会通过回调函数,将响应结果进行返回。

2.5、写入数据

当 Follow 收到响应之后,就会对数据进行处理,将数据写入,基本流程与数据追加流程一致。