1.零拷贝技术

每次说到kafka的高吞吐量,不得不提的就是零拷贝技术,
说实话这个概念有一些复杂,涉及到比较多的计算机硬件知识点。不要着急,不要慌,我们一点点来渗透这个知识点

用户态和内核态

存储空间

众所周知,一个服务器的存储空间分为内存和硬盘。

  1. 内存:服务器内部的数据存储交互空间,内存中的数据在服务器重启后会消失。内存与服务器紧密关联,并且大小受服务器限制,但内存的读取效率很高,且并几乎不受CPU数量影响
  2. 硬盘:独立于服务器的外部数据存储空间,硬盘中的数据是持久性的,其大小也可以无限扩张。但是,服务器无法直接从硬盘读取数据处理,需要将数据读取到内存后再处理,因此硬盘的读取效率,相比较内存要慢很多,并且CPU数越多,从硬盘读取数据效率就越高

    用户空间与内核空间

    操作系统将可访问的内存分为二部分,一部分是内核空间,一部分是用户空间。

  3. 内核空间:操作系统内核访问的区域,独立于普通的应用程序,是受保护的内存空间。可以用于与其他外部设备进行数据交互,例如硬盘、网卡等。

  4. 用户空间:普通应用程序可访问的内存区域。 :::success PS:其实早期操作系统是不区分内核空间和用户空间的,但是应用程序能访问任意内存空间,如果程序不稳定常常把系统搞崩溃,比如清除操作系统的内存数据。
    后来觉得让应用程序随便访问内存太危险了,就按照CPU 指令的重要程度对指令进行了分级,指令分为四个级别:Ring0~Ring3 (和电影分级有点像),linux 只使用了 Ring0 和 Ring3 两个运行级别
    进程运行在 Ring3 级别时运行在用户态,指令只访问用户空间,而运行在 Ring0 级别时被称为运行在内核态,可以访问任意内存空间。
    用户态的程序不能随意操作内核地址空间,这样对操作系统具有一定的安全保护作用 :::

    用户态和内核态

    当进程/线程运行在内核空间时就处于内核态,而进程/线程运行在用户空间时则处于用户态。

  5. 在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。

  6. 在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的很多检查,比如:进程只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址。

    传统数据传输流程

    image.png
    如图,常规的客户端系统调用大致流程如下

  7. 应用程序接收到客户端请求,向CPU发起一个读数据的请求

  8. CPU收到指令,开始向磁盘发起一次磁盘IO
  9. 磁盘收到IO指定,将数据读取到磁盘缓冲区,读取完毕后中断IO
  10. CPU收到IO中断指令,代表磁盘读取完成,开始将数据从磁盘缓冲区读取到内存的内核空间-硬盘数据缓存区(线程从用户态->内核态)
  11. CPU将内核空间-硬盘数据缓存区的数据,拷贝到用户空间中(线程从内核态->用户态)
  12. CPU将用户空间的数据,写入到内核空间的Socket缓存区中(线程从用户态到内核态)
  13. CPU将内核空间-Socket缓存区中的数据,拷贝到网卡(Socket拷贝完成-线程从内核态到用户态)

经过上述步骤的描述,可以发现其中存在的弊端,也就是优化点

  1. CPU全程参与数据读写流程,并且与外部系统进行交互。对于计算机中最宝贵的资源,很明显这是非常浪费的资源的
  2. 数据从内核空间->用户空间->内核空间,其中用户空间的IO操作十分冗余
  3. 线程在一次读写过程中经过了4次状态的变更,也就是4次上下文切换,微观上说十分的耗费时间和性能

    DMA

    首先,我们先对上述的第一个优化点进行优化,也就是CPU全程参与数据读写并直接与外部设备进行交互。为了解决这个问题,引入了DMA机制
    DMA,翻译为直接内存访问(Direct Memory Access),是一种硬件设备绕开CPU直接访问内存的机制。所以说DMA在一定程度上解放了CPU,把之前CPU的杂活让硬件直接自己做了,提高了CPU效率。
    目前支持DMA的硬件包括:网卡、声卡、显卡、磁盘控制器等。
    在引入了DMA机制后,读写流程变成了下图所示,可以看到,CPU仅仅负责内存中数据的交互,而与外部设备数据交互的功能交给了DMA机制实现
    image.png

零拷贝方案

