kafka 日志存储与查询

4.1 日志存储

[流程图]
  • Topic:Kafka中的消息是以主题为单位进行归类的,每个主题在逻辑上相互独立
  • Partition:每个主题可以分为一个或者多个分区
  • Replica:每个分区会有多个副本
  • log:每个副本会有其对应的日志
  • LogSegment:为了防止单个log文件过大,引入日志分段(LogSegment),便于消息的维护和清理,每个LogSegment由log文件和索引文件组成

4.1.1 消息格式

4.1.1.1 V0与V1

[流程图]
  • offset:偏移量
  • message size:消息的大小
  • crc32: crc32校验值,校验范围是magic和value之间
  • magic:消息格式版本号
  • attributes:消息的属性。例如设置消息的压缩类型
  • key length:消息的key长度
  • key:消息的key
  • value length:value的长度
  • value:消息体
  • timestamp:创建或者追加消息的时间戳

4.1.1.2 V2

[流程图]
  • first offset:表示当前RecordBatch的起始位移
  • length:计算从patition leader epoch的字段到末尾的长度
  • partition leader epoch:分区leader的版本号
  • magic:消息格式的版本号
  • attrubutes:消息属性
  • last offset delta:RecordBatch中最后一个Record的offset与first offset的差值
  • first timestamp:RecordBatch中第一条Record的时间戳
  • max timestamp:RecordBatch中最大的时间戳
  • producer id:PID,用来支持幂等和事物
  • producer epoch:和producer id一样,用来支持幂等和事物
  • first sequence:用来支持幂等和事务
  • records count:RecordBatch中Record的个数
  • length:消息的总长度
  • attrbutes:弃用
  • timestamp delta:时间戳增量,保存与RecordBatch的起始时间戳的差值
  • offset delta:位移增量,保存与RecordBatch的起始位移增量
  • headers:这个字段用来支持应用级别的扩展

4.1.2 日志索引

索引文件有两种
 
  • 偏移量索引
  • 时间戳索引
Kafka中的索引文件以稀疏索引的形式,并不保证每个消息都有对应的索引项
 
在查找索引时,采用二分查找法查找
 

4.1.3 偏移量索引

[流程图]
  • realativeOffset:相对偏移量
  • position:实际物理地址
查找索引步骤:
 
  • 首先日志分段是通过跳表实现,加快查找速度,通过跳表查找到对应的日志分段(baseOffset)
  • 找到对应的日志分段后,通过二分查找对应的物理位置

4.1.4 时间戳索引Å

[流程图]
  • timestamp:当前日志分段的最大时间戳
  • realativeOffset:时间戳对应的相对偏移量
通过时间戳索引查找步骤:
 
  • 将targetTimeStamp 和每个日志分段中的最大时间戳largestTimeStamp对比,直到找到不小于targetTimeStamp的largestTimeStamp 所对应的日志分段,日志分段中的largestTimeStamp 的计算是先查询该日志分段所对应的时间戳索引文件,找到最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则取该日志分段的最近修改时间
  • 找到相应的日志分段之后,在时间戳索引文件中使用二分查找算法查找到不大于targetTimeStamp 最大索引项,如此便找到了相对偏移offset
  • 在偏移量索引文件中使用二分算法查找到不大于offset的最大索引项,找到其对应的物理位置,从该物理位置开始查找不小targetTimeStamp的消息

4.1.4 磁盘存储

  • Kafka在设计时采用了文件追加的方式来写入消息,即只能在日志文件的为尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作。
  • Kafka中大量使用了页缓存,这是Kafka实现高吞吐的重要因素之一,虽然消息都是被写入页缓存,然后由操作系统负责具体的刷盘任务,Kafka中同样提供了同步刷盘和异步刷盘的功能。
  • Kafka使用零拷贝技术来进一步提升性能。零拷贝就是将数据直接从磁盘文件复制到网卡设备中,而不需要经过应用程序,减少了各级缓存的时间消耗。
[流程图]

4.2 副本管理

4.2.1 日志同步

