作者:Jeff Dean, Sanjay Ghemawat
譯者:phylips@bmy
Files
LevelDB的實現本質上類似於Bigtable中的tablet(參見Bigtable論文5.3節)。但是,與論文中的具體的文件組織方式稍有不同,解釋如下:
每個數據庫由一組存儲在指定目錄下的一個文件集合組成。有如下幾種文件類型:
Log files
日誌文件(*.log)存儲了最近的一系列更新。每個更新操作會被追加到當前的日誌文件中。當日志文件達到預定義的大小後,會被轉化爲一個sorted table,同時一個新的日誌文件會被創建出來以接受未來的更新。
同時會在一個內存結構(memtable)中保存一份當前的日誌文件的一個copy。該copy會參與到每次讀操作中,這樣最新的已日誌化的更新就能夠反映到讀操作中。
Sorted tables
sorted table (*.sst) 存儲了一系列根據key值排好序的記錄。每條記錄要麼是某key值對應的value,要麼是一個針對該key值的刪除標記。(刪除標記會被用來去掉那些老的sorted tables中已過時的value值)。
sorted tables集合是通過一系列的level來進行組織的。從日誌文件生成的sorted table會被放置在一個特殊的young level(也稱作level 0)內。當該level下的文件數超過一定閾值(當前是4)時,該level下的所有文件會與level 1下與之有重疊的那些文件merge到一塊,從而產生一系列新的level 1下的文件(我們會爲每2MB的數據創建一個level 1文件)。
在level 0下的不同文件可能包含重疊的key值。但是其他level下的文件,它們的key range都是不重疊的(是指同一個level內部的文件不會重疊,不同的level之間會存在重疊)。設現有level L(L>=1),當level L下的文件總大小超過(10^L)MB時(比如,對應level 1就是10MB,level 2就是100MB,…),level L下的一個文件就會與level L+1下的所有與它有重疊的key的文件merge成一系列level L+1下的新文件。爲最小化昂貴的seek開銷,這些merge操作通過批量的讀寫操作,逐步的將新的更新從young level遷移到最大的level下。
Manifest
MANIFEST文件列出了組成每個level的sorted tables集合,對應的key range,以及其他一些重要的元數據。只要數據庫被重新打開,就會產生一個新的MANIFEST文件(文件名會嵌入一個新的數字)。MANIFEST文件會被格式化爲一個log,所有導致狀態改變(文件增加或刪除)的變更都會追加到該log裏。
Current
CURRENT 是一個包含了最新的MANIFEST文件名稱的文本文件。
Info logs
一些信息會被輸出到名爲LOG及LOG.old的文件中。
Others
其他用於各種目的的可能會產生的文件 (LOCK, *.dbtmp).
Level 0
當日志文件超過一定大小(默認1MB)時,會進行如下操作:
- 創建一個全新的memtable結構和log文件,同時將未來的更新指向它們
- 同時在後臺進行如下操作:
- 將之前的memtable的內容寫入到sstable裏
- 丟棄該memtable
- 刪除老的log文件及memtable
- 將新的sstable添加到level 0下
Compactions
當level L的大小超過自身的限制時,我們會在一個後臺線程中進行compact操作。該操作會選擇來自level L的一個文件,以及那些level L+1中所有與該文件重疊的文件。需要注意的是,即使level L下的這個文件只與level L+1下的某文件的一部分有重疊,也需要讀取整個level L+1下的那個文件進行compaction,之後這個舊的level L+1下的文件就會被丟棄。另外:因爲level 0很特殊(文件相互之間可能是有重疊的),因此對於從level 0到level 1的compaction需要特殊對待:當level 0的某些文件相互重疊時,它的compaction就需要選擇不止一個level 0的下的文件。Compaction會merge它選定的那些文件產生一系列新的level L+1下的文件。噹噹前輸出文件大小達到目標大小(2MB)時,我們就會產生出一個新的level L+1下的文件。另外噹噹前輸出文件的key range已經大的與10個以上的level L+2下的文件有重疊時,我們也會立即產生出一個新文件,而不一定非要等到它達到2MB,這是爲了保證後面針對level L+1的Compaction不會從level L+2下獲取過多數據。
老的文件會被丟棄,新的文件會被添加到服務狀態。針對特定level下的Compaction是在整個key值空間內螺旋式地進行的。詳細來說,比如對於Level L,我們會記住它上次compaction的那個最後的key值,在對於Level L的下次compaction時,我們會選擇排在該key之後的第一個文件開始(如果沒有這樣的文件,那我們就再從頭開始)。
Compaction會丟棄掉被覆蓋的那些value值。同時如果更高level下的文件的key range不包含當前key時,針對它的刪除標記也可以被丟棄掉{!更高level下的文件實際上是一些更老的值,如果它們包含該key,那麼如果我們丟棄該低level下的刪除標記,會導致該刪除操作的丟失}
Timing
Level 0的compaction可能會從level 0下讀取最多4個1MB文件,以及最壞情況下會讀取所有的level 1下的文件(10MB),這意味着,我們可能需要讀14MB,寫14MB。
除了比較特殊的Level 0的compaction,其他情況下我們會選取level L下的一個2MB的文件。最壞情況下,level L+1下可能有12個文件與它重疊(10是因爲level L+1比level L大10倍,另外的2是因爲在level L下的文件邊界通常與level L+1下的邊界並不是對其的)。因此compaction會讀26MB,寫26MB。假設磁盤IO帶寬是100MB/s,最壞情況下的compaction可能需要大概0.5秒。
如果我們對後臺操作進行一些限制,比如限制在全部IO帶寬的10%,那麼compaction時間可能會達到5秒。如果用戶在用戶以10MB/s的速度寫入,我們就可能會創建出大量的level 0下的文件(可能會達到50,因爲compaction需要5秒,而5秒內客戶端已經又寫入了50MB,而每個1MB,因此是50個)。這可能會顯著增加讀操作的開銷,因爲每次讀操作都需要merge更多的文件。
- 解決方案 1:爲了減少這種問題,我們可以在level-0下的文件數很大的時候,增加log切換的閾值。缺點是,閾值越大,相對應的memtable用掉的內存也就會越多。
- 解決方案2: 當level 0下的文件數上升很快時,我們可以人爲地降低寫操作速率。
- 解決方案3: 儘量降低merge的開銷。由於大多數的level 0下的文件的block都已經緩存在cache裏了,因此我們只需要關注merge迭代過程中的O(N)的複雜度。
Number of files
爲降低文件數,我們可以爲更高level下的文件使用更大的文件大小,取代固定的2MB文件。當然這可能導致更多的compactions過程中的波動。另外我們也可以將文件集合劃分到多個目錄下。
2011-02-04,我們在ext3文件系統下做了一個關於目錄下的文件數與文件打開時間的關係的實驗:
Files in directory Microseconds to open a file 1000 9 10000 10 100000 16看起來在現代文件系統中,沒有必要進行目錄切分。
Recovery
- 讀取CURRENT文件找到最新提交的MANIFEST文件名
- 讀取該 MANIFEST文件
- 清空垃圾文件
- 我們可以打開所有的sstable,但是使用惰性加載的方式會更好些…
- 將日誌轉化爲一個新的level 0下的sstable
- Start directing new writes to a new log file with recovered sequence#
- Garbage collection of files
DeleteObsoleteFiles()會在每次compaction結束及recovery結束後調用。它會找到數據庫中所有文件的名稱。刪掉除當前日誌文件的所有日誌文件。刪掉那些不屬於任何level及任何活動的compaction輸出的table文件。
Immutable table文件格式
文件格式如下:
=========== [data block 1] [data block 2] ... [data block N] [meta block 1] ... [meta block K] [metaindex block] [index block] [Footer] (fixed size; starts at file_size - sizeof(Footer))文件包含一些內部指針。每個這樣的指針被稱爲一個BlockHandle,包含如下信息:
- offset: varint64
- size: varint64
- (1)文件內的key/value對序列有序排列,然後劃分到一系列的data blocks裏。這些blocks一個接一個的分佈在文件的開頭。每個data block會根據block_builder.cc裏的代碼進行格式化,然後進行可選地壓縮。
- (2)在數據blocks之後存儲的一些meta blocks,目前支持的meta block類型會在下面進行描述。未來也可能添加更多的meta block類型。每個meta block也會根據block_builder.cc裏的代碼進行格式化,然後進行可選地壓縮。
- (3) A “metaindex” block.會爲每個meta block保存一條記錄,記錄的key值就是meta block的名稱,value值就是指向該meta block的一個BlockHandle。
- (4) An “index” block. 會爲每個data block保存一條記錄,key值是>=對應的data block裏最後那個key值,同時在後面的那個data block第一個key值之前的那個key值,value值就是指向該meta block的一個BlockHandle。
- (5) 文件的最後是一個定長的footer,包含了metaindex和index這兩個blocks的BlockHandle,以及一個magic number。
metaindex_handle: char[p]; // Block handle for metaindex index_handle: char[q]; // Block handle for index padding: char[40-p-q]; // 0 bytes to make fixed length // (40==2*BlockHandle::kMaxEncodedLength) magic: fixed64; // == 0xdb4775248b80fb57 "stats" Meta Block ------------------該meta block包含一系列統計信息。Key就是該統計單元的名稱,value包含一系列統計信息如下:
data size index size key size (uncompressed) value size (uncompressed) number of entries number of data blocks日誌文件格式
日誌文件內容由一系列的32KB blocks組成。唯一的異常是文件的末尾可能只包含一個部分塊。每個block由一系列記錄組成: block := record* trailer? {!‘*’可以看做是正則表達式裏的*,代表0個或n個record。‘?’也是,代表0個或者1個trailer } record := checksum: uint32 // crc32c of type and data[] length: uint16 type: uint8 // One of FULL, FIRST, MIDDLE, LAST data: uint8[length]一條記錄永遠都不會從block的最後6個字節開始(因爲它肯定放不下,看上面的記錄checksum+length+type就佔了7個字節了)。在這裏組成trailer的最左處那些字節,要麼完全是由0字節組成要麼必須被讀取者跳過。
此外: 如果當前block目前只剩下7個字節,然後現在需要添加一個非0長度的記錄,那麼寫入者需要輸出一個FIRST記錄(不包含任何的用戶數據)來填充該block剩餘的7字節的空,然後將用戶數據存放到下一個block裏。
未來可以添加更多的類型。某些讀取者可能會直接skip掉那些它不理解的記錄,其他的一些可能會報告某些數據被skip掉了。
FULL == 1 FIRST == 2 MIDDLE == 3 LAST == 4FULL 類型的記錄包含了完整的用戶記錄
FIRST, MIDDLE, LAST 用於那些被分成多個片段(通常是因爲block的邊界導致的)的用戶記錄的。FIRST表明是用戶記錄的第一個片段,FIRST表明是用戶記錄的最後一個片段,MID表明用戶記錄的中間片段類型。
Example: consider a sequence of user records: A: length 1000 B: length 97270 C: length 8000A會作爲一個FULL類型的記錄存放在第一個block裏。B 會被劃分成三個片段:第一個片段會填充第一個block的剩餘部分, 第二個片段會填充整個的第二個block, 第二個片段會填充第三個block的前面一部分. 最後,第三個block就只剩下6個字節,會作爲trailer而留空。C將會作爲一個FULL類型的記錄存放在第四個block裏。
===================
Some benefits over the recordio format:
- (1) We do not need any heuristics for resyncing – just go to next block boundary and scan. If there is a corruption, skip to the next block. As a side-benefit, we do not get confused when part of the contents of one log file are embedded as a record inside another log file.
- (2) Splitting at approximate boundaries (e.g., for mapreduce) is simple: find the next block boundary and skip records until we hit a FULL or FIRST record.
- (3) We do not need extra buffering for large records.
Some downsides compared to recordio format:
(2) No compression. Again, this could be fixed by adding new record types.
- (1) No packing of tiny records. This could be fixed by adding a new record type, so it is a shortcoming of the current implementation,not necessarily the format.
LevelDB內部實現
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.