当前位置: 首页 > 科技观察

Kafka精妙的高性能设计II

时间:2023-03-20 10:16:29 科技观察

大家好,我是吴哥。这是《吃透 MQ 系列》:Kafka高性能设计连载的第二篇。上一篇文章指出了高性能设计的两个关键维度:计算和IO,可以理解为“道”。同时将Kafka的高性能设计尽收眼底,可以理解为“技能”。图1:Kafka的高性能设计全景图。本文将继续一一分析8种高性能的消息存储和消费设计方法。让我们开始吧,不用多说。1.存储消息的性能优化方法存储消息是Broker端的核心功能。以下是它使用的四种优化方法。1.IO多路复用对于KafkaBroker来说,要实现高性能,首先要考虑的是:设计一个高效的网络通信模型来处理它与Producer和Consumer之间的消息传递。先引用Kafka2.8.0源码中SocketServer类中的一个关键注释:通过这个注释,其实可以了解到Kafka采用了非常典型的Reactor网络通信模型。完整的网络通信层框架图如下:图2:Kafka网络通信层框架图。流行的内存是1+N+M:1:表示1个Acceptor线程,负责监听新连接,然后将新连接交给Processor线程处理。N:表示N个Processor线程,每个Processor都有自己的selector,负责从socket中读写数据。M:表示M个KafkaRequestHandler业务处理线程,调用KafkaApis进行业务处理,然后产生响应,再交给Processor线程。学过IO的同学应该清楚:Reactor模式采用了非常经典的IO复用技术,可以复用一个线程来处理大量的Socket连接,从而保证高性能。为什么Netty和Redis能做到十万甚至百万并发呢?它们都使用Reactor网络通信模型。2、磁盘序列写好,通过IO多路复用完成网络通信后,Broker下一步要考虑的是:如何快速存储消息?在Kafka存储选择之谜一文中提到:Kafka选择“日志文件”来存储消息,那么这种写磁盘文件的方式是如何实现高性能的呢?这一切都得益于磁盘顺序写,你是怎么理解的?Kafka,作为消息队列,本质上是一个队列,先进先出,一旦消息产生,就是不可变的,这种有序性和不可变性使得Kafka可以“顺序写入”日志文件,也就是简单的追加消息到文件末尾。在顺序写入的前提下,我们来看一个对比实验。从下图可以看出,顺序磁盘写入的性能远高于磁盘随机写入,甚至高于磁盘随机写入。图3:磁盘和内存的IO速度对比原因很简单:对于普通机械盘来说,如果是随机写入,性能真的很差,就是要在fi中找到某个位置le写入数据。但是,如果是顺序写入,性能可以提升3个数量级,因为可以大大节省磁盘寻道和盘片旋转的时间。3、PageCache磁盘顺序写已经非常快了,但是还是比内存顺序写慢了几个数量级。是否可以继续优化?答案是肯定的。这里Kafka使用了PageCache技术。简单的理解就是:利用操作系统本身的缓存技术,在读写磁盘日志文件时,操作的其实是内存,然后由操作系统决定什么时候真正存储PageCache中的数据。闪存到磁盘。通过下面的示例图一目了然。图4:Kafka的PageCache原理那么PageCache什么时候发挥最大的威力呢?这就不得不提到PageCache使用的两个经典原理。PageCache缓存最近将要使用的磁盘数据,利用“时间局部性”的原理,基于最近访问过的数据以后很可能会被访问??到。PageCache中磁盘数据的预读也是利用了“空间局部性”的原理,基于数据经常被连续访问的特点。Kafka作为一个消息队列,消息先顺序写入,消费者立即读取,这无疑非常符合上述两个局部性原则。因此,页面缓存可以说是Kafka实现高吞吐量的重要因素之一。除此之外,页面缓存还有另一个巨大的优势。用过Java的都知道,如果不使用pagecache,而是在JVM进程中使用cache,对象的内存开销是非常大的(通常是真实数据大小的几倍甚至更多),而垃圾回收是必需的。由此产生的StopTheWorld问题也会产生性能问题。可见页面缓存优势明显,大大简化了Kafka的代码实现。4.分区分段结构磁盘顺序写加上页缓存,解决了日志文件的高性能读写问题。但是如果一个Topic只对应一个日志文件,显然只能存放在一台Broker机器上。当面对海量消息时,单机的存储容量和读写性能必然受到限制,这就引出了另一个精巧的存储设计:分区存储数据。我在Kafka架构设计那篇文章中详细解释了分区的概念和作用。是Kafka并发处理的最小粒度,很好的解决了存储的可扩展性问题。随着partition数量的增加,Kafka的吞吐量可以进一步提升。实际上,在Kafka存储的最底层,分区之下还有一层:那就是“切分”。简单理解:分区对应文件夹,段对应真实的日志文件。图5:Kafka的分区分段存储每个Partition又分为多个Segment,那么Partition之后为什么还需要Segment呢?如果不引入Segment,一个Partition只对应一个文件,而这个文件会不断变大,势必会造成单个Partition文件过大,不方便查找和维护。另外,删除历史消息时,需要删除文件前面的内容。只有一个文件显然不符合Kafka顺序写入的思想。引入segment后,只需要删除旧的segment文件就可以保证每个segment的写入顺序。2、消费消息的性能优化方法Kafka除了要达到百万级TPS的写入性能,还需要解决高性能的消息读取问题,否则就称不上高吞吐量。下面我们来看看Kafka在消费消息时使用的四种优化方式。1、如何提高稀疏索引的读性能,大家很容易想到:index.Kafka面临的查询场景其实很简单:根据offset或者timestamp查找消息即可。如果使用BTree类的索引结构来实现,每次写入数据(随机IO操作)都需要维护索引,也会导致“分页”等耗时操作。而这些成本对于只需要实现简单查询需求的Kafka来说是非常沉重的。所以BTree类的索引不适合Kafka。相比之下,哈希索引似乎很合适。为了加快读取速度,如果只需要在内存中维护一个“从offset到日志文件offset”的映射关系,则每次根据offset查找一条消息,从hash表中获取offset,然后读取文件。(同样的思路也可以根据时间戳来查消息。)但是哈希索引是驻留在内存中的,显然不能处理数据量很大的情况。Kafka每秒写入的消息可能有几百万条之多,内存肯定会Burst。但是我们发现可以将消息的偏移量设计成有序的(其实就是一个单调递增long类型的字段),这样消息本身就有序的存储在了日志文件中,而我们不需要构建为每条消息建立一个hash索引,可以将消息分成几个块,只需要索引每个块的第一条消息的偏移量,先根据大小关系找到块,然后在块中依次查找,这就是“Kafka设计思想的“稀疏索引”。图6:Kafka的稀疏索引设计采用“稀疏索引”,可以认为是磁盘空间、内存空间和搜索性能之间的折衷。使用稀疏索引,当给定一个offset时,Kafka使用二分查找高效定位不大于offset的物理位移,进而找到目标消息。2、mmap使用稀疏索引基本解决了高效查询的问题,但是在这个过程中还有进一步优化的空间,即通过mmap(内存映射文件)读写上面提到的稀疏索引文件,进一步提高query消息的速度。注意:mmap和pagecache是??两个概念,网上很多资料把他们混淆在一起。另外还有资料说Kafka在读取日志文件的时候也是使用mmap的。分析了2.8.0版本的源码,这个信息也是错误的。其实只有索引文件的读写使用了mmap。如何理解mmap?如前所述,常规的文件操作使用PageCache机制来提高读写性能。但是由于pagecache在内核空间,不能被用户进程直接寻址,所以读取文件时需要调用page。缓存中的数据再次复制到用户空间。使用mmap后,映射磁盘文件和进程虚拟地址,不会产生系统调用和额外的内存拷贝开销,从而提高了文件读取效率。图7:mmap示意图,引自《码农的荒岛求生》关于mmap,朋友小风写了一篇很火的文章:mmap可以让程序员解锁哪些操作?你可以参考一下。具体到Kafka的源码层面,是基于JDKnio包下的MappedByteBuffer的map函数,将磁盘文件映射到内存中。至于为什么日志文件没有使用mmap?其实是一个特别好的问题。社区对于这个问题并没有给出官方的回答,网上的回答也只能推测作者的意图。我个人比较同意stackoverflow上的这个回答:mmap能映射到内存多少字节跟地址空间有关,32位架构只能处理4GB甚至更小的文件。Kafka日志通常很大,一次可能只能映射其中的一部分,因此读取它们会变得非常复杂。但是,索引文件是稀疏的,而且相对较小。将它们映射到内存中可以加快查找过程,这是内存映射文件提供的主要好处。3.借助稀疏索引查询到零拷贝消息后,下一步就是从磁盘文件中读取消息,通过网卡发送给消费者。如何优化这一步?Kafka使用零拷贝(Zero-Copy)技术来提高性能。所谓零拷贝是指数据直接从磁盘文件拷贝到网卡设备,不经过应用程序,减少了内核态和用户态的上下文切换。下面的过程是在没有使用零拷贝技术的情况下,从磁盘读取一个文件,通过网卡发送出去的过程。可以看出它经历了4次副本和4次上下文切换。图8:非零拷贝技术流程图,引用自《艾小仙》如果采用零拷贝技术(底层通过sendfile方法实现),流程会变成如下。可以看出只需要3个副本和2次上下文切换,性能明显更高。图9:零拷贝技术流程图,引用自《艾小仙》4。批量拉取类似于生产者批量发送消息。Messenger也是分批拉取消息,每次拉取一组消息,大大减少了网络传输的开销。另外,在Kafka精巧的高性能设计(上)中介绍过,生产者实际上是在客户端对批量消息进行压缩。然后做解压操作。3.上面最后写的是Kafka的12种高性能设计方法的详解。这两篇文章从IO和计算两个维度,从宏观的角度入手,然后沿着MQ的一次发送,一次存储,一次消费的脉络,从微观层面解构了Kafka高性能的全景。可以说Kafka在高性能设计方面是教科书般的存在。从Prodcuer,到Broker,再到Consumer,每一个细节都在努力优化,最终达到了单机每秒几十万TPS的极致性能。最后希望本文的分析技巧可以帮助大家理解其他的高性能中间件。我是吴哥,下次见!