[流程图]
  • 生产者将消息发送到leader副本中
  • 消息被追加到leader副本的本地日志中,并且会更新日志的偏移量
  • follower副本向leader副本请求同步数据
  • leader副本所在的服务器读取本地日志,并更新对应拉取的follower副本的信息
  • leader副本所在的broker将拉取结果返回给folloeer副本
  • follower副本收到leader副本返回的拉取结果,将消息追加到本地日志中,并更新日志的偏移量信息
[流程图]
在一个分区中,leader副本所在的broker节点会记录所有follower副本的LEO,而follower副本所在的broker节点都只记录它自身的LEO,而不会记录其他副本的LEO。对HW而言,各个副本所在的节点都只记录它本身的HW。如图所示,使其带有相应的LEO和HW信息,leader副本中带有其他follower副本的LEO,leader副本收到follower副本的FetchRequest请求之后,他首先会从自己的日志文件中读取数据,然后在返回给follower副本数据前先更新follower副本的LEO。
 

4.2.2 ISR的管理

Kafka到底是如何维护ISR列表的?什么样的Follower才有资格放到ISR列表里呢?
 
  • replica.lag.max.messages
在0.9.x之前的版本里,Kafka Broker有一个核心的参数:replica.lag.max.messages,默认值4000,表示如果Follower落后Leader的消息数量超过了这个参数值,就认为Follower是 out-of-sync,就会从ISR列表里移除。
 
我们通过一个例子来理解下,假设一个分区有3个副本:一个Leader,两个Follower,配置是:replica.lag.max.messages = 3min.insync.replicas = 2ack = -1
 
也就是说,生产者发送一条消息后,当ISR中至少存在2个副本(包含Leader)且这些副本都写成功后,生产者才会收到写入成功的响应。那么每个Follower会不断地发送Fetch请求拉取消息(上送自己的LEO),此时Kafka会判断Leader和Follower的LEO相差多少,如果差的数量超过了replica.lag.max.messages参数值,就会把Follower踢出ISR列表。
 
存在的问题
replica.lag.max.messages这一机制,在瞬间高并发访问的情况下会出现问题:比如Leader瞬间接收到几万条消息,然后所有Follower还没来得及同步过去,此时所有follower都会被踢出ISR列表,然后同步完成之后,又会再被加入ISR列表。
 
也就是说,这种依靠同步消息数量来判断Follower是否落后的机制,可能会导致在系统高峰时期,Follower被频繁踢出ISR列表,然后再回到ISR列表,这种操作是完全无意义的。
 
  • replica.lag.time.max.ms
Kafka从0.9.x版本开始,引入了replica.lag.max.ms参数,默认值10秒,表示如果某个Follower的LEO一直落后Leader超过了10秒,那么才判定这个Follower是 out-of-sync,就会从ISR列表里移除。
 
这样的话,即使出现瞬间的流量洪峰,一下子导致几个Follower都落后了不少数据,但是只要在限定的时间内尽快追上来,别一直落后,就不会认为是out-of-sync
 
上面就是ISR的核心工作机制了,一般导致Follower同步数据较慢的原因主要有以下三种:
 
  1. Follower所在机器的性能变差,比如网络负载过高,I/O负载过高,CPU负载过高等等;
  2. Follower所在机器的Kafka Broker进程出现卡顿,最常见的就是发生了Full GC;
  3. 动态增加了Partition的副本,此时新加入的Follower会拼命从Leader上同步数据,但是这个是需要时间的,所以如果参数配置不当,会导致生产者等待同步完成。

在老版本的Kafka中,ISR机制可能引发数据丢失数据不一致问题。
 
  • 数据丢失
[流程图]
通过Kafka的日志同步可以知道,follower副本从leader副本拉取消息后,会在下一次的请求中携带自己的LEO,leader副本根据follower副本的LEO进而更新HW,并发送给follower副本,假设follower副本还未更新HW(HW=1),此时发生重启,于此同时,leader副本宕机(HW=2)。于是在follower副本重启之后便被选举为leader副本,进行初始化时,会根据HW进行日志截断,此时leader副本的HW=2,于是消息m2被截断,造成数据丢失。
 
  • 数据不一致
