在磁盘IO上,第2部分:IO的更多风味

如果您喜欢该系列,请查阅我即将出版的有关Database Internals的书!

系列包括5件:

  • IO的风格:页面缓存,标准IO,O_DIRECT
  • IO的更多风味:mmap,fadvise,AIO
  • LSM树
  • LSM树中的访问模式
  • B树和RUM猜想

可以在这里找到有关数据库中分布式系统概念的新系列。

内存映射

内存映射( mmap )使您可以访问文件,就像文件已完全加载到内存中一样。 它简化了文件访问,数据库和应用程序开发人员经常使用它。

使用mmap ,文件可以私下共享模式映射到内存段。 私有映射允许读取文件,但是任何写操作都会触发相关页面的写时复制,以使原始页面保持完整并保持私有更改,因此所有更改都不会反映在文件本身上。 在共享模式下,文件映射与其他进程共享,因此他们可以看到对映射的内存段的更新。 此外,更改会传递到基础文件中(对精确文件的控制要求使用msync)。

除非另有说明,否则文件内容不会立即而是以惰性方式加载到内存中。 内存映射所需的空间已保留,但没有立即分配。 第一次读取或写入操作会导致页面错误,从而触发相应页面的分配。 通过传递MAP_POPULATE ,可以对映射区域进行预故障处理并强制文件预读。

在上一篇文章中,我们触及了虚拟内存和页面缓存的主题。 内存映射是通过页面缓存完成的,与标准IO操作(如读写)的方式相同,并且使用了需求分页。

在第一次内存访问期间,发出页面错误 ,该错误指示内核当前请求的页面尚未加载到内存中,而必须加载。 内核标识必须从何处加载数据。 Page Fault对开发人员是透明的:程序流将继续进行,好像什么都没发生。 有时,页面错误可能会对性能产生负面影响,我们将在本文后面的部分中讨论改善这种情况的可能方法。

也可以使用保护标志将文件映射到内存中(例如,在只读模式下)。 如果对映射的内存段的操作违反了请求的保护,则会发出分段错误

mmap是用于IO的非常有用的工具:它避免了在内存中创建缓冲区的多余副本(与标准IO不同,在标准IO中,必须在进行系统调用之前将数据复制到用户空间缓冲区中)。 此外,除了发生页面错误时,它还避免了触发实际IO操作的系统调用(和后续的上下文切换)开销。 从开发人员的角度来看,使用mmap ped文件发出随机读取看起来就像是正常的指针操作,并且不涉及lseek调用。

大多数时候提到的mmap的缺点与现代硬件不太相关:

  • m map带来了管理内存映射所需的内核数据结构的开销:在当今的现实和内存大小中,此参数并不起作用。
  • 内存映射文件的大小限制:多数情况下,内核代码无论如何对内存更友好,并且64位体系结构允许映射更大的文件。

当然,这并不意味着必须使用内存映射文件来完成所有工作。

数据库实现者经常使用mmap 。 例如,MongoDB支持默认的存储引擎,而SQLite正在广泛使用内存映射。

页面缓存优化

从到目前为止的讨论来看,使用Standard IO似乎可以简化许多事情并有一些好处,但是却以控制丢失为代价:您处于内核和页面缓存的宽限期。 这是正确的,但仅限于一定范围。 通常,内核可以使用内部统计信息更好地预测何时执行回写和预取页面。 但是,有时可以帮助内核以对应用程序有利的方式来管理页面缓存。

向内核通知您的意图的一种方法是使用fadvise 。 使用以下标志,可以指示内核您的意图,并使其优化页面缓存的使用:

  • FADV_SEQUENTIAL指定从低偏移量到高偏移量顺序读取文件,因此内核可以确保在实际读取发生之前提前获取页面。
  • FADV_RANDOM禁用读,逐出那些不太可能很快从页面缓存中访问的页面。
  • FADV_WILLNEED通知OS,该进程将在不久的将来使用该页面。 这为内核提供了提前缓存页面的机会,并且在发生读取操作时,可以从页面缓存中为页面提供服务,而不是页面错误。
  • FADV_DONTNEED建议内核可以释放相应页面的缓存(确保数据事先与磁盘同步)。
  • 还有一个标志( FADV_NOREUSE ),但是在Linux上没有任何作用。

顾名思义, fadvise只是作为顾问。 内核没有义务完全按照fadvise的建议进行操作。

