第五章:日志存储
本章讨论kafka日志存储底层原理:存储介质、存储格式、快速索引、清理规则等
5.1 文件目录布局
- 整体逻辑结构与日志的关系
- 日志(Log)
- 对应关系:一个Partition对应一个Log(多副本的情况如上图)
- 物理形式:Log在物理上只以文件夹的形式存储,对应了一个命名形式为<topic>-<partition>的文件夹
- 例:假如存在一个"topic-log"的主题,此主题中具有 4 个分区。那么在实际物理存储上表现为"topic-log-0"、"topic-log-1"、"topic-log-2"、"topic-log-3"这 4个文件夹
- 日志分段(LogSegment)
- 对应关系:一个Log对应多个LogSegment(为了防止单Log 过大,Kafka 引入了日志分段LogSegment的概念,相当于一个巨型文件被平均分配为多个相对较小的文件)
- 物理形式:每个LogSegment对应于磁盘上的一个日志文件(.log)和两个索引文件(.index),以及可能的其他文件(比如以".txnindex"为后缀的事务索引文件)(见上图)
- activeSegment的概念:向 Log 中追加消息时是顺序写入的,只有最后一个LogSegment才能执行写入操作。最后一个 LogSegment 即 "activeSegment",表示当前活跃的日志分段
- LogSegment命名:【基准偏移量.后缀名】
- 基准偏移量(baseOffset):64位的长整型数,表示当前LogSegment中第一条消息的offset。日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数则用0填充
- 例(下图):第一个LogSegment文件包含offset在[0,132]的消息,第二个包含offset在[133,250]的消息......
- 整体布局如下
5.2 kafka消息格式
Kafka的消息格式经历了3个版本:v0版本、v1版本、v2版本
5.2.1 消息格式
5.2.1.1 v0版本(kafka 0.10.0前)
单条消息
- 日志头(LOG OVERHEAD):日志头固定12B,包含 offset (8B)+ message size(4B)
- 消息体(RECORD):记录了消息内容
- crc32(4B):crc32 校验值。校验范围为magic至value之间
- magic(1B):消息格式版本号,此版本的magic值为0
- attributes(1B):消息的属性。总共占 1 个字节,低 3 位表示压缩类型:0表示NONE、1表示GZIP、2表示SNAPPY、3表示LZ4(LZ4自Kafka 0.9.x引入),其余位保留
- key length(4B):表示消息的key的长度。如果为 1,则表示没有设置key,即 key=null
- key:可选,如果没有key则无此宇段
- value length(4B):实际消息体的长度。如果为-1,则表示消息为空
- value:消息体。可以为空,比如墓碑(tombstone)消息
消息集(Message Set)
- 消息集中包含一条或多条消息,如下图所示
- 消息集不仅是存储于磁盘及在网络上传输(Produce&Fetch)的基本形式,而且是Kafka中压缩的基本单元
5.2.1.2 v1版本(kafka 0.10.0至kafka 0.11.0)
相比v0就在RECORD里多了一个timestamp 字段(见下图),表示消息的时间戳
timestamp
- 值由生产者创建:timestamp类型由broker端参数log.message.timestamp.type来配置,默认值为CreateTime,即采用生产者创建消息时的时间戳
- 如果在创建ProducerRecord时没有显式指定消息的时间戳,那么KafkaProducer也会在发送这条消息前自动添加上
5.2.1.3 v2版本
v2相比前几个版本,参考了Protocol Buffer而引入了变长整型(Varints)和ZigZag编码
- 消息集:称为Record Batch,不再是Message Set
- 在消息压缩的情形下,Record Batch Header部分(参见图5-7左部,从first offset到records count字段)是不被压缩的,被压缩的是records字段中的所有内容
- 生产者客户端中的ProducerBatch对应这里的RecordBatch,而ProducerRecord对应这里的Record
- Record Batch相比前几个版本,多了以下字段
- first offset:表示当前RecordBatch的起始位移
- length:计算从partition leader epoch字段开始到末尾的长度
- partition leader epoeh:分区leader纪元,可以看作分区leader的版本号或更新次数
- magic:消息格式的版本号,对v2版本而言,magic等于2
- attributes:消息属性(这里占用了两个字节)。低3位表示压缩格式,可以参考v0和v1;第4位表示时间戳类型;第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。第6位表示是否是控制消息(ControlBatch),0表示非控制消息,而1表示是控制消息,控制消息用来支持事务功能
- last offset delta:RecordBatch中最后一个Record的offset与first offset的差值。主要被broker用来确保RecordBatch中Record组装的正确性
- first timestamp:RecordBatch中第一条Record的时间戳
- max timestamp:RecordBatch中最大的时间戳,一般情况下是指最后一个Record的时间戳,和last offset delta的作用 一样,用来确保消息组装的正确性
- producer id:PID,用来支持幂等和事务
- producer epoch:和producer id一样,用来支持幕等和事务
- first sequence:和produeer id、producer epoeh一样,用来支持幂等和事务
- records count:RecordBatch中Record的个数
总结:v2 版本的消息不仅提供了更多的功能,比如事务、幂等性等,某些情况下还减少了消息的空间占用,总体性能提升很大
5.3 日志索引
前面提到每个LogSegment对应于磁盘上的一个日志文件和两个索引文件,主要用来提高查找消息的效率
- 偏移量索引文件:用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置
- 时间戳索引文件:根据指定的时间戳(timestamp)来查找对应的偏移量信息
索引文件的构造:稀疏索引(sparse index)
- 概述:稀疏索引不保证每个消息在索引文件中都有对应的索引项,稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面的一个折中
- 稀疏索引的密度:由参数log.index.interval.bytes控制,默认4KB。即每当写入4KB的消息时,偏移量文件和时间戳文件会分别增加一个索引项
- 查找方式:查找时根据二分法在索引文件中找到大概的位置,然后再去物理地址顺序查找
- 偏移量索引文件:文件中的偏移量是单调递增的。查询指定偏移量时,使用二分查找快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量
- 时间戳索引文件:文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位
5.3.1 根据偏移量索引
- 偏移量索引项的格式如上图所示,每个索引项占4B + 4B = 8B大小
- relativeOffset:相对偏移量,表示消息相对于baseOffset的偏移量,占用4个字节。当前索引文件的文件名即为baseOffset的值
- relativeOffset = offset - baseOffset
- 例子:一个日志分段的baseOffset为32,那么其文件名就是00000000000000000032.log,offset为35的消息在索引文件中的relativeOffset的值为35-32=3
- position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用 4个字节
- relativeOffset:相对偏移量,表示消息相对于baseOffset的偏移量,占用4个字节。当前索引文件的文件名即为baseOffset的值
- 例子:如何根据偏移量索引查找log的物理地址
- 例1(见下图):想要查找偏移量为23的消息
- ①首先通过二分法在偏移量索引文件中找到不大于23的最大索引项,即[22,656]
- ②然后从日志分段文件中的物理位置656开始顺序查找偏移量为23的消息
- 例2(下图):要查找偏移量为 268 的消息
- 定位到 baseOffset为251的日志分段,然后计算相对偏移量 relativeOffset= 268 - 251=17,之后再在对应的索引文件中找到不大于 17 的索引项
- 如何查找 baseOffset 为 251 的日志分段呢:非顺序查找,采用跳跃表的结构Kafka的每个日志对象中使用了ConcurrentSkipListMap来保存各个日志分段,每个日志分段的baseOffset作为key,这样可以根据指定偏移量来快速定位到消息所在的日志分段
- 例1(见下图):想要查找偏移量为23的消息
5.3.2 根据时间戳索引
- 时间戳索引项的格式如上图所示,每个索引项占8B + 4B = 12B大小
- timestamp:当前日志分段最大的时间戳
- 如何保证索引单调顺序
- 如果不设置该参数,则可能会造成当前分区的时间戳索引乱序
- 设置参数log.message.timestamp.type为LogAppendTime:则每个追加的时间戳索引项中的timestamp必须大于之前追加的索引项的timestamp,否则不予追加
- relativeOffset:时间戳所对应的消息的相对偏移量
- 如何保证索引单调顺序
- timestamp:当前日志分段最大的时间戳
- 例子:如何根据时间戳索引查找log的物理地址(过程见下图)
- ①查找时间索引(获得relativeOffset)
- ②查找偏移量索引(获得物理文件地址)
- ③查找Log文件
5.4 日志清理
两种日志清理策略:日志删除和日志压缩。通过broker端参数log.cleanup.policy来设置,默认值"delete",即日志删除的清理策略
5.4.1 日志删除
日志删除(Log Retention):按照一定的保留策略直接删除不符合条件的日志分段(LogSegment)
- 定时日志删除任务:在Kafka日志管理器中有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件
- 这个周期可以通过broker端参数 log.retention.check.interval.ms来配置,默认值为300000,即5分钟
- 保留策略:当前有3种,基于时间的保留策略、基于日志大小的保留策略、基于日志起始偏移量的保留策略
- 基于时间:检查当前日志文件中是否有保留时间超过设定阈值的文件集合
- 保留时间的阈值由log.retention.hours、log.retention.minutes、log.retention.ms来配置
- 默认log.retention.hours=168,即保留7天
- 基于时间:检查当前日志文件中是否有保留时间超过设定阈值的文件集合
- 基于日志大小:检查当前日志总大小是否超过设定的阈值
- 通过broker端参数log.retention.bytes来配置,默认值为-1,表示无穷大(注意:该参数配置的是Log中所有日志文件的总大小,而不是单个".log"文件)
- 单个日志分段(".log"文件)的大小由broker端参数log.segment.bytes来限制,默认值为1GB
- 基于日志起始偏移量:判断依据是某日志分段的下一个日志分段的起始偏移量baseOffset是否小于等于logStartOffset。若是,则可以删除此日志分段
5.4.2 日志压缩
日志压缩(Log Compaction):针对每个消息的key进行整合,对于有相同key的不同value值,只保留最后一个版本
- 如果应用只关心key对应的最新value值,则可以开启该策略
- 该策略的优势在于如果只关心key的最新value值(而非历史版本每一个值),如果kafka发生崩溃,重启时可以减少数据的加载量进而加快系统的恢复速度
5.5 磁盘存储
Kafka依赖于文件系统(更底层地来说就是磁盘)来存储和缓存消息(反观RabbitMQ,使用内存作为默认的存储介质,磁盘作为备选介质)
kafka如何保证存储速度?
5.5.1 顺序写磁盘
有关测试结果表明,顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快
-
Kafka采用文件追加方式来写入消息,即只能在日志文件的尾部追加新的消息,且不允许修改己写入的消息。这种顺序写盘的方式提高了性能
5.5.2 页缓存
- 什么是页缓存(page cache):操作系统提供的一种内存管理方案
- 具体地讲:当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的I/O操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性
- kafka中大量使用了页缓存,提高性能:消息直接被写入页缓存,然后由操作系统负责刷盘
- kafka也提供了同步刷盘及强制刷盘(fsync)功能,通过log.flush.interval.messages、log.flush.interval.ms 等参数配置
5.5.3 零拷贝
- 零拷贝(Zero-Copy):零拷贝技术是一种减少CPU拷贝操作,提高数据传输效率的技术。在传统的数据传输模式中,数据需要从用户空间拷贝到内核空间,然后再从内核空间拷贝到网络协议栈,这种多次拷贝操作增加了CPU的负担并降低了数据传输效率。零拷贝技术通过减少这些拷贝步骤,直接在内核空间与网络协议栈之间传输数据,从而提高性能
- 简而言之:零拷贝减少了内核和用户模式之间的上下文切换(见下图左与下图右的对比)
- kafka中零拷贝的实现:主要体现以下两方面
- ①sendfile()系统调用:Kafka使用了Linux提供的sendfile()系统调用,该调用允许数据在文件描述符之间传输而不必通过用户空间。这意味着数据可以直接从磁盘文件移动到Socket缓冲区,省去了从用户空间到内核空间的拷贝过程
- ②FileChannel.transferTo()方法:在Java NIO中,FileChannel的transferTo()方法被用于实现零拷贝。Kafka中的Broker在发送消息给消费者时,会使用这个方法将数据直接从文件通道传输到网络通道,这样数据就不需要先被读取到用户空间然后再写出