很多同学私信问我 Kafka 在性能优化方面做了哪些举措,对于相关问题的答案其实我早就写过了,就是没有系统的整理一篇,最近思考着花点时间来整理一下,下次再有同学问我相关的问题我就可以潇洒的甩个链接了。这个问题也是 Kafka 面试的时候的常见问题,面试官问你这个问题也不算刁难你。在网上也有很多相关的文章开讲解这个问题,比如之前各大公众号转载的《为什么 Kafka 这么快?》,这些文章我看了,写的不错,问题在于只是罗列了部分的要领,没有全部的详述出来。本文所罗列的要领会比你们网上搜寻到的都多,如果你在看完本篇文章之后,在面试的时候遇到相关问题,相信你一定能让面试官眼前一亮。
PS: 本文章的所有要领在《深入理解 Kafka 》一书中都有详细的描述,如果对相关知识点生疏的话,可以再去翻看一下。
传统消息中间件的消息发送和消费整体上是针对单条的。对于生产者而言,它先发一条消息,然后 broker 返回 ACK 表示已接收,这里产生 2 次 rpc ;对于消费者而言,它先请求接受消息,然后 broker 返回消息,最后发送 ACK 表示已消费,这里产生了 3 次 rpc (有些消息中间件会优化一下,broker 返回的时候返回多条消息)。而 Kafka 采用了批量处理:生产者聚合了一批消息,然后再做 2 次 rpc 将消息存入 broker,这原本是需要很多次的 rpc 才能完成的操作。假设需要发送 1000 条消息,每条消息大小 1KB,那么传统的消息中间件需要 2000 次 rpc,而 Kafka 可能会把这 1000 条消息包装成 1 个 1MB 的消息,采用 2 次 rpc 就完成了任务。这一改进举措一度被认为是一种“作弊”的行为,然而在微批次理念盛行的今日,其它消息中间件也开始纷纷效仿。
这里接着批量处理的概念继续来说,新版生产者客户端摒弃了以往的单线程,而采用了双线程:主线程和 Sender 线程。主线程负责将消息置入客户端缓存,Sender 线程负责从缓存中发送消息,而这个缓存会聚合多个消息为一个批次。有些消息中间件会把消息直接扔到 broker。
Kafka 从 0.8 版本开始日志格式历经了三次变革:v0、v1、v2。在之前发过的一篇文章《一文看懂 Kafka 消息格式的演变》中详细介绍了 Kafka 日志格式,Kafka 的日志格式越来越利于批量消息的处理,有兴趣的同学可以阅读一下这篇文章以作了解。
如果了解了 Kafka 具体的日志格式(可以参考上图),那么你应该了解日志( Record,或者称之为消息)本身除了基本的 key 和 value 之外,还有一些其它的字段,原本这些附加字段按照固定的大小占用一定的篇幅(参考上图左),而 Kafka 最新的版本中采用了变成字段 Varints 和 ZigZag 编码,有效地降低了这些附加字段的占用大小。日志(消息)尽可能变小了,那么网络传输的效率也会变高,日志存盘的效率也会提升,从而整理的性能也会有所提升。
Kafka 支持多种消息压缩方式( gzip、snappy、lz4 )。对消息进行压缩可以极大地减少网络传输 量、降低网络 I/O,从而提高整体的性能。消息压缩是一种使用时间换空间的优化方式,如果对 时延有一定的要求,则不推荐对消息进行压缩。
每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率,这也是提升性能的一种方式。(具体的内容在书中的第 5 章有详细的讲解,公众号里好像忘记发表了,找了一圈没找到)
很多人会忽略掉这个因素,其实分区也是提升性能的一种非常有效的方式,这种方式所带来的效果会比前面所说的日志编码、消息压缩等更加的明显。分区在其他分布式组件中也有大量涉及,至于为什么分区能够提升性能这种基本知识在这里就不在赘述了。不过需要注意,一昧地增加分区并不能一直带来性能的提升,有兴趣的同学可以看一下这篇《 Kafka 主题中的分区数越多吞吐量就越高?》。
绝大多数的资料在讲述 Kafka 性能优化的举措之时是不会提及一致性的东西的。我们所了解的通用的一致性协议如 Paxos、Raft、Gossip 等,而 Kafka 另辟蹊径采用类似 PacificA 的做法不是“拍大腿”拍出来的,采用这种模型会提升整理的效率。具体的细节后面会整理一篇,类似《在 Kafka 中使用 Raft 替换 PacificA 的可行性分析及优缺点》。
操作系统可以针对线性读写做深层次的优化,比如预读(read-ahead,提前将一个比较大的磁盘块读入内存) 和后写(write-behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。Kafka 在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消 息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作,所以就算 Kafka 使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。
为什么 Kafka 性能这么高?当遇到这个问题的时候很多人都会想到上面的顺序写盘这一点。其实在顺序斜盘前面还有页缓存( PageCache )这一层的优化。
页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘 I/O 的操作。具体 来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。为了弥补性 能上的差异,现代操作系统越来越“激进地”将内存作为磁盘缓存,甚至会非常乐意将所有 可用的内存用作磁盘缓存,这样当内存回收时也几乎没有性能损失,所有对于磁盘的读写也 将经由统一的缓存。
当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页 (page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物 理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入 页缓存,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的 数据写入磁盘,以保持数据的一致性。
对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操 作系统的页缓存中,因此同一份数据有可能被缓存了两次。并且,除非使用 Direct I/O 的方式, 否则页缓存很难被禁止。此外,用过 Java 的人一般都知道两点事实:对象的内存开销非常大, 通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java 的垃圾回收会随着堆内数据的 增多而变得越来越慢。基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一 个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在 32GB 的机器上使用 28GB 至 30GB 的内存而不用担心 GC 所带来的性能问题。此外,即使 Kafka 服务重启, 页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为 维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。
Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的。
我在很久之前就之前就发过一篇《什么是 Zero Copy》,如果对 Zero Copy 不了解的同学可以翻阅一下。Kafka 使用了 Zero Copy 技术提升了消费的效率。前面所说的 Kafka 将消息先写入页缓存,如果消费者在读取消息的时候如果在页缓存中可以命中,那么可以直接从页缓存中读取,这样又节省了一次从磁盘到页缓存的 copy 开销。另外对于读写的概念可以进一步了解一下什么是写放大和读放大。
一个磁盘 IO 流程可以参考下图:
具体解析参考《Linux IO 磁盘篇整理小记》
本文罗列了一些 Kafka 的在性能优化方面的要领。本文中的所有内容都在《深入理解 Kafka 》一书中有讲解,只是散落在各处而已,按照既定的顺序编排,力求从易入难。如果在书中再采用篇幅去罗列类似主题的话,会出现知识讲解的冗余,故没有在书中再次整理赘述,不过这些内容会在公众号里发表出来,前面已经按照其它维度整理过好几篇了。如果需要新的维度内容,可以在公众号里留言,诉求很大的话我会对此整理一篇的,这篇文章就是这么来的。
欢迎支持笔者新作:《深入理解 Kafka:核心设计与实践原理》和《 RabbitMQ 实战指南》,同时欢迎关注笔者的微信公众号:朱小厮的博客。