写点什么

从 Kafka 中学习高性能系统如何设计 | 京东云技术团队

  • 2023-06-28
    北京
  • 本文字数:10614 字

    阅读完需:约 35 分钟

从Kafka中学习高性能系统如何设计 | 京东云技术团队

1 前言

相信各位小伙伴之前或多或少接触过消息队列,比较知名的包含 Rocket MQ 和 Kafka,在京东内部使用的是自研的消息中间件 JMQ,从 JMQ2 升级到 JMQ4 的也是带来了性能上的明显提升,并且 JMQ4 的底层也是参考 Kafka 去做的设计。在这里我会给大家展示 Kafka 它的高性能是如何设计的,大家也可以学习相关方法论将其利用在实际项目中,也许下一个顶级项目就在各位的代码中产生了。

2 如何理解高性能设计

2.1 高性能设计的”秘籍”

先抛开 kafka,咱们先来谈论一下高性能设计的本质,在这里借用一下网上的一张总结高性能的思维导图:



从中可以看到,高性能设计的手段还是非常多,从”微观设计”上的无锁化、序列化,到”宏观设计”上的缓存、存储等,可以说是五花八门,令人眼花缭乱。但是在我看来本质就两点:计算和 IO。下面将从这两点来浅析一下我认为的高性能的”道”。

2.2 高性能设计的”道法”

2.2.1 计算上的”道”

计算上的优化手段无外乎两种方式:1.减少计算量 2.加快单位时间的计算量


  • 减少计算量:比如用索引来取代全局扫描、用同步代替异步、通过限流来减少请求处理量、采用更高效的数据结构和算法等。(举例:mysql 的 BTree,redis 的跳表等)

  • 加快单位时间的计算量:可以利用 CPU 多核的特性,比如用多线程代替单线程、用集群代替单机等。(举例:多线程编程、分治计算等)

2.2.2 IO 上的”道”

IO 上的优化手段也可以从两个方面来体现:1.减少 IO 次数或者 IO 数据量 2.加快 IO 速度


  • 减少 IO 次数或者 IO 数据量:比如借助系统缓存或者外部缓存、通过零拷贝技术减少 IO 复制次数、批量读写、数据压缩等。

  • 加快 IO 速度:比如用磁盘顺序写代替随机写、用 NIO 代替 BIO、用性能更好的 SSD 代替机械硬盘等。

3 kafka 高性能设计

理解了高性能设计的手段和本质之后,我们再来看看 kafka 里面使用到的性能优化方法。各类消息中间件的本质都是一个生产者-消费者模型,生产者发送消息给服务端进行暂存,消费者从服务端获取消息进行消费。也就是说 kafka 分为三个部分:生产者-服务端-消费者,我们可以按照这三个来分别归纳一下其关于性能优化的手段,这些手段也会涵盖在我们之前梳理的脑图里面。

3.1 生产者的高性能设计

3.1.1 批量发送消息

之前在上面说过,高性能的”道”在于计算和 IO 上,咱们先来看看在 IO 上 kafka 是如何做设计的。


IO 上的优化


kafka 是一个消息中间件,数据的载体就是消息,如何将消息高效的进行传递和持久化是 kafka 高性能设计的一个重点。基于此分析 kafka 肯定是 IO 密集型应用,producer 需要通过网络 IO 将消息传递给 broker,broker 需要通过磁盘 IO 将消息持久化,consumer 需要通过网络 IO 将消息从 broker 上拉取消费。


  • 网络 IO 上的优化:producer->broker 发送消息不是一条一条发送的,kafka 模式会有个消息发送延迟机制,会将一批消息进行聚合,一口气打包发送给 broker,这样就成功减少了 IO 的次数。除了传输消息本身以外,还要传输非常多的网络协议本身的一些内容(称为 Overhead),所以将多条消息合并到一起传输,可有效减少网络传输的 Overhead,进而提高了传输效率。

  • 磁盘 IO 上的优化:大家知道磁盘和内存的存储速度是不同的,在磁盘上操作的速度是远低于内存,但是在成本上内存是高于磁盘。kafka 是面向大数据量的消息中间件,也就是说需要将大批量的数据持久化,这些数据放在内存上也是不现实。那 kafka 是怎么在磁盘 IO 上进行优化的呢?在这里我先直接给出方法,具体细节在后文中解释(它是借助于一种磁盘顺序写的机制来提升写入速度)。

