在众多的消息中间件中,Kafka 的性能和吞吐量绝对是顶尖级别的,那么问题来了, Kafka 是如何做到高吞吐的。在性能优化方面,它使用了哪些技巧呢?下面我们就来分析一下。
以'批'为单位
批量处理是一种非常有效的提升系统吞吐量的方法,操作系统提供的缓冲区也是如此。在 Kafka 内部,消息处理是以"批"为单位的,生产者、Broker、消费者,都是如此。
在 Kafka 的客户端 SDK 中,生产者只提供了单条发送的 send() 方法,并没有提供任何批量发送的接口。原因是 Kafka 根本就没有提供单条发送的功能,是的你没有看错,虽然它提供的 API 每次只能发送一条消息,但实际上 Kafka 的客户端 SDK 在实现消息发送逻辑的时候,采用了异步批量发送的机制。
当你调用 send() 方法发送一条消息之后,无论你是同步发送还是异步发送,Kafka 都不会立即就把这条消息发送出去。它会先把这条消息,存放在内存中缓存起来,然后选择合适的时机把缓存中的所有消息组成一批,一次性发给 Broker。简单地说,就是攒一波一起发。
而 Kafka Broker 在收到这一批消息后,也不会将其还原成多条消息、再一条一条地处理,这样太慢了。Kafka 会直接将"批消息"作为一个整体,也就是说,在 Broker 整个处理流程中,无论是写入磁盘、从磁盘读出来、还是复制到其他副本,在这些流程中,批消息都不会被解开,而是一直作为一条"批消息"来进行处理的。
在消费时,消息同样是以批为单位进行传递的,消费者会从 Broker 拉到一批消息。然后将批消息解开,再一条一条交给用户代码处理。
比如生产者发送 30 条消息,在业务程序看来虽然是发送了 30 条消息,但对于 Kafka 的 Broker 来说,它其实就是处理了 1 条包含 30 条消息的"批消息"而已。显然处理 1 次请求要比处理 30 次请求快得多,因为构建批消息和解开批消息分别在生产者和消费者所在的客户端完成,不仅减轻了 Broker 的压力,最重要的是减少了 Broker 处理请求的次数,提升了总体的处理能力。
批处理只能算是一种常规的优化手段,它是通过减少网络 IO 从而实现优化。而 Kafka 每天要处理海量日志,那么磁盘 IO 也是它的瓶颈。并且对于处在同一个内网的数据中心来说,相比读写磁盘,网络传输是非常快的。
接下来我们看一下,Kafka 在磁盘 IO 这块儿做了哪些优化。
磁盘顺序读写
我们知道 kafka 是将消息存储在文件系统之上的,高度依赖文件系统来存储和缓存消息,因此可能有人觉得这样做效率是不是很低呢?因为要和磁盘打交道,而且使用的还是机械硬盘。
首先机械硬盘不适合随机读写,但如果是顺序读写,那么吞吐量实际上是不差的。在 SSD(固态硬盘)上,顺序读写的性能要比随机读写快几倍,如果是机械硬盘,这个差距会达到几十倍。因为操作系统每次从磁盘读写数据的时候,需要先寻址,也就是先要找到数据在磁盘上的物理位置,然后再进行数据读写。如果是机械硬盘,这个寻址需要比较长的时间,因为它要移动磁头,这是个机械运动,机械硬盘工作的时候会发出咔咔的声音,就是移动磁头发出的声音。
顺序读写相比随机读写省去了大部分的寻址时间,因为它只要寻址一次,就可以连续地读写下去,所以说性能要比随机读写好很多。
而 kafka 正是利用了这个特性,任何发布到分区的消息都会被追加到 "分区数据文件" 的尾部,如果一个文件写满了,就创建一个新的文件继续写。消费的时候,也是从某个全局的位置开始,也就是某一个 log 文件中的某个位置开始,顺序地把消息读出来。这样的顺序写操作让 kafka 的效率非常高。
使用 PageCache
任何系统,不管大小,如果想提升性能,使用缓存永远是一个不错的选择,而 PageCache 就是操作系统在内存中给磁盘上的文件建立的缓存,它是由内核托管的。无论我们使用什么语言,编写的程序在调用系统的 API 读写文件的时候,并不会直接去读写磁盘上的文件,应用程序实际操作的都是 PageCache,也就是文件在内存中缓存的副本。
应用程序在写入文件的时候,操作系统会先把数据写入到内存中的 PageCache,然后再一批一批地写到磁盘上。读取文件的时候,也是从 PageCache 中来读取数据,这时候会出现两种可能情况。
一种是 PageCache 中有数据,那就直接读取,这样就节省了从磁盘上读取的时间;另一种情况是,PageCache 中没有数据,这时候操作系统会引发一个缺页中断,应用程序的读取线程会被阻塞,操作系统把数据从文件复制到 PageCache 中,然后应用程序再从 PageCache 继续把数据读出来,这时会真正读一次磁盘上的文件,这个读的过程就会比较慢。
用户的应用程序在使用完某块 PageCache 后,操作系统并不会立刻就清除这个 PageCache,而是尽可能地利用空闲的物理内存保存这些 PageCache,除非系统内存不够用,操作系统才会清理掉一部分 PageCache。清理的策略一般是 LRU 或它的变种算法,核心逻辑就是:优先保留最近一段时间最常使用的那些 PageCache。
另外 PageCache 还有预读功能,假设我们读取了 1M 的内容,但 Linux 实际读取的却并不止 1M,因为这样你后续再读取的时候就不需要从磁盘上加载了。因为从磁盘到内存的数据传输速度是很慢的,如果物理内存有空余,那么就可以多缓存一些内容。
而 Kafka 在读写消息文件的时候,充分利用了 PageCache 的特性。一般来说,消息刚刚写入到服务端就会被消费,读取的时候,对于这种刚刚写入的 PageCache,命中的几率会非常高。也就是说,大部分情况下,消费读消息都会命中 PageCache,带来的好处有两个:一个是读取的速度会非常快,另外一个是,给写入消息让出磁盘的 IO 资源,间接也提升了写入的性能。
ZeroCopy(零拷贝)
Kafka 还使用了零拷贝技术,首先 Broker 将消息发送给消费者的过程如下:
- 将指定的消息日志从文件读到内存中;
- 将消息通过网络发送给消费者客户端;
这个过程会经历几次复制,以及用户空间和内核空间的切换,示意图如下。
整个过程大概是以上 6 个步骤,我们分别解释一下。
1)应用程序要读取磁盘文件,但只有内核才能操作硬件设备,所以此时会从用户空间切换到内核空间。
2)通过 DMA 将文件读到 PageCache 中,此时的数据拷贝是由 DMA 来做的,不耗费 CPU。关于 DMA,它是一种允许硬件系统访问计算机内存的技术,说白了就是给 CPU 打工的,帮 CPU 干一些搬运数据的简单工作。
CPU 告诉 DMA 自己需要哪些数据,然后 DMA 负责搬运到 PageCache,等搬运完成后,DMA 控制器再通过中断通知 CPU,这样就极大地节省了 CPU 的资源。
但如果要读取的内容已经命中 PageCache,那么这一步可以省略。
3)将文件内容从 PageCache 拷贝到用户空间中,因为应用程序在用户空间,磁盘数据必须从内核空间搬运到用户空间,应用程序才能操作它。注意:这一步的数据搬运不再由 DMA 负责,而是由 CPU 负责。
因为 DMA 主要用于硬件设备与内存之间的数据传输,例如从磁盘到 RAM,从 RAM 到网卡。虽然 DMA 可以减少 CPU 的负担,但通常不用于内核空间和用户空间之间的数据搬运,至于原因也很简单:
- 操作系统需要保护内核空间,防止用户程序直接访问,以维护系统的安全和稳定。通过 CPU 进行数据拷贝,操作系统可以控制哪些数据和资源可以被用户程序访问。
- CPU 可以处理复杂的逻辑和任务调度,更适合执行这种涉及系统安全和资源管理的任务。
- 在数据从内核空间传输到用户空间的过程中,可能需要进行一些额外的处理,例如格式转换、权限检查等,这些都是 CPU 更擅长的。
另外用户空间和内核空间的切换,本质上就是 CPU 的执行上下文和权限级别发生了改变。
因此这一步会涉及用户态和内核态之间的切换,和一个数据的拷贝。
4) 文件内容读取之后,要通过网络发送给消费者客户端。而内核提供了一个 Socket 缓冲区,位于用户空间的应用程序在发送数据时,会先通过 CPU 将数据拷贝到内核空间的 Socket 缓冲区中,再由内核通过网卡发送给消费者。
同样的,当数据从网络到达时,也会先被放在 Socket 缓冲区中。应用程序从该缓冲区读取数据,数据被拷贝到用户空间。
所以应用程序在通过网络收发数据时,其实都是在和 Socket 缓冲区打交道,具体的发送和接收任务都是由内核来做的,因为只有内核才能操作硬件设备。用户空间的代码要想与硬件设备交互,必须通过系统调用或操作系统提供的其它接口,然后由内核代为执行。
所以通过网络发送数据,会涉及一次数据的拷贝,以及用户空间和内核空间的切换。因为 CPU 要将数据从用户空间搬运到内核空间的 Socket 缓冲区中。
5) 内核要将 Socket 缓冲区里的数据通过网卡发送出去,于是再将数据从 Socket 缓冲区搬到网卡的缓冲区里面,而这一步搬运是由 DMA 来做的。只要不涉及用户空间,大部分的数据搬运都可以由 DMA 来做,而一旦涉及到用户空间,数据搬运就必须由 CPU 来做。
6) 发送完毕之后,再从内核空间切换到用户空间,应用程序继续干其它事情。
如果想要提升性能,那么关键就在于减少上下文切换的次数和数据拷贝的次数,因为用户空间和内核空间的切换是需要成本的,至于数据拷贝就更不用说了。
而整个过程涉及了 4 次的上下文切换,因为用户空间没有权限操作磁盘或网卡,这些操作都需要交由操作系统内核来完成。而通过内核去完成某些任务的时候,需要使用操作系统提供的系统调用函数。而一次系统调用必然会发生两次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由应用程序执行其它代码。
然后是数据拷贝,这个数据也被拷贝了 4 次,其中两次拷贝由 DMA 负责,另外两次由 CPU 负责。但很明显,CPU 的两次拷贝没有太大必要,先将数据从 PageCache 拷贝到用户空间,然后再从用户空间拷贝到 Socket 缓冲区。既然这样的话,那直接从 PageCache 拷贝到 Socket 缓冲区不行吗。
如果文件在读取之后不对它进行操作,或者说不对文件数据进行加工,只是单纯地通过网卡发送出去,那么就没必要到用户空间这里绕一圈。
此时的 4 次上下文切换就变成了 2 次,因为系统调用只有 1 次。数据搬运也由 4 次变成了 3 次,所以总共减少了两次上下文切换和一次数据拷贝。
而这种减少数据拷贝(特别是在用户和内核之间的数据拷贝)的技术,便称之为零拷贝。
Linux 内核提供了一个系统调用函数 sendfile(),便可以实现上面这个过程。
#include
ssize_t sendfile(int out_fd, int in_fd,
off_t *offset, size_t count);
out_fd 和 in_fd 均为文件描述符,分别代表要写入的文件和要读取的文件,offset 表示从文件的哪个位置开始读,count 表示写入多少个字节。返回值是实际写入的长度。
当然像 Python、Java 都对 sendfile 进行了封装,我们在使用 Python 进行 Socket 编程时,便可以使用该方法。
当然该方法会调用 os.sendfile(),它和 C 的 sendfile() 是一致的,如果是 Linux 系统,那么不存在问题。如果是 Windows 系统,os.sendfile() 则不可用,此时 Socket 的 sendfile 会退化为 send 方法。
然而目前来说,虽然实现了零拷贝,但还不是零拷贝的终极形态。我们看到 CPU 还是进行了一次拷贝,并且此时虽然不涉及用户空间,但数据搬运依旧是 CPU 来做的。因为 DMA 主要负责硬件(例如磁盘或网卡)和内存的数据传输,但不适用于内存到内存的数据拷贝。
那么问题来了,数据文件从磁盘读到 PageCache 之后,可不可以直接搬到网卡缓冲区里面呢?如果你的网卡支持 SG-DMA 技术,那么通过 CPU 将数据从 PageCache 拷贝到 socket 缓冲区这一步也可以省略。
你可以通过以下命令,查看网卡是否支持 SG(scatter-gather)特性:
[root@satori ~]# ethtool -k eth0 | grep scatter-gather
scatter-gather: on
tx-scatter-gather: on
tx-scatter-gather-fraglist: off [fixed]
Linux 内核从 2.4 版本开始起,对于那些支持 SG-DMA 技术的网卡,会进一步优化 sendfile() 系统调用的过程,优化后的过程如下:
- DMA 将数据从磁盘拷贝到 PageCache;
- 将描述符和数据长度发送到 Socket 缓冲区,网卡的 SG-DMA 控制器基于该信息直接将 PageCache 的数据拷贝到网卡缓冲区中;
整个过程如下:
此时便是零拷贝(Zero-copy)技术的终极形态,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
使用零拷贝技术只需要两次上下文切换和数据拷贝,就可以完成文件的传输,因为它通过一次系统调用(sendfile 方法)将磁盘读取与网络发送两个操作给合并了,从而降低了上下文切换次数。而且两次的数据拷贝过程也不需要通过 CPU,都是由 DMA 来搬运。所以总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
但需要注意的是,零拷贝技术不允许进程对文件内容作进一步加工,比如压缩数据再发送。如果希望对读取的文件内容做额外的操作,那么就只能拷贝到用户空间了。
另外当传输大文件时,不建议使用零拷贝,因为 PageCache 可能被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率也不高,因此这种情况建议绕过 PageCache。
使用 PageCache 的 IO 叫做缓存 IO,不使用 PageCache 的 IO 叫做直接 IO。
小结
以上我们就探讨了 Kafka 为什么会有如此高的吞吐量,在处理海量数据时为什么这么快。核心就在于以下几点:
1)消息是以 "批" 为单位的。
2)利用磁盘的顺序读写远远快于随机读写。
3)使用 PageCache。
4)使用零拷贝技术。