解决了CPU直接与外部设备交互的问题,还剩下一个最关键的问题,就是用户空间与内核空间数据的频繁拷贝,这对这个问题的解决方案,就是我们要说的零拷贝方案
目前常见的零拷贝方案如下:

mmap

内存映射机制,将内核空间缓冲区地址、用户空间缓冲区地址与数据的物理地址进行映射,从而实现内核缓冲区与用户缓冲区的共享,进而减少用户空间和内核空间的数据拷贝过程。
同时可以通过指针的方式对映射内存区域进行读写操作,而系统会自动回写脏页面到对应的文件磁盘上

sendfile

Linux 内核2.1版本中被引入,它建立了两个文件之间的传输通道。可以实现内核空间的硬盘数据缓冲区,直接将数据拷贝到内核空间的Socket缓冲区,从而减少用户空间和内核空间的数据拷贝过程

splice

splice系统调用是Linux 在 2.6 版本引入的,其不需要硬件支持,并且不再限定于socket上,实现两个普通文件之间的数据零拷贝。splice也有一些局限,它的两个文件描述符参数中有一个必须是管道设备。splice全程不需要CPU参与,只要有支持DMA机制的设备就可以实现

2.Kafka高吞吐量的原因

批量压缩

概念

很多时候,网络IO是一个系统实现高性能的最大阻碍。对于kafka来说,网络IO同样是一个不得不解决的问题。为了解决这个问题,Kafka采用了消息批量压缩策略,在牺牲了少部分CPU性能的情况下,极大地提高了网络IO效率

压缩算法

  1. Gzip:吞吐量最低
  2. Snappy:网络IO时占用带宽最多
  3. LZ4 :吞吐量最高

在实际使用中,GZIP、Snappy、LZ4 甚至是 zstd 的表现各有千秋。但对于 Kafka 而言,它们的性能测试结果却出奇得一致,

  1. 在吞吐量方面:LZ4 > Snappy > zstd 和 GZIP
  2. 在压缩比方面:zstd > LZ4 > GZIP > Snappy。
  3. 具体到物理资源:使用 Snappy 算法占用的网络带宽最多,zstd 最少,这是合理的,毕竟 zstd 就是要提供超高的压缩比;
  4. CPU 使用率方面:各个算法表现得差不多,只是在压缩时 Snappy 算法使用的 CPU 较多一些,而在解压缩时 GZIP 算法则可能使用更多的 CPU。

    压缩与解压缩的时机

  5. Producer:生产者可以在发送消息到Broker时,指定消息的压缩算法,对消息进行批量压缩

  6. Broker:当Broker与Producer消息压缩算法不一样时,Broker会先解压消息,然后对消息重新压缩
  7. Consumer:消费者收到消息后,进行消息的解压缩

    批量发送/批量拉取

    同理,批量压缩后,为了提高网络IO的效率,Kafka的消息也是可以批量发送的
    可以通过以下两个参数的配置来实现消息的批量发送

  8. batch.size:通过这个参数来设置批量提交的数据大小,默认是16k,当积压的消息达到这个值的时候就会统一发送(发往同一分区的消息)

  9. linger.ms:这个设置是为发送设置一定是延迟来收集更多的消息,默认大小是0ms(就是有消息就立即发送)

当这两个参数同时设置的时候,只要两个条件中满足一个就会发送。比如说batch.size设置16kb,linger.ms设置50ms,那么当消息积压达到16kb就会发送,如果没有到达16kb,那么在第一个消息到来之后的50ms之后消息将会发送。

顺序读写/分区分段存储

Producer生产的消息是以顺序写入磁盘的形式,在Broker上进行持久化的,磁盘顺序写的效率要比随机写高很多。并且在数据读取时,也是基于偏移量顺序读取,这也是Kafka高吞吐量的一个重要原因
详细的读写原理参考:Kafka消息的发布与消费原理

零拷贝技术

Kafka利用了mmap以及sendfile两种零拷贝方案

  1. 写入数据时,通过mmap内存映射机制,将Socket缓冲区数据直接映射到用户空间,然后调用write直接写入内核空间的硬盘缓冲区,再通过DMA写入硬盘,无需用户空间到内核空间的转换
  2. 读取数据时,通过sendfile机制,内核空间的硬盘缓冲区的数据直接copy到Socket缓冲区数据,避免了硬盘数据内核空间到用户空间的多次copy