3.1.2 负载均衡

1.kafka 负载均衡设计



Kafka 有主题(Topic)概念,他是承载真实数据的逻辑容器,主题之下还分为若干个分区,Kafka 消息组织方式实际上是三级结构:主题-分区-消息。主题下的每条消息只会在某一个分区中,而不会在多个分区中被保存多份。


Kafka 这样设计,使用分区的作用就是提供负载均衡的能力,对数据进行分区的主要目的就是为了实现系统的高伸缩性(Scalability)。不同的分区能够放在不同的节点的机器上,而数据的读写操作也都是针对分区这个粒度进行的,每个节点的机器都能独立地执行各自分区读写请求。我们还可以通过增加节点来提升整体系统的吞吐量。Kafka 的分区设计,还可以实现业务级别的消息顺序的问题。


2.具体分区策略


  • 所谓的分区策略是指决定生产者将消息发送到那个分区的算法。Kafka 提供了默认的分区策略是轮询,同时 kafka 也支持用户自己制定。

  • 轮询策略:也称为 Round-robin 策略,即顺序分配。轮询的优点是有着优秀的负载均衡的表现。

  • 随机策略:虽然也是追求负载均衡,但总体表现差于轮询。

  • 消息键划分策略:还要一种是为每条消息配置一个 key,按消息的 key 来存。Kafka 允许为每条消息指定一个 key。一旦指定了 key ,那么会对 key 进行 hash 计算,将相同的 key 存入相同的分区中,而且每个分区下的消息都是有序的。key 的作用很大,可以是一个有着明确业务含义的字符串,也可以是用来表征消息的元数据。

  • 其他的分区策略:基于地理位置的分区。可以从所有分区中找出那些 Leader 副本在某个地理位置所有分区,然后随机挑选一个进行消息发送。

3.1.3 异步发送

1.线程模型



之前已经说了 kafka 是选择批量发送消息来提升整体的 IO 性能,具体流程是 kafka 生产者使用批处理试图在内存中积累数据,主线程将多条消息通过一个 ProduceRequest 请求批量发送出去,发送的消息暂存在一个队列(RecordAccumulator)中,再由 sender 线程去获取一批数据或者不超过某个延迟时间内的数据发送给 broker 进行持久化。


优点:


  • 可以提升 kafka 整体的吞吐量,减少网络 IO 的次数;

  • 提高数据压缩效率(一般压缩算法都是数据量越大越能接近预期的压缩效果);


缺点:


  • 数据发送有一定延迟,但是这个延迟可以由业务因素来自行设置。

3.1.4 高效序列化

1.序列化的优势


Kafka 消息中的 Key 和 Value,都支持自定义类型,只需要提供相应的序列化和反序列化器即可。因此,用户可以根据实际情况选用快速且紧凑的序列化方式(比如 ProtoBuf、Avro)来减少实际的网络传输量以及磁盘存储量,进一步提高吞吐量。


2.内置的序列化器


  • org.apache.kafka.common.serialization.StringSerializer;

  • org.apache.kafka.common.serialization.LongSerializer;

  • org.apache.kafka.common.serialization.IntegerSerializer;

  • org.apache.kafka.common.serialization.ShortSerializer;

  • org.apache.kafka.common.serialization.FloatSerializer;

  • org.apache.kafka.common.serialization.DoubleSerializer;

  • org.apache.kafka.common.serialization.BytesSerializer;

  • org.apache.kafka.common.serialization.ByteBufferSerializer;

  • org.apache.kafka.common.serialization.ByteArraySerializer;

3.1.5 消息压缩

1.压缩的目的


压缩秉承了用时间换空间的经典 trade-off 思想,即用 CPU 的时间去换取磁盘空间或网络 I/O 传输量,Kafka 的压缩算法也是出于这种目的。并且通常是:数据量越大,压缩效果才会越好。


因为有了批量发送这个前期,从而使得 Kafka 的消息压缩机制能真正发挥出它的威力(压缩的本质取决于多消息的重复性)。对比压缩单条消息,同时对多条消息进行压缩,能大幅减少数据量,从而更大程度提高网络传输率。


2.压缩的方法


