Linux下的同步IO-sync、fsync与fdatasync
2017-10-02 17:53:17 阿炯

传统的UNIX实现在内核中设有缓冲区高速缓存或页面高速缓存,大多数磁盘I/O都通过缓冲进行。当将数据写入文件时,内核通常先将该数据复制到其中一个缓冲区中,如果该缓冲区尚未写满,则并不将其排入输出队列,而是等待其写满或者当内核需要重用该缓冲区以便存放其他磁盘块数据时,再将该缓冲排入输出队列,然后待其到达队首时,才进行实际的I/O操作。这种输出方式被称为延迟写(delayed write)(Bach [1986]第3章详细讨论了缓冲区高速缓存)。

延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成文件更新内容的丢失。为了保证磁盘上实际文件系统与缓冲区高速缓存中内容的一致性,UNIX系统提供了sync、fsync和fdatasync三个函数。

sync函数只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘操作结束。

通常称为update的系统守护进程会周期性地(一般每隔30秒)调用sync函数。这就保证了定期冲洗内核的块缓冲区。命令sync(1)也调用sync函数。

fsync函数只对由文件描述符filedes指定的单一文件起作用,并且等待写磁盘操作结束,然后返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保将修改过的块立即写到磁盘上。

fdatasync函数类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。

对于提供事务支持的数据库,在事务提交时,都要确保事务日志(包含该事务所有的修改操作以及一个提交记录)完全写到硬盘上,才认定事务提交成功并返回给应用层。

一个简单的问题:在*nix操作系统上,怎样保证对文件的更新内容成功持久化到硬盘?

1、write不够,需要fsync

一般情况下,对硬盘(或者其他持久存储设备)文件的write操作,更新的只是内存中的页缓存(page cache),而脏页面不会立即更新到硬盘中,而是由操作系统统一调度,如由专门的flusher内核线程在满足一定条件时(如一定时间间隔、内存中的脏页达到一定比例)内将脏页面同步到硬盘上(放入设备的IO请求队列)。

因为write调用不会等到硬盘IO完成之后才返回,因此如果OS在write调用之后、硬盘同步之前崩溃,则数据可能丢失。虽然这样的时间窗口很小,但是对于需要保证事务的持久化(durability)和一致性(consistency)的数据库程序来说,write()所提供的“松散的异步语义”是不够的,通常需要OS提供的同步IO(synchronized-IO)原语来保证:

#include <unistd.h>
int fsync(int fd);

fsync的功能是确保文件fd所有已修改的内容已经正确同步到硬盘上,该调用会阻塞等待直到设备报告IO完成。

PS:如果采用内存映射文件的方式进行文件IO(使用mmap,将文件的page cache直接映射到进程的地址空间,通过写内存的方式修改文件),也有类似的系统调用来确保修改的内容完全同步到硬盘之上:
#incude <sys/mman.h>
int msync(void *addr, size_t length, int flags)

msync需要指定同步的地址区间,如此细粒度的控制似乎比fsync更加高效(因为应用程序通常知道自己的脏页位置),但实际上(Linux)kernel中有着十分高效的数据结构,能够很快地找出文件的脏页,使得fsync只会同步文件的修改内容。

2、fsync的性能问题,与fdatasync

除了同步文件的修改内容(脏页),fsync还会同步文件的描述信息(metadata,包括size、访问时间st_atime & st_mtime等等),因为文件的数据和metadata通常存在硬盘的不同地方,因此fsync至少需要两次IO写操作,fsync的man page这样说:

"Unfortunately fsync() will always initialize two write operations : one for the newly written data and another one in order to update the modification time stored in the inode. If the modification time is not a part of the transaction concept fdatasync() can be used to avoid unnecessary inode disk write operations."

多余的一次IO操作,有多么昂贵呢?根据Wikipedia的数据,当前硬盘驱动的平均寻道时间(Average seek time)大约是3~15ms,7200RPM硬盘的平均旋转延迟(Average rotational latency)大约为4ms,因此一次IO操作的耗时大约为10ms左右。这个数字意味着什么?下文还会提到。