由于数据库开发人员通常可以预测访问,因此fadvise是一个有用的工具。 例如,RocksDB使用它来通知内核有关访问模式的信息,具体取决于文件类型(SSTable或HintFile),模式(随机或顺序)和操作(写入或压缩)。

另一个有用的调用是mlock 它允许您强制将页面保留在内存中。 这意味着一旦页面被加载到内存中,所有后续操作都将从页面缓存中进行。 必须谨慎使用它,因为在每个页面上调用它只会耗尽系统资源。

情报局

在IO风格方面,我们将讨论的最后一部分是Linux异步IO(AIO)。 AIO是一个接口,允许启动多个IO操作并注册将在其完成时触发的回调。 操作将异步执行(例如,系统调用将立即返回)。 使用异步IO可以帮助应用程序在处理提交的IO作业的同时继续在主线程上工作。

负责Linux AIO的两个主要syscall是io_submitio_geteventsio_submit允许传递一个或多个命令,其中包含缓冲区,偏移量和必须执行的操作。 可以使用io_getevents查询完成情况 ,该调用允许收集相应命令的结果事件。 这允许使用完全异步的接口来处理IO,流水线化IO操作并释放应用程序线程,从而有可能减少上下文切换和唤醒的次数。

不幸的是,Linux AIO有几个缺点:glibc没有公开syscalls API,并且需要一个库来连接它们(libaio似乎是最流行的)。 尽管已尝试解决此问题,但仅支持带有O_DIRECT标志的文件描述符,因此缓冲的异步操作将不起作用。 此外,某些操作(例如statfsyncopen和其他一些操作)不是完全异步的。

值得一提的是,Linux AIO不应与Posix AIO混淆,这完全是另一回事。 Linux上的Posix AIO实施完全在用户空间中实施,根本不使用此Linux特定的AIO子系统。

向量IO

一种可能不太流行的执行IO操作的方法是Vectored IO(也称为Scatter / Gather)。 之所以这样称呼它,是因为它在缓冲区的向量上运行,并且允许每个系统调用使用多个缓冲区来向磁盘读写数据。

当执行向量读取时,将首先从源将字节读取到缓冲区中(直到第一个缓冲区的长度偏移量为止)。 然后,从源开始,从第一个缓冲区的长度开始一直到第二个缓冲区的长度偏移量为止的字节将被读入第二个缓冲区,依此类推, 就好像源在一个接一个地填充缓冲区(尽管操作顺序和并行性不是确定性)。 向量写入的工作方式与此类似:缓冲区将被写入,就好像它们在写入之前被连接在一起一样。

通过允许读取较小的块(从而避免为连续的块分配大的内存区域),并同时减少用磁盘数据填充所有这些缓冲区所需的系统调用量,这种方法可以提供帮助。 另一个优点是读写都是原子的:内核可防止其他进程在读写操作期间对同一描述符执行IO,从而确保数据完整性。

从开发的角度来看,如果数据以某种方式放在文件中(例如,将其拆分为固定大小的标头和多个固定大小的块),则可以发出单个调用来填充单独的缓冲区分配给这些零件。

这听起来很有用,但是以某种方式,只有少数几个数据库使用矢量IO。 这可能是因为通用数据库同时处理一堆文件,试图确保每个运行操作的活动性并减少其延迟,因此可以按块访问和缓存数据。 向量化IO对于分析工作负载和/或列式数据库(数据连续存储在磁盘上,并且其处理可以在稀疏块中并行进行)更有用。 示例之一是Apache Arrow。

结束语

如您所见,有很多东西可供选择,每一个都有自己的优点和缺点。 使用特定工具并不能保证会带来积极的结果:由于特定的原因,这些IO风格很容易被误解和滥用。 实施,调整和最终用户使用数据库的方式仍可能发挥重要作用。

您可以看到现有方法似乎仍然有一种模式:使用O_DIRECT可能需要您编写缓冲区缓存,使用页面缓存可能需要使用fadvise ,使用AIO可能要求您将其与类似Futures的接口连接起来。 其中一些用于更具体的用例,而某些则更通用。 这些系列的主要目的是帮助人们掌握基本词汇,并了解数据库中包含哪些内容,从而可以更轻松地调查子系统,调整,优化和选择适合工作的合适工具。

在下一篇文章中,我们将讨论不可变的磁盘数据结构和LSM树。

如果您发现任何要添加的内容或我的帖子中有错误,请随时与我联系,我们将很乐意进行相应的更新。

如果您喜欢该帖子并且希望收到有关下一部分的通知,则可以在Twitter上关注我或订阅我的邮件列表。