LevelDB 源码阅读:Posix 文件操作接口实现细节
LevelDB 支持在各种操作系统上运行,为了适配不同的操作系统,需要封装一些系统调用,比如文件操作、线程操作、时间操作等。在对外暴露的 include 文件中,env.h 文件定义了 LevelDB 用到的各种接口。包括 Env 类,封装文件操作,目录操作等,还有一些文件抽象类,比如 SequentialFile、WritableFile、RandomAccessFile 3 个类,用于顺序读取,随机读取和写入文件。
通过抽象接口,只需要为每个平台实现相应的 Env 子类,LevelDB 就可以在不同的操作系统上运行。这篇文章以 POSIX 系统环境为例,先来看看抽象出来的和文件操作相关的接口是怎么实现的。
顺序读文件
首先看看顺序读文件的抽象基类 SequentialFile,它为文件的顺序读取和跳过操作提供了一个标准的接口,可以用于 WAL 日志文件的读取。类中定义了2个主要的虚函数:
- Read(size_t n, Slice* result, char* scratch):这个函数用于从文件中读取多达 n 字节的数据。result 是一个指向 Slice 类型的指针,用来存储读取的数据。scratch 是一个字符数组,用作临时缓冲区,函数可能会向这个缓冲区写入数据。
- Skip(uint64_t n):这个函数用于跳过文件中的 n 字节数据。如果文件读取到末尾,跳过操作将停止在文件末尾,函数返回 OK 状态。
当然,注释里也说明了这个类需要调用者进行同步,以确保线程安全。在 POSIX 环境下,这个类的实现是在 env_posix.cc 文件中,PosixSequentialFile 类 final 继承自 SequentialFile,阻止被其他任何类继承,同时实现了上述两个虚函数。其中 Read 的实现如下:
1 | Status Read(size_t n, Slice* result, char* scratch) override { |
这里当系统调用 read() 返回值小于 0 时,会根据 errno 的值判断是否是 EINTR 错误,如果是则重试读取。这是因为,当对一个设置了 O_NONBLOCK 标志的文件描述符进行 read() 操作时,如果没有足够的数据可供读取,read() 会立即返回而不是阻塞等待数据变得可用。这种情况下,read() 将返回 -1 并且 errno 被设置为 EAGAIN,表明没有数据可读,可以稍后再试。
Skip 的实现则比较简单,直接调用系统调用 lseek() 来跳过文件中的 n 个字节。这里第三个参数是 SEEK_CUR,表示从当前位置开始跳过 n 个字节。操作系统中,每个打开的文件都有一个与之关联的文件位置指针(有时也称为文件偏移量)。这个指针指示了下一次读取或写入操作将在文件中的哪个位置进行。操作系统负责跟踪和维护这个文件位置指针。当然也可以指定 SEEK_SET 或 SEEK_END,分别表示从文件开始和文件末尾开始跳过 n 个字节。
1 | Status Skip(uint64_t n) override { |
在对象销毁时也要关闭文件描述符,确保资源被正确释放。每次打开文件,操作系统会分配一些资源,比如内核缓冲区、文件锁等。然后返回给用户一个文件描述符(非负整数),之后用户通过这个文件描述符来操作文件。当我们调用 close 时,操作系统会减少对该文件的引用计数,如果引用计数为 0,操作系统会释放相应资源。此外每个进程能打开的文件数量有限制,不调用 close(fd) 可能导致进程无法打开新的文件。
1 | ~PosixSequentialFile() override { close(fd_); } |
随机读文件
RandomAccessFile 是一个抽象基类,定义随机读取文件的接口。它声明了一个纯虚函数 Read,强制子类实现这个方法。Read 方法的设计允许从文件的任意位置读取指定数量的字节。因为是一个只读接口,所以支持无锁多线程并发访问。
1 | virtual Status Read(uint64_t offset, size_t n, Slice* result, |
在 POSIX 环境下,这个类有 2 种实现,一个是用 pread() 实现的 PosixRandomAccessFile,另一个是用 mmap() 实现的 PosixMmapReadableFile。
pread 随机读
PosixRandomAccessFile 类实现了 RandomAccessFile 接口,主要用的是 POSIX 的 pread() 系统调用。该类的构造函数比较有意思,接收 filename,fd 和外部传入的 fd_limiter 指针。fd_limiter 用于限制持有的文件描述符的数量,避免打开的文件描述符过多,limiter 的具体实现在本文 Limiter 部分。构造的时候,如果 fd_limiter->Acquire() 返回 true,说明可以一直持有这个文件描述符。否则的话,需要在构造函数中关闭文件描述符,在后面每次从文件读内容的时候再使用临时文件描述符。
这里 fd_limiter 在 PosixEnv的工厂函数里面创建,持久文件描述符的最大个数由 MaxOpenFiles 函数获得。首先检查全局变量 g_open_read_only_file_limit 是否被修改为非负数,如果是则使用这个值。如果没设置,则需要根据系统的资源限制来决定。这里通过系统调用 getrlimit 来获取当前进程可以打开的最大文件描述符数。如果系统不限制进程可以打开的文件描述符数量,那么返回一个 int 类型的最大值,否则将这个限制数的20%分配给只读文件的操作。如果拿资源限制失败,或者系统(比如 Fuchsia 操作系统)不支持获取资源限制,则使用一个硬编码的数值 50。
接下来看看 PosixRandomAccessFile 的构造函数:
1 | // The new instance takes ownership of |fd|. |fd_limiter| must outlive this |
构造函数中还用成员变量 has_permanent_fd_ 来记录是否一直持有打开的文件描述符,如果没有则 fd_ 为 -1。对应的,在析构函数中,如果 has_permanent_fd_ 为 true,就需要调用 close() 关闭文件描述符,并释放 fd_limiter_ 的资源计数。接下来看该类的核心 Read 方法,代码如下:
1 | Status Read(uint64_t offset, size_t n, Slice* result, |
这里首先判断是否持有持久文件描述符,如果没有则需要在每次读取文件时打开文件。然后调用 pread() 读取文件内容,pread() 与 read() 类似,但是它可以从文件的指定位置读取数据。pread() 的第一个参数是文件描述符,第二个参数是读取的缓冲区,第三个参数是读取的字节数,第四个参数是文件中的偏移量。如果读取成功,将读取的数据存入 result 中,否则返回错误状态。最后如果没有持有持久文件描述符,需要在读取完数据后关闭临时文件描述符。
PosixRandomAccessFile 类实现相对简单,直接使用系统文件API,无需额外的内存映射管理,适用于小文件或者不频繁的读取操作。但是如果访问比较频繁,过多的系统调用可能导致性能下降,这时候就可以使用mmap 内存映射文件来提高性能。
mmap 随机读
PosixMmapReadableFile 类同样实现了 RandomAccessFile 接口,不过通过内存映射(mmap)将文件或文件的一部分映射到进程的地址空间,访问这部分内存就相当于访问文件本身。内存映射允许操作系统利用页缓存,可以显著提高频读取的性能,尤其是在大文件场景下,可以提高读取效率。
和 PosixRandomAccessFile 有些不同,这里在构造的时候需要传入 mmap_base 指针,指向通过 mmap 系统调用映射的文件内容,同时还需要传入 length 即映射区域的长度,即文件的大小。这里的映射在外面 NewRandomAccessFile 方法中做,PosixMmapReadableFile 直接使用映射好的地址。
1 | PosixMmapReadableFile(std::string filename, char* mmap_base, size_t length, |
当然,这里 mmap 也需要限制资源,避免耗尽虚拟内存,这里同样用的是 Limiter 类,后面会详细介绍。Read 方法直接从 mmap_base_ 中读取数据,不需要再调用系统调用,效率高很多,整体代码如下:
1 | Status Read(uint64_t offset, size_t n, Slice* result, |
顺序写文件
前面都是读文件,当然也少不了写文件接口了。WritableFile 是一个抽象基类,定义顺序写入文件的接口。它为文件的顺序写入和同步操作提供了一个标准的接口,可以用于 WAL 日志文件的写入。类中定义了3个主要的虚函数:
- Append(const Slice& data):向文件对象中追加数据,对于小块数据追加在对象的内存缓存中,对于大块数据则调用 WriteUnbuffered 写磁盘。
- Flush():将目前内存缓存中的数据调用系统 write 写磁盘,注意这里不保证数据已被同步到物理磁盘。
- Sync():确保内部缓冲区的数据被写入文件,还确保数据被同步到物理磁盘,以保证数据的持久性。调用 Sync() 之后,即使发生电源故障或系统崩溃,数据也不会丢失了。
在 POSIX 环境下,这个类的实现是 PosixWritableFile。类内部使用了一个大小为 65536 字节的缓冲区 buf_
,只有缓冲区满才会将数据写入磁盘文件。如果有大量的短内容写入,就可以先在内存中合并,从而减少对底层文件系统的调用次数,提高写操作的效率。
1 | constexpr const size_t kWritableFileBufferSize = 65536; |
这里合并写入的策略在 Append 中实现,代码比较清晰。对于写入的内容,如果能够完全放入缓冲区,则直接拷贝到缓冲区中,然后就返回成功。否则先填满缓冲区,然后将缓存区中的数据写入文件,此时如果剩余的数据能够写入缓冲区则直接写,不然就直接刷到磁盘中。完整实现如下:
1 | Status Append(const Slice& data) override { |
上面将数据写入磁盘调用的是 WriteUnbuffered 函数,该函数通过系统调用 write() 实现,主要代码如下:
1 | Status WriteUnbuffered(const char* data, size_t size) { |
除了 Append 函数,WritableFile 还提供了 Flush 接口,用于将内存缓冲区 buf_ 的数据写入文件,它内部也是通过调用 WriteUnbuffered 来实现。不过值得注意的是,这里 Flush 写磁盘成功,并不保证数据已经写入磁盘,甚至不能保证磁盘有足够的空间来存储内容。如果要保证数据写物理磁盘文件成功,需要调用 Sync() 接口,实现如下:
1 | Status Sync() override { |
这里核心是调用 SyncFd() 方法,确保文件描述符 fd 关联的所有缓冲数据都被同步到物理磁盘。该函数的实现考虑了不同的操作系统特性和文件系统行为,使用了条件编译指令(#if、#else、#endif)来处理不同的环境。在 macOS 和 iOS 系统上,使用了 fcntl() 函数的 F_FULLFSYNC
选项来确保数据被同步到物理磁盘。如果定义了 HAVE_FDATASYNC,将使用 fdatasync() 来同步数据。其他情况下,默认使用 fsync() 函数来实现同样的功能。
注意这里 SyncDirIfManifest 确保如果文件是 manifest 文件(以 “MANIFEST” 开始命名的文件),相关的目录更改也得到同步。mainfest 文件记录数据库文件的元数据,包括版本信息、合并操作、数据库状态等关键信息。文件系统在创建新文件或修改文件目录项时,这些变更可能并不立即写入磁盘。在更新 manifest 文件前确保所在目录的数据已被同步到磁盘,防止系统崩溃时,manifest 文件引用的文件尚未真正写入磁盘。
资源并发限制
上面提到为了避免打开的文件描述符过多,使用 Limiter 类的 Acquire 来进行限制,该类的也在实现在 env_posix.cc中。这个类的注释也写的特别棒,把它的作用讲的很明白,主要用来限制资源使用,避免资源耗尽。目前用于限制只读文件描述符和 mmap 文件使用,以避免耗尽文件描述符或虚拟内存,或者在非常大的数据库中遇到内核性能问题。
1 | // Helper class to limit resource usage to avoid exhaustion. |
构造函数接受一个参数 max_acquires,这个参数设定了可以获取的最大资源数量。类内部维护了一个原子变量 acquires_allowed_ 来跟踪当前允许被获取的资源数量,初始值设置为 max_acquires。这里用到了条件编译,NDEBUG 是一个常用的预处理宏,用来指明程序是否在非调试模式下编译。
1 | // Limit maximum number of resources to |max_acquires|. |
如果在调试模式下,就用 max_acquires_ 来记录最大资源数量,同时在 Acquire 和 Release 方法中加入了断言,确保资源的获取和释放操作正确。在生产环境中,当 NDEBUG 被定义时,所有的 assert 调用将被编译器忽略,不会产生任何执行代码。
该类的核心接口是 Acquire 和 Release,这两个方法分别用来获取和释放资源,Acquire 的代码如下:
1 | bool Acquire() { |
这里使用 fetch_sub(1, std::memory_order_relaxed) 原子地减少 acquires_allowed_ 的值,并返回减少前的值 old_acquires_allowed。如果 old_acquires_allowed 大于0,说明在减少之前还有资源可以被获取,因此返回 true。如果没有资源可用(即 old_acquires_allowed 为0或负),则通过 fetch_add(1, std::memory_order_relaxed) 原子地将计数器加回1,恢复状态,并返回 false。
Release 方法用来释放之前通过 Acquire 方法成功获取的资源。它使用 fetch_add(1, std::memory_order_relaxed) 原子地增加 acquires_allowed_ 的值,表示资源被释放,同时用断言保证 Release 的调用次数不会超过 Acquire 的成功次数,防止资源计数错误。
这里在操作原子计数的时候,使用的是 std::memory_order_relaxed,表明这些原子操作不需要对内存进行任何特别的排序约束,只保证操作的原子性。这是因为这里的操作并不依赖于任何其他的内存操作结果,只是简单地递增或递减计数器。
Env 封装接口
除了上面的几个文件操作类来,还有一个重要的 Env 抽象基类,在 Posix 下派生了 PosixEnv,封装了不少实现。
工厂构造对象
首先是几个工厂方法,用于创建前面提到的文件读写对象 SequentialFile、RandomAccessFile 和 WritableFile 对象。NewSequentialFile 工厂方法来创建一个 PosixSequentialFile 文件对象,这里封装了打开文件的调用。这里用工厂方法的好处是,可以在工厂方法中处理一些错误,比如文件打开失败。此外这里入参是 WritableFile**
,支持了多态,如果后续加入其他的 WritableFile 实现,可以在不修改调用代码的情况下,通过修改工厂方法来切换到不同的实现。
1 | Status NewSequentialFile(const std::string& filename, |
这里打开文件时候,传入 flag 除了 O_RDONLY 表示只读外,还有一个 kOpenBaseFlags。kOpenBaseFlags 是一个根据编译选项 HAVE_O_CLOEXEC 来决定是否设置的 flag,如果系统支持 O_CLOEXEC,就会设置这个 flag。O_CLOEXEC 确保在执行 exec() 系列函数时自动关闭文件描述符,从而防止文件描述符泄露到执行的新程序中。
默认情况下,当一个进程创建子进程时,所有的文件描述符都会被子进程继承。除非显式地对每个文件描述符进行处理,否则它们在 exec 执行后仍然会保持打开状态。大多数情况下,如果一个进程打算执行另一个程序(通常通过 exec 系列函数),很有可能不希望新程序访问当前进程的某些资源,特别是文件描述符。O_CLOEXEC 标志确保这些文件描述符在 exec 后自动关闭,从而不会泄露给新程序。虽然 LevelDB 本身不会调用 exec 函数,但是这里还是加上了这个 flag,这是一个良好的防御编程习惯。
当然这个 flag 不一定是所有平台支持,为了跨平台,在 CmakeLists.txt 中,用check_cxx_symbol_exists 来检测当前环境的 fcntl.h 文件是否有 O_CLOEXEC,有的话则定义 HAVE_O_CLOEXEC 宏。这里特别提下,check_cxx_symbol_exists 还挺有用的,可以在编译之前确定特定的特性是否被支持,以便根据检测结果适当调整编译设置或源代码。LevelDB 中有多个宏就是这样检测的,比如 fdatasync、F_FULLFSYNC 等。
1 | check_cxx_symbol_exists(fdatasync "unistd.h" HAVE_FDATASYNC) |
NewWritableFile 和 NewAppendableFile 工厂函数都是类似的,先打开文件,然后创建 PosixWritableFile 对象。不过这里 open 文件的时候,用的不同 flag:
1 | int fd = ::open(filename.c_str(), O_TRUNC | O_WRONLY | O_CREAT | kOpenBaseFlags, 0644); |
O_TRUNC 表示如果文件存在,就将文件长度截断为 0。O_APPEND 表示在写入数据时,总是将数据追加到文件末尾,而不是覆盖文件中已有的数据。
NewRandomAccessFile 稍微复杂了一些,因为要支持两种随机读的模式。首先打开文件拿到 fd,然后根据 mmap_limiter_ 来限制内存映射打开文件数量,如果超过 mmap 限制,就用 pread 来随机读。没超过限制的话,就用 mmap 来内存映射文件,拿到映射的地址和文件大小,然后创建 PosixMmapReadableFile 对象。
1 | Status NewRandomAccessFile(const std::string& filename, |
这里 mmap_limiter_ 限制的最大文件数量由 MaxMmaps 函数获得。对于64位系统,由于有非常大的虚拟内存地址空间(实际应用中通常超过 256TB),因此 LevelDB 允许分配 1000 个内存映射区,应该不会对系统的整体性能产生显著影响。而对于32位系统,由于虚拟内存地址空间有限,LevelDB 不允许分配内存映射区。
1 | // Up to 1000 mmap regions for 64-bit binaries; none for 32-bit. |
文件工具类
除了上面几个核心的文件类,Env 还提供了一系列文件操作的接口,包括文件元信息获取、文件删除等,刚好可以借此来熟悉下 Posix 环境下的各种系统调用。
FileExists: 判断 当前进程是否可以访问该文件(不能访问不代表文件不存在) ,通过调用系统调用 access() 实现;
RemoveFile: 如果没有任何进程正在使用该文件(即没有任何打开的文件描述符指向这个文件),则会删除该文件。通过系统调用 unlink() 实现,unlink 实际上删除的是文件名和其对应 inode 之间的链接。如果这个 inode 没有其他链接,并且没有任何进程打开这个文件,文件实际的数据块和 inode 才会被释放。
GetFileSize: 获取文件的大小,如果文件不存在或者获取失败,返回 0。这里通过 stat 系统调用实现。调用 stat 函数时,需要传递文件名和一个 stat 结构体的指针。系统会检查文件名对应的路径权限,然后获取文件的 inode。inode 是文件系统中的一个数据结构,保存了文件的元数据,包括文件大小、权限、创建时间、最后访问时间等。在文件系统会保持一个 inode 表,用于快速查找和访问 inode 信息,对于大部分文件系统(如 EXT4, NTFS, XFS 等)来说,通常会在内存中缓存常用的 inode,因此获取 inode 一般会十分高效。
RenameFile: 重命名文件或者文件夹,这里可以指定新旧文件名,通过系统调用 rename() 实现。
CreateDir: 创建一个目录,默认权限是 755。这里通过系统调用 mkdir() 实现,如果 pathname 已经存在,这里返回失败。
RemoveDir: 删除一个目录,这里通过系统调用 rmdir() 实现。
GetChildren: 稍微复杂一点,通过系统调用 opendir 获得目录,然后用 readdir 遍历其中的文件,最后还要记得 closedir 来清理资源。
文件操作总结
不得不说,一个简单的文件操作封装,包含了不少实现细节,这里简单总结下吧:
- 缓冲区优化: 在 WritableFile 实现中使用了内存缓冲区,可以合并小型写入操作,减少系统调用次数,提高写入效率。
- 资源限制管理: 使用 Limiter 类来限制同时打开的文件描述符数量和内存映射(mmap)数量,通过设置合理的限制上限,避免资源耗尽,提高系统稳定性和性能。
- 灵活的读取策略: 对于随机读取,LevelDB 提供了基于 pread 和 mmap 两种实现,可以根据系统资源情况动态选择最合适的方式。
- 工厂方法模式: 使用工厂方法创建文件对象,封装了文件打开等操作,方便错误处理和未来的扩展。
- 跨平台兼容性: 通过条件编译和特性检测(如 O_CLOEXEC 的检查),保证了代码在不同平台上的兼容性。
- 同步机制: 提供了 Flush 和 Sync 接口,允许用户根据需要选择不同级别的数据持久化保证。
除了封装文件操作,Env 里面还有其他封装,下篇见吧。