深入解析Kafka Broker核心读写机制

Apache Kafka Broker 的核心读写逻辑,涵盖了以下关键功能:

  • 日志偏移滞后计算(getLogEndOffsetLag
  • 删除记录(deleteRecords
  • 写入消息(appendToLocalLog
  • 读取消息(fetchMessages + readFromLocalLog

这些方法共同构成了 Kafka 副本管理器(ReplicaManager) 的核心能力:处理生产者写入、消费者/Follower 拉取、日志清理、延迟操作等

下面我将从 整体架构 → 各模块详解 → 关键机制总结 三个层面帮你系统理解。


🧱 一、整体架构定位

这些方法都属于 ReplicaManager 类(或其伴生对象),它是 Kafka Broker 中 负责分区副本生命周期和读写协调的核心组件

功能 对应方法 触发来源
生产者写入 appendToLocalLogappendRecordsToLeader ProduceRequest
消费者/Follower 拉取 fetchMessagesreadFromLocalLog FetchRequest
删除旧数据(按 offset) deleteRecords DeleteRecordsRequest
查询迁移进度 getLogEndOffsetLag DescribeLogDirsRequest

💡 所有对日志(Log)的读写操作,最终都会通过 Partition 对象委托给 LogManager 和底层 LogSegment


🔍 二、逐方法详解

1️⃣ getLogEndOffsetLag(...):计算日志偏移滞后

scala 复制代码
def getLogEndOffsetLag(topicPartition: TopicPartition, logEndOffset: Long, isFuture: Boolean): Long
✅ 作用:

返回某个日志(可能是 current 或 future)相对于"权威源"的 offset 滞后量(lag)

📌 逻辑:
  • 如果是 future log(正在迁移中)

    scala 复制代码
    log.logEndOffset - logEndOffset
    • log.logEndOffset:当前主日志(current log)的 LEO
    • logEndOffset:future log 自己的 LEO
    • lag = 主日志比它多写了多少条
    • lag 越小,说明迁移越接近完成
  • 如果是 current log(正常副本)

    scala 复制代码
    math.max(log.highWatermark - logEndOffset, 0)
    • 这里用于 DescribeLogDirs,目的是展示"该副本是否落后于高水位"

    • 实际上在副本同步中,lag 是用 LEO 算的,这里是为了监控用途

      各变量含义:

      log.highWatermark:高水位线,表示已提交(committed)消息的最大偏移量,消费者只能读到这个位置之前的消息

      logEndOffset:传入的日志末端偏移量(通常是一个副本的当前末端偏移量)

      math.max(..., 0):确保结果不小于0

      逻辑解释:

      计算滞后量:log.highWatermark - logEndOffset

      如果副本的末端偏移量落后于高水位线,结果为正数,表示滞后的大小

      如果副本已经追上或超过了高水位线,结果为负数或0

      取最大值确保非负:使用 math.max(..., 0) 保证返回值不小于0

      当副本已经超过高水位线时,返回0(表示没有滞后)

      当副本落后时,返回实际的滞后量

      应用场景:

      这行代码用于非未来副本(isFuture = false)的滞后计算:

      对于普通的 follower 副本,比较它落后于高水位线多少

      如果副本已经达到或超过高水位线,滞后期视为0(没有滞后)

  • 如果分区不存在 → 返回 -1INVALID_OFFSET_LAG

用途describeLogDirs 接口用它来显示迁移进度或副本健康度。


2️⃣ deleteRecords(...):按 offset 删除数据(日志截断)

scala 复制代码
  /**
   * 删除指定分区中的记录
   *
   * @param timeout 超时时间(毫秒)
   * @param offsetPerPartition 每个主题分区要删除的偏移量映射
   * @param responseCallback 响应回调函数,处理删除结果
   */
  def deleteRecords(timeout: Long,
                    offsetPerPartition: Map[TopicPartition, Long],
                    responseCallback: Map[TopicPartition, DeleteRecordsPartitionResult] => Unit): Unit = {
    val timeBeforeLocalDeleteRecords = time.milliseconds
    val localDeleteRecordsResults = deleteRecordsOnLocalLog(offsetPerPartition)
    debug("Delete records on local log in %d ms".format(time.milliseconds - timeBeforeLocalDeleteRecords))

    // 构建删除记录状态映射
    val deleteRecordsStatus = localDeleteRecordsResults.map { case (topicPartition, result) =>
      topicPartition ->
        DeleteRecordsPartitionStatus(
          result.requestedOffset, // requested offset
          new DeleteRecordsPartitionResult()
            .setLowWatermark(result.lowWatermark)
            .setErrorCode(result.error.code)
            .setPartitionIndex(topicPartition.partition)) // response status
    }

    // 判断是否需要延迟删除记录操作
    if (delayedDeleteRecordsRequired(localDeleteRecordsResults)) {
      // 创建延迟删除记录操作
      val delayedDeleteRecords = new DelayedDeleteRecords(timeout, deleteRecordsStatus, this, responseCallback)

      // 创建用于此延迟删除记录操作的键列表
      val deleteRecordsRequestKeys = offsetPerPartition.keys.map(TopicPartitionOperationKey(_)).toSeq

      // 尝试立即完成请求,否则将其放入等待队列
      // 这是因为在创建延迟删除记录操作时,可能有新请求到达并使该操作可完成
      delayedDeleteRecordsPurgatory.tryCompleteElseWatch(delayedDeleteRecords, deleteRecordsRequestKeys)
    } else {
      // 可以立即响应
      val deleteRecordsResponseStatus = deleteRecordsStatus.map { case (k, status) => k -> status.responseStatus }
      responseCallback(deleteRecordsResponseStatus)
    }
  }
✅ 作用:

实现 DeleteRecords API (KIP-107),允许管理员 将日志截断到指定 offset 之前(即删除旧数据)。

⚠️ 注意:这不同于基于时间的 retention,而是强制按 offset 删除

🔄 流程:
  1. 立即执行本地删除

    scala 复制代码
    val localDeleteRecordsResults = deleteRecordsOnLocalLog(offsetPerPartition)
    • 调用 Log.truncateTo(targetOffset) 截断日志
    • 更新 LSO(Log Start Offset)
  2. 判断是否需要延迟响应

    scala 复制代码
    if (delayedDeleteRecordsRequired(...))
    • 虽然代码没展开,但通常 DeleteRecords 不需要等待 ISR 同步(因为只是删旧数据,不影响一致性)
    • 所以多数情况会 立即回调
  3. 否则放入 Purgatory(延迟队列)

    • 使用 DelayedDeleteRecords + delayedDeleteRecordsPurgatory
    • 等待条件满足(如所有副本都完成截断?但实际 Kafka 目前只在 Leader 执行)

💡 实际上,Kafka 的 deleteRecords 只在 Leader 上执行,不保证 Follower 同步删除(因为旧数据对 Follower 无害)。


3️⃣ appendToLocalLog(...):处理生产者写入

scala 复制代码
  /**
   * 将消息追加到本地副本日志中。
   *
   * @param internalTopicsAllowed 是否允许向内部主题写入数据
   * @param origin                消息来源(例如客户端、follower等)
   * @param entriesPerPartition   每个分区对应的消息记录集合
   * @param requiredAcks          要求的确认副本数(0表示不需要确认,1表示leader确认,-1表示所有ISR副本确认)
   * @return                      返回每个分区的追加结果映射,包含追加信息及可能发生的异常
   */
  private def appendToLocalLog(internalTopicsAllowed: Boolean,
                               origin: AppendOrigin,
                               entriesPerPartition: Map[TopicPartition, MemoryRecords],
                               requiredAcks: Short): Map[TopicPartition, LogAppendResult] = {
    val traceEnabled = isTraceEnabled

    /**
     * 处理追加失败的情况:更新统计指标并记录错误日志。
     *
     * @param topicPartition 发生错误的分区
     * @param t              抛出的异常
     * @return               当前分区的日志起始偏移量
     */
    def processFailedRecord(topicPartition: TopicPartition, t: Throwable) = {
      val logStartOffset = getPartition(topicPartition) match {
        case HostedPartition.Online(partition) => partition.logStartOffset
        case HostedPartition.None | HostedPartition.Offline => -1L
      }
      brokerTopicStats.topicStats(topicPartition.topic).failedProduceRequestRate.mark()
      brokerTopicStats.allTopicsStats.failedProduceRequestRate.mark()
      error(s"Error processing append operation on partition $topicPartition", t)

      logStartOffset
    }

    if (traceEnabled)
      trace(s"Append [$entriesPerPartition] to local log")

    entriesPerPartition.map { case (topicPartition, records) =>
      brokerTopicStats.topicStats(topicPartition.topic).totalProduceRequestRate.mark()
      brokerTopicStats.allTopicsStats.totalProduceRequestRate.mark()

      // 如果不允许写入内部主题,并且当前是内部主题,则拒绝写入
      if (Topic.isInternal(topicPartition.topic) && !internalTopicsAllowed) {
        (topicPartition, LogAppendResult(
          LogAppendInfo.UnknownLogAppendInfo,
          Some(new InvalidTopicException(s"Cannot append to internal topic ${topicPartition.topic}"))))
      } else {
        try {
          // 获取目标分区对象,如果不存在则抛出异常
          val partition = getPartitionOrException(topicPartition)
          // 在leader上追加消息记录
          val info = partition.appendRecordsToLeader(records, origin, requiredAcks)
          val numAppendedMessages = info.numMessages

          // 更新成功写入的字节数和消息数量统计
          brokerTopicStats.topicStats(topicPartition.topic).bytesInRate.mark(records.sizeInBytes)
          brokerTopicStats.allTopicsStats.bytesInRate.mark(records.sizeInBytes)
          brokerTopicStats.topicStats(topicPartition.topic).messagesInRate.mark(numAppendedMessages)
          brokerTopicStats.allTopicsStats.messagesInRate.mark(numAppendedMessages)

          if (traceEnabled)
            trace(s"${records.sizeInBytes} written to log $topicPartition beginning at offset " +
              s"${info.firstOffset.getOrElse(-1)} and ending at offset ${info.lastOffset}")

          // 成功处理后返回结果
          (topicPartition, LogAppendResult(info))
        } catch {
          // 已知异常不会增加失败请求数量指标,这些通常是由客户端行为引起的预期错误
          case e@ (_: UnknownTopicOrPartitionException |
                   _: NotLeaderOrFollowerException |
                   _: RecordTooLargeException |
                   _: RecordBatchTooLargeException |
                   _: CorruptRecordException |
                   _: KafkaStorageException) =>
            (topicPartition, LogAppendResult(LogAppendInfo.UnknownLogAppendInfo, Some(e)))

          // 记录验证异常需要特殊处理,包括记录具体的错误项
          case rve: RecordValidationException =>
            val logStartOffset = processFailedRecord(topicPartition, rve.invalidException)
            val recordErrors = rve.recordErrors
            (topicPartition, LogAppendResult(LogAppendInfo.unknownLogAppendInfoWithAdditionalInfo(
              logStartOffset, recordErrors, rve.invalidException.getMessage), Some(rve.invalidException)))

          // 其他未预期异常统一处理
          case t: Throwable =>
            val logStartOffset = processFailedRecord(topicPartition, t)
            (topicPartition, LogAppendResult(LogAppendInfo.unknownLogAppendInfoWithLogStartOffset(logStartOffset), Some(t)))
        }
      }
    }
  }

这是 ProduceRequest 的核心处理逻辑

📌 关键点:
✅ 写入流程:
  1. 拒绝写入内部 topic(除非 internalTopicsAllowed = true
  2. 获取 Partition 对象
  3. 调用 partition.appendRecordsToLeader(...)
    • 加锁(leaderEpoch 校验)
    • 写入本地 Log(追加到 active segment)
    • 更新 LEO、HW(如果 requiredAcks = 1)
  4. 更新指标(bytesInRate, messagesInRate)
✅ 异常处理:
  • 已知异常(如 NotLeaderOrFollowerException)→ 直接返回错误码
  • 未知异常(如磁盘 IO 错误)→ 记录 failedProduceRequestRate
✅ requiredAcks 支持:
  • 0:不等确认
  • 1:等 Leader 写入成功
  • -1(all):等 ISR 全部同步(此时可能触发 DelayedProduce

🔗 注意:requiredAcks = -1 时,不会在这里等待 Follower 同步

而是在上层调用 handleProducerRequest 时,根据 delayedProduceRequestRequired 决定是否放入 DelayedProduce 队列。


4️⃣ fetchMessages(...) + readFromLocalLog(...):处理拉取请求

scala 复制代码
/**
 * 从副本中获取消息,并等待直到满足足够的数据量后返回;
 * 回调函数将在超时或满足所需获取信息时被触发。
 * 消费者可以从任意副本获取数据,但跟随者只能从领导者获取数据。
 *
 * @param timeout           请求处理超时时间(毫秒)。
 *                          对于消费者来说,这是 `request.timeout.ms` 参数的值;
 *                          对于 Follower 副本来说,这是 Broker 端参数 `replica.fetch.wait.max.ms` 的值。
 * @param replicaId         发送请求的副本编号。
 *                          对于消费者,该值为 -1;
 *                          对于 Follower 副本,该值为其所在 Broker 的 ID。
 * @param fetchMinBytes     最小响应字节数。如果当前可读取的数据不足此值,则会等待更多数据到达。
 * @param fetchMaxBytes     最大响应字节数。用于控制一次 fetch 请求的最大返回数据大小。
 * @param hardMaxBytesLimit 是否对最大字节数进行硬性限制。
 *                          若为 true,则严格遵守 fetchMaxBytes 上限;
 *                          否则允许在某些情况下突破上限以保证至少一条消息能被读取。
 * @param fetchInfos        要获取的分区及其对应的读取信息列表。
 *                          包括:要读取的分区、起始偏移量、最大读取字节数等。
 * @param quota             副本配额对象,用于流量控制。
 * @param responseCallback  当获取完成或超时时执行的回调函数。
 *                          接收一个包含每个分区及对应获取结果的序列作为参数。
 * @param isolationLevel    隔离级别,决定消费者能看到哪种状态的消息。
 *                          READ_UNCOMMITTED 表示可见所有已提交和未提交事务的消息;
 *                          READ_COMMITTED 表示仅可见已提交事务的消息。
 * @param clientMetadata    客户端元数据,可能为空。
 *                          主要用于判断客户端版本以及支持的功能特性。
 */
def fetchMessages(timeout: Long,
                  replicaId: Int,
                  fetchMinBytes: Int,
                  fetchMaxBytes: Int,
                  hardMaxBytesLimit: Boolean,
                  fetchInfos: Seq[(TopicPartition, PartitionData)],
                  quota: ReplicaQuota,
                  responseCallback: Seq[(TopicPartition, FetchPartitionData)] => Unit,
                  isolationLevel: IsolationLevel,
                  clientMetadata: Option[ClientMetadata]): Unit = {
  val isFromFollower = Request.isValidBrokerId(replicaId)
  val isFromConsumer = !(isFromFollower || replicaId == Request.FutureLocalReplicaId)

  // 根据请求来源确定可读取范围:
  // - 来自消费者的请求可以读到高水位(HW)或LSO(取决于隔离级别)
  // - 来自follower的请求可以读到LEO
  val fetchIsolation = if (!isFromConsumer)
    FetchLogEnd
  else if (isolationLevel == IsolationLevel.READ_COMMITTED)
    FetchTxnCommitted
  else
    FetchHighWatermark

  // 如果请求来自follower或旧版consumer客户端,则只允许从leader副本读取
  val fetchOnlyFromLeader = isFromFollower || (isFromConsumer && clientMetadata.isEmpty)

  /**
   * 从本地日志中实际读取消息的核心方法封装
   */
  def readFromLog(): Seq[(TopicPartition, LogReadResult)] = {
    val result = readFromLocalLog(
      replicaId = replicaId,
      fetchOnlyFromLeader = fetchOnlyFromLeader,
      fetchIsolation = fetchIsolation,
      fetchMaxBytes = fetchMaxBytes,
      hardMaxBytesLimit = hardMaxBytesLimit,
      readPartitionInfo = fetchInfos,
      quota = quota,
      clientMetadata = clientMetadata)
    
    // 如果是follower发起的请求,更新其拉取状态
    if (isFromFollower) updateFollowerFetchState(replicaId, result)
    else result
  }

  val logReadResults = readFromLog()

  // 统计本次读取的所有字节数并检查是否有错误发生
  var bytesReadable: Long = 0
  var errorReadingData = false
  val logReadResultMap = new mutable.HashMap[TopicPartition, LogReadResult]
  logReadResults.foreach { case (topicPartition, logReadResult) =>
    brokerTopicStats.topicStats(topicPartition.topic).totalFetchRequestRate.mark()
    brokerTopicStats.allTopicsStats.totalFetchRequestRate.mark()

    if (logReadResult.error != Errors.NONE)
      errorReadingData = true
    bytesReadable = bytesReadable + logReadResult.info.records.sizeInBytes
    logReadResultMap.put(topicPartition, logReadResult)
  }

  // 判断是否立即响应请求:
  // 1. 超时时间为0
  // 2. 不需要获取任何数据
  // 3. 已经有足够的数据可供返回
  // 4. 在读取过程中发生了异常
  if (timeout <= 0 || fetchInfos.isEmpty || bytesReadable >= fetchMinBytes || errorReadingData) {
    val fetchPartitionData = logReadResults.map { case (tp, result) =>
      tp -> FetchPartitionData(result.error, result.highWatermark, result.leaderLogStartOffset, result.info.records,
        result.lastStableOffset, result.info.abortedTransactions, result.preferredReadReplica, isFromFollower && isAddingReplica(tp, replicaId))
    }
    responseCallback(fetchPartitionData)
  } else {
    // 构造延迟获取操作所需的元数据与状态信息
    val fetchPartitionStatus = new mutable.ArrayBuffer[(TopicPartition, FetchPartitionStatus)]
    fetchInfos.foreach { case (topicPartition, partitionData) =>
      logReadResultMap.get(topicPartition).foreach(logReadResult => {
        val logOffsetMetadata = logReadResult.info.fetchOffsetMetadata
        fetchPartitionStatus += (topicPartition -> FetchPartitionStatus(logOffsetMetadata, partitionData))
      })
    }

    val fetchMetadata: SFetchMetadata = SFetchMetadata(fetchMinBytes, fetchMaxBytes, hardMaxBytesLimit,
      fetchOnlyFromLeader, fetchIsolation, isFromFollower, replicaId, fetchPartitionStatus)

    val delayedFetch = new DelayedFetch(timeout, fetchMetadata, this, quota, clientMetadata, responseCallback)

    // 使用主题+分区作为键来注册这个延迟获取任务
    val delayedFetchKeys = fetchPartitionStatus.map { case (tp, _) => TopicPartitionOperationKey(tp) }

    // 尝试立刻完成请求,否则将其放入延迟队列中等待后续事件唤醒
    delayedFetchPurgatory.tryCompleteElseWatch(delayedFetch, delayedFetchKeys)
  }
}

/**
 * 从多个指定的主题分区中按给定偏移量读取最多指定数量的字节数据。
 *
 * @param replicaId           发起请求的副本ID。
 *                            -1表示由消费者发起,正数表示follower副本。
 * @param fetchOnlyFromLeader 是否强制要求必须从leader副本读取。
 * @param fetchIsolation      数据可见性的隔离策略。
 *                            可选值包括:FetchLogEnd(读到LEO)、FetchHighWatermark(读到HW)、FetchTxnCommitted(读到LSO)。
 * @param fetchMaxBytes       允许读取的最大总字节数。
 * @param hardMaxBytesLimit   是否启用严格的字节上限限制。
 * @param readPartitionInfo   待读取的分区列表及其相关参数。
 * @param quota               流控配额对象。
 * @param clientMetadata      客户端元数据,可用于副本选择逻辑。
 * @return 返回每个分区的实际读取结果列表。
 */
def readFromLocalLog(replicaId: Int,
                     fetchOnlyFromLeader: Boolean,
                     fetchIsolation: FetchIsolation,
                     fetchMaxBytes: Int,
                     hardMaxBytesLimit: Boolean,
                     readPartitionInfo: Seq[(TopicPartition, PartitionData)],
                     quota: ReplicaQuota,
                     clientMetadata: Option[ClientMetadata]): Seq[(TopicPartition, LogReadResult)] = {

  val traceEnabled = isTraceEnabled

  /**
   * 实际从单个分区中读取数据的方法
   *
   * @param tp                分区标识符
   * @param fetchInfo         当前分区的读取参数(如起始偏移量、最大字节数等)
   * @param limitBytes        当前剩余可用字节数额度
   * @param minOneMessage     是否忽略字节限制以确保至少读取一条完整消息
   * @return                  读取结果,包含记录集、偏移量快照等信息
   */
  def read(tp: TopicPartition, fetchInfo: PartitionData, limitBytes: Int, minOneMessage: Boolean): LogReadResult = {
    val offset = fetchInfo.fetchOffset
    val partitionFetchSize = fetchInfo.maxBytes
    val followerLogStartOffset = fetchInfo.logStartOffset

    val adjustedMaxBytes = math.min(fetchInfo.maxBytes, limitBytes)
    try {
      if (traceEnabled)
        trace(s"Fetching log segment for partition $tp, offset $offset, partition fetch size $partitionFetchSize, " +
          s"remaining response limit $limitBytes" +
          (if (minOneMessage) s", ignoring response/partition size limits" else ""))

      val partition = getPartitionOrException(tp)
      val fetchTimeMs = time.milliseconds

      // 如果是leader副本且有客户端元数据,尝试查找更优的读副本
      val preferredReadReplica = clientMetadata.flatMap(
        metadata => findPreferredReadReplica(partition, metadata, replicaId, fetchInfo.fetchOffset, fetchTimeMs))

      if (preferredReadReplica.isDefined) {
        replicaSelectorOpt.foreach { selector =>
          debug(s"Replica selector ${selector.getClass.getSimpleName} returned preferred replica " +
            s"${preferredReadReplica.get} for $clientMetadata")
        }

        // 如果存在推荐读副本,则跳过本次读取直接返回空结果
        val offsetSnapshot = partition.fetchOffsetSnapshot(fetchInfo.currentLeaderEpoch, fetchOnlyFromLeader = false)
        LogReadResult(info = FetchDataInfo(LogOffsetMetadata.UnknownOffsetMetadata, MemoryRecords.EMPTY),
          highWatermark = offsetSnapshot.highWatermark.messageOffset,
          leaderLogStartOffset = offsetSnapshot.logStartOffset,
          leaderLogEndOffset = offsetSnapshot.logEndOffset.messageOffset,
          followerLogStartOffset = followerLogStartOffset,
          fetchTimeMs = -1L,
          lastStableOffset = Some(offsetSnapshot.lastStableOffset.messageOffset),
          preferredReadReplica = preferredReadReplica,
          exception = None)
      } else {
        // 执行真正的日志读取操作
        val readInfo: LogReadInfo = partition.readRecords(
          fetchOffset = fetchInfo.fetchOffset,
          currentLeaderEpoch = fetchInfo.currentLeaderEpoch,
          maxBytes = adjustedMaxBytes,
          fetchIsolation = fetchIsolation,
          fetchOnlyFromLeader = fetchOnlyFromLeader,
          minOneMessage = minOneMessage)

        val fetchDataInfo = if (shouldLeaderThrottle(quota, partition, replicaId)) {
          // 如果受到流控限制,则返回空记录集
          FetchDataInfo(readInfo.fetchedData.fetchOffsetMetadata, MemoryRecords.EMPTY)
        } else if (!hardMaxBytesLimit && readInfo.fetchedData.firstEntryIncomplete) {
          // 如果不是硬限制并且第一条记录不完整,则返回空记录集避免抛出 RecordTooLargeException
          FetchDataInfo(readInfo.fetchedData.fetchOffsetMetadata, MemoryRecords.EMPTY)
        } else {
          readInfo.fetchedData
        }

        LogReadResult(info = fetchDataInfo,
          highWatermark = readInfo.highWatermark,
          leaderLogStartOffset = readInfo.logStartOffset,
          leaderLogEndOffset = readInfo.logEndOffset,
          followerLogStartOffset = followerLogStartOffset,
          fetchTimeMs = fetchTimeMs,
          lastStableOffset = Some(readInfo.lastStableOffset),
          preferredReadReplica = preferredReadReplica,
          exception = None)
      }
    } catch {
      // 处理各种预期中的异常情况,例如未知主题/分区、非leader副本等
      case e@ (_: UnknownTopicOrPartitionException |
               _: NotLeaderOrFollowerException |
               _: UnknownLeaderEpochException |
               _: FencedLeaderEpochException |
               _: ReplicaNotAvailableException |
               _: KafkaStorageException |
               _: OffsetOutOfRangeException) =>
        LogReadResult(info = FetchDataInfo(LogOffsetMetadata.UnknownOffsetMetadata, MemoryRecords.EMPTY),
          highWatermark = Log.UnknownOffset,
          leaderLogStartOffset = Log.UnknownOffset,
          leaderLogEndOffset = Log.UnknownOffset,
          followerLogStartOffset = Log.UnknownOffset,
          fetchTimeMs = -1L,
          lastStableOffset = None,
          exception = Some(e))

      // 处理其他意外异常,并增加失败指标统计
      case e: Throwable =>
        brokerTopicStats.topicStats(tp.topic).failedFetchRequestRate.mark()
        brokerTopicStats.allTopicsStats.failedFetchRequestRate.mark()

        val fetchSource = Request.describeReplicaId(replicaId)
        error(s"Error processing fetch with max size $adjustedMaxBytes from $fetchSource " +
          s"on partition $tp: $fetchInfo", e)

        LogReadResult(info = FetchDataInfo(LogOffsetMetadata.UnknownOffsetMetadata, MemoryRecords.EMPTY),
          highWatermark = Log.UnknownOffset,
          leaderLogStartOffset = Log.UnknownOffset,
          leaderLogEndOffset = Log.UnknownOffset,
          followerLogStartOffset = Log.UnknownOffset,
          fetchTimeMs = -1L,
          lastStableOffset = None,
          exception = Some(e))
    }
  }

  var limitBytes = fetchMaxBytes
  val result = new mutable.ArrayBuffer[(TopicPartition, LogReadResult)]
  var minOneMessage = !hardMaxBytesLimit

  // 遍历所有待读取的分区并逐个执行读取操作
  readPartitionInfo.foreach { case (tp, fetchInfo) =>
    val readResult = read(tp, fetchInfo, limitBytes, minOneMessage)
    val recordBatchSize = readResult.info.records.sizeInBytes

    // 一旦读到了有效数据,就不再强制读取至少一条消息
    if (recordBatchSize > 0)
      minOneMessage = false

    limitBytes = math.max(0, limitBytes - recordBatchSize)
    result += (tp -> readResult)
  }

  result
}

这是 FetchRequest 的核心处理逻辑 ,支持 消费者 和 Follower 副本

🧩 核心设计:区分请求来源 & 隔离级别
请求来源 可读到的位置 fetchIsolation
Follower 副本 (replicaId >= 0) LEO(最新写入) FetchLogEnd
普通消费者 (replicaId = -1) HW(高水位) FetchHighWatermark
事务消费者 (isolation=READ_COMMITTED) LSO(Last Stable Offset) FetchTxnCommitted

✅ 这保证了:

  • Follower 能同步全部数据(包括未提交)
  • 普通消费者看不到未提交数据
  • 事务消费者看不到未提交/中止事务的数据
🔄 执行流程:
  1. 确定可读范围(fetchIsolation)

  2. 调用 readFromLocalLog 读取数据

    • 遍历每个分区,调用 partition.readRecords(...)
    • 应用 quota 限流
    • 支持"至少返回一条消息"(避免因 maxBytes 太小而空转)
  3. 判断是否立即返回

    scala 复制代码
    if (timeout <= 0 || bytesReadable >= fetchMinBytes || errorReadingData)
      → 立即回调
    else
      → 创建 DelayedFetch,放入 purgatory 等待新数据
🌟 DelayedFetch 机制:
  • 如果消费者要求 fetch.min.bytes=1024,但当前只有 500 字节
  • Broker 不立即返回,而是 挂起请求,等新消息写入后再唤醒
  • 使用 DelayedFetchPurgatory 管理这些等待中的请求
  • 当有新消息写入(appendRecordsToLeader)时,会尝试 唤醒相关 DelayedFetch

💡 这是 Kafka 低延迟 + 高吞吐 的关键:避免消费者频繁轮询。


⚙️ 三、关键机制总结

机制 说明
Fetch Isolation 根据客户端类型控制可见性(HW / LSO / LEO)
Delayed Operation 使用 Purgatory 实现"条件满足再响应"(Produce/Fetch/Delete)
Metrics Tracking 细粒度监控(成功/失败请求、字节速率、消息速率)
Error Handling 区分"预期异常"(如 NotLeader)和"系统异常"(如 IO 错误)
Quota & Throttling 支持副本同步限流(shouldLeaderThrottle
Preferred Replica Read 支持 KIP-392:消费者可从 Follower 读(需 ClientMetadata)

🧪 四、典型场景举例

场景 1:消费者拉取

  • replicaId = -1, isolation = READ_UNCOMMITTED
  • Broker 从 HW 之前 读数据
  • 若数据不足 fetch.min.bytes → 挂起请求,等新消息

场景 2:Follower 同步

  • replicaId = 2(Broker ID)
  • Broker 从 LEO 读全部数据(包括未提交)
  • 无延迟,立即返回(即使只有 1 字节)

场景 3:事务消费者

  • isolation = READ_COMMITTED
  • 只能读到 LSO 之前的数据(过滤未提交/中止事务)

场景 4:强制删除数据

  • 管理员执行 kafka-delete-records.sh --offset-json-file ...
  • Leader 直接截断日志,更新 LSO
  • 不通知 Follower(Follower 会在后续同步中自然清理)

✅ 总结

你看到的这段代码,是 Kafka 高可靠、高性能、多租户 能力的集中体现:

  • 写入路径:安全追加 + 多级 ACK + 延迟确认
  • 读取路径:隔离级别 + 智能等待 + 限流控制
  • 运维接口:日志迁移 + 强制删除 + 状态查询

它们共同支撑了 Kafka 作为 分布式日志系统 的核心价值。

如果你想深入某个子模块(比如 DelayedProduce 如何等待 ISR 同步,或 readRecords 如何遍历 segment),我可以继续展开。

相关推荐
lang201509284 小时前
Kafka高水位与日志末端偏移量解析
分布式·kafka
Tadas-Gao4 小时前
GraphQL:下一代API架构的设计哲学与实践创新
java·分布式·后端·微服务·架构·graphql
lang201509285 小时前
Kafka副本管理核心:ReplicaManager揭秘
分布式·kafka·linq
GGBondlctrl7 小时前
【Redis】从单机架构到分布式,回溯架构的成长设计美学
分布式·缓存·架构·微服务架构·单机架构
编织幻境的妖8 小时前
Zookeeper在大数据集群中的作用详解
大数据·分布式·zookeeper
beijingliushao8 小时前
102-Spark之Standalone环境安装步骤-2
大数据·分布式·spark
acrelgxy9 小时前
告别盲测,预见温度:安科瑞如何用无线技术革新变电站安全
分布式·安全·电力监控系统·智能电力仪表
Wang's Blog9 小时前
RabbitMQ: 全面安装与运维指南之从基础部署到高级配置
运维·分布式·rabbitmq
回家路上绕了弯9 小时前
代码的三大核心素养:如何同时兼顾可维护性、可扩展性、可测试性
分布式·后端