想了解 kafka 消息压缩的设计,就需要先了解 kafka 消息的格式:


  • Kafka 的消息层次分为:消息集合(message set)和消息(message);一个消息集合中包含若干条日志项(record item),而日志项才是真正封装消息的地方。

  • Kafka 底层的消息日志由一系列消息集合-日志项组成。Kafka 通常不会直接操作具体的一条条消息,他总是在消息集合这个层面上进行写入操作。


每条消息都含有自己的元数据信息,kafka 会将一批消息相同的元数据信息给提升到外层的消息集合里面,然后再对整个消息集合来进行压缩。批量消息在持久化到 Broker 中的磁盘时,仍然保持的是压缩状态,最终是在 Consumer 端做了解压缩操作。


压缩算法效率对比


Kafka 共支持四种主要的压缩类型:Gzip、Snappy、Lz4 和 Zstd,具体效率对比如下:


3.2 服务端的高性能设计

3.2.1 Reactor 网络通信模型

kafka 相比其他消息中间件最出彩的地方在于他的高吞吐量,那么对于服务端来说每秒的请求压力将会巨大,需要有一个优秀的网络通信机制来处理海量的请求。如果 IO 有所研究的同学,应该清楚:Reactor 模式正是采用了很经典的 IO 多路复用技术,它可以复用一个线程去处理大量的 Socket 连接,从而保证高性能。Netty 和 Redis 为什么能做到十万甚至百万并发?它们其实都采用了 Reactor 网络通信模型。


1.kafka 网络通信层架构



从图中可以看出,SocketServer 和 KafkaRequestHandlerPool 是其中最重要的两个组件:


  • SocketServer:主要实现了 Reactor 模式,用于处理外部多个 Clients(这里的 Clients 指的是广义的 Clients,可能包含 Producer、Consumer 或其他 Broker)的并发请求,并负责将处理结果封装进 Response 中,返还给 Clients

  • KafkaRequestHandlerPool:Reactor 模式中的 Worker 线程池,里面定义了多个工作线程,用于处理实际的 I/O 请求逻辑。


2.请求流程


  • Clients 或其他 Broker 通过 Selector 机制发起创建连接请求。(NIO 的机制,使用 epoll)

  • Processor 线程接收请求,并将其转换成可处理的 Request 对象。

  • Processor 线程将 Request 对象放入共享的 RequestChannel 的 Request 队列。

  • KafkaRequestHandler 线程从 Request 队列中取出待处理请求,并进行处理。

  • KafkaRequestHandler 线程将 Response 放回到对应 Processor 线程的 Response 队列。

  • Processor 线程发送 Response 给 Request 发送方。

3.2.2 Kafka 的底层日志结构

基本结构的展示



Kafka 是一个 Pub-Sub 的消息系统,无论是发布还是订阅,都须指定 Topic。Topic 只是一个逻辑的概念。每个 Topic 都包含一个或多个 Partition,不同 Partition 可位于不同节点。同时 Partition 在物理上对应一个本地文件夹(也就是个日志对象 Log),每个 Partition 包含一个或多个 Segment,每个 Segment 包含一个数据文件和多个与之对应的索引文件。在逻辑上,可以把一个 Partition 当作一个非常长的数组,可通过这个“数组”的索引(offset)去访问其数据。


2.Partition 的并行处理能力


  • 一方面,topic 是由多个 partion 组成,Producer 发送消息到 topic 是有个负载均衡机制,基本上会将消息平均分配到每个 partion 里面,同时 consumer 里面会有个 consumer group 的概念,也就是说它会以组为单位来消费一个 topic 内的消息,一个 consumer group 内包含多个 consumer,每个 consumer 消费 topic 内不同的 partion,这样通过多 partion 提高了消息的接收和处理能力

  • 另一方面,由于不同 Partition 可位于不同机器,因此可以充分利用集群优势,实现机器间的并行处理。并且 Partition 在物理上对应一个文件夹,即使多个 Partition 位于同一个节点,也可通过配置让同一节点上的不同 Partition 置于不同的 disk drive 上,从而实现磁盘间的并行处理,充分发挥多磁盘的优势。


