Kafka 依赖于文件系统(更底层地来说就是磁盘)来存储和缓存消息。通常在我们的印象中,磁盘的性能并没有那么好,这不禁让我们怀疑 Kafka 采用这种持久化形式能否提供有竞争力的性能。然而,事实上磁盘可以比我们预想的要快,也可能比我们预想的要慢,这完全取决于我们如何使用它。

操作系统可以针对线性读写做深层次的优化,比如预读(提前将一个比较大的磁盘块读入内存)和后写(将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快,测试结果如下图所示。
image.png
Kafka 在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘操作,所以就算 Kafka 使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。但这并不是让 Kafka 在性能上具备足够竞争力的唯一因素,下面我们继续分析。

页缓存

页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘 IO 的操作。具体来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。 为了弥补性能上的差异,现代操作系统越来越激进地将内存作为磁盘缓存,甚至会非常乐意将所有可用的内存用作磁盘缓存,这样当内存回收时也几乎没有性能损失,所有对于磁盘的读写也将经由统一的缓存。

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

Linux 操作系统中的 vm.dirty_background_ratio 参数用来指定当脏页数量达到系统内存的百分之多少之后就会触发 pdflush/flush/kdmflush 等后台回写进程的运行来处理脏页,一般设置为小于 10 的值即可,但不建议设置为0。与这个参数对应的还有一个 vm.dirty_ratio 参数,它用来指定当脏页数量达到系统内存的百分之多少之后就不得不开始对脏页进行处理,在此过程中,新的 I/O 请求会被阻挡直至所有脏页被冲刷到磁盘中。

Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在 Kafka 中同样提供了同步刷盘及间断性强制刷盘(fsync)的功能。这些功能可以通过 log.flush.interval.messages、log.flush.interval.ms 等参数来控制。同步刷盘可以提高消息的可靠性,防止由于机器异常宕机造成处于页缓存而没有及时写入磁盘的消息丢失。不过并不建议这么做,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。

Linux 系统会使用磁盘的一部分作为 swap 分区,这样可以进行进程的调度:把当前非活跃的进程调入 swap 分区,以此把内存空出来让给活跃的进程。对大量使用系统页缓存的 Kafka 而言,应当尽量避免这种内存的交换,否则会对它各方面的性能产生很大的负面影响。我们可以通过修改 Linux 系统参数 vm. swampiness 来调节。该参数的上限为 100,它表示积极地使用 swap 分区,并把内存上的数据及时地搬运到 swap 分区中;该参数的下限为 0,表示在任何情况下都不要发生交换,这样当内存耗尽时会根据一定的规则突然中止某些进程。建议将这个参数的值设置为 1,这样保留了 swap 的机制而又最大限度地限制了它对 Kafka 性能的影响。

磁盘 IO 流程

如下图所示,从编程角度而言,一般磁盘 IO 的场景有以下四种:

  • 用户调用标准 C 库进行 IO 操作,数据流为:应用程序 buffer → C 库标准 IObuffer → 文件系统页缓存 → 通过具体文件系统到磁盘。

  • 用户调用文件 IO,数据流为:应用程序 buffer → 文件系统页缓存 → 通过具体文件系统到磁盘。

  • 用户打开文件时使用 O_DIRECT,绕过页缓存直接读写磁盘。

  • 用户使用类似 dd 工具,并使用 direct 参数,绕过系统 cache 与文件系统直接写磁盘。

image.png

1. I/O 请求步骤

发起 I/O 请求的步骤可以表述为如下的内容,以最长链路为例解释:

写操作
用户调用 fwrite 把数据写入 C 库标准 IObuffer 后就返回,即写操作通常是异步操作;数据写入 IObuffer 后不会立即刷新到磁盘,会将多次小数据量相邻写操作先缓存起来合并,最终调用 wite 函数一次性写入页缓存;数据到达页缓存后也不会立即刷新到磁盘,内核有 pdflush 线程在不停地检测脏页,判断是否要写回到磁盘,如果是则发起磁盘 IO 请求。

读操作
用户调用 fread 到 C 库标准 IObuffer 中读取数据,如果成功则返回,否则继续;到页缓存中读取数据,如果成功则返回,否则继续;发起IO请求,读取数据后缓存 buffer 和 C 库标准 IObuffer 并返回。可以看到读操作是同步请求。

请求处理
通用块层根据 IO 请求构造一个或多个 bio 结构并提交给调度层;调度器将 bio 结构进行排序和合并组织成队列且确保读写操作尽可能理想:将一个或多个进程的读操作合并到一起读,将一个或多个进程的写操作合并到一起写,尽可能变随机为顺序,读必须优先满足,而写也不能等太久。

2. I/O 调度策略

针对不同的应用场景,I/O 调度策略也会影响 I/O 的读写性能,目前 Linux 系统中的 I/O 调度策略有 4 种,分别为 NOOP、CFQ、DEADLINE 和 ANTICIPATORY,默认为 CFQ。

1)NOOP
NOOP 算法的全写为 No Operation,该算法实现了最简单的 FIFO 队列,所有 IO 请求大致按照先来后到的顺序进行操作。之所以说大致,原因是 NOOP 在 FIFO 的基础上还做了相邻 IO 请求的合并,并不是完全按照先进先出的规则满足 IO 请求。

2)CFQ
CFQ 算法的全写为 Completely Fair Queuing,该算法的特点是按照 I/O 请求的地址进行排序,而不是按照先来后到的顺序进行响应。

