RocksDB 源码分析 – I/O
这篇文章介绍 RocksDB
中的文件 I/O
。
文件 I/O
page cache
操作系统(文件系统)为了提高文件 I/O
性能,会增加一层 page cache
,用于缓存文件数据,大部分读写操作只需要访问 page cache
即可,不需要发起真正的 I/O
,
page size
可用 sysconf(_SC_PAGESIZE)
获取,一般为 4KB
。
第一次读文件时,会发起真正的 I/O
操作,从 disk
(指代各种存储设备)读取保存到 page cache
中,还有 read-ahead
机制,
会将后面的部分内容也一并读取保存在 page cache
中,之后的顺序读只需要返回 cache
中数据即可。
写操作复杂一些,因为 disk
的最小存储单位是 sector
,而这里以 page size
为单位,所以不满足 page size boundaries
的写入要从 disk
中读出到 page cache
中
再修改,否则 write back
时会覆盖原有数据:
- 如果命中
page cache
,直接修改cache
中的数据,标记page
为dirty
;否则 - 如果写入正好落在
page size boundaries
,直接分配page cache
,标记为dirty
;否则 - 读入再修改,标记为
dirty
。
当内存紧缺时,会回收 page cache
。clean page
可以直接回收,dirty page
要 write back
后变为 clean
再回收。
write back
write back
即将 dirty page
写回到 disk
。write()
系统调用对于一般的文件而言(非 O_DIRECT|O_SYNC|O_DSYNC
),只是修改 page cache
,所以只能保证进程挂掉时数据是完好的。
fdatasync()
会将文件对应的 dirty page
写回 disk
,从而保证机器挂掉时数据是完好的,fsync()
除了 dirty page
外,还会把文件 metadata(inode)
写回 disk
。
除了主动 sync
外,操作系统也会帮助 write back
,Linux
有如下几个配置,见sysctl/vm.txt:
dirty_background_ratio
:当dirty pages
占total available memory(free pages + reclaimable pages)
的比例超过该值时,会由后台flusher
线程开始写回,默认为10
。total available memory
不是total system memory
,因为还有unreclaimable pages
。dirty_background_bytes
类似,以字节为单位,设置其中一个,另一个会置零。dirty_writeback_centisecs
:flusher
线程定期写回dirty pages
的周期,单位为1/100s
,默认为500
。dirty_expire_centisecs
:当dirty pages
驻留时间超过该值时,会在下一次flusher
线程工作时写回,单位为1/100s
,默认为3000
。dirty_ratio
:和dirty_background_ratio
意义相同,但是当超过该值时,发起写操作的进程要等待flusher
线程写回部分dirty pages
保证dirty_ratio
满足要求,相当于变为了同步写。 所以dirty_ratio
也就是dirty pages
的上限。dirty_bytes
和dirty_background_bytes
类似。
block layer
block layer
会对 I/O
请求进行合并、排序等调度来提高性能,如电梯算法,然后通过队列下发任务给各个 storage device
,
每个 storage device
各有一个队列来保存 I/O
任务,互不影响。
疑问:
storage device
一般不支持并行写,是按顺序执行任务,那么 flusher
线程、fsync()/fdatasync()
和其他 I/O
操作是如何协调的?虽说都是提交任务,
但应该有优先级或者 flusher
任务的粒度很细,否则其他操作的延迟可能会很大。至于 Redis
作者发现的 fsync() 阻塞 write() 可能就是顺序执行任务导致的,
因为 write()
有可能要发起真正的 I/O
。
O_DIRECT
使用 O_DIRECT
会 bypass page cache
,直接提交到 block layer
,节省了从用户空间到内核空间的拷贝。当程序自己实现了 cache
或者不想污染 page cache
时可以使用 direct I/O
。
但 direct I/O
不能保证 synchronized I/O file/data integrity completion
,即成功返回时不保证数据已写到 disk
,相当于只提交了任务。使用 fsync()/fdatasync()
或 O_SYNC/O_DSYNC
可以保证
数据已写到 disk
(当然 disk
可能也有 disk cache
,但这是操作系统能做到的最大保证了),且不会 bypass page cache
,
见 stackoverflow。
direct I/O
对文件 offset
、buffer
地址和大小有对齐要求,要按照 the logical block size of the underlying storage (typically 512 bytes)
对齐,可通过 ioctl(2) BLKSSZGET
获取,
见 man open(2),很好理解,毕竟 disk
的最小存储单位是 sector
。所以 direct I/O
用起来比较麻烦,比如追加写时数据大小如果不满足对齐要求
就要 padding
到对齐要求再写,后面再写时要把之前那部分重新写一遍,还要找到对应的 offset
。
mmap()
mmap()
的行为比较复杂,而且底层实现也在变化,这里主要讨论 file-backed
、MAP_SHARED
这种。mmap()
让程序以使用内存的形式读取和修改文件,省去了 read()/write()
的调用,原理是建立了页表到文件的映射,
且没有立即读取文件内容,而是访问时触发 page fault
将文件加载到用户空间。
mmap()
不能映射超过文件大小的区域,所以当文件大小发生变化时,就需要重新映射,如果是追加写,要先用 ftruncate()/fallocate()/lseek()
等增大文件大小,然后再映射写。
可能在最初的实现中,mmap()
和 page cache
是不相关的,当触发 page fault
时直接从磁盘加载到用户空间,也就可以减少内核到用户空间的拷贝。
对文件映射的修改只有调用 msync()
或 munmap()
或者内存不够用发生 swap
时才会写回文件,所以使用 mmap()
来写文件时,如果文件大小超过内存可能因 swap
发生很多随机 I/O
,
性能就会很差,而且带宽用的也不高,这时候需要搭配 msync()
和 madvise()
使用:
msync()
:写回指定部分的文件映射,MS_ASYNC
是异步写回,即相当于提交了写任务,MS_SYNC
是同步写回,即写到磁盘才返回。madvise()
:告诉操作系统对映射操作的建议,MADV_DONTNEED
可以释放资源,降低内存使用。
使用 mmap()
来读文件,也需要 madvise()
,否则性能也会差:
MADV_NORMAL
:默认的行为,会发生少量read-ahead
和read-behind
。MADV_SEQUENTIAL
:read-ahead
更多的pages
,而且访问过的page
会被很快释放。MADV_RANDOM
:随机访问模式,不会进行read-ahead
和read-behind
。
mmap()
也有可见性问题,比如使用 mmap()
修改了共享文件映射,那么使用 read()
能否读到;使用 write()
修改了文件,mmap()
能否读到:
- 不同进程相同映射的修改是立即可见的,可用于
IPC
。 mmap()
修改后要调用msync(MS_ASYNC||MS_SYNC)
,read()
才能读到修改。write()
修改后要调用msync(MS_INVALIDATE)
,mmap()
才能读到修改。
但是在 boltdb
中,是用共享文件 mmap()
作为 page pool
,修改是调用 write()
,也没调用 msync(MS_INVALIDATE)
,这是怎么保证可见性的呢?原因是大多数操作系统(包括 Linux
)都采用了 unified virtual memory
,或者叫
unified buffer cache
,也就是 mmap()
和 page cache
会尽可能共享物理内存页,当触发 page fault
时,会先从 page cache
中查找对应的文件 page
,read-ahead
等也是保存在 page cache
(见 mmap, page cache 与 cgroup)。
所以 mmap()
和 read()/write()
是保持一致的(direct-io
有影响吗?),也就不再需要同步,msync()
的用途只用于写回磁盘。这也影响了 mmap()
的写回,之前只有主动 msync()
、munmap()
或者 swap
时写回,
现在因为已经是 dirty page
了,会被操作系统异步写回,fsync()/fdatasync()
也有效。
Writable File
RocksDB
只会用顺序写,支持 direct I/O
和 mmap()
,设置对应的 Options
即可。具体实现就不说了,说几个细节:
mmap()
追加写的时候,要扩大文件大小再重新映射,RocksDB
用的是fallocate()
,在close
的时候要记得ftruncate()
调整大小为真实大小。RocksDB
是读设备文件获取direct I/O
对齐要求的,见io_posix.cc:GetLogicalBufferSize()
。WritableFileWriter
用AlignedBuffer
缓冲写数据,AlignedBuffer
的起始地址和大小都是对齐的,以便使用direct I/O
。- 在一些场景下会先
fallocate()
预先分配空间然后再写,好像可以提高性能并且减少空间浪费,有几个配置也个这个功能有关,如fallocate_with_keep_size
分配空间时不会改变文件大小,可用于提高顺序写性能(为啥):This allows for pre-allocation of space on devices where it can result in less file fragmentation and/or less waste from over-zealous filesystem pre-allocation.
After a successful call, subsequent writes into the range specified by offset and len are guaranteed not to fail because of lack of disk space.
If the FALLOC_FL_KEEP_SIZE flag is specified in mode, the behavior of the call is similar, but the file size will not be changed even if offset+len is greater than the file size. Preallocating zeroed blocks beyond the end of the file in this manner is useful for optimizing append workloads. - 会用
sync_file_range()
定期刷盘,因为如果只靠操作系统异步刷盘的话,在I/O
很重的情况下可能导致阻塞,原因见上面的 write back。但是最新的1MB
不会刷,为了避免后面再追加写时重复写入。
Readable File
RocksDB
中读文件有顺序读和随机读,顺序读不会用 mmap()
,具体实现也没什么好说的。需要注意的是 readahead
,操作系统默认会做 readahead
,能够提高顺序读的性能,但是对随机读毫无作用,可能还会降低性能,
因为可能会把其他有用的 page
从 page cache
中挤出去,可以用 posix_fadvise(POSIX_FADV_RANDOM)
禁用 readahead
,RocksDB
会对随机访问的文件自己做 readahead
,见 ReadaheadRandomAccessFile
和 FilePrefetchBuffer
。
posix_fadvise(POSIX_FADV_DONTNEED)
用于释放 page cache
,也可以避免有用的 page
被挤出去。
留下评论