3.过期消息的清除


  • Kafka 的整个设计中,Partition 相当于一个非常长的数组,而 Broker 接收到的所有消息顺序写入这个大数组中。同时 Consumer 通过 Offset 顺序消费这些数据,并且不删除已经消费的数据,从而避免了随机写磁盘的过程。

  • 由于磁盘有限,不可能保存所有数据,实际上作为消息系统 Kafka 也没必要保存所有数据,需要删除旧的数据。而这个删除过程,并非通过使用“读-写”模式去修改文件,而是将 Partition 分为多个 Segment,每个 Segment 对应一个物理文件,通过删除整个文件的方式去删除 Partition 内的数据。这种方式清除旧数据的方式,也避免了对文件的随机写操作。

3.2.3 朴实高效的索引

1.稀疏索引



可以从上面看到,一个 segment 包含一个.log 后缀的文件和多个 index 后缀的文件。那么这些文件具体作用是干啥的呢?并且这些文件除了后缀不同文件名都是相同,为什么这么设计?


  • .log 文件:具体存储消息的日志文件

  • .index 文件:位移索引文件,可根据消息的位移值快速地从查询到消息的物理文件位置

  • .timeindex 文件:时间戳索引文件,可根据时间戳查找到对应的位移信息

  • .txnindex 文件:已中止事物索引文件

  • 除了.log 是实际存储消息的文件以外,其他的几个文件都是索引文件。索引本身设计的原来是一种空间换时间的概念,在这里 kafka 是为了加速查询所使用。kafka 索引不会为每一条消息建立索引关系,这个也很好理解,毕竟对一条消息建立索引的成本还是比较大的,所以它是一种稀疏索引的概念,就好比我们常见的跳表,都是一种稀疏索引。

  • kafka 日志的文件名一般都是该 segment 写入的第一条消息的起始位移值 baseOffset,比如 000000000123.log,这里面的 123 就是 baseOffset,具体索引文件里面纪录的数据是相对于起始位移的相对位移值 relativeOffset,baseOffset 与 relativeOffse 的加和即为实际消息的索引值。假设一个索引文件为:00000000000000000100.index,那么起始位移值即 100,当存储位移为 150 的消息索引时,在索引文件中的相对位移则为 150 - 100 = 50,这么做的好处是使用 4 字节保存位移即可,可以节省非常多的磁盘空间。(ps:kafka 真的是极致的压缩了数据存储的空间)


2.优化的二分查找算法


kafka 没有使用我们熟知的跳表或者 B+Tree 结构来设计索引,而是使用了一种更为简单且高效的查找算法:二分查找。但是相对于传统的二分查找,kafka 将其进行了部分优化,个人觉得设计的非常巧妙,在这里我会进行详述。


在这之前,我先补充一下 kafka 索引文件的构成:每个索引文件包含若干条索引项。不同索引文件的索引项的大小不同,比如 offsetIndex 索引项大小是 8B,timeIndex 索引项的大小是 12B。



这里以 offsetIndex 为例子来详述 kafka 的二分查找算法:


1)普通二分查找


offsetIndex 每个索引项大小是 8B,但操作系统访问内存时的最小单元是页,一般是 4KB,即 4096B,会包含了 512 个索引项。而找出在索引中的指定偏移量,对于操作系统访问内存时则变成了找出指定偏移量所在的页。假设索引的大小有 13 个页,如下图所示:



由于 Kafka 读取消息,一般都是读取最新的偏移量,所以要查询的页就集中在尾部​,即第 12 号页上。根据二分查找,将依次访问 6、9、11、12 号页。



当随着 Kafka 接收消息的增加,索引文件也会增加至第 13 号页,这时根据二分查找,将依次访问 7、10、12、13 号页。



可以看出访问的页和上一次的页完全不同。之前在只有 12 号页的时候,Kafak 读取索引时会频繁访问 6、9、11、12 号页,而由于 Kafka 使用了​mmap​来提高速度,即读写操作都将通过操作系统的 page cache,所以 6、9、11、12 号页会被缓存到 page cache 中,避免磁盘加载。但是当增至 13 号页时,则需要访问 7、10、12、13 号页,而由于 7、10 号页长时间没有被访问(现代操作系统都是使用 LRU 或其变体来管理 page cache),很可能已经不在 page cache 中了,那么就会造成​缺页中断​(线程被阻塞等待从磁盘加载没有被缓存到 page cache 的数据)。在 Kafka 的官方测试中,这种情况会造成几毫秒至 1 秒的延迟。


2)kafka 优化的二分查找


