轉載自:https://youjiali1995.github.io/rocksdb/io/
這篇文章介紹 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 被擠出去。
