Kafka日志和我们平时熟悉的程序请求日志、错误日志等不同,kafka日志则属于另一种类型:一种专门为程序访问的日志。
从某种意义上说,kafka日志的设计更像是关系型数据库中的记录,抑或是某些系统中所谓的提交日志(commit log)或日志(journal)。这些日志有一个共同的特点就是:只能按照时间顺序在日志尾部追加写入记录(record)。Kafka其实并不是直接将原生消息写入日志文件,相反,他会将消息和一些必要的元数据信息打包在一起封装成一个record写入日志。
日志记录按照被写入的顺序保存,读取日志以从左到右的方式进行。每条记录以从左到右的方式进行。每条记录都会被分配一个唯一且顺序增加的记录号作为定位该消息的唯一标识,这就是前面提到的位移(offset)信息。
日志中记录的排序通常按照时间顺序,即位于日志左边部分的记录的发生时间通常要小于位于右边部分的记录。kafka自0.10.0.0版本开始在消息体中加了时间戳信息。默认情况下,消息创建时间会被封装进消息中,因此kafka记录大部分遵循按时间排序这一规则。
kafka的日志设计都是以分区为单位的,即每个分区都有它自己的日志。不考虑多副本的情况,一个分区对应一个日志(Log),为了防止日志过大,kafka又引入了日志分段(LogSegment)的概念。Producer生产kafka消息时需要确定该消息被发送到的分区,然后kafka broker把该消息写入到该分区对应的日志中。
每个日志分段,对应磁盘上的一个日志文件(即后缀为.log文件)和两个索引文件(即后缀为.index和.timeindex文件)。还可能包含 ".delete",".cleaned",". swap"等临时文件,以及可能的".snapshot",".txnindex","leader-epoch-chedpoint"等文件。
创建topic时,kafka为该topic的每个分区在文件系统中创建了一个对应的子目录,名字就是
向日志(Log)中追加消息时是顺序写入的,只有最后一个日志分段(LogSegment)才能执行写入操作,在此之前所有的日志分段(LogSegment)都不能写入数据。最后一个日志分段(LogSegment)称为“activeSegment ”,即表示当前活跃的日志分段。随着消息的不断写入,当activeSegment满足一定的条件时,就需要创建新的activeSegment,之后追加的消息将写入新activeSegment。
每个日志分段(LogSegment)中的日志文件(".log"文件后缀)都有对应的两个索引文件:偏移量索引文件(".index"文件后缀)和时间戳索引文件(以".timeindex"为文件后缀)。每个LogSegment都有一个基准偏移量(baseOffset),用来表示当前LogSegment中第一条消息的offset 。基准偏移量是一个64位的长整型数,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数则用0填充。比如第一个LogSegment的基准偏移量为0,对应的日志文件为00000000000000000000.log。
假设第二组日志分段对应的文件名是0000000000000133.log。说明了该LogSegment中的第一条消息的偏移量为133。同时可以反映出第一个LogSegment中共有133 条消息(偏移量从0 至132的消息) 。
每个日志文件(即后缀名为.log的文件)是有上限大小的。由broker端参数log.segment.bytes控制(对应topic级别的segment.bytes参数),默认就是1GB大小。
.index被称为偏移量索引文件,.index文件可以帮助broker更快的定位记录所在的物理文件位置。
.timeindex被称为时间戳索引文件,.timeindex根据给定的时间戳查找对应的日志信息。
Kafka中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(由broker端参数log.index.interval.bytes指定,默认值为4096,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。
索引文件的大小由broker 端参数log.index.size.max.bytes配置,默认值是10MB。和日志文件不同,Kafka 在创建索引文件的时候会为其预分配log.index.size.max.bytes大小的空间,只有当索引文件进行切分的时候, Kafka才会把该索引文件裁剪到实际的数据大小。
在某一个时刻,kafka的data目录布局如下图所示,每一个根目录都会包含最基本的N个检查点文件(xxx- checkpoint,之所以是N个,是因为随着版本的更新在不断新增checkpoint文件)和meta.properties文件,在创建topic的时候,如果当前broker中不止配置了一个data目录,那么会挑选分区数量最少的那个data目录来完成本次创建任务。
meta.properties: 存储了version和broker.id 信息
recovery-point-offset-checkpoint:表示已经刷写到磁盘的消息,对应LEO信息。
kafka中会有一个定时任务负责将所有分区的LEO刷写到恢复点文件recovery-point-offset-checkpoint中,定时周期由broker端参数log.flush.offset.checkpoint.interval.ms配置,默认值60000,即60s
replication-offset-checkpoint:用来存储每个replica的HW,表示已经被commited的消息。
kafka有一个定时任务负责将所有分区的HW刷写到复制点文件replication-offset-checkpoint中,定时周期由broker端参数replica.high.watermark.checkpoint.interval.ms配置,默认值5000,即5s
log-start-offset-checkpoint:对用logStartOffset(注意不能缩写成LSO,因为在kafka中LSO是LastStableOffset的缩写)
改检查点文件在0.11.0版本中引入。kafka中有一个定时任务负责将所有分区的logStartOffset刷写到起始点文件log-start-offset-checkpoint中,定时周期有broker端参数log.flush.start.offset.checkpoint.interval.ms配置,默认值60000,即60s
cleaner-offset-checkpoint:存了每个log的最后清理offset
这些都是归于LogManager使用。
当日志分段文件达到一定的条件时需要进行切分,那么对应的索引文件也需要进行切分。日志分段文件切分包含以下几个条件,满足其一即可:
对非当前活跃的日志分段而言,其对应的索引文件内容己经固定而不需要再写入索引项,所以会被设定为只读。而对当前活跃的日志分段而言,索引文件还会追加更多的索引项,所以被设定为可读写。在索引文件切分的时候,Kafka 会关闭当前正在写入的索引文件并置为只读模式,同时以可读写的模式创建新的索引文件。
kafka提供了两种日志清理策略:
通过broker端参数log.cleanup.policy来设置日志清理策略,默认值为delete,即采用日志删除策略。如果要采用日志压缩的清理策略,需要将log.cleaner.enable(默认为true)设定为true。通过将log.cleanup.policy参数设置为delete,compact,还可以同时支持日志删除和日志压缩策略。日志清理的粒度可以控制到topic级别,比如与log.cleanup.policy对应的topic级别的参数为cleanup.policy
kafka是会定期清除日志的,而且清除的单位是日志段,即删除符合清除策略的日志段文件和对应的两个索引文件。日志管理器中有一个专门的日志删除任务来周期性的检测和删除不符合保留条件的日志分段文件,这个周期通过broker端参数log.retention.check.interval.ms控制,默认值为30000ms。
常用的清除策略有如下两种:
(1)基于时间的留存策略:kafka默认会清除7天前的日志段数据(包括索引文件)。kafka提供了3个broker端参数,其中log.retention.{hours|minutes|ms}用于配置清除日志的时间间隔,其中的ms优先级最高,minutes次之,hours优先级最低。默认情况下只配置了log.retention.hours=168的参数,即7天。
在基于时间清除的策略中,0.10.0.0版本引入时间戳字段后,该策略会计算当前时间戳与日志段首条消息的时间戳之差作为衡量日志段是否留存的依据。如果第一条消息设计没有时间戳信息,kafka才会使用最近修改时间的属性。
查找过期的日志文件,是根据日志段文件中最大的时间戳largestTimeStamp来计算的。要获取最大时间戳largestTimeStamp的值,首先要查询该日志分段所对应的.timeindex文件,查询.timeindex文件中最后一项索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则才设置为最近修改时间lastModifiedTime。
若待删除的日志分段的总数等于该日志文件中所有的日志分段的数量,那么说明所有的日志分段都已过期,但该日志文件中还要有一个日志分段文件用于接收消息的写入,即必须要保证有一个活跃的日志分段,在此种情况下,会先切分出一个新的日志分段作为当前活跃日志段,然后执行删除操作。(这个可能是后期版本出现的特性)
(2)基于日志大小的留存策略:kafka默认只会为每个分区日志保存log.retention.bytes参数值大小的字节数。默认值是-1,表示kafka不会对log进行大小方面的限制。注意:log.retention.bytes配置的是分区中所有日志文件(确切的说是.log文件)的总大小。单个日志分段文件的大小由broker端参数log.segment.bytes来限制,默认为1G。
首先计算分区日志的总大小和log.retention.bytes的差值,即计算需要删除的日志总大小,然后从分区日志中的第一个日志分段文件开始进行查找可删除的日志分段文件集合,然后在进行删除。
日志清除是一个异步过程,kafka broker启动会创建单独的线程处理日志清除事宜。另外,一定要注意的是,日志清除对于当前日志段是不生效的。也就是说kafka永远不会清除当前日志段。因此,若有用户把日志段文件最大文件大小设置的过大而导致没有出现日志切分,那么日志清除也就永远无法执行。
前面讨论的所有topic都有这样一个特点:clients端通常需要访问和处理这种topic下的所有消息,但考虑这样一种应用场景,某个kafka topic保存的是用户的邮箱地址,每次用户更新邮件地址时都会发送一条kafka消息。该消息的可以就是用户ID,而value保存了邮件地址信息。假设用户ID为user123的用户连续修改了3次邮件地址,那么就会产生3条对应的kafka消息,
user123=>[email protected]
user123=>[email protected]
user123=>[email protected]
显然,在这种情况下用户只关系最近修改的邮件地址,即user123=>[email protected]的那条消息,而之前的其他消息都是“过期”的。可以放心删除。但是前面的清除策略都无法实现这样的处理逻辑,因此kafka社区引入了log compaction。
log compaction确保kafka topic每个分区下的每条具有相同key的消息都至少保存最新value的消息。他提供了更细粒度的留存策略。这也说明了如果要使用log compaction,kafka消息必须要设置key。无key消息是无法为其进行压实操作的。
Kafka 依赖于文件系统(更底层地来说就是磁盘)来存储和缓存消息。而在传统的消息中间件 RabbitMQ 中,就使用内存作为默认的存储介质,而磁盘作为备选介质,以此实现高吞吐和低延迟的特性。
Kafka 在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消息,井且也不允许修改己写入的消息,这种方式属于典型的顺序写盘的操作,顺序写磁盘的速度要比随机写内存的速度更块。
页缓存是操作系统实现的一种主要的磁盘缓存 ,以此用来减少对磁盘I/O操作,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。
当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页 (page)是否在页缓存(pagecache)中,如果存在(命中) 则直接返回数据,从而避免了对物理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性 。
对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了两次。并且除非使用Direct I/O的方式, 否则页缓存很难被禁止。此外,用过Java的人一般都知道两点事实:对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java 的垃圾回收会随着堆内数据的增多而变得越来越慢。基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在32GB的机器上使用28GB至30GB的内存而不用担心GC所带来的性能问题。此外,即使Kafka服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。
Kafka中大量使用了页缓存,这是Kafka实现高吞吐的重要因素之一。 虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在Kafka中同样提供了同步刷盘及间断性强制刷盘( fsync )的功能,这些功能可以通过log.flush.interval.messages、log.flush.interval.ms等参数来控制。同步刷盘可以提高消息的可靠性,防止由于机器断电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过并不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。
除了消息顺序追加、页缓存等技术,Kafka还使用零拷贝(Zero-Copy)技术来进一步提升性能。所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换。对Linux操作系统而言,零拷贝技术依赖于底层的sendfile()方法实现。对应于Java语言,FileChannal.transferTo()方法的底层实现就是sendfile()方法。
单纯从概念上理解“零拷贝”比较抽象,这里简单地介绍一下它。考虑这样一种常用的情形:你需要将静态内容(类似图片、文件)展示给用户。这个情形就意味着需要先将静态内容从磁盘中复制出来放到一个内存buf中,然后将这个buf通过套接字(Socket)传输给用户,进而用户获得静态内容。这看起来再正常不过了,但实际上这是很低效的流程,我们把上面的这种情形抽象成下面的过程:
read(file,tmp buf, len);
write(socket,tmp buf, len) ;
首先调用read()将静态内容(这里假设为文件A)读取到tmp buf,然后调用write()将tmp_buf写入 Socket,如下图所示。在这个过程中,文件A经历了4次复制的过程:
(1)调用 read()时,文件 A 中的内容被复制到了内核模式下的 Read Buffer 中。
(2)CPU 控制将内核模式数据复制到用户模式下 。
(3)调用 write()时,将用户模式下的内容复制到内核模式下的 Socket Buffer 中 。
(4)将内核模式下的 Socket Buffer 的数据复制到网卡设备中传迭 。
从上面的过程可以看出,数据平白无故地从内核模式到用户模式“走了一圈”,浪费了 2 次复制过程:第一次是从内核模式复制到用户模式;第二次是从用户模式再复制回内核模式,即上面 4 次过程中的第 2 步和第 3 步。而且在上面的过程中,内核和用户模式的上下文的切换也是 4 次。如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给 Socket, 如下图所示。
零拷贝技术通过 DMA (Direct Memory Access)技术将文件内容复制到内核模式下的 Read Buffer 中 。不过没有数据被复制到 Socket Buffer,相反只有包含数据的位置和长度的信息的文件描述符被加到 Socket Buffer 中 。 DMA引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了 2 次复制就从磁盘中传送出去了,并且上下文切换也变成了 2 次。 零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝 。