前言
本文主要从Kafka的文件高效读写机制剖析,这是Kafka非常重要的一个设计,同时也是面试频率超高的问题。
觉得不错的同学可以加我公众号,会经常分享一些技术干货,以及热点AI和科技新闻
一、Kafka的文件结构
Kafka的数据文件结构设计可以加速日志文件的读取。比如同一个Topic下的多个Partition单独记录日志文件,并行进行读取,这样可以加快Topic下的数据读取速度。然后index的稀疏索引结构,可以加快log日志检索的速度。
这里具体可以看我之前的文章,Kafka日志索引详解做了详细的介绍
二、顺序写磁盘
这个跟操作系统有关,主要是硬盘结构。
有很多分布式中间件都采用了这种方式来提高文件写效率。
对每个Log文件,Kafka会提前规划固定的大小,这样在申请文件时,可以提前占据一块连续的磁盘空间。然后,Kafka的log文件只能以追加的方式往文件的末端添加(这种写入方式称为顺序写),这样,新的数据写入时,就可以直接往之前申请的磁盘空间中写入,而不用再去磁盘其他地方寻找空闲的空间(普通的读写文件需要先寻找空闲的磁盘空间,再写入。这种写入方式称为随机写)。由于磁盘的空闲空间有可能并不是连续的,也就是说有很多文件碎片,所以磁盘写的效率会很低。
kafka的官网有测试数据,表明了同样的磁盘,顺序写速度能达到600M/s,基本与写内存的速度相当。而随机写的速度就只有100K/s,差距比加大。
三、零拷贝
首先要清楚一个概念:零拷贝并不是完全没有拷贝,而是减少了不必要的拷贝造成的资源浪费
零拷贝是Linux操作系统提供的一种IO优化机制,而Kafka大量的运用了零拷贝机制来加速文件读写。
传统的一次硬件IO是这样工作的。如下图所示:
其中,内核态的内容复制是在内核层面进行的,而零拷贝的技术,重点是要配合内核态的复制机制,减少用户态与内核态之间的内容拷贝。
具体实现时有两种方式:
- mmap文件映射机制
这种方式是在用户态不再缓存整个IO的内容,改为只持有文件的一些映射信息。通过这些映射,"遥控"内核态的文件读写。这样就减少了内核态与用户态之间的拷贝数据大小,提升了IO效率。
mmap文件映射机制是操作系统提供的一种文件操作机制,可以使用man 2 mmap查看。实际上在Java程序执行过程当中就会被大量使用。可以参考下JDK中的DirectByteBuffer实现机制
这种mmap文件映射方式,适合于操作不是很大的文件,通常映射的文件不建议超过2G。所以kafka将.log日志文件设计成1G大小,超过1G就会另外再新写一个日志文件。这就是为了便于对文件进行映射,从而加快对.log文件等本地文件的写入效率。
- sendfile文件传输机制
这种机制可以理解为用户态,也就是应用程序不再关注数据的内容,只是向内核态发一个sendfile指令,要他去复制文件就行了。这样数据就完全不用复制到用户态,从而实现了零拷贝。相比mmap,连索引都不读了,直接通知操作系统去拷贝就是了。
例如在Kafka中,当Consumer要从Broker上poll消息时,Broker需要读取自己本地的数据文件,然后通过网卡发送给Consumer。这个过程当中,Broker只负责传递消息,而不对消息进行任何的加工。所以Broker只需要将数据从磁盘读取出来,复制到网卡的Socket缓冲区,然后通过网络发送出去。这个过程当中,用户态就只需要往内核态发一个sendfile指令,而不需要有任何的数据拷贝过程。Kafka大量的使用了sendfile机制,用来加速对本地数据文件的读取过程。
具体细节可以在linux机器上使用man 2 sendfile指令查看操作系统的帮助文件。JDK中8中java.nio.channels.FileChannel类提供了transferTo和transferFrom方法,底层就是使用了操作系统的sendfile机制。
这些底层的优化机制都是操作系统提供的优化机制,其实针对任何上层应用语言来说,都是一个黑盒,只能去调用,但是控制不了具体的实现过程。而上层的各种各样的语言,也只能根据操作系统提供的支持进行自己的实现。虽然不同语言的实现方式会有点不同,但是本质都是一样的。
四、合理配置刷盘频率
缓存数据断电就会丢失,这是大家都能理解的,所以缓存中的数据如果没有及时写入到硬盘,也就是常说的刷盘,那么当服务突然崩溃,就会有丢消息的可能。所以,最安全的方式是写一条数据,就刷一次盘,成为同步刷盘。刷盘操作在Linux系统中对应了一个fsync的系统调用。
# 将处于核心状态的文件与存储设备同步
fsync, fdatasync - synchronize a file's in-core state with storage device
但是,这里真正容易产生困惑的,是这里所提到的in-core state。这并不是我们平常开发过程中接触到的缓存,而是操作系统内核态的缓存-pageCache。这是应用程序接触不到的一部分缓存。
比如我们用应用程序打开一个文件,实际上文件里的内容,是从内核态的PageCache中读取出来的。因为与磁盘这样的硬件交互,相比于内存,效率是很低的。操作系统为了提升性能,会将磁盘中的文件加载到PageCache缓存中,再向应用程序提供数据。修改文件时也是一样的。用记事本修改一个文件的内容,不管你保存多少次,内容都是写到PageCache里的。然后操作系统会通过他自己的缓存管理机制,在未来的某个时刻将所有的PageCache统一写入磁盘。这个操作就是刷盘。比如在操作系统正常关系的过程中,就会触发一次完整的刷盘机制。
说这么多,就是告诉你,其实对于缓存断掉,造成数据丢失,这个问题,应用程序其实是没有办法插手的。他并不能够决定自己产生的数据在什么时候刷入到硬盘当中。应用程序唯一能做的,就是尽量频繁的通知操作系统进行刷盘操作。但是,这必然会降低应用的执行性能,而且,也不是能百分之百保证数据安全的。应用程序在这个问题上,只能取舍,不能解决。
Kafka其实在Broker端设计了一系列的参数,来控制刷盘操作的频率。如果对这些频率进行深度定制,是可以实现来一个消息就进行一次刷盘的“同步刷盘”效果的。但是,这样的定制显然会大大降低Kafka的执行效率,这与Kafka的设计初衷是不符合的。所以,在实际应用时,我们通常也只能根据自己的业务场景进行权衡。
Kafka在服务端设计了几个参数,来控制刷盘的频率:
- flush.ms : 多长时间进行一次强制刷盘
flush.ms
This setting allows specifying a time interval at which we
will force an fsync of data written to the log. For example if this
was set to 1000 we would fsync after 1000 ms had passed. In general we
recommend you not set this and use replication for durability and
allow the operating system’s background flush capabilities as it is
more efficient.
Type: long
Default: 9223372036854775807
Valid Values: [0,…]
Server Default Property: log.flush.interval.ms
Importance: medium
- log.flush.interval.messages:表示当同一个Partiton的消息条数积累到这个数量时,就会申请一次刷盘操作。默认是Long.MAX。
The number of messages accumulated on a log partition before messages
are flushed to diskType: long Default: 9223372036854775807 Valid Values: [1,…]
Importance: high Update Mode: cluster-wide
- log.flush.interval.ms:当一个消息在内存中保留的时间,达到这个数量时,就会申请一次刷盘操作。他的默认值是空。如果这个参数配置为空,则生效的是下一个参数。
log.flush.interval.ms The maximum time in ms that a message in any
topic is kept in memory before flushed to disk. If not set, the value
in log.flush.scheduler.interval.ms is usedType: long Default: null Valid Values: Importance: high Update Mode:
cluster-wide
- log.flush.scheduler.interval.ms:检查是否有日志文件需要进行刷盘的频率。默认也是Long.MAX。
log.flush.scheduler.interval.ms The frequency in ms that the log
flusher checks whether any log needs to be flushed to diskType: long Default: 9223372036854775807 Valid Values: Importance: high
Update Mode: read-only
这里可以看到,Kafka为了最大化性能,默认是将刷盘操作交由了操作系统进行统一管理。
渔与鱼 从这里也能看到,Kafka并没有实现写一个消息就进行一次刷盘的“同步刷盘”操作。但是在RocketMQ中却支持了这种同步刷盘机制。但是如果真的每来一个消息就调用一次刷盘操作,这是任何服务器都无法承受的。那么RocketMQ是怎么实现同步刷盘的呢?后面会单独写一篇文章来介绍
五、客户端消费进度管理
kafka为了实现分组消费的消息转发机制,需要在Broker端保持每个消费者组的消费进度。而这些消费进度,就被Kafka管理在自己的一个内置Topic中。这个Topic就是__consumer__offsets。这是Kafka内置的一个系统Topic,在日志文件可以看到这个Topic的相关目录。Kafka默认会将这个Topic划分为50个分区。
同时,Kafka也会将这些消费进度的状态信息记录到Zookeeper中。但是Kafka在很早就选择了将Offset从Zookeeper中转移到Broker上。这也体现了Kafka其实早就意识到,Zookeeper这样一个外部组件在面对三高问题时,是不太"靠谱"的,所以Kafka逐渐转移了Zookeeper上的数据。而后续的Kraft集群,其实也是这种思想的延伸。
另外,这个系统Topic里面的数据是非常重要的,因此Kafka在消费者端也设计了一个参数来控制这个Topic应该从订阅关系中剔除。但是这个参数有些版本并没有用。
public static final String EXCLUDE_INTERNAL_TOPICS_CONFIG = "exclude.internal.topics";
private static final String EXCLUDE_INTERNAL_TOPICS_DOC = "Whether internal topics matching a subscribed pattern should " +
"be excluded from the subscription. It is always possible to explicitly subscribe to an internal topic.";
public static final boolean DEFAULT_EXCLUDE_INTERNAL_TOPICS = true;