Posix同样定义了fdatasync,放宽了同步的语义以提高性能:
#include <unistd.h>
int fdatasync(int fd);

fdatasync的功能与fsync类似,但是仅仅在必要的情况下才会同步metadata,因此可以减少一次IO写操作。那么,什么是“必要的情况”呢?根据man page中的解释:

    "fdatasync does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be corretly handled."

举例来说,文件的尺寸(st_size)如果变化,是需要立即同步的,否则OS一旦崩溃,即使文件的数据部分已同步,由于metadata没有同步,依然读不到修改的内容。而最后访问时间(atime)/修改时间(mtime)是不需要每次都同步的,只要应用程序对这两个时间戳没有苛刻的要求,基本无伤大雅。

PS:open时的参数O_SYNC/O_DSYNC有着和fsync/fdatasync类似的语义:使每次write都会阻塞等待硬盘IO完成。(实际上,Linux对O_SYNC/O_DSYNC做了相同处理,没有满足Posix的要求,而是都实现了fdatasync的语义)相对于fsync/fdatasync,这样的设置不够灵活,应该很少使用。

3、使用fdatasync优化日志同步

文章开头时已提到,为了满足事务要求,数据库的日志文件是常常需要同步IO的。由于需要同步等待硬盘IO完成,所以事务的提交操作常常十分耗时,成为性能的瓶颈。

在Berkeley DB下,如果开启了AUTO_COMMIT(所有独立的写操作自动具有事务语义)并使用默认的同步级别(日志完全同步到硬盘才返回),写一条记录的耗时大约为5~10ms级别,基本和一次IO操作(10ms)的耗时相同。

已经知道,在同步上fsync是低效的。但是如果需要使用fdatasync减少对metadata的更新,则需要确保文件的尺寸在write前后没有发生变化。日志文件天生是追加型(append-only)的,总是在不断增大,似乎很难利用好fdatasync。
 
且看Berkeley DB是怎样处理日志文件的:
1.每个log文件固定为10MB大小,从1开始编号,名称格式为“log.%010d"

2.每次log文件创建时,先写文件的最后1个page,将log文件扩展为10MB大小

3.向log文件中追加记录时,由于文件的尺寸不发生变化,使用fdatasync可以大大优化写log的效率

4.如果一个log文件写满了,则新建一个log文件,也只有一次同步metadata的开销


数据库到固态硬盘的读写过程

从应用程序->数据库->操作系统->固态硬盘。

在研究了从应用程序发送的简单 SQL 查询是如何最终存储到磁盘的过程中,发现术语 “页(page)” 和 “块(block)” 可能是软件工程中最多用的概念之一。有数据库页(database page),操作系统虚拟内存页(virtual memory page),文件系统块(file system block),固态硬盘页(SSD page),两种类型的固态硬盘块(SSD block),其中一个称为逻辑块(logical block),对应于文件系统,另一个是更大的单元,称为擦除块(erase unit),其中包含多个页。所有这些单元都可以具有不同的大小,有些匹配,有些不匹配。

在此将详细介绍一个 SELECT 语句以及在执行过程中不同层次的输入/输出(I/O)是如何一直执行到磁盘的底层。这里将解释的完整图示:


但首先讲基础知识

当在数据库中创建一个表时,会在磁盘上创建一个文件,并将数据布局在固定大小的数据库页中。数据在页中的布局方式取决于引擎是行存储还是列存储。将页视为一种结构,它具有标头和数据,数据部分是行所在的位置。

数据库页可以是 8KB(Postgres)或 16KB(例如 MySQL InnoDB)或更大。表被存储为文件中的页数组,其中页面索引 + 大小告诉数据库确切的偏移量和读取量。例如,假设数据库页大小为 8KB,要从磁盘上读取第 7 页,您需要寻找偏移量 7 * 8192 + 1,并且将读取 8192 字节的长度。

