一般广为人知的主要是三个重点:
- 顺序写
- 页缓存
- 零拷贝
顺序写
满足顺序写的前提是追加操作占绝大多数的场景。最早从 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——当然这不太科学,所以后来合并在了一起:
从某种意义上,我们可以认为系统提供的缓存就是 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模式等等。不过大家都提的很少,就不多说了。