1.副本截断和 epoch 的关系
每个副本都会在本地定期生成一个 leader-epoch-checkpoint 文件,用于保存集群 leader 的 epoch 信息:
直接打开可以发现主从节点epoch文件:
Leader Epoch: 32位,单调递增的数字。代表单个分区所处的leader时代。每发生一次leader转换,就+1。例如leader epoch =2,说明处于第二leader时代。
Leader Epoch Start Offset:该epoch版本的leader写入第一条消息的位移。
Leader Epoch Sequence File: 存储Leader Epoch和Leader Epoch Start Offset对,类似如下形式: 0,100 1,200 3,500
LeaderEpoch的工作机制:
使每个消息都包含一个4字节的Leader Epoch number。
每个log目录,创建Leader Epoch Sequence file用来存储Leader Epoch和Start Offset。
当一个副本成为leader,它首先在Leader Epoch Sequence file 末尾添加一条新的记录,并把他flush到磁盘。每条新的消息就会被新的Leader Epoch标记。
当一个副本成为follower时(比如重启),它会做以下事情:
从Leader Epoch Sequence file恢复所有的Leader Epoch。比如宕机太久,这期间换了好几次leader,那么就要把这些leader时代的消息都恢复过来。
向分区leader发送一个LeaderEpoch请求,请求包含了该follower的 Leader Epoch Sequence文件中最新的Leader Epoch。
Leader向follower返回对应LeaderEpoch的LastOffset。这个LastOffset有两种可能,一种是比follower发送的请求中的Leader Epoch大1的开始offset,另一种是如果当前leader epoch与请求中的leader epoch相等,那么就返回当前leader的LEO。
如果当前follower有任何LeaderEpoch的开始偏移量大于从leader中返回的LastOffset 。那么他会重置Leader Epoch Sequence来和leader保持一直。
follower截断local log 到leader的LastOffset位置。
follower开始从leader获取数据。
在获取数据时,如果follower发现消息中的LeaderEpoch比自己的最新的LeaderEpoch 大,他会添加这个LeaderEpoch和开始偏移到LeaderEpochSequence文件,并刷写到磁盘。
follower继续获取数据。
使用HW数据丢失的情况:
假设有A、B两个Broker,初始时B为leader,A从B中取到消息m2,所以A中有消息m2了,但是由于在下一轮RPC中,A才会更新自己的HW,所以此时A的HW没变。
如果这时候A重启了,他截取自己的日志到HW并发送一个fetch request到B。
不幸的是,B这时宕机了,A成了新的leader,那么此时消息m2就会永久的丢失了。
使用HW数据不一致的情况:
假设我们有两个Broker,初始时A是leader,B是follower。
A接收到m2消息,但B还没来得及复制时,断电了。过了一会,B重启了,成为了leader,接收了m3消息,HW+1。
然后A重启了,截断日志到高水位,但是此时的消息却出现了不一致。
副本截断的作用:
副本截断主要是根据集群epoch值,从副本将这个值以后新提交的消息全部删除,以此为参考点然后再重新执行FETCH同步,这样保证主从同步完整以及顺序性。
HW 和 epoch 作为日志截断的区别:
旧版本的kafka使用hw做日志截断可能造成一条数据的丢失,最差的情况也只是一条数据的丢失。而使用 epoch 就能防止这一条消息的丢失。
也就是说在主从切换时,能够选举的新的主节点和以前的旧主节点的数据是一致的只是有可能 hw的落后。
一个从节点重启期间落后太多的话会被ISR移除列表不具备选主资格。只要当他追上来从新加入ISR列表才能恢复正常。
另外主副本在处理生产者的消息提交需要等到ISR中足够多的从副本都二次FETCH成功才会对生产者进行响应,这也相当于强行要求主从保持数据一致一条不差,再配合 epoch 机制就能保证任意的主从切换都不会丢失数据。
使用leader epoch不会造成数据丢失的原因是:
1、follower副本B先请求leader副本A的leaderEpoch and Leader LogEndOffset,再从leader副本A复制消息;
2、如果消息写入了follower副本B,则follower副本B的LEO与leader副本的LEO是一致的,对应的offset是最新的,即使此后follow副本B宕机也不受影响。如果消息没写入follower副本B,follower副本B宕机重启后,使用上次分派好的 Leader LogEndOffset,从leader副本A复制消息;
3、如果发生leader切换,B成为leader,则leaderEpoch+1,B根据自身的LEO生成新的Leader LogEndOffset。A称为follower,如果A请求复制B,A发现LEO比leader LEO大,则进行日志截断。
2.日志截断调用流程
首先会由定时任务定时判断是否需要日志截断,maybeTruncate()执行截断操作。AbstractFetcherThread 线程总要不断尝试去做截断的原因是,分区的 Leader 可能会随时发生变化。每当有新 Leader 产生时,Follower 副本就必须主动执行截断操作,将自己的本地日志裁剪成与 Leader 一模一样的消息序列,甚至,Leader 副本本身也需要执行截断操作,将 LEO 调整到分区高水位处。
老版的kafka使用 hw做截断,因为hw的二次FETCT延迟机制,在A为主时写入一条最新消息,此时B还未发起二次FETCH时B挂机,此时A的hw值还未更新。
B恢复后会在恢复阶段将自己leo调整到集群的hw值(落后一条)并日志截断删除自己比A多的那一条消息的offset,接着发起FETCH,而此时A宕机了那么B选为了新主,A恢复后同样进行恢复执行hw日志截断并删除了自己比B多的那一条消息。这样AB都永久丢失这条数据。
新版的kafka引入了 leader epoch 机制,每个主副本在位期间会自己保存自己的 epoch 版本号以及自己提交了多少数据。不管是A还是B在恢复时做日志截断是都参考 epoch 上面记录的 offset 做截断,而不是使用hw 做截断防止数据丢失。
日志截断主要发生在节点恢复时、或者定时任务两种情况中:
(1)恢复期间的日志截断:
Log.loadSegments() => 节点启动 locally 执行
Log.recoverLog() => 日志恢复入口,遍历每个日志段
LogSegment.truncateTo() => 段日志截断入口,根据传入 offset 对一系列文件做截断。
(2)定时任务的日志截断:
ReplicaFetcherThread.truncate()
Partition.truncateTo() =>
LogManager.truncateTo() =>
Log.truncateTo() =>
LogSegment.truncateTo() => 段日志截断入口,根据传入 offset 对一系列文件做截断。
3.主副本通过请求判断返回的 epoch
主副本在处理从副本的 FETCH、OFFSET_FOR_LEADER_EPOCH 两个请求的时候都会计算主从之间的 epoch 以返回 LeaderEpoch、EndOffset 来告知从副本是否发生过主副本切换。
(1)从副本所在节点定时执行截断判断:
AbstractFetcherThread.doWork() => 定时执行 Fetch、Truncate
AbstractFetcherThread.maybeTruncate() => 截断处理
AbstractFetcherThread.fetchTruncatingPartitions() 将本地所有处于截断中状态的分区依据有无Leader Epoch值进行分组
AbstractFetcherThread.truncateToHighWatermark() => 没有Leader Epoch值的分区,将日志截断到hw处
AbstractFetcherThread.truncateToEpochEndOffsets() => 有Leader Epoch值的分区,将日志截断到 leaderEpoch值对应的位移值处
ReplicaFetcherThread.fetchEpochEndOffsets() => 向主副本发送 OffsetsForLeaderEpochRequest 请求
ReplicaFetcherBlockingSend.sendRequest() => 发送请求 OffsetsForLeaderEpochRequest,并 while.poll() 阻塞获取响应
(2)主节点获取目标分区和副本的:
KafkaApis.handle() => 处理客户端请求:OFFSET_FOR_LEADER_EPOCH
KafkaApis.handleOffsetForLeaderEpochRequest() => 处理epoch获取请求
ReplicaManager.lastOffsetForLeaderEpoch() => 获取每个Partition最近上一次的leaderEpoch和对应的LEO,迭代requestedEpochInfo集合
Partition.lastOffsetForLeaderEpoch() => 返回小于或等于requestedLeaderEpoch的最大Epoch的LEO
Log.endOffsetForEpoch() => 根据请求的 epoch 获取
LeaderEpochFileCache.endOffsetFor() => 根据请求的 epoch 计算应该返回的二元组
(3)从节点获取到 epochMap,对每个分区的副本进行截断:
ReplicaFetcherThread.truncate() => 根据 leader返回的 epoch值进行日志截断的入口,epochMap中包含发生了主从切换而影响到的分区和副本
Partition.truncateTo() => 段日志截断入口
LogManager.truncateTo() => 段日志截断入口
Log.truncateTo() => 段日志截断入口
LogSegment.truncateTo() => 段日志截断入口,根据传入 offset 对一系列文件做截断。
在主副本处理请求时会按照从副本传递的epoch值进行响应:
如果Follower副本请求中的 Leade Epoch 值等于Leader副本端的LeaderEpoch,那么就返回 Leader 副本的LEO
如果Follower副本请求中的 LeaderEpoch 值小于Leader副本端的LeaderEpoch,说明发生过leader副本的切换。
对于LeaderEpoch,返回Leader副本端保存的不大于Follower副本请求中LeaderEpoch 的最大LeaderEpoch
max(<= LeaderEpoch request 请求参数的LeaderEpoch)
对于EndOffset,返回 Leader 副本端保存的第一个比 Follower 副本请求中LeaderEpoch大的 LeaderEpoch 对应StartOffset
min(> LeaderEpoch request 请求参数的LeaderEpoch. StartOffset)
举个例子,假设Follower副本请求的LeaderEpoch = 2。Leader 副本保存的LeaderEpoch -> StartOffset 对应关系为:(其中 StartOffset 是对应的 LeaderEpoch 第一条写入消息的偏移量,相当于上一任 LeaderEpoch 的 LEO 值)
LeaderEpoch:2 -> StartOffset:30
LeaderEpoch:3 -> StartOffset:50
LeaderEpoch:4 -> StartOffset:70
那么给Follower副本返回的是:(LeaderEpoch:2,EndOffset:50).
如图:
这一步在主节点处理请求中调用,LeaderEpochFileCache.endOffsetFor(),代码如下:
正常情况下返回小一轮的 epoch(floorEntry.getValue.epoch) 和大一轮的 offset(higherEntry.getValue.startOffset)。
def endOffsetFor(requestedEpoch: Int): (Int, Long) = {
// 基于requestedLeaderEpoch,返回一个(leaderEpoch, logEndOffset)二元组
inReadLock(lock) {
val epochAndOffset =
if (requestedEpoch == UNDEFINED_EPOCH) {
// This may happen if a bootstrapping follower sends a request with undefined epoch or
// a follower is on the older message format where leader epochs are not recorded
(UNDEFINED_EPOCH, UNDEFINED_EPOCH_OFFSET)
}
// 未发生过主副本切换, requestedEpoch=latestEpoch
// 这种情况从副本截断位置直接从主副本 leo 位置即可因为前面数据已经确保正常同步了
else if (latestEpoch.contains(requestedEpoch)) {
// For the leader, the latest epoch is always the current leader epoch that is still being written to.
// Followers should not have any reason to query for the end offset of the current epoch, but a consumer
// might if it is verifying its committed offset following a group rebalance. In this case, we return
// the current log end offset which makes the truncation check work as expected.
(requestedEpoch, logEndOffset())
}
// 如果发生过主副本切换,主副本领先,则查出小于等于 requestedEpoch 的最大 epoch 以及对应那一代主副本的 leo
else {
val higherEntry = epochs.higherEntry(requestedEpoch)
if (higherEntry == null) {
// The requested epoch is larger than any known epoch. This case should never be hit because
// the latest cached epoch is always the largest.
(UNDEFINED_EPOCH, UNDEFINED_EPOCH_OFFSET)
} else {
val floorEntry = epochs.floorEntry(requestedEpoch)
if (floorEntry == null) {
// The requested epoch is smaller than any known epoch, so we return the start offset of the first
// known epoch which is larger than it. This may be inaccurate as there could have been
// epochs in between, but the point is that the data has already been removed from the log
// and we want to ensure that the follower can replicate correctly beginning from the leader's
// start offset.
(requestedEpoch, higherEntry.getValue.startOffset)
} else {
// 正常情况下取小的 epoch 和大的 offset
(floorEntry.getValue.epoch, higherEntry.getValue.startOffset)
}
}
}
epochAndOffset
}
}
4.日志截断的代码
LogSegment.truncateTo() => 段日志截断入口,根据传入 offset 对一系列文件做截断。主要是按顺序删除 offset 之后的记录,然后重新进行复制来补上。
def truncateTo(offset: Long): Int = {
val mapping = translateOffset(offset)
offsetIndex.truncateTo(offset)
timeIndex.truncateTo(offset)
txnIndex.truncateTo(offset)
// After truncation, reset and allocate more space for the (new currently active) index
offsetIndex.resize(offsetIndex.maxIndexSize)
timeIndex.resize(timeIndex.maxIndexSize)
val bytesTruncated = if (mapping == null) 0 else log.truncateTo(mapping.position)
if (log.sizeInBytes == 0) {
created = time.milliseconds
rollingBasedTimestamp = None
}
bytesSinceLastIndexEntry = 0
if (maxTimestampSoFar >= 0)
loadLargestTimestamp()
bytesTruncated
}
5.主从副本如何维护 epoch 缓存,以及如何判断主副本切换
先来看副本执行的 epoch 缓存类 LeaderEpochFileCache:
class LeaderEpochFileCache(topicPartition: TopicPartition,
logEndOffset: () => Long,
checkpoint: LeaderEpochCheckpoint) extends Logging {
this.logIdent = s"[LeaderEpochCache $topicPartition] "
private val lock = new ReentrantReadWriteLock()
// 缓存 epoch,startOffset 对
private val epochs = new util.TreeMap[Int, EpochEntry]()
// 判断是否需要新写入 epoch entry
private def assign(entry: EpochEntry): Boolean = {}
}
主节点处理 Produce 请求后执行本地log写入之后,可能触发 epoch 更新:
KafkaApis.handle() => 处理客户端请求:PRODUCE
ReplicaManager.appendRecords() => 处理log本地写入,以及副本管理
ReplicaManager.appendToLocalLog() => 追加消息到本地log
Log.appendAsLeader() => 消息记录,records=MemoryRecords(size=227, buffer=java.nio.HeapByteBuffer[pos=0 lim=227 cap=227]),leaderEpoch=33
Log.append() => 消息写入本地log
Log.maybeAssignEpochStartOffset() => 当前的epoch值, leaderEpoch=33,startOffset=7002
LeaderEpochFileCache.assign() => 当前的epoch值, entry=EpochEntry(epoch=33, startOffset=7002)
从节点处理 Fetch 响应后执行本地 log 写入之后,可能触发 epoch 更新:
AbstractFetcherThread.doWork() => 定时执行 FETCH
AbstractFetcherThread.maybeFetch() => 同副本复制处理入口
AbstractFetcherThread.processFetchRequest() => 发送FETCH请求
Log.appendAsFollower() => 消息记录,records=MemoryRecords(size=227, buffer=java.nio.HeapByteBuffer[pos=0 lim=227 cap=230])
Log.append() => 消息写入本地log
Log.maybeAssignEpochStartOffset() => 当前的epoch值,leaderEpoch=33,startOffset=7002
LeaderEpochFileCache.assign() => 当前的epoch值, entry=EpochEntry(epoch=33, startOffset=7002)
正常情况下虽然调用了 Log.maybeAssignEpochStartOffset(),但是缓存不会有新的 entry 存入,只有产生了主副本切换时才会新建一个 entry 进行存入。
在最后一步 LeaderEpochFileCache.assign() 会根据当前的 epoch 以判断是否需要新写入一个 entry。代码如下:
private def assign(entry: EpochEntry): Boolean = {
if (entry.epoch < 0 || entry.startOffset < 0) {
throw new IllegalArgumentException(s"Received invalid partition leader epoch entry $entry")
}
// 判断是否需要更新
def isUpdateNeeded: Boolean = {
// 获取最后一个 enrty
latestEntry match {
case Some(lastEntry) =>
// 产生了新的 epoch,这个由Controller分配
entry.epoch != lastEntry.epoch || entry.startOffset < lastEntry.startOffset
// 如果一个 entry都没有则需要写入
case None =>
true
}
}
if (!isUpdateNeeded)
return false
inWriteLock(lock) {
// 判断是否需要更新
if (isUpdateNeeded) {
maybeTruncateNonMonotonicEntries(entry)
epochs.put(entry.epoch, entry)
true
} else {
false
}
}
}
也就是说单个 broker 重启、或者整个集群重启时,首先是 Controller 感知 ISR 列表的变更后发出同步请求,而各自成为主、从副本的 broker 在本地执行下列方法:
becomeLeaderOrFollower() => makeLeaders()
becomeLeaderOrFollower() => makeFollowers()
之后主副本开始处理 Produce请求、从副本开始循环 Fetch 请求,各自维护本地的 LeaderEpochFileCache。而在主副本处理请求时,请求中是携带了 Controller 分配的 epoch 值的。
如果 Controller 选了新的主副本,那么在给主副本转发 Produce 请求的时候会携带这个更新的 epoch,这个主副本所在 broker 就通过 LeaderEpochFileCache.assign() 将新的 epoch 写入本地缓存。
回到 kafka 官网的说明:
Evolve the message format so that every message set carries a 4-byte Leader Epoch number.
例如主副本在重启后调用下面方法初始化时,会将集群元数据ISR中记录的 epoch (由Controller维护),调用 this.leaderEpoch = partitionState.leaderEpoch 缓存到主副本 Partition 对象中:
Partition.makeLeaders() 代码如下:
def makeLeader(partitionState: LeaderAndIsrPartitionState,highWatermarkCheckpoints: OffsetCheckpoints): Boolean = {
val (leaderHWIncremented, isNewLeader) = inWriteLock(leaderIsrUpdateLock) {
// 省略
// 保存分区分配信息和ISR信息
updateAssignmentAndIsr(
assignment = partitionState.replicas.asScala.map(_.toInt),
isr = isr,
addingReplicas = addingReplicas,
removingReplicas = removingReplicas
)
// 省略
//We cache the leader epoch here, persisting it only if it's local (hence having a log dir)
leaderEpoch = partitionState.leaderEpoch
leaderEpochStartOffsetOpt = Some(leaderEpochStartOffset)
zkVersion = partitionState.zkVersion
// 省略
}
然后主副本在处理生产者消息提交时保存 log,会将这个 Controller 分配的 leaderEpoch 传入 log 保存函数,在log保存完毕之后调用 LeaderEpochFileCache.assign() 根据这个 leaderEpoch 来判断当前自己作为主副本是否时新产生的,如果时新产生的则写入新的 entry。
Partition.appendRecordsToLeader() 代码如下:
def appendRecordsToLeader(records: MemoryRecords, origin: AppendOrigin, requiredAcks: Int): LogAppendInfo = {
val (info, leaderHWIncremented) = inReadLock(leaderIsrUpdateLock) {
leaderLogIfLocal match {
case Some(leaderLog) =>
// 省略
// 将当前分区的 leaderEpoch 传入 this.leaderEpoc
val info = leaderLog.appendAsLeader(records, leaderEpoch = this.leaderEpoch, origin, interBrokerProtocolVersion)
// 省略
}
}
info.copy(leaderHwChange = if (leaderHWIncremented) LeaderHwChange.Increased else LeaderHwChange.Same)
}
6.通过 checkpoint 持久化 epoch 文件
由定时任务 kafka-log-start-offset-checkpoint 进行驱动。
周期执行:
LogManager.checkpointLogStartOffsetsInDir()