轉載自: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
被擠出去。