一、前言
Kafka 对消息的存储和缓存严重依赖于磁盘文件系统。人们对于“磁盘速度慢”的普遍印象,使得人们对于持久化的架构能够提供强有力的性能产生怀疑。事实上,磁盘的速度比人们预期的要慢的多,也快得多,这取决于人们使用磁盘的方式。而且设计合理的磁盘结构通常可以和网络一样快。Kafka采用顺序磁盘访问技术、紧凑的字节结构设计、页缓存以及零拷贝等技术保证了系统的高性能、低延迟、良好的扩展(50kb和50TB的数据在Kafka server上的表现是一致的)。
二、文件目录结构
- Kafka中的消息是以主题来归类消息的,一个主题分为一个或多个分区,一个分区具有一个或多个副本,一个副本对应一个日志(Log),为了防止Log过大,一个日志(Log)又切分为多个日志分段(LogSegment), Log在物理存储上是一个文件夹形式,而每个日志分段(LogSegment)在磁盘上包括一个日志文件.log、一个偏移量索引文件、一个时间戳索引文件以及可能的临时文件和其他文件。
- 存储消息(日志)文件的目录通过参数log.dirs配置,可配置多个目录用逗号隔开,默认存储目录是/tmp/kafka-logs。在创建主题Topic时,如果当前broker中log.dirs不止配置了一个根目录,那么会挑选分区数最少的那个根目录来完成创建任务。
- 我们先创建一个主题topic_test_01,包含3个分区。那么在broker服务器上存储消息目录下会生成三个Log子文件夹,一个分区对应一个文件夹,文件夹名为“主题名-分区序号”。如下图所示。
-
任意打开一个Log日志文件夹(例如topic_test_01-0),会看到一个日志文件.log、一个偏移量索引文件.index、一个时间戳索引文件.timeindex,这三个相同名字的文件属于同一个日志分段,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数则用0填充。目录文件如下图所示。
其中第二个日志分段名称为“00000000000000618900”表示该日志分段的第一条消息的偏移量为618900。
- 向Log中写入消息(日志),是按照顺序往日志分段中追加的形式写入,参数log.segment.bytes设置了单个日志分段大小限制(默认为1G),当达到这个参数值时,将创建一个新的日志分段。
- 当消费者消费消息并提交位移后,会在log.dirs配置的目录下创建一个__consumer_offsets主题来保存消费者位移,__consumer_offsets主题的日志清理采用的是日志压缩策略。
三、日志清理
- Kafka会默认启用一个日志清理器(Log Cleaner),这个日志清理器会开启一个清理的线程池,对保留了指定时间或累计到规定大小的日志分段进行清理。
- 日志清理策略分为日志删除(delete)和日志压缩(compact)两种,通过broker服务端参数log.cleanup.policy设置,默认为delete,可设置为“delele,compact”同时支持日志删除和日志压缩两种策略。
3.1 日志删除策略(delete)
- Kafka日志清理器会有一个定时任务周期性检查是否有不符合保留条件的日志分段文件,并且删除符合删除条件的分段日志文件,这个周期通过参数log.retention.check.interval.ms配置,默认检查周期为5分钟。所以在默认情况下,每隔5分钟,当日志分段的日志数据保留时间超过指定时间或日志大小达到规定大小后会被直接删除。
3.1.1 基于保留时间删除
- 检查日志文件中是否有保留时间超过设定阀值的并删除符合条件的文件集合。日志保留的时间阈值可以通过broker端参数log.retention.hours、log.retention.minutes、log.retention.ms设置,其中优先级最高的是log.retention.ms,其次是log.retention.minutes,最低的是log.retention.hours,默认情况下只配置了参数log.retention.hours,默认值168。所以默认情况下日志分段文件的保留时间为7天。
- 基于保留时间删除分为几个步骤。
(1)首先需要获取到日志分段最大的时间戳largestTimeStamp,通过查找日志分段所对应的时间戳索引文件的最后一条索引项,如果最后一条索引项的时间戳字段值大于 0,则取其值为largestTimeStamp,否则设置为最近修改时间lastModifiedTime;然后比较largestTimeStamp与保留时间参数值来计算出待删除的日志分段文件。
(2)执行删除日志分段,首先会从Log对象中所维护日志分段的跳跃表中移除待删除的日志分段,以保证没有线程对这些日志分段进行读取操作。然后将日志分段所对应的所有文件添加上“.deleted”的后缀(当然也包括对应的索引文件)。最后交由一个以“delete-file”命名的延迟任务来删除这些以“.deleted”为后缀的文件,这个任务的延迟执行时间可以通过file.delete.delay.ms参数来调配,此参数的默认值为60000,即1分钟。
(3)若该日志文件中所有的日志分段都为待删除状态。在这种情况下,会切分出一个新的日志分段作为活跃的日志分段activeSegment来接收新消息的写入,其他的日志分段则会被删除。
3.1.2 基于日志大小删除
- 检查日志文件中是否有大小超过设定阀值的并删除符合条件的文件集合。参数log.retention.bytes设置的是Log(日志文件)的保留大小,默认值为-1,表示不限制大小。参数log.segment.bytes设置的是单个日志分段的大小限制,默认为1GB。
- 基于日志大小的删除分为几个步骤。
(1)首先比较日志文件的总大小与log.retention.bytes的值,计算需要删除的日志总大小,然后从日志文件中的第一个日志分段开始进行查找,查找出可删除的日志分段的文件集合之后直接执行删除操作。
(2)与基于保留时间类似执行删除操作。
3.2 日志压缩策略(compact)
- 如果要开启日志压缩策略,需要将参数log.cleanup.policy设置为compact,并且参数log.cleaner.enable(默认为true)设定为true。
-
日志压缩为主题分区中的每个相同key的消息只保留一个最新的值,其他相同key的旧值会被移除。实际的日志分段压缩过程类似下图:
-
日志压缩提供的保障
(1)始终保持消息的有序性。日志压缩执行后,只是删除一些消息,并且生成新的日志分段文件,但不会更换消息的顺序。
(2)消息的偏移量不会变更,和压缩之前的偏移量保持一致,偏移量是消息在日志中的永久标志。
(3)执行日志压缩后,日志内消息的偏移量不再连续,但不影响消息的查询。
- 上图展示了日志压缩后的日志逻辑图。
(1)日志压缩Log Tail部分中的消息保存了消息首次写入时的偏移量,即使消息被压缩,所有消息的偏移量仍然在日志中是有效的。
(2)Topic使用参数min.compaction.lag.ms来确保消息写入后必须经过的最小时间才能被压缩,这限制了一条消息在Log Head中的最短存在时间。
(3)在Kafka存放目录log.dirs下存放有一个命名为“cleaner-offset-checkpoint”的清理检查点文件,记录着每个分区中已清理的偏移量,通过检查点将Log日志文件划分出一个已经清理过的Log Tail部分和一个还未清理过的Log Head部分。 - 为了控制日志清理的频率,使用参数log.cleaner.threads来设定日志清理器启动后台清理任务的线程数,清理任务优先选择脏数据比例最高的日志文件进行处理,log.cleaner.min.cleanable.ratio来限定可进行清理操作的最小脏数据比例,默认值为0.5。
脏数据比例 = LogHead部分的日志占用大小 / 总共日志大小。
- 系统异常宕机进行恢复过程中,通过读取服务器消息来恢复其状态,系统关心的是消息原本的最新状态,一般无需关心历史时刻每一个状态,如果采用日志压缩策略,那么就可以减少数据的加载量进而加快系统的恢复速度。
3.3 日志清理其他参数
log.cleaner.backoff.ms
- 描述:检查Log是否需要清除的时间间隔。
- 类型:long。
- 默认值:15000。
log.cleaner.io.buffer.load.factor
- 描述:日志清理器去重的缓存区负载因子。去重的缓存区可以设置为完全百分比。该值越高将允许一次清除更多的日志,但会导致更多的哈希冲突。
- 类型:double。
- 默认值:0.9。
四、磁盘存储设计
4.1 顺序磁盘访问
- 有测试结果表明,使用6个7200rpm、SATA接口、RAID-5的磁盘阵列在JBOD配置下的顺序写入的性能约为600MB/秒,但随机写入的性能仅约为100k/秒,相差6000倍以上。因为线性的读取和写入是磁盘使用模式中最有规律的,并且由操作系统进行了大量的优化。而且现代操作系统提供了 read-ahead 和 write-behind 技术,read-ahead 是以大的 data block 为单位预先读取数据,而 write-behind 是将多个小型的逻辑写合并成一次大型的物理磁盘写入。发现实际上顺序磁盘访问在某些情况下比随机内存访问还要快。
- Kafka使用磁盘作为存储介质,通过文件追加的方式顺序写入消息到磁盘,也就是只能在文件尾部加入追加新的消息,并且写入的消息不允许修改。
4.2 页缓存
页缓存是操作系统实现的一种重要的高速磁盘缓存,它由若干个物理上不一定连续的磁盘块组成,被用来缓存文件的内容,从而加快文件数据的访问、避免频繁读取磁盘操作,提高文件读取效率。
进程读写磁盘使用页缓存的过程如下:
(1)当一个进程读取磁盘上的文件内容时,操作系统查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。
(2)当一个进程将数据写入磁盘上的文件时,操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先将数据添加到页缓存中,最后再数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。Kafka运行在JVM上,对于JVM而言,对象的内存开销非常高,通常是所存储的数据的两倍,甚至更多;此外,随着堆中数据的增加,Java 的垃圾回收变得越来越复杂和缓慢;由于这些因素的影响,相比于进程内缓存或其他结构,使用文件系统和页缓存技术会有很大优势,比如:
(1)可以自动访问所有空闲内存从而大大提升可用缓存的容量。
(2)通过存储紧凑的字节结构替代独立的对象的方式节省更多的缓存空间。
(3)如果Kafka服务重启,页缓存还是会保持有效,而进程内缓存却需要重建,所以使用页缓存能减少启动重建时间,加快服务启动速度。
(4)使用页缓存,由操作系统来负责维护页缓存和文件之间的交互及一致性,比进程内维护更加安全有效,而且这样也极大的简化了代码逻辑。Kafka中消息首先被写入页缓存中,然后由操作系统负责具体的刷盘任务(Kafka也提供了同步刷盘及间断性刷盘的功能),页缓存的大量使用是Kafka实现高吞吐的重要因素之一。
4.3 零拷贝
- 所谓零拷贝,是指将数据从磁盘文件拷贝到网卡设备中,不需要经过应用程序用户空间。零拷贝技术减少了数据的复制次数,减少了内核与用户空间上下文的切换,提升了数据传输性能。
- 传统的非零拷贝数据传输过程:
(1)read()方法调用,通过DMA(Direct Memory Access)技术将磁盘文件中内容复制到内核模式下的Read buffer中,用户模式到内核模式上下文切换。
(2)CPU控制将内核模式下的buffer数据复制到用户模式下应用程序buffer,内核模式到用户模式上下文切换。
(3)write()方法调用,将用户模式下的数据复制到内核模式下的Socket buffer中,用户模式到内核模式上下文切换。
(4)通过DMA 引擎,将内核模式下的Socket buffer的数据复制到网卡设备中,无上下文切换。
(5)write()方法结果响应到应用程序,内核模式到用户模式上下文切换。
非零拷贝需要4次复制,内核和用户模式的上下文切换也是4次,过程如下图所示:
- 零拷贝数据传输过程如下:
(1)tranferTo()方法调用,通过DMA(Direct Memory Access)技术读取磁盘文件中内容复制到内核模式下的Read buffer中,用户模式到内核模式上下文切换。
(2)通过DMA 引擎,将内核模式下的数据复制到网卡设备中,无上下文切换。
(3)tranferTo()方法结果响应到应用程序,内核模式到用户模式上下文切换。
采用零拷贝技术只需要2次复制,上下文切换也是2次,缩小了复制次数和上下文切换次数,过程如下图所示:
- Kafka客户端传输数据到broker时采用了零拷贝,使用transferFrom方法,其委托给JDK的FileChannel.transferTo()方法,对应操作系统底层sendFile()方法。
五、结语
Kafka独特的文件结构设计,灵活的日志清理策略,并采用顺序磁盘访问、页缓存以及零拷贝等技术保证了系统的高吞吐、低延迟、易扩展、分布式存储能力。