Kafka的高性能设计要点

1,369 阅读6分钟

一般广为人知的主要是三个重点:

  • 顺序写
  • 页缓存
  • 零拷贝

顺序写

满足顺序写的前提是追加操作占绝大多数的场景。最早从 GFS 开始,就通过剥夺随机写,即修改文件的权力,来获得更高的效率。我们在 Kafka 中看到的各种术语,比 如log,segment,compact,其实都是来源于一种叫做 LSM 树的数据结构。而设计 LSM 树的这篇文章被大范围普及得益于谷歌的 Bigtable——这也是为什么你会发现 HBase 和 Kafka 有如此多的相似点。

LSM 树是一种磁盘的索引数据结构,与之对应的是数据库中的常青树,B+树。B+树的本质是平衡树,它通过建立起高效的排序结构来优化数据的查询。但它的写入都是以磁盘块为单位的,即使最好的情况下(不发生分裂),也依然是随机写入。

在介绍 LSM 的顺序写之前,首先需要明白的是,什么是真正的顺序写?真正的顺序读写意味着磁盘的磁头不用换道(这个大家都知道电梯算法,就不多说了)。也就是说,分配的磁盘块都是连续的。但这一点也许不能完全保证。不过,LSM 可以保证只要操作系统分配了连续的空间,它就会顺序写入,而不会跳到之前的某个位置进行随机写入。而如何分配连续空间呢?就是这个文件尽可能比较大。LSM 树使用一个仅追加文件记录所有的数据变更,也就是日志(log)。

在 Kafka 中,这个文件每1G就会滚动生成新的。这是因为日志并不能无限大,而且切分成一个比较大的段(segment),可以方便进行回收。这其实是在顺序写和磁盘空间管理之间做了一个折衷。

到目前为止,LSM 树看似是非常简单的。但考虑一下,如何对数据进行查询呢?而如果需要对数据进行更新呢(虽然在 Kafka 中不需要考虑这个问题)?

对于一个仅追加文件,更新依然以新的条目追加,这一点类似于状态机。对应的,查询需要找到日志中最后一条状态。解决这一点似乎不太难——我们需要一个内存中的索引,它的键是数据的键,值是数据在文件中的偏移。因为日志会被切分,所以给每个段都分配一个索引,然后每次查询总是从最新的段的开始。

这个索引的数据结构值得思考。它可以是哈希索引,也可以是顺序索引——但哈希无法进行范围搜索,且必须装入所有的键。顺序索引可以是稀疏的,但那要求数据必须是排序的——LSM 树为了满足这一点,将 Segment 设计成 SSTable 这样的排序结构。由于磁盘写入是批量的,所以可以先在内存中构建排序结构,然后写入磁盘。这个排序结构一般是跳表,因为平衡树并发性很差。

不过,Kafka 不需要考虑这些问题,因为它没有更新,甚至没有查询。它通过消费者显式的保存消费消息的序号来决定读的位置,没有按照消息的键查询数据这一说法。所以 Kafka 甚至不需要这个排序的过程,它直接把这里的序号作为键即可。

页缓存

现在的电脑使用者可能不太能体会到虚拟内存、交换区这些概念。在以前内存非常小的时候,很容易电脑就会变得非常卡,因为开启的应用太多了。而即使是这么多应用依然能够同时运行,这就得益于虚拟内存用磁盘扩展了程序空间的大小。由于这种地址转换比较频繁,所以有专门的硬件设备MMU来处理(之前Intel的CPU爆出的漏洞就和分页有关)。有换入换出,就会有高速缓存,这就是我们在 Kafka 里最常看到的术语,page cache。

操作系统给文件系统提供了一种叫做 buffer cache 的高速缓存。Linux 早期既有 page cache,也有 buffer cache——当然这不太科学,所以后来合并在了一起:

What is the major difference between the buffer cache and the page cache? Why were they separate entities in older kernels? Why were they merged later on?

从某种意义上,我们可以认为系统提供的缓存就是 page cache。而 Kafka 做的事情很简单,其实是无招胜有招:不使用任何代码层面的缓存,纯粹依赖于 page cache。而这和消息系统追加写入的特点紧密相关:由于 page cache 采用的是类似于LRU的页面置换算法,所以最近写入的消息可以始终停留在 page cache 里等待消费。这也意味着,如果一个消费者不是过分的lag,它可以始终从 page cache 里读取数据。Kafka 写入日志部分的代码可以参考FileRecord(旧版本是FileMessageSet)。

通常我们在提到 page cache 时,还会涉及一个术语——内存映射技术(memory mapping)。它指的是,进程可以使用系统调用将文件映射到自己的虚拟内存空间。另外这个映射是懒加载的,也就是缺页时才会把对应的页加载进来。但这和 page cache 又有什么关系呢?内存映射——对应系统调用mmap最大的特点就是直接使用 page cache:

Page Cache, the Affair Between Memory and Files

它们的区别如下:

为什么mmap可以直接使用 page cache,但read,即正常读写就需要从内核缓冲区拷贝呢?一个原因是,正常的读写提供的用户缓冲区可能会被置换出去(因为它本身也属于虚拟内存)。但mmap通常并不是我们读写文件的选择,尤其是当这个文件对应的页已经在页表中时。在 Kafka 中,只有索引文件使用了mmap,而 log 文件文件没有。

零拷贝

零拷贝各种分析的文章比较多,其实关键还在于理解为什么普通的读写需要4次拷贝和2次上下文切换。

Kafka 调用了FileChannel.transferTo等方法,其本质是系统调用sendfile。在有DMA支持下,也许可以做到零拷贝——也就是说,直接从外存拷贝到内存指定的缓冲区,不需要从输入的缓冲区拷贝到输出的缓冲区(比如socket的)。

除了sendfile,零拷贝技术还有许多,包括mmap也能实现。


当然Kafka还有许多优秀的设计,比如批量压缩、Reactor模式等等。不过大家都提的很少,就不多说了。