[流程图]
如图所示,follower副本的HW=1,leader副本的HW=2,如果此时均发生宕机,并且之前的follower副本成为leader副本,之前的leader副本成为follower副本,并且在follower副本恢复之前,生产者将数据写入leader副本,此时leader副本更新LEO跟HW均为2,当follower副本恢复后并向leader副本请求同步日志时,由于LEO跟HW均是一样,所以消息m3不会被同步到follower副本,造成数据不一致
 
解决方案
 
上述数据丢失的场景是一种非常极端的场景,一般只会在0.11.x版本之前出现。0.11.x版本时,Kafka引入了Leader Epoch机制。所谓Leader Epoch,大致可以理解为每个Leader的版本号,以及自己是从哪个offset开始写数据的,类似[epoch = 0, offset = 0]
 
  • 解决数据丢失问题
[流程图]
  • 解决数据不一致问题
[流程图]

4.2.3 leader副本选举

4.2.3.1 控制器

在Kafka集群中会有一个或多个broker,其中有一个broker会被选举为控制器(Kafka Controller),主要负责:
 
  • 管理整个集群中所有分区和副本的状态
  • 当某个分区的leader副本出现故障,控制器负责该分区选举新的leader副本
  • 负责更新ISR集合,并通知其他broker更新其原数据信息
控制的选举依赖于zookeeper,如果琐事,竞争成功的broker的信息会保存在zookeeper的/controller节点下,流程如下:
 
  • 每个broker启动的时候都会尝试读取/controller节点的brokerid的值,如果读取到的值不为-1,则表示已经有其他broker节点成功竞选为控制器,则放弃竞选
  • 如果zookeeper下不存在/controller节点,则尝试创建controller节点,于此同时,也有可能存在其他broker节点在同时创建,只有创建成功的broker会成为控制器
  • 创建失败的broker节点,会在内存中保存当前控制器的brkerid的值

4.2.3.2 leader副本的选举

分区leader副本的选举由Kafka Controller 负责具体实施。当创建分区(创建主题或增加分区都有创建分区的动作)或分区上线(比如分区中原先的leader副本下线,此时分区需要选举一个新的leader上线来对外提供服务)的时候都需要执行leader的选举动作。
 
基本思路是按照AR集合中副本的顺序查找第一个存活的副本,并且这个副本在ISR集合中。一个分区的AR集合在分配的时候就被指定,并且只要不发生重分配的情况,集合内部副本的顺序是保持不变的,而分区的ISR集合中副本的顺序可能会改变。注意这里是根据AR的顺序而不是ISR的顺序进行选举的。
 
如下图所示:此时分区0,1,2 的leader副本分别为1005,1011,1012
 
如下图所示:此时关闭1011节点,分区1需要进行leader副本的选举,根据规则,按照顺序从AR集合中选举第一个存在于ISR的节点,此时1011已经下线,因此选择1012作为leader副本
 
此时重新上线1011节点,并不会触发发生leader选举
 
此时停掉1013节点,会触发分区1,2的选举,这是Kafka被优雅关闭时,会触发此节点有关的分区发生leader选举,根据规则,分区1选举1011节点,分区2依然选择1012节点
 
此时,1012节点被停掉,引发分区1,2的leader选举,根据规则,分区1选择1011节点,分区2选择1013节点
 
 
 
 

如何通过mmap查询索引找到具体的消息数据

以kafka_2.13-2.8.0为例,分析Kafka消息在磁盘上的存储结构、配置以及如何通过索引找到具体的消息数据。既然是日志索引相关的问题,正好以此来分析存储模块下的索引文件:
 

分区目录

一个分区(Partition)有1到多个副本(Replica),是主从结构,主(Leader)负责处理读写请求,从(Follower)只负责同步数据并在主宕机的时候顶替主实现高可用。在Kafka数据目录下存放着各分区目录(Partition),名称格式为 topic-partitionNo,如test-0代表名为test的Topic的0号分区。分区目录下存放消息的文件。
 

分段日志和索引

Kafka的消息是分段(Segment)存储在文件里的,当达到配置指定的条件就会创建新的分段文件。每个分段都都对应消息日志(.log),偏移量索引(.index)和时间索引(.timeindex)三个文件,文件名为起始偏移量(Offset),代表这个文件第一条消息的偏移量值。
 