行及其所有列依次存储在页中。如果行无法适应页中剩余的空间,则会分配一个新页面,并将该行放入新页面中。

当从磁盘读取页面并放入缓冲池时,可以免费获取该页面中的任何行(和列)。无论是否相信,这可能是数据库优化和数据建模中最重要的认识。

有了这些基础知识,来执行此查询,查询表为 “STUDENTS”,其中包含一个类型为 “serial” 的 ID 字段(单调递增)。该表有 20,000 行,分布在 20 个页面中。所需的行(1008 行)位于页面 1(第二个页面)。缓存中没有任何数据。为了保持简单,我不打算在这里包含索引,只是为了保持简单,虽然会比较慢。为简洁起见,只画出前三个页面。

SELECT NAME FROM STUDENTS WHERE ID = 1008;

无论是使用主索引作为表的聚簇索引,还是在没有主索引的情况下使用堆表(如在 PostgreSQL 中),可认为这里的模型是相似的。尽管 ID 字段没有建立索引,在 InnoDB 的情况下,可以将另一个字段视为主键,它会强制进行全索引扫描,其中读取主索引的叶子页面;而在 PostgreSQL 中,则会对表进行顺序扫描。假设每个页面大小为 8KB。

数据库

数据库解析并理解查询,由于 ID 字段没有建立索引,所以它进入了全表扫描模式,以获取值为 1008 的行。

数据库从页面 0 开始,并检查页面 0 是否在缓冲池中。缓冲池是所有数据库进程之间共享的内存空间,用于存储页面,也可以在其中进行写入操作。数据库没有找到页面 0,因此它向表文件发送读取页面 0 的请求。

文件的偏移量是 0*8192,我们要读取 8192 字节。页面被读取并放置在共享缓冲区内存中。页面包含行 1 到 1000,数据库解析页面,反序列化并在内存中查找每行的 ID 值,但没有找到。因此,我们继续读取页面 1。

读取页面 1 时,文件的偏移位置为 1*8192,要读取 8192 字节。页面 1 被放置在共享缓冲区中,与页面 0 一起存储。数据库在页面中查找行 1008,并找到并返回给用户。


现在进一步细分,看看数据库向操作系统发出读取请求时会发生什么。

文件系统

文件系统以块或逻辑块的单位从磁盘读取和写入数据。这些单位的大小可以从 512 字节到 4KB 不等,其中 4KB 是最常见的大小。

操作系统接收到读取文件偏移量为 0,读取 8192 字节的请求,并使用文件系统索引节点或 "inode" 将请求的字节映射到文件系统块。每个逻辑文件系统块地址(LBA)映射到存储设备中的特定物理块,将在后面看到这一点。如果读取单个字节,实际上会从磁盘中读取整个块。


当请求从偏移量 0 开始读取 8192 字节时,操作系统执行以下检查:
1).文件的偏移量 0 到 8192 字节之间的块是哪些?
2).假设文件系统块大小为 4KB,则得到两个块。
3).文件系统查找文件的索引节点以查找逻辑块地址(LBA),假设这些块是 100 和 101。
4).操作系统然后在文件系统页缓存中查找块 100 和 101,以查看先前的读取是否已将它们获取并放置在内存中。
5).文件系统页缓存位于内存中,存储块地址和主内存中虚拟内存页的地址。
6).操作系统中的虚拟内存页通常是 4KB,与块大小相匹配。因此,一个块可以适配到一个内存页中。
7).假设操作系统在页缓存中未找到块 100 和 101,因此准备从磁盘中读取。

注意,索引节点包含有关文件的其他元数据,例如权限信息。


存储

了解到读取页 0 相当于偏移量 0 和长度 8192,它被转换为文件系统块 100 和 101,每个块的大小为 4KB。操作系统检查了缓存,但找不到这些块,因此从磁盘中读取。