Kafka 对二分查找进行了改进。既然一般读取数据集中在索引的尾部。那么​将索引中最后的 8192B(8KB)划分为“热区”(刚好缓存两页数据),其余部分划分为“冷区”,分别进行二分查找。这样做的好处是,在频繁查询尾部的情况下,尾部的页基本都能在 page cahce 中,从而避免缺页中断。


下面我们还是用之前的例子来看下。由于每个页最多包含 512 个索引项,而最后的 1024 个索引项所在页会被认为是热区。那么当 12 号页未满时,则 10、11、12 会被判定是热区;而当 12 号页刚好满了的时候,则 11、12 被判定为热区;当增至 13 号页且未满时,11、12、13 被判定为热区。假设我们读取的是最新的消息,则在热区中进行二分查找的情况如下:



当 12 号页未满时,依次访问 11、12 号页,当 12 号页满时,访问页的情况相同。当 13 号页出现的时候,依次访问 12、13 号页,不会出现访问长时间未访问的页,则能有效避免缺页中断。


3.mmap 的使用


利用稀疏索引,已经基本解决了高效查询的问题,但是这个过程中仍然有进一步的优化空间,那便是通过 mmap(memory mapped files) 读写上面提到的稀疏索引文件,进一步提高查询消息的速度。


究竟如何理解 mmap?前面提到,常规的文件操作为了提高读写性能,使用了 Page Cache 机制,但是由于页缓存处在内核空间中,不能被用户进程直接寻址,所以读文件时还需要通过系统调用,将页缓存中的数据再次拷贝到用户空间中。


1)常规文件读写



  • app 拿着 inode 查找读取文件

  • address_space 中存储了 inode 和该文件对应页面缓存的映射关系

  • 页面缓存缺失,引发缺页异常

  • 通过 inode 找到磁盘地址,将文件信息读取并填充到页面缓存

  • 页面缓存处于内核态,无法直接被 app 读取到,因此要先拷贝到用户空间缓冲区,此处发生内核态和用户态的切换


tips:这一过程实际上发生了四次数据拷贝。首先通过系统调用将文件数据读入到内核态 Buffer(DMA 拷贝),然后应用程序将内存态 Buffer 数据读入到用户态 Buffer(CPU 拷贝),接着用户程序通过 Socket 发送数据时将用户态 Buffer 数据拷贝到内核态 Buffer(CPU 拷贝),最后通过 DMA 拷贝将数据拷贝到 NIC Buffer。同时,还伴随着四次上下文切换。


2)mmap 读写模式



  • 调用内核函数 mmap(),在页表(类比虚拟内存 PTE)中建立了文件地址和虚拟地址空间中用户空间的映射关系

  • 读操作引发缺页异常,通过 inode 找到磁盘地址,将文件内容拷贝到用户空间,此处不涉及内核态和用户态的切换


tips:采用 mmap 后,它将磁盘文件与进程虚拟地址做了映射,并不会招致系统调用,以及额外的内存 copy 开销,从而提高了文件读取效率。具体到 Kafka 的源码层面,就是基于 JDK nio 包下的 MappedByteBuffer 的 map 函数,将磁盘文件映射到内存中。只有索引文件的读写才用到了 mmap。

3.2.4 消息存储-磁盘顺序写

对于我们常用的机械硬盘,其读取数据分 3 步:


  1. 寻道;

  2. 寻找扇区;

  3. 读取数据;


前两个,即寻找数据位置的过程为机械运动。我们常说硬盘比内存慢,主要原因是这两个过程在拖后腿。不过,硬盘比内存慢是绝对的吗?其实不然,如果我们能通过顺序读写减少寻找数据位置时读写磁头的移动距离,硬盘的速度还是相当可观的。一般来讲,IO 速度层面,内存顺序 IO > 磁盘顺序 IO > 内存随机 IO > 磁盘随机 IO。这里用一张网上的图来对比一下相关 IO 性能:



Kafka 在顺序 IO 上的设计分两方面看:


  1. LogSegment 创建时,一口气申请 LogSegment 最大 size 的磁盘空间,这样一个文件内部尽可能分布在一个连续的磁盘空间内;

  2. .log 文件也好,.index 和.timeindex 也罢,在设计上都是只追加写入,不做更新操作,这样避免了随机 IO 的场景;

3.2.5 Page Cache 的使用


为了优化读写性能,Kafka 利用了操作系统本身的 Page Cache,就是利用操作系统自身的内存而不是 JVM 空间内存。这样做的好处有:


  • 避免 Object 消耗:如果是使用 Java 堆,Java 对象的内存消耗比较大,通常是所存储数据的两倍甚至更多。

  • 避免 GC 问题:随着 JVM 中数据不断增多,垃圾回收将会变得复杂与缓慢,使用系统缓存就不会存在 GC 问题


相比于使用 JVM 或 in-memory cache 等数据结构,利用操作系统的 Page Cache 更加简单可靠。


  • 首先,操作系统层面的缓存利用率会更高,因为存储的都是紧凑的字节结构而不是独立的对象。

  • 其次,操作系统本身也对于 Page Cache 做了大量优化,提供了 write-behind、read-ahead 以及 flush 等多种机制。

  • 再者,即使服务进程重启,JVM 内的 Cache 会失效,Page Cache 依然可用,避免了 in-process cache 重建缓存的过程。


通过操作系统的 Page Cache,Kafka 的读写操作基本上是基于内存的,读写速度得到了极大的提升。

3.3 消费端的高性能设计

3.3.1 批量消费

生产者是批量发送消息,消息者也是批量拉取消息的,每次拉取一个消息 batch,从而大大减少了网络传输的 overhead。在这里 kafka 是通过 fetch.min.bytes 参数来控制每次拉取的数据大小。默认是 1 字节,表示只要 Kafka Broker 端积攒了 1 字节的数据,就可以返回给 Consumer 端,这实在是太小了。我们还是让 Broker 端一次性多返回点数据吧。


并且,在生产者高性能设计目录里面也说过,生产者其实在 Client 端对批量消息进行了压缩,这批消息持久化到 Broker 时,仍然保持的是压缩状态,最终在 Consumer 端再做解压缩操作。

3.3.2 零拷贝-磁盘消息文件的读取

1.zero-copy 定义


零拷贝并不是不需要拷贝,而是减少不必要的拷贝次数。通常是说在 IO 读写过程中。


零拷贝字面上的意思包括两个,“零”和“拷贝”:


  • “拷贝”:就是指数据从一个存储区域转移到另一个存储区域。

  • “零” :表示次数为 0,它表示拷贝数据的次数为 0。


实际上,零拷贝是有广义和狭义之分,目前我们通常听到的零拷贝,包括上面这个定义减少不必要的拷贝次数都是广义上的零拷贝。其实了解到这点就足够了。


我们知道,减少不必要的拷贝次数,就是为了提高效率。那零拷贝之前,是怎样的呢?


2.传统 IO 的流程


做服务端开发的小伙伴,文件下载功能应该实现过不少了吧。如果你实现的是一个 web 程序 ,前端请求过来,服务端的任务就是:将服务端主机磁盘中的文件从已连接的 socket 发出去。关键实现代码如下:


while((n = read(diskfd, buf, BUF_SIZE)) > 0)    write(sockfd, buf , n);
复制代码


传统的 IO 流程,包括 read 和 write 的过程。


  • read:把数据从磁盘读取到内核缓冲区,再拷贝到用户缓冲区

  • write:先把数据写入到 socket 缓冲区,最后写入网卡设备

  • 流程图如下:



  • 用户应用进程调用 read 函数,向操作系统发起 IO 调用,上下文从用户态转为内核态(切换 1)

  • DMA 控制器把数据从磁盘中,读取到内核缓冲区。

  • CPU 把内核缓冲区数据,拷贝到用户应用缓冲区,上下文从内核态转为用户态(切换 2) ,read 函数返回

  • 用户应用进程通过 write 函数,发起 IO 调用,上下文从用户态转为内核态(切换 3)

  • CPU 将用户缓冲区中的数据,拷贝到 socket 缓冲区

  • DMA 控制器把数据从 socket 缓冲区,拷贝到网卡设备,上下文从内核态切换回用户态(切换 4) ,write 函数返回


从流程图可以看出,传统 IO 的读写流程 ,包括了 4 次上下文切换(4 次用户态和内核态的切换),4 次数据拷贝(两次 CPU 拷贝以及两次的 DMA 拷贝 ),什么是 DMA 拷贝呢?我们一起来回顾下,零拷贝涉及的操作系统知识点。


3.零拷贝相关知识点


1)内核空间和用户空间


操作系统为每个进程都分配了内存空间,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。 以 32 位操作系统为例,它会为每一个进程都分配了 4G (2 的 32 次方)的内存空间。


  • 内核空间:主要提供进程调度、内存分配、连接硬件资源等功能

  • 用户空间:提供给各个程序进程的空间,它不具有访问内核空间资源的权限,如果应用程序需要使用到内核空间的资源,则需要通过系统调用来完成。进程从用户空间切换到内核空间,完成相关操作后,再从内核空间切换回用户空间。


2)用户态 &内核态


  • 如果进程运行于内核空间,被称为进程的内核态

  • 如果进程运行于用户空间,被称为进程的用户态。


3)上下文切换


cpu 上下文


CPU 寄存器,是 CPU 内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做 CPU 上下文。


cpu 上下文切换


它是指,先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。


一般我们说的上下文切换 ,就是指内核(操作系统的核心)在 CPU 上对进程或者线程进行切换。进程从用户态到内核态的转变,需要通过系统调用 来完成。系统调用的过程,会发生 CPU 上下文的切换 。


4)DMA 技术


DMA,英文全称是 Direct Memory Access ,即直接内存访问。DMA 本质上是一块主板上独立的芯片,允许外设设备和内存存储器之间直接进行 IO 数据传输,其过程不需要 CPU 的参与 。


我们一起来看下 IO 流程,DMA 帮忙做了什么事情。



可以发现,DMA 做的事情很清晰啦,它主要就是帮忙 CPU 转发一下 IO 请求,以及拷贝数据 。


之所以需要 DMA,主要就是效率,它帮忙 CPU 做事情,这时候,CPU 就可以闲下来去做别的事情,提高了 CPU 的利用效率。


4.kafka 消费的 zero-copy


1)实现原理


零拷贝并不是没有拷贝数据,而是减少用户态/内核态的切换次数以及 CPU 拷贝的次数。零拷贝实现有多种方式,分别是


  • mmap+write

  • sendfile


在服务端那里,我们已经知道了 kafka 索引文件使用的 mmap 来进行零拷贝优化的,现在告诉你 kafka 消费者在读取消息的时候使用的是 sendfile 来进行零拷贝优化。


linux 2.4 版本之后,对 sendfile 做了优化升级,引入 SG-DMA 技术,其实就是对 DMA 拷贝加入了 scatter/gather 操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点搞零拷贝,即还可以多省去一次 CPU 拷贝 。


sendfile+DMA scatter/gather 实现的零拷贝流程如下:



  • 用户进程发起 sendfile 系统调用,上下文(切换 1)从用户态转向内核态。

  • DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。

  • CPU 把内核缓冲区中的文件描述符信息 (包括内核缓冲区的内存地址和偏移量)发送到 socket 缓冲区

  • DMA 控制器根据文件描述符信息,直接把数据从内核缓冲区拷贝到网卡

  • 上下文(切换 2)从内核态切换回用户态 ,sendfile 调用返回。


可以发现,sendfile+DMA scatter/gather 实现的零拷贝,I/O 发生了 2 次用户空间与内核空间的上下文切换,以及 2 次数据拷贝。其中 2 次数据拷贝都是包 DMA 拷贝 。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。


2)底层实现


Kafka 数据传输通过 TransportLayer 来完成,其子类 PlaintextTransportLayer 通过 Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法实现零拷贝。底层就是 sendfile。消费者从 broker 读取数据,就是由此实现。


@Overridepublic long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {   return fileChannel.transferTo(position, count, socketChannel);}
复制代码


tips: transferTo 和 transferFrom 并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供 sendfile 这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。

4 总结

文章第一部分为大家讲解了高性能常见的优化手段,从”秘籍”和”道法”两个方面来诠释高性能设计之路该如何走,并引申出计算和 IO 两个优化方向。


文章第二部分是 kafka 内部高性能的具体设计——分别从生产者、服务端、消费者来进行全方位讲解,包括其设计、使用及相关原理。


希望通过这篇文章,能够使大家不仅学习到相关方法论,也能明白其方法论具体的落地方案,一起学习,一起成长。


作者:京东物流 李鹏

来源:京东云开发者社区

发布于: 22 小时前阅读数: 2
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
从Kafka中学习高性能系统如何设计 | 京东云技术团队_云计算_京东科技开发者_InfoQ写作社区