以下是日志分段和索引创建的配置项,详情见 Apache Kafka Broker 配置。除了log.index.interval.bytes只影响单个索引的创建时机,其他配置都会触发日志分段。
 
配置项
 
默认值
 
单位
 
描述
 
log.roll.ms
 
null
 
毫秒
 
新日志段滚出的最大时间。如果未设置,则使用log.roll.hours中的值
 
log.roll.hours
 
168
 
小时
 
新日志段滚出的最大时间,从属于log.roll.ms属性
 
log.segment.bytes
 
1073741824(1G)
 
B
 
单个日志文件的最大大小
 
log.index.size.max.bytes
 
10485760(1MB)
 
B
 
偏移索引的最大字节数
 
log.index.interval.bytes
 
4096(4 KB)
 
B
 
将一个项添加到偏移索引中的间隔
 
 

消息日志与索引关系

Kafka数据最终都会保持在磁盘上,对于消息有三个关键的文件消息日志(.log),偏移量索引(.index)和时间索引(.timeindex)。消息日志保存的是消息的原数据,接收到的生产者(Producer)的消息会以追加的方式顺序写到这个文件中,顺序写效率远高于随机写,减轻了磁盘寻址压力。这是Kafka使用磁盘做存储却能保证高性能的原因之一。每个消息都会有一个自增的偏移量值,从0开始,每条消息都递增这个值,所以偏移量代表即将到来的下一条消息的偏移量值。Kafka中索引有偏移量索引和时间索引两种。它没有为每一条消息建立索引,那样索引文件会太过于庞大,而是分段建立,所以一个索引只能指明消息所在位置的范围,最终要在这个范围遍历查找。时间索引指向的是偏移量索引,偏移量索引指向了消息日志二进制位置。通过时间戳或者偏移量最终都可以定位到消息的具体位置。可以通过配置参数 log.index.interval.bytes控制两个索引间隔的字节数,超过这个大小就建立新索引。这个值越小,索引越密集,查询快但是文件体积大。
 

消息日志(.log)

通过消息日志(.log)可以看到每条消息具体的内容。
 
 
# 只输出消息日志描述信息
kafka-dump-log.sh --files /var/kafka-logs/test-0/00000000000000023147.log

# 输出消息日志完整信息
kafka-dump-log.sh --files /var/kafka-logs/test-0/00000000000000023147.log --print-data-log
可以看到下图这个消息日志起始偏移量(Starting offset)是23147,代表这个日志第一条消息的偏移量,这个偏移量同时也是消息日志和两个索引文件的文件名。每n条消息组成一批(batch),每一批消息对应有一个描述信息,记录了这批消息的大小,偏移量范围baseOffset和lastOffset,位置(position)以及大小(batchSize)等信息。描述信息下面就是对应这一批具体的消息。如下图:
 

偏移量索引(.index)

 
# 查看偏移量索引内容
kafka-dump-log.sh --files /var/kafka-logs/test-0/00000000000000023147.index
偏移量索引是稀疏结构,每隔一段记录一条消息的索引。Offset指消息的偏移量,position指这个偏移量的消息所在的一批(batch)消息在.log中的起始二进制位置。
 

时间索引(.timeindex)

 
# 查看时间索引内容
kafka-dump-log.sh --files /var/kafka-logs/test-0/00000000000000023147.timeindex1.2.
时间索引也是稀疏结构,每隔一段记录一条消息的索引。时间戳(timestamp)指这条消息的创建时间,Offset指这个消息的偏移量。上面这条指令同时会输出根据时间戳索引查找消息的结果,比如创建时间为1632390207745的消息偏移量为23388,这条消息所在那一批消息的起始偏移量(Indexed offset / baseOffset:)为23388,终止偏移量(found log offset / lastOffset:)为23390,这一批消息一起有23390 ~ 23388 = 3条消息。
 
Kafka通过MappedByteBuffer将索引文件映射到内存中,来加快索引的查询速度。
 

位移索引

不同索引类型保存不同的<Key , Value>对,对OffsetIndex位移索引而言,Key就是消息的相对位移,Value保存该消息的日志段文件中该消息第一个字节的物理文件位置。
 
  • 偏移量索引文件:
定义:对于偏移量索引文件,保存的是 <相对偏移量,物理地址> 的对应关系,文件中的相对偏移量是单调递增的。 查找:查询指定偏移量对应的消息时,使用改进的二分查找算法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回文件中小于指定偏移量的最大偏移量及对应的物理地址,该逻辑通过OffsetIndex.lookup()方法实现。一个参考的 稀疏索引.index文件的内容,大致如下
 
Offset
 
Position
 
100
 
4000
 
110
 
8200
 
120
 
13000
 
130
 
18000
 
 
假设 ,要寻找 offset 为115位点对应的文件position,因为115介于「110-120」之间,因此稀疏索引能够提供的信息就是,110 需要从 8200 的位置开始往后找,这样也就粗略定位了115的大致position 索引项:偏移量索引文件的索引项结构如下图所示,每个索引项记录了相对偏移量relativeOffset和对应消息的第一个字节在日志段文件中的物理地址position,共占用8个字节。
 
  • relativeOffset:相对偏移量,表示消息相对于 baseOffset 的偏移量,占用 4 个字节,当前索引文件的文件名即为 baseOffset 的值;
  • position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用 4 个字节。
提示:本质上, 消息的偏移量(offset)如果是 绝对偏移量, 那是一个long ,是要占用 8 个字节滴,那么,为啥这里是四个字节呢?
 
为啥?索引项中没有直接使用long 类型绝对偏移量,而改为只占用 4 个字节 int 的相对偏移量(relativeOffset=offset-baseOffset),这样可以减小索引文件占用的空间。举个例子看一下:
 
  • 如果一个日志分段的 baseOffset (基础偏移量) 为 32,
  • 那么其文件名就是 00000000000000000032.log,
  • offset 为 35 的消息在索引文件中的 relativeOffset 的值为 35-32=3。
为什么使用相对偏移量?这样可以节约存储空间。每条消息的绝对偏移量占用8个字节,而相对偏移量只占用4个字节(relativeOffset=offset-baseOffset)。在日志段文件滚动的条件中,有一个是:追加消息的最大偏移量和当前日志段的baseOffset的差值大于Int.MaxValue(4个字节),因为如果 相对偏移量 大于这个4个字节值,就无法存储相对偏移量了。所以, kafka有两个偏移量:
 
  • 绝对偏移量: OffsetIndex位移索引中是override def entrySize = 8,8个字节。
  • relativeOffset:相对偏移量,表示消息相对于 baseOffset 的偏移量,占用 4 个字节
relativeOffset 相对位移是一个整型,占用4个字节,物理文件位置也是一个整型,同样占用4个字节,因此总共8个字节。总之,Kafka中的消息位移值是一个长整型,应该占用8个字节才对,但是,在保存OffsetIndex<Key , Value>对,Kafka做了一些优化,每个OffsetIndex对象在创建时,都已经保存了对应日志段对象的起始位移,因此保存与起始位移的差值就够了。
 
  1. 为了节省空间,一个索引项节省了4字节,想想那些日消息处理数万亿的公司。
  2. 因为内存资源是很宝贵的,索引项越短,内存中能存储的索引项就越多,索引项多了直接命中的概率就高了。

通过索引查询消息过程

偏移量索引和时间戳索引对应的类分别为:OffsetIndex 和 TimeIndex,其公共的抽象父类为AbstractIndex:
 
与之相关的源码如下:
 
  1. AbstractIndex.scala:抽象类,封装了所有索引的公共操作
  2. OffsetIndex.scala:位移索引,保存了位移值和对应磁盘物理位置的关系
  3. TimeIndex.scala:时间戳索引,保存了时间戳和对应位移值的关系
  4. TransactionIndex.scala:事务索引,启用Kafka事务之后才会出现这个索引
这里先介绍 OffsetIndex位移索引 文件。
 
1.索引项大小定义:
 
 
//偏移量索引文件索引项override def entrySize = 8
//时间戳索引文件索引项override def entrySize = 12
2.根据绝对偏移量计算相对偏移量:relativeOffset
 
 
def relativeOffset(offset: Long): Int = {

val relativeOffset = toRelative(offset)

if (relativeOffset.isEmpty)

throw new IndexOffsetOverflowException(s"Integer overflow for offset: $offset (${file.getAbsoluteFile})")

relativeOffset.get

}