假设使用 NVMe 固态硬盘,操作系统通过 NVMe 驱动向存储设备发送读取命令。读取命令有许多参数,但最重要的是起始 LBA(逻辑块地址),第二个参数是要读取的块数。这意味着驱动程序发出了一个读取命令,传递了 (100, 0)。长度为 0 表示在 NVM 命令集中读取 1 个块。

现在有趣的地方来了。在 NVMe 中,“块” 的大小可能与文件系统块大小不同。例如,在这里我们假设 NVMe 块与文件系统块大小相同,都是 4KB。如果它们不同,操作系统需要更改读取参数。例如,如果 NVMe 块大小为 2KB,则文件系统块大小将包含 2 个 NVMe 块。因此,读取命令将为 101, 3。

固态硬盘(SSD)被分成页面,这是最小的读写单元。SSD 的 NAND 页面目前的大小为 16KB。页面被分组成更大的单元,通常也称为块,以擦除单元的方式。要写入 SSD 页面,页面必须处于擦除状态,而单独擦除页面是不可能的,必须擦除整个擦除单元。

NVMe 逻辑块地址映射到这个页面中的一个偏移量。因此在这种情况下,由于 NVMe 逻辑块大小为 4KB,4 个块适合于 SSD NAND 页面。

SSD 实际上并不使用逻辑块地址,它只知道页面的物理位置。因此,需要进行从逻辑块地址到物理页面偏移量的转换。由于 SSD 页面可以大于块,多个块可以映射到不同偏移量的同一页面上。


NVMe 控制器接收到读取 LBA 100 和 LBA 101 的命令,NVMe 驱动器的一个特点是,这些逻辑块地址(这两个块)被转换为物理页面和偏移量,例如页面 99 和偏移量 0x0001 和 0x1002。接下来,NVMe 控制器检查本地 SSD DRAM 缓存,以查看页面 99 是否在缓存中。是的,这里有一个 SSD 缓存。如果页面 99 不在缓存中,则整个页面(整个 16KB)被完全获取到缓存中并放置在缓存中。

一旦页面完全被获取到缓存中(整个 16KB),相应的块会从页面中提取并返回给操作系统主机。在这种情况下,只返回了前 8KB。

可能会问为什么不让操作系统直接访问页面的物理地址?为什么需要进行这种转换?原因是磁盘有时需要移动数据,当操作系统指向物理位置时,移动数据变得困难。具有讽刺意味的是,可以将数据的移动卸载到主机上,但这不会增加应用程序的复杂性成本。没有免费的午餐。这是一个深入研究的课题。

回到文件系统

操作系统获取代表两个块(100、101)的 8KB 数据,并将其放入两个内存页面中。然后它更新文件系统页面缓存,以便下一次请求读取 100 或 101 时可以从主机内存中获取。

回到数据库

然后,操作系统将控制权返回给数据库应用程序,如果你还记得,它发出了读取偏移量为 0、长度为 8192 的请求,对应于页面 0。数据库将原始字节放入共享缓冲池内存(与文件系统缓存不同)。页面 0 现在对于从中提取数据的任何其他查询都是 “热点”,在刷新到磁盘之前,页面 0 可以接收写入操作。当然,下一个页面,即页面 1,也经历了同样的过程,直到找到行 ID 为 1008 的行。

小结

要从数据库中读取一行数据,必须读取包含该行的页面。为了读取数据库页面,数据库会向文件发出正确偏移量和长度对应于页面的读取请求。操作系统将这些字节映射到文件系统块地址(或 LBAs),对比文件系统页面缓存,看是否存在具有这些块的内存页面。否则会向存储控制器发送读取命令。设备将逻辑块转换为物理地址,并将页面加载到缓存中,并将所请求的字节返回给操作系统主机。主机将块放入文件系统页面缓存,并返回给数据库,数据库将页面放入共享缓冲池中并开始处理,并将所请求的单行返回给用户。

如果使用索引会怎样?类似的情况,索引是存储在磁盘上的 B+ 树结构,也具有页面。在遍历树时将获取页面,因此概念是相同的。


本文源自:互联网