1.大体流程
分区副本log会维护的几个总要的字段:
high watermark:这个分区副本在当前节点本地log文件中记录的 hw 位置。hw位置以前的消息都已提交,hw位置以后的消息都未提交。在主副本所在节点hw之前的位置才是消费者能够消费的。
log end offset:这个分区副本在当前节点本地log文件中记录的待写入消息的 offset,也就是已存在最后一条记录的 offset+1。
log start offset:这个分区副本在当前节点本地log文件中记录的第一条消息的 offset。
last stable offset:在开启事务消息后,这个值就是最新的commit的 offset,读未提交的客户端只能读取这个 offset 以前的数据。
以两个节点,broker-1 为主副本,broker-2 从副本为例解释消息提交流程:
(1)生产者向集群提交提交记录,broker-1 作为主副本执行本地写入,同时更新本地log系统中 leo 字段加1。此时消息还未提交,hw还未更新。
(2)从副本 broker-2 循环发送 第一次 FETCH 请求到 broker-1,请求数据同步。请求中会携带各自的 leo。
(3)主副本 broker-1 处理 broker-2 的 FETCH 请求,将未同步的返回发送给 broker-2。
(4)从副本 broker-2 更新本地记录,修改各自的leo +1。
(5)从副本 broker-2 循环发送 第二次 FETCH 请求到 broker-1,这是第二轮rpc了。请求中会携带各自的 leo+1。
(5)主副本 broker-1 发现本地的 log end offset 和请求中一致了,都加一了说明每个从副本都更新了,此时 broker-1 才确保包括主从的所有副本都本地记录成功,此时更新 hw 值加1。
(6)主副本 broker-1 返回生产者的提交请求,标志这条消息成功写入集群,因为更新了 hw 值这条消息也能够被消费者消费。
log end offset 和二次FETCH请求的关系:
主分区副本所在 broker-1 上会缓存主分区副本、和所有从分区副本的 leo 值。从分区副本所有 broker-2/broker-3 会缓存各自从分区副本的 leo 值。
broker-1 在接收 broker-2 的 FETCH 请求后先修改本地缓存将 broker-2 的对应从分区副本 leo 加1再将数据进行返回。
broker-2 在接收到 FETCH 请求的响应后将记录写入本地副本,再更新自己的 leo 值加1。
这样主副本 broker-1 通过本地缓存,配合从副本的两次 FETCH 请求来确保一条记录被所有从副本写入成功。
2.源码调用
以两个节点,broker-1 为主副本,broker-2 为从副本为例解释消息提交源码,以及二次FETCH调用:
(1)broker-1 主副本处理客户端 Produce 请求
KafkaApis.handle() => 处理客户端请求:PRODUCE
KafkaApis.handleProduceRequest() => 处理生产者 PRODUCE 提交请求
ReplicaManager.appendRecords() => 处理log本地写入,以及副本管理
ReplicaManager.appendToLocalLog() => 追加消息到本地log
Partition.appendRecordsToLeader() =>
Log.append() => 消息写入本地log
Partition.maybeIncrementLeaderHW() => 尝试更新本地 hw 值,调用校验方法判断是否达到 hw 值更新条件。
DelayedOperation.tryCompleteElseWatch() =>
DelayedOperation.safeTryCompleteOrElse() =>
DelayedProduce.tryComplete() =>
Partition.checkEnoughReplicasReachOffset() => 校验从副本是否都同步成功 requiredOffset=1,当前是否主副本:Some(Log(dir=C:\workspace\kafkaData\data1\flinkin-01-0, topic=flinkin-01, partition=0, highWatermark=0, lastStableOffset=0, logStartOffset=0, logEndOffset=1))
(2)broker-2 从副本发送第一次 FETCH 请求,获取未同步的数据
ReplicaFetcherThread.addFetcherForPartitions() => 创建FETCH线程,定时向主副本发送FETCH请求
ReplicaFetcherManager.createFetcherThread() => 创建FETCH线程
AbstractFetcherThread.doWork() => 定时执行 FETCH
AbstractFetcherThread.maybeFetch() => 同副本复制处理入口
AbstractFetcherThread.processFetchRequest() => 发送FETCH请求
ReplicaFetchThread.fetchFromLeader() => 从副本节点FETCH数据,发送请求
NetworkClient.doSend() => 发送集群内部请求 ApiKey=FETCH
(3)broker-1 主副本处理第一次 FETCH 请求,返回未同步数据。
KafkaApis.handle() => 处理客户端请求:FETCH
KafkaApis.handleFetchRequest() => 处理从副本FETCH同步请求,请求节点 fetchRequest.isFromFollower=true
ReplicaManager.fetchMessages() => 从本地查询未同步数据
ReplicaManager.readFromLog() => 从log读取
ReplicaManager.readFromLocalLog() => 从本地log读取
Partition.readRecords() => 读取当前主副本中未同步记录 fetchOffset=0
Log.read() => 读取log
RequestHandlerHelper.sendResponse() 响应 reqApiKey=FETCH
DelayedProduce.tryComplete() =>
Partition.checkEnoughReplicasReachOffset() => 校验从副本是否都同步成功 requiredOffset=1,当前是否主副本:Some(Log(dir=C:\workspace\kafkaData\data1\flinkin-01-0, topic=flinkin-01, partition=0, highWatermark=0, lastStableOffset=0, logStartOffset=0, logEndOffset=1))
(4)broker-2 从副本从响应中获取分区未同步数据,进行本地写入。
FetchSessionHandler.handleResponse() => 处理FETCH响应,PartitionData.size=1
ReplicaFetcherThread.processPartitionData() => 处理FETCH回来的消息集合
Partition.appendRecordsToFollowerOrFutureReplica()
Log.append() => 消息写入本地log
(5)broker-2 从副本发送第二次 FETCH 请求,获取未同步的数据,和(2)一样只是请求中的 offset 加一了。
ReplicaFetcherThread.addFetcherForPartitions() => 创建FETCH线程,定时向主副本发送FETCH请求
ReplicaFetcherManager.createFetcherThread() => 创建FETCH线程
AbstractFetcherThread.doWork() => 定时执行 FETCH
AbstractFetcherThread.maybeFetch() => 同副本复制处理入口
AbstractFetcherThread.processFetchRequest() => 发送FETCH请求
ReplicaFetchThread.fetchFromLeader() => 从副本节点FETCH数据,发送请求
NetworkClient.doSend() => 发送集群内部请求 ApiKey=FETCH
(6)broker-1 主副本处理第二次 FETCH 请求,返回未同步数据。
KafkaApis.handle() => 处理客户端请求:FETCH
KafkaApis.handleFetchRequest() => 处理从副本FETCH同步请求,请求节点 fetchRequest.isFromFollower=true
ReplicaManager.fetchMessages() => 从本地查询未同步数据
ReplicaManager.readFromLog() => 从log读取
ReplicaManager.readFromLocalLog() => 从本地log读取
Partition.readRecords() => 读取当前主副本中未同步记录 fetchOffset=1
Log.read() => 读取log
RequestHandlerHelper.sendResponse() 响应 reqApiKey=FETCH
DelayedProduce.tryComplete() =>
Partition.checkEnoughReplicasReachOffset() => 校验从副本是否都同步成功 requiredOffset=1,当前是否主副本:Some(Log(dir=C:\workspace\kafkaData\data1\flinkin-01-0, topic=flinkin-01, partition=0, highWatermark=1, lastStableOffset=1, logStartOffset=0, logEndOffset=1))
(6)broker-1 更新 hw 值成功之后,响应生产者客户端。
RequestHandlerHelper.sendResponse() 响应 reqApiKey=PRODUCE
3.两次 FETCH 触发 HW 更新
从副本查看 FETCH 请求的响应,可以看到第一次FETCH时,副本拿到了新数据,第二次FETCH时没拿到新数据,但是返回的HW值更新了,这个就是因为第二次FETCH请求中的 LEO 相比第一次 FETCH 加一了,触发了主副本HW的更新:
ReplicaFetcherThread.processPartitionData() => FetchablePartitionResponse(highWatermark=0,recordSet=MemoryRecords(size=227, buffer=java.nio.HeapByteBuffer[pos=0 lim=227 cap=230])
ReplicaFetcherThread.processPartitionData() => FetchablePartitionResponse(highWatermark=1,recordSet=MemoryRecords(size=0, buffer=java.nio.HeapByteBuffer[pos=0 lim=0 cap=3])
主副本会在处理一个 Produce请求和两次 Fetch 请求的时候共调用三次 checkEnoughReplicasReachOffset() 函数来更新自己的 HW值,观察 hw 和 leo 的变化就能够清楚了解整个更新过程:
Partition.checkEnoughReplicasReachOffset() => 校验从副本是否都同步成功 requiredOffset=1:Some(Log(highWatermark=0, lastStableOffset=0, logStartOffset=0, logEndOffset=1))
Partition.checkEnoughReplicasReachOffset() => 校验从副本是否都同步成功 requiredOffset=1:Some(Log(highWatermark=0, lastStableOffset=0, logStartOffset=0, logEndOffset=1))
Partition.checkEnoughReplicasReachOffset() => 校验从副本是否都同步成功 requiredOffset=1:Some(Log(highWatermark=1, lastStableOffset=0, logStartOffset=0, logEndOffset=1))
Partition.checkEnoughReplicasReachOffset() 就是比较 highWatermark 和 requiredOffset 中那个更大。
代码如下:
def checkEnoughReplicasReachOffset(requiredOffset: Long): (Boolean, Errors) = {
// 当前 borker 是主副本
leaderLogIfLocal match {
case Some(leaderLog) =>
// keep the current immutable replica list reference
val curMaximalIsr = isrState.maximalIsr
if (isTraceEnabled) {
def logEndOffsetString: ((Int, Long)) => String = {
case (brokerId, logEndOffset) => s"broker $brokerId: $logEndOffset"
}
val curInSyncReplicaObjects = (curMaximalIsr - localBrokerId).map(getReplicaOrException)
val replicaInfo = curInSyncReplicaObjects.map(replica => (replica.brokerId, replica.logEndOffset))
val localLogInfo = (localBrokerId, localLogOrException.logEndOffset)
val (ackedReplicas, awaitingReplicas) = (replicaInfo + localLogInfo).partition { _._2 >= requiredOffset}
}
val minIsr = leaderLog.config.minInSyncReplicas
if (leaderLog.highWatermark >= requiredOffset) {
/*
* The topic may be configured not to accept messages if there are not enough replicas in ISR
* in this scenario the request was already appended locally and then added to the purgatory before the ISR was shrunk
*/
if (minIsr <= curMaximalIsr.size)
(true, Errors.NONE)
else
(true, Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND)
} else
(false, Errors.NONE)
case None =>
(false, Errors.NOT_LEADER_OR_FOLLOWER)
}
}
Partition.maybeIncrementLeaderHW() => 尝试更新本地 hw 值,调用校验方法判断是否达到 hw 值更新条件。
private def maybeIncrementLeaderHW(leaderLog: Log, curTime: Long = time.milliseconds): Boolean = {
// 获取位于 ISR 集合中,或最近一次从 leader 拉取消息的时间戳位于指定时间范围(对应 replica.lag.time.max.ms 配置)内的所有副本的 LEO 值
// 以这些副本中最小的 LEO 值作为 leader 副本新的 HW 值
var newHighWatermark = leaderLog.logEndOffsetMetadata
remoteReplicasMap.values.foreach { replica =>
// 远端副本的leo小于新leader的leo ,并且,远端副本在isr内或者还没有在replicaLagTimeMaxMs超时前没跟上。那么用远端副本更新HW值
if (replica.logEndOffsetMetadata.messageOffset < newHighWatermark.messageOffset &&
(curTime - replica.lastCaughtUpTimeMs <= replicaLagTimeMaxMs || isrState.maximalIsr.contains(replica.brokerId))) {
newHighWatermark = replica.logEndOffsetMetadata
}
}
// 调用log.scala中的更新HW逻辑
leaderLog.maybeIncrementHighWatermark(newHighWatermark) match {
case Some(oldHighWatermark) =>
debug(s"High watermark updated from $oldHighWatermark to $newHighWatermark")
true
case None =>
def logEndOffsetString: ((Int, LogOffsetMetadata)) => String = {
case (brokerId, logEndOffsetMetadata) => s"replica $brokerId: $logEndOffsetMetadata"
}
if (isTraceEnabled) {
val replicaInfo = remoteReplicas.map(replica => (replica.brokerId, replica.logEndOffsetMetadata)).toSet
val localLogInfo = (localBrokerId, localLogOrException.logEndOffsetMetadata)
trace(s"Skipping update high watermark since new hw $newHighWatermark is not larger than old value. " +
s"All current LEOs are ${(replicaInfo + localLogInfo).map(logEndOffsetString)}")
}
false
}
}
更新高水位值:
源码定义了两个更新高水位值的方法:updateHighWatermark 和 maybeIncrementHighWatermark。从名字上来看,前者是一定 要更新高水位值的,而后者是可能会更新也可能不会。
最终都是调用 updateHighWatermarkMetadata() 进行更新。
首先查看Log对象的定义,通过 LogOffsetMetadata 封装了 offset,而 hw 值也使用的这个对象。
class Log(@volatile private var _dir: File,
@volatile var config: LogConfig,
@volatile var logStartOffset: Long,
@volatile var recoveryPoint: Long,
scheduler: Scheduler,
brokerTopicStats: BrokerTopicStats,
val time: Time,
val maxProducerIdExpirationMs: Int,
val producerIdExpirationCheckIntervalMs: Int,
val topicPartition: TopicPartition,
val producerStateManager: ProducerStateManager,
logDirFailureChannel: LogDirFailureChannel,
private val hadCleanShutdown: Boolean = true,
val keepPartitionMetadataFile: Boolean = true) extends Logging with KafkaMetricsGroup {
@volatile private var nextOffsetMetadata: LogOffsetMetadata = _
@volatile private var firstUnstableOffsetMetadata: Option[LogOffsetMetadata] = None
@volatile private var highWatermarkMetadata: LogOffsetMetadata = LogOffsetMetadata(logStartOffset)
@volatile var leaderEpochCache: Option[LeaderEpochFileCache] = None
}
更新 HW 值也就是更新 LogOffsetMetadata 对象,HW值的更新、读取函数如下:
Log.fetchHighWatermarkMetadata() 查询hw值
Log.maybeIncrementHighWatermark() 更新hw值
Log.updateHighWatermark() 更新hw值
Log.updateHighWatermarkMetadata() 更新hw值
4.Replica机制启动
从副本会定时发送 Fetch 到主副本请求数据,这个就是 Replica 主从同步机制,从副本会在系统初始化后或者副本创建后启动一个后台线程来定时触发。流程如下:
becomeLeaderOrFollower() => 创建主题后会触发本地log目录初始化,或系统启动时会初始化。
makeFollowers() => 分区从副本处理
ReplicaFetcherThread.addFetcherForPartitions() => 创建FETCH线程,定时向主副本发送FETCH请求
ReplicaFetcherManager.createFetcherThread() => 创建FETCH线程
AbstractFetcherThread.doWork() => 定时执行 FETCH
AbstractFetcherThread.maybeFetch() => 同副本复制处理入口
AbstractFetcherThread.processFetchRequest() => 发送FETCH请求
ReplicaFetchThread.fetchFromLeader() => 从主副本节点FETCH数据,发送请求
NetworkClient.doSend() => 发送集群内部请求 ApiKey=FETCH
FetchSessionHandler.handleResponse() => 处理FETCH响应,PartitionData.size=1,里面就是消息。
AbstractFetcherThread.processFetchRequest() 就是循环调用 FETCH 请求的入口。
代码如下:
override protected def fetchFromLeader(fetchRequest: FetchRequest.Builder): Map[TopicPartition, FetchData] = {
try {
val clientResponse = leaderEndpoint.sendRequest(fetchRequest)
val fetchResponse = clientResponse.responseBody.asInstanceOf[FetchResponse[Records]]
if (!fetchSessionHandler.handleResponse(fetchResponse)) {
Map.empty
} else {
fetchResponse.responseData.asScala
}
} catch {
case t: Throwable =>
fetchSessionHandler.handleError(t)
throw t
}
}
5.通过ISR机制维护主从副本
每个主题的每个分区都有一个ISR列表,用来记录哪个是主哪个是从,并且非正常同步的从副本距离主副本太远还会从ISR列表中删除。主副本因为broker重启或崩溃造成主从切换等统统从这个列表中进行。每个 broker 都保存了集群元数据,一旦 Controller 节点发现集群元数据更新,会广播元数据更新请求,让所有其他节点更新自己的元数据。调用 METADATA 请求可以获取集群元数据:
MetadataResponseTopic(
errorCode=0,
name='flinkin-01',
topicId=4I1Ms6FqSBWRkJMPI6BdcA,
isInternal=false,
partitions=[
MetadataResponsePartition(errorCode=0, partitionIndex=0, leaderId=2, leaderEpoch=7, replicaNodes=[1, 2], isrNodes=[2, 1], offlineReplicas=[])
],
topicAuthorizedOperations=-2147483648
)
集群元数据会记录所有副本列表、离线副本列表、ISR列表:
其中分区信息定义:
public static class UpdateMetadataPartitionState implements Message {
String topicName; // 主题
int partitionIndex; // 分区编号
int controllerEpoch; // Controller 的 epoch 任期
int leader; // 主副本所在 brokerId
int leaderEpoch; // 主副本所在 broker 的 epoch 任期
List<Integer> isr; // ISR列表,也就是保持 FETCH 同步的所有 broker 节点
int zkVersion;
List<Integer> replicas; // 副本列表
List<Integer> offlineReplicas; // 离线副本列表
private List<RawTaggedField> _unknownTaggedFields;
}
而定期维护这个ISR列表由 ReplicaManager 来完成,他包含两个重要的方法:
ReplicaManager.maybeShrinkIsr() 定时任务,定期检查ISR中是否有与主副本相差太远的从副本,将它从ISR列表中进行剔除。
定时任务执行:
ReplicaManager.startup() => 副本管理模块启动
ReplicaManager.maybeShrinkIsr() => 定期检查ISR是否需要收缩
会对所有在线的分区执行 ReplicaManager.maybeShrinkIsr() 检查是否落后太远:,除了ISR变更之外还可能处理HW的更新,这个是可能主从切换造成的hw回滚。
其中 Partition.getOutOfSyncReplicas() 就是用来根据设置的 maxLagMs,最大落后时间来获取所有同步落后的副本、
def maybeShrinkIsr(): Unit = {
val needsIsrUpdate = !isrState.isInflight && inReadLock(leaderIsrUpdateLock) {
needsShrinkIsr()
}
val leaderHWIncremented = needsIsrUpdate && inWriteLock(leaderIsrUpdateLock) {
leaderLogIfLocal.exists { leaderLog =>
val outOfSyncReplicaIds = getOutOfSyncReplicas(replicaLagTimeMaxMs)
if (outOfSyncReplicaIds.nonEmpty) {
val outOfSyncReplicaLog = outOfSyncReplicaIds.map { replicaId =>
s"(brokerId: $replicaId, endOffset: ${getReplicaOrException(replicaId).logEndOffset})"
}.mkString(" ")
val newIsrLog = (isrState.isr -- outOfSyncReplicaIds).mkString(",")
shrinkIsr(outOfSyncReplicaIds)
maybeIncrementLeaderHW(leaderLog)
} else {
false
}
}
}
以 alert 新增分区、新增副本来看整个ISR更新流程:
首先是 Controller 处理新增分区的请求:
KafkaApis.handle() => 处理客户端请求:CREATE_PARTITIONS
KafkaApis.handleCreatePartitionsRequest() => 处理新增分区的请求
ZkAdminManager.createPartitions() => 创建分区,后续写入zk
然后由ZK数据节点监听器,Controller 监听到 event 事件是 ISR 更新时间,然后和创建主题的后续流程一样,发送 ISR 更新请求、META元DATA数据更新请求。
而 Controller 所负责的 主副本选举,也就是从 ISR 列表中进行选择,提到Kafka官方的解释是,它的选举算法和微软的PacificA算法最相近。大致意思就是,默认是让ISR中第一个Replica变成Leader。
6.分区副本的主从切换
主从副本切换和主题创建时或者集群节点启动时监听到 ISR 变化执行的代码入口是一样的:
KafkaApis.handle() => 处理客户端请求:LEADER_AND_ISR
KafkaApis.handleLeaderAndIsrRequest() => 处理ISR列表请求
ReplicaManager.becomeLeaderOrFollower() => 处理当前节点的身份,调用 makeLeaders() 或者 makeFollowers(),将当前节点变更为指定分区副本的主或者从
每个 broker 处理 LEADER_AND_ISR 请求实际上是主从切换的结果,而 LEADER_AND_ISR 请求由 Controller 节点发出。
也就是说 Controller 会监控副本状态,如果检测到主从副本异常判断到应该主从切换了会发出 LEADER_AND_ISR 请求,要求其他 Broker 来执行主从切换。
Controller 发送 ISR变更通知的入口:
ControllerChannelManager.sendLeaderAndIsrRequest() => 发送ISR变更消息
ReplicaStateMachine.handleStateChanges() => 处理分区状态机状态转换:NewReplica
Controller 会在下面几个地方调用 broker 的请求广播:
(1)副本状态机变更时
ReplicaStateMachine.handleStateChanges() => 处理分区状态机状态转换
ControllerChannelManager.sendRequestsToBrokers() => 广播集群消息
(2)分区状态机变更时
PartitionStateMachine.handleStateChanges() => 处理分区状态机状态转换
ControllerChannelManager.sendRequestsToBrokers() => 广播集群消息
而处理分区主从切换就是由分区状态机 ReplicaStateMachine 实现的一共有七种状态:
NewReplica$ 新建的副本,这个状态的副本只能是从,因为新建副本时要么还未参加选举,要么已经有了 leader。
NonExistentReplica$ 还未创建的副本,或已经删除。
OfflineReplica$ 副本上线,开始正常工作。
OnlineReplica$ 副本离线,副本所在 broker 异常。
ReplicaDeletionIneligible$ 开始删除副本时的过渡状态
ReplicaDeletionstarted$ 删除副本成功时的状态
ReplicaDeletionsuccessful$ 删除副本失败时的状态
而 ZkReplicaStateMachine.handleStateChanges() 被调用的地方其实就是检查到副本状态变更的地方,由 Controller 事件机制实现,同样是监听zk的变化:
ControllerEventThread.doWork => 监控event事件处理
ControllerEventThread.process() => 监听到事件,执行事件
QueuedEvent.process() => 监听到事件,执行事件
KafkaController.process() => 监听到事件,执行事件
ZkReplicaStateMachine.handleStateChanges() => 副本状态变更