Kafka 进阶学习(九)—— 磁盘存储

232 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情

前言

今天是我 Kafka 学习的第 9 天,学习的内容是 Kafka 的磁盘存储。在传统的消息中间件 RabbitMQ 中,就使用内存作为默认的存储介质,而磁盘作为备选介质,以此实现高吞吐和低延迟的特性。而 Kafka 采用磁盘作为默认的存储介质,那么 Kafka 是如何保证高性能的呢,下面就让我们一起来看一看。

顺序读写

研究表明,磁盘的顺序写入速度远远高于随机写入速度,顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快。操作系统还可以针对线性读写做深层次的优化,比如 预读(read-ahead,提前将一个比较大的磁盘块读入内存)  和 后写(write-behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)  技术。

Kafka 在设计时采用了 文件追加 的方式来写入消息,即只能在日志文件的尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作,所以就算 Kafka 使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。

页缓存

减少 I/O 操作

页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘 I/O 的操作。具体来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。

当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性

减少进程缓存

1. 不必缓存两份数据

对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了两次。

并且,除非使用 Direct I/O 的方式,否则页缓存很难被禁止。

2. 减少垃圾回收

此外,用过 Java 的人一般都知道两点事实:对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java 的垃圾回收会随着堆内数据的增多而变得越来越慢。

基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在 32GB 的机器上使用 28GB 至 30GB 的内存而不用担心 GC 所带来的性能问题。

3. 减少进程内缓存重建

此外,即使 Kafka 服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。

4. 刷盘

Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一。

虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在 Kafka 中同样提供了 同步刷盘 及 间断性强制刷盘(fsync)  的功能。

同步刷盘可以提高消息的可靠性,防止由于机器掉电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障

零拷贝

除了消息顺序追加、页缓存等技术,Kafka 还使用零拷贝(Zero-Copy)技术来进一步提升性能。所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换。对 Linux 操作系统而言,零拷贝技术依赖于底层的 sendfile()方法实现。

这里我们简单看下零拷贝的概念。

考虑这样一种常用的情形:我们需要将静态内容(类似图片、文件)展示给用户。即我们需要先将静态内容从磁盘中复制出来放到一个内存 buf 中,然后将这个 buf 通过套接字(Socket)传输给用户,进而用户获得静态内容。我们把上面的这种情形抽象为下面的过程:

  1. read(file, tmp_buf, len)
  2. write(socket, tmp_buf, len)

首先调用 read()将静态内容(这里假设为文件 A)读取到 tmp_buf,然后调用 write()将 tmp_buf 写入 Socket,在这个过程中,文件 A 经历了 4 次复制的过程:

  1. 调用 read()时,文件 A 中的内容被复制到了内核模式下的 Read Buffer 中;
  2. CPU 控制将内核模式数据复制到用户模式下;
  3. 调用 write()时,将用户模式下的内容复制到内核模式下的 Socket Buffer 中;
  4. 将内核模式下的 Socket Buffer 的数据复制到网卡设备中传送。

从上面的过程可以看出,数据平白无故地 从内核模式到用户模式 “走了一圈”,浪费了 2 次复制过程:

  • 第一次是从内核模式复制到用户模式;
  • 第二次是从用户模式再复制回内核模式,

即上面 4 次过程中的第 2 步和第 3 步。而且在上面的过程中,内核和用户模式的上下文的切换也是 4 次。如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给Socket。

零拷贝技术通过 DMA(Direct Memory Access)技术将文件内容复制到内核模式下的 Read Buffer 中。不过没有数据被复制到 Socket Buffer,相反只有包含数据的位置和长度的信息的 文件描述符 被加到 Socket Buffer 中。DMA 引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了 2 次复制就从磁盘中传送出去了,并且上下文切换也变成了 2 次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝

参考文档

  • 《深入理解 Kafka:核心设计与实践原理》—— 朱忠华

往期文章