relativeOffset方法内部调用了toRelative方法:用给定的偏移量-日志段起始偏移量,如果结果合法则返回
 
 
private def toRelative(offset: Long): Option[Int] = {

val relativeOffset = offset - baseOffset

if (relativeOffset < 0 || relativeOffset > Int.MaxValue)

None

else

Some(relativeOffset.toInt)

}

3.将相对偏移量还原成绝对偏移量:parseEntry偏移量索引:
 
 
override protected def parseEntry(buffer: ByteBuffer, n: Int): OffsetPosition = {

OffsetPosition(baseOffset + relativeOffset(buffer, n), physical(buffer, n))

}

这个方法返回一个 OffsetPosition 类型。该类有两个方法,分别返回索引项的 Key 和 Value。这里的 parseEntry 方法,就是要构造 OffsetPosition 所需的 Key 和 Value。Key 是绝对偏移量,根据索引项中的相对偏移量计算,代码使用 baseOffset + relativeOffset(buffer, n) 的方式将相对偏移量还原成绝对偏移量;Value 是这个偏移量上消息在日志段文件中的物理位置,代码调用 physical 方法计算这个物理位置并把它作为 Value。最后,parseEntry 方法把 Key 和 Value 封装到一个 OffsetPosition 实例中,然后将这个实例返回。
 
4.快速定位消息所在的物理文件位置e.g. 假设要查找偏移量为 230 的消息?
 
第一步: 通过跳 表 ,找 分段的index文件Kafka 中存在一个 ConcurrentSkipListMap 来保存在每个日志分段,通过跳跃表方式,定位到在 00000000000000000217.index ,第二步: 通过 改进的二分查找, 找到不大于 相对偏移量的 最大索引项通过二分法在偏移量索引文件中找到不大于 230-217 =13 的最大索引项,即 offset 12 那栏,第三步:找日志文件,找到相对的目标 记录从日志文件物理位置456开始,继续向后查找找到相对偏移量为13的消息。
 
 
def lookup(targetOffset: Long): OffsetPosition = {

maybeLock(lock) {

//复制出整个索引映射区

val idx = mmap.duplicate

// largestLowerBoundSlotFor 方法底层使用了改进版的二分查找算法寻找对应的槽

val slot = largestLowerBoundSlotFor(idx, targetOffset, IndexSearchType.KEY)

// 如果没找到,返回一个空的位置,即物理文件位置从0开始,表示从头读日志文件

// 否则返回slot槽对应的索引项

if(slot == -1)

OffsetPosition(baseOffset, 0)

else

parseEntry(idx, slot)

}

}

从上面 OffsetIndex.scala#lookup()` 的源,可以看到关键处有两点:
 
  • 偏移量索引使用 mmap 来映射操作索引数据,这样索引数据不需要拷贝到用户态,提高了性能
  • 调用 AbstractIndex.scala#largestLowerBoundSlotFor() 方法从索引数据中查找确定消息数据读取的起始位置
AbstractIndex.scala#largestLowerBoundSlotFor()` 的源码如下:
 
 
protected def largestLowerBoundSlotFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchType): Int =
indexSlotRangeFor(idx, target, searchEntity)._1

private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchType): (Int, Int) = {
// check if the index is empty
if(_entries == 0)
return (-1, -1)

def binarySearch(begin: Int, end: Int) : (Int, Int) = {
// binary search for the entry
var lo = begin
var hi = end
while(lo < hi) {
val mid = (lo + hi + 1) >>> 1
val found = parseEntry(idx, mid)
val compareResult = compareIndexEntry(found, target, searchEntity)
if(compareResult > 0)
hi = mid - 1
else if(compareResult < 0)
lo = mid
else
return (mid, mid)
}
(lo, if (lo == _entries - 1) -1 else lo + 1)
}

//使用所有索引数据 entry 的总量 _entries 减去热区数据大小_warmEntries,
// 确定一个热区索引的起始位置,这样可以保障只在索引数据的尾部进行二分查找
val firstHotEntry = Math.max(0, _entries - 1 - _warmEntries)
// check if the target offset is in the warm section of the index
if(compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
return binarySearch(firstHotEntry, _entries - 1)
}

// check if the target offset is smaller than the least offset
if(compareIndexEntry(parseEntry(idx, 0), target, searchEntity) > 0)
return (-1, 0)

binarySearch(0, firstHotEntry)
}