CFQ 是默认的磁盘调度算法,对于通用服务器来说是最好的选择。它试图均匀地分布对 IO 带宽的访问。CFQ 为每个进程单独创建一个队列来管理该进程所产生的请求,各队列之间的调度使用时间片进行调度,以此来保证每个进程都能被很好地分配到 I/O 带宽。

在传统的 SAS 盘上,磁盘寻道花去了绝大多数的 I/O 响应时间。CFQ 的出发点是对 I/O 地址进行排序,以尽量少的磁盘旋转次数来满足尽可能多的 I/O 请求。在 CFQ 算法下,SAS 盘的吞吐量大大提高了。相比 NOOP 的缺点是,先来的 I/O 请求并不一定能被满足,可能会出现饿死的情况。

3)DEADLINE
DEADLINE 在 CFQ 的基础上解决了 I/O 请求饿死的极端情况。除了 CFQ 本身具有的 I/O 排序队列,还额外分别为读 I/O 和写 I/O 提供了 FIFO 队列。读 FIFO 队列的最大等待时间为 500ms,写 FIFO 队列的最大等待时间为 5s。FIFO 队列内的 IO 请求优先级要比 CFQ 队列中的高,而读 FIFO 队列的优先级又比写 FIFO 队列的优先级高。

4)ANTICIPATORY
CFQ 和 DEADLINE 考虑的焦点在于满足零散 I/O 请求上。对于连续的 I/O 请求,比如顺序读,并没有做优化。为了满足随机 I/O 和顺序 I/O 混合的场景,Linux 还支持 ANTICIPATORY 调度算法。ANTICIPATORY 在 DEADLINE 的基础上为每个读 I/O 都设置了 6ms 的等待时间窗口。如果在 6ms 内 OS 收到了相邻位置的读 I/O 请求,就可以立即满足。该算法通过增加等待时间来获得了更高的性能。

假设一个块设备只有一个物理查找磁头(例如一个单独的 SATA 硬盘),将多个随机的小写入流合并成一个大写入流(相当于将随机读写变顺序读写),通过这个原理来使用读取/写入的延时换取最大的读取/写入吞吐量。适用于大多数环境,特别是读取/写入较多的环境。

总结
不同的磁盘调度算法对 Kafka 这类依赖磁盘运转的应用的影响很大,建议根据不同的业务需求来测试并选择合适的磁盘调度算法。从文件系统层面分析,Kafka 操作的都是普通文件,并没有依赖于特定的文件系统,但是依然推荐使用 EXT4 或 XFS。尤其是对 XFS 而言,它通常有更好的性能,这种性能的提升主要影响的是 Kafka 的写入性能。

零拷贝

1. sendfile

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

考虑这样一种常用的情形:你需要将静态内容(类似图片、文件)展示给用户。这个情形就意味着需要先将静态内容从磁盘中复制出来放到一个内存 buf 中,然后将这个 buf 通过 Socket 传输给用户,进而用户才获得静态内容。这看起来再正常不过了,但实际上这是很低效的流程,我们把上面的这种情形抽象成下面的过程:

首先调用 read() 将静态内容 A 读取到 tmp_buf,然后调用 write() 将 tmp_buf 写入 Socket,整个过程如下图所示,在这个过程中,文件 A 一共经历了 4 次复制的过程:
image.png

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

从上面的过程可以看出,数据平白无故地从内核模式到用户模式走了一圈,浪费了 2 次复制过程:第一次是从内核模式复制到用户模式;第二次是从用户模式再复制回内核模式。而且在上面的过程中,内核和用户模式的上下文的切换也是 4 次。

如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给 Socket,如下图所示:
image.png
零拷贝技术通过 DMA(Direct Memory Access)技术将文件内容复制到内核模式下的 Read Buffer 中,不过没有数据被复制到 Socket Buffer 中,相反只有包含数据的位置和长度的信息的文件描述符(Descriptor)被加到了 Socket Buffer 中。DMA 引擎直接将数据从内核模式中传递到网卡设备。

这里数据只经历了 2 次复制就从磁盘中传送出去了,并且上下文切换也变成了 2 次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。

2. mmap

在 Kakfa 服务端接收 Provider 的消息并持久化的场景下,Kafka 中的索引文件底层还依赖了 mmap 机制,实现方式即采用 Java 中的 MappedByteBuffer。使用内存映射文件的主要优势在于,它有很高的 I/O 性能,特别是对于索引这样的小文件来说,由于文件内存被直接映射到一段虚拟内存上,访问内存映射文件的速度要快于普通的读写文件速度。另外,在 Linux 操作系统中,这段映射的内存区域实际上就是内核的页缓存(Page Cache)。这就意味着,里面的数据不需要重复拷贝到用户态空间,避免了很多不必要的时间、空间消耗。

比如:如果我们要计算索引对象中当前有多少个索引项,那么只需要执行下列计算即可:

  1. protected var _entries: Int = mmap.position() / entrySize

如果我们要计算索引文件最多能容纳多少个索引项,只要定义下面的变量就行了:

private[this] var _maxEntries: Int = mmap.limit() / entrySize

如果我们要向索引中写入数据:

// 向mmap中写入相对位移值
mmap.putInt(relativeOffset(offset))
// 向mmap中写入物理位置信息
mmap.putInt(position)