AbstractIndex.scala#largestLowerBoundSlotFor()` 的主要逻辑是从索引数据中二分查找确定消息数据在文件中的物理起始点,这里需要注意索引文件实际进行了冷热分区,其中关键如下:
 
  1. 使用所有索引数据 entry 的总量 _entries 减去热区数据大小_warmEntries,确定一个热区索引的起始位置,这样可以保障只在索引数据的尾部进行二分查找
  2. 之所以这样处理,是因为 Kafka 的索引是在末尾追加写入的,并且一般写入的数据很快就会被读取,数据热点集中在尾部。索引数据一般都在页缓存中,而操作系统的内存是有限的,必然要通过类似 LRU 的机制淘汰页缓存。
  3. 如果每次二分查找都从头开始,则索引中间部分的数据所在的页缓存大概率已经被淘汰掉,从而导致缺页中断,必须重新从磁盘上读文件,影响性能
提示:页缓存也叫文件缓冲,是文件系统数据在内存中的缓存结构,Kafka 的消息数据存储也充分利用了页缓存,如果消息写入消费速度相当,则消费时大概率直接命中缓存而不经过磁盘IO,极大提高性能。但是当某个消费者消费速度落后时,可能会导致 Kafka 节点上的页缓存频繁切换,拖累整个集群的性能,偏移量索引文件的查找原理:假设要查找偏移量为230的消息,查找过程如下:
 
  • 首先找到baseOffset=217的日志段文件(这里使用了跳跃表的结构来加速查找)
  • 计算相对偏移量relativeOffset=230-217=13
  • 在索引文件中查找 不大于13 的最大相对偏移量对应的索引项,即[12,456]
  • 根据12对应的物理地址456,在日志文件.log中定位到准确位置
  • 从日志文件物理位置456继续向后查找找到相对偏移量为13,即绝对偏移量为230,物理地址为468的消息
注意:
 
  • 消息在log文件中是以批次存储的,而不是单条消息进行存储。索引文件中的偏移量保存的是该批次消息的最大偏移量,而不是最小的。
  • Kafka强制要求索引文件大小必须是索引项大小(8B)的整数倍,假设broker端参数log.index.size.max.bytes 设置的是67,那么Kafka内部也会将其转为64,即不大于67的8的最大整数倍。

改进的二分查找

就Kafka而言,索引是在文件末尾追加的写入的,并且一般写入的数据立马就会被读取。所以数据的热点集中在尾部。并且操作系统基本上都是用页为单位缓存和管理内存的,内存又是有限的,因此会通过类LRU机制淘汰内存。看起来LRU非常适合Kafka的场景,但是使用标准的二分查找会有缺页中断的情况,毕竟二分是跳着访问的。简单的来讲,假设某索引占page cache 13页,此时数据已经写到了12页。按照kafka访问的特性,此时访问的数据都在第12页,因此二分查找的特性,此时缓存页的访问顺序依次是0,6,9,11,12。因为频繁被访问,所以这几页一定存在page cache中。
 
当第12页不断被填充,满了之后会申请新页第13页保存索引项,而按照二分查找的特性,此时缓存页的访问顺序依次是:0,7,10,12。这7和10很久没被访问到了,很可能已经不再缓存中了,然后需要从磁盘上读取数据。注释说:在他们的测试中,这会导致至少会产生从几毫秒跳到1秒的延迟。基于以上问题,Kafka使用了改进版的二分查找,改的不是二分查找的内部,而且把所有索引项分为热区和冷区 这个改进可以让查询热数据部分时,遍历的Page永远是固定的,这样能避免缺页中断。看到这里其实我想到了一致性hash,一致性hash相对于普通的hash不就是在node新增的时候缓存的访问固定,或者只需要迁移少部分数据。
 

Log 类采用跳跃表(SkipList)管理 LogSegment 对象

每个 topic 分区对应一个 Log 类对象(一个 broker 节点上只允许存放分区的一个副本,所以从 broker 视角来看一个分区对应一个 Log 类对象),其中包含了一系列隶属对应 topic 分区的 LogSegment 对象,Log 类采用跳跃表(SkipList)数据结构对这些 LogSegment 对象进行管理。
 
上图展示了 LogSegment 在 Log 中基于 SkipList 的组织形式(其中青色小圆圈表示单个 LogSegment 对象)。
 

写入索引项的方法

偏移量索引:append写入索引项append方法的实现, 通过mmap 实现 idex 文件读写的 零复制,流程图如下
 
写入索引项append方法的实现, 通过mmap 实现 idex 文件读写的 零复制,代码如下
 
 
def append(offset: Long, position: Int): Unit = {
inLock(lock) {
// 索引文件如果已经写满,直接抛出异常
require(!isFull, "Attempt to append to a full index (size = " + _entries + ").")
// 要保证待写入的位移offset比当前索引文件中所存的位移值要大
// 这主要是为了维护索引的单调性
if (_entries == 0 || offset > _lastOffset) {
trace(s"Adding index entry $offset => $position to ${file.getAbsolutePath}")
mmap.putInt(relativeOffset(offset))//向mmap写入相对位移值
mmap.putInt(position)//向mmap写入物理文件位置
_entries += 1//更新索引项个数
_lastOffset = offset//更新当前索引文件最大位移值
// 确保写入索引项格式符合要求
require(_entries * entrySize == mmap.position(), s"$entries entries but file position in index is ${mmap.position()}.")
} else {
throw new InvalidOffsetException(s"Attempt to append an offset ($offset) to position $entries no larger than" +
s" the last offset appended (${_lastOffset}) to ${file.getAbsolutePath}.")
}
}
}

时间戳索引

TimeIndex保存的是<时间戳,相对位移值>,时间戳需要长整型来保存,相对位移值使用Integer来保存。因此TimeIndex单个索引项需要占用12个字节。
 

写入时间戳索引的索引项

 
def maybeAppend(timestamp: Long, offset: Long, skipFullCheck: Boolean = false): Unit = {
inLock(lock) {
if (!skipFullCheck)
// 索引文件如果已经写满,直接抛出异常
require(!isFull, "Attempt to append to a full time index (size = " + _entries + ").")
// 这主要是为了维护索引的单调性
if (_entries != 0 && offset < lastEntry.offset)
throw new InvalidOffsetException(s"Attempt to append an offset ($offset) to slot ${_entries} no larger than" +
s" the last offset appended (${lastEntry.offset}) to ${file.getAbsolutePath}.")
// 这主要是为了维护索引的单调性
if (_entries != 0 && timestamp < lastEntry.timestamp)
throw new IllegalStateException(s"Attempt to append a timestamp ($timestamp) to slot ${_entries} no larger" +
s" than the last timestamp appended (${lastEntry.timestamp}) to ${file.getAbsolutePath}.")

if (timestamp > lastEntry.timestamp) {
trace(s"Adding index entry $timestamp => $offset to ${file.getAbsolutePath}.")
mmap.putLong(timestamp)//向mmap写入时间戳
mmap.putInt(relativeOffset(offset))//向mmap写入相对位移值
_entries += 1
_lastEntry = TimestampOffset(timestamp, offset)
require(_entries * entrySize == mmap.position(), s"${_entries} entries but file position in index is ${mmap.position()}.")
}
}
}

位移索引和时间戳索引的区别是什么?

Kafka中有三大类索引:位移索引、时间戳索引和已中止事务索引。分别对应了.index、.timeindex、.txnindex文件。与之相关的源码如下:
 
  1. AbstractIndex.scala:抽象类,封装了所有索引的公共操作
  2. OffsetIndex.scala:位移索引,保存了位移值和对应磁盘物理位置的关系
  3. TimeIndex.scala:时间戳索引,保存了时间戳和对应位移值的关系
  4. TransactionIndex.scala:事务索引,启用Kafka事务之后才会出现这个索引

 

posted on 2025-07-30 11:42  paulgeo  阅读(32)  评论(0)    收藏  举报