LevelDB,你好~
上篇文章初識:LevelDB介紹了啥是LevelDB,LevelDB有啥特性,以及Linux環境下編譯,使用及除錯方法。
這篇文章的話,算是LevelDB原始碼學習的開端吧,主要講下LevelDB的原始碼結構及LevelDB官方給出一些幫助文件內容,對於我個人來說,我感覺搞懂一門技術,不能直接陷到最層原始碼實現,而是先了解其設計原理,然後對照學習底層原始碼時才不會頭昏腦脹~
LevelDB原始碼結構
LevelDB原始碼下載地址:https://github.com/google/leveldb.git。
leveldb-1.22
- cmake
- db LevelDB底層核心程式碼目錄,諸如跳錶,版本,MemTable等實現都在該目錄下
- doc LevelDB的幫助文件
- helpers
- include LevelDB使用時需要引入的標頭檔案
- issues
- port LevelDB底層是接入不同的檔案系統的,這個目錄主要是為了配置不同的檔案系統平臺的
- table LevelDB底層MemTable相關的諸如合併,遍歷,布隆過濾器等相關實現原始碼
- util LevelDB底層實現中的一些工具類,例如hash,status等
- ...
上面對LevelDB原始碼的目錄結構做了基本介紹,原始碼嘛,先不著急看,我們先來看看LevelDB官方給出了哪些幫助文件。
doc
目錄下是LevelDB提供給我們的一些幫助文件,如下圖所示。
leveldb-1.22
- doc
- bench
- benchmark.html # 這兩個檔案呢,是LevelDB與SQLite等KV儲存的效能對比,有興趣的自己去看吧
- impl.md # 這個檔案主要講LevelDB底層實現原理,磁碟上儲存的檔案及大合併設計原理等
- index.md # 這個檔案主要講LevelDB基本API的使用方法
- log_format.md # 這個檔案主要講LevelDB的日誌檔案格式
- table_format.md # 這個檔案主要講LevelDB底層排序表的格式
接下來四部分內容依次對應doc
目錄中後四部分,第一部分效能對比有興趣自己看吧~
LevelDB實現原理
LevelDB基本上是高度復刻了BigTable中的Tablet的,具體Tablet是啥樣子,可以參考初識:BigTable,裡面挺詳細的,不清楚的小夥伴可以先去看下這篇文章。
儘管LevelDB高度復刻了Tablet的設計,然而,在底層檔案組織中,還是與Tablet存在一些不同的。
對於資料庫管理系統來說,每個資料庫最終都是與某個目錄下的一組檔案對應的,對於LevelDB來說,每個資料庫檔案目錄下的檔案大致分為日誌(Log)檔案,排序表(Sorted Table)檔案,Manifest檔案,Current檔案等等。
日誌(Log)檔案
日誌(Log)檔案中儲存一系列順序的更新操作,每次更新操作都會被追加到當前日誌檔案中。
當日志檔案大小達到預定的大小(預設配置為4MB)時,日誌檔案會被轉換為排序表(Sorted Table),同時建立新的日誌檔案,後續的更新操作會追加到新的日誌檔案中。
當前日誌(Log)檔案的拷貝會在記憶體中以跳錶的資料結構形式(稱為MemTable)儲存,每次讀取操作都會先查詢該記憶體中的MemTable,以便所有的讀操作都拿到的是最新更新的資料。
排序表(Sorted Table)
LevelDB中,每個排序表都儲存一組按Key排序的KV鍵值對。每個鍵值對中要麼儲存的是Key的Value,要麼儲存的是Key的刪除標記(刪除標記主要用來隱藏之前舊的排序表中的過期Key)。
排序表(Sorted Tables)以一系列層級(Level)形式組織,由日誌(Log)生成的排序表(Sorted Table)會被放在一個特殊的年輕(young)層級(Level 0),年輕(young)層級的檔案數量超過某個閾值(預設為4個)時,所有年輕(young)層級的檔案與Level 1層級重疊的所有檔案合併在一起,生成一系列新的Level 1層級檔案(預設情況下,我們將每2MB的資料生成一個新的Level 1層級的檔案)。
年輕層級(Level 0)的檔案可能存在重疊的Key,但是,其他級別的每個檔案的Key範圍都是非重疊的。
對於Level L(L>=1)層級的檔案,當L層級的合併檔案大小超過10^LMB(即Level為10MB, Level2為100MB...時進行合併。
將Level L層的一個檔案file{L}與Level L+1層中所有與檔案file{L}存在衝得的檔案合併為Level L+1層級的一組新檔案,這些合併過程會元件將最近的key更新操作與層級最高的檔案通過批量讀寫的方式進行,優點在於,這種方式可以最大程度地減少昂貴的磁碟尋道操作。
清單/列表(Manifest)檔案
單詞Manifest
本意是指清單,檔案列表的意思,這裡指的是每個Level中包含哪些排序表檔案清單。
Manifest檔案會列出當前LevelDB資料庫中每個Level包含哪些排序表(Sorted Table),以及每個排序表檔案包含的Key的範圍以及其他重要的後設資料。
只要重新開啟LevelDB資料庫,就回自動重新建立一個新的Manifest檔案(檔名字尾使用新的編號)。
注意:Manifest會被格式化為log檔案,LevelDB底層檔案發生改變(新增/新建檔案)時會自動追加到該log中。
當前(Current)檔案
當前(CURRENT)檔案是一個文字檔案,改檔案中只有一個檔名(最近生成的Manifest的檔名)。
Info Logs檔案
Informational messages are printed to files named LOG and LOG.old.
其他檔案
在某些特定場景下,LevelDB也會建立一些特定的檔案(例如,LOCK, *.dbtmp等)。
Level 0層級
當日志(Log)檔案大小增加到特定值(預設為4MB)時,LevelDB會建立新的MemTable和日誌(Log)檔案,並且後續使用者寫入的資料及更新操作都會直接寫到新的MemTable和Log中。
後臺主要完成的工作:
- 將之前記憶體中的MemTable寫入到SSTable中
- 丟棄掉舊的MemTable
- 刪除舊的日誌(Log)檔案和MemTable
- 新增新的SSTable到年輕(Level 0)層級
合併基本原理
當Level L層級的大小超過限制時,LevelDB會在後臺進行大合併。
大合併會從Level L選擇一個檔案file{L},假設檔案file{L}的key範圍為[keyMin, keyMax],LevelDB會從Level L+1層級選擇在檔案file{L}的key範圍內的所有檔案files{L+1}與檔案file{L}進行合併。
注意:如果Level L層級中檔案file{L}僅僅與Level L+1層中某個檔案的一部分key範圍重疊,則需要將L+1層級中的整個檔案作為合併的輸入檔案之一進行合併,並在合併之後丟棄該檔案。
注意:Level 0層級是特殊的,原因在於,Level 0層級中的不同檔案的範圍可能是重疊的,這種場景下,如果0層級的檔案需要合併時,則需要選擇多個檔案,以避免出現部分檔案重疊的問題。
大合併操作會對前面選擇的所有檔案進行合併,並生成一系列L+1層級的檔案,當輸出的檔案達到指定大小(預設大小為2MB)時,會切換到新的L+1層級的檔案中;另外,噹噹前輸出檔案的key範圍可以覆蓋到L+2層級中10個以上的檔案時,也會自動切換到新的檔案中;最後這條規則可以確保以後在壓縮L+1層級的檔案時不會從L+2層級中選擇過多的檔案,避免一次大合併的資料量過大。
大合併結束後,舊的檔案(排序表SSTable)會被丟棄掉,新檔案則會繼續對外提供服務。
其實,LevelDB自己有一個版本控制系統,即使在合併過程中,也可以正常對外提供服務的。
特定特定層級的大合併過程會在該層級key範圍內進行輪轉,更直白點說,就是針對每個層級,會自動記錄該層級最後一次壓縮時最大的key值,下次該層級壓縮時,會選擇該key之後的第一個檔案進行壓縮,如果沒有這樣的檔案,則自動回到該層級的key最小的檔案進行壓縮,壓縮在該層級是輪轉的,而不是總是選第一個檔案。
對於制定key,大合併時會刪除覆蓋的值;如果當前合併的層級中,該key存在刪除標記,如果在更高的層級中不存在該key,則同時會刪除該key及該key的刪除標記,相當於該key從資料庫中徹底刪除了!!!
合併耗時分析
Level 0層級合併時,最多讀取該層級所有檔案(預設4個,每個1MB),最多讀取Level 1層級所有檔案(預設10個,每個大小約1MB),則對於Level 0層級合併來說,最多讀取14MB,寫入14MB。
除了特殊的Level 0層級大合併之外,其餘的大合併會從L層級選擇一個2MB的檔案,最壞的情況下,需要從L+1層級中選擇12個檔案(選擇10個檔案是因為L+1層級檔案總大小約是L層的10倍,另外兩個檔案則是邊界範圍,因為層級L的檔案中key範圍通常不會與層級L+1的對齊),總的來說,這些大合併最多讀取26MB,寫入26MB。
假設磁碟IO速率為100MB(現代磁碟驅動器的大致範圍),最差的場景下,一次大合併需要約0.5秒。
假設我們將後臺磁碟寫入速度限制在較小的範圍內,比如10MB/s,則大合併大約需要5秒,假設使用者以10MB/s的速度寫入,則我們可能會建立大量的Level 0級檔案(約50個來容納5*10MB檔案),由於每次讀取時需要合併更多的檔案,則資料讀取成本會大大增加。
解決方案一:為了解決這個問題,在Level 0級檔案數量過多時,考慮增加Log檔案切換閾值,這個解決方案的缺點在於,日誌(Log)檔案閾值越大,儲存相應MemTable所需的記憶體就越大。
解決方案二:當Level 0級檔案數量增加時,需要人為地降低寫入速度。
解決方案三:致力於降低非常廣泛的合併的成本,將大多數Level 0級檔案的資料塊以不壓縮的方式放在記憶體中即可,只需要考慮合併的迭代複雜度為O(N)即可。
總的來說,方案一和方案三合起來應該就可以滿足大多數場景了。
LevelDB生成的檔案大小是可配置的,配置更大的生成檔案大小,可以減少總的檔案數量,不過,這種方式可能會導致較多的突發性大合併。
2011年2月4號,在ext3檔案系統上進行的一個實驗結果顯示,單個檔案目錄下不同檔案數量時,執行100k次檔案開啟平均耗費時間結果如下:
目錄下檔案數量 | 開啟單個檔案平均耗時時間 |
---|---|
1000 | 9 |
10000 | 10 |
100000 | 16 |
從上面的結果來看,單個目錄下,檔案數量小於10000時,開啟檔案平均耗時差不多的,儘量控制單個目錄下檔案數量不要超過1w。
LevelDB資料庫重啟流程
- 讀取CURRENT檔案中儲存的最近提交的MANIFEST檔名稱
- 讀取MANIFEST檔案
- 清理過期檔案
- 這一步可以開啟所有SSTable,不過,最好使用懶載入,避免記憶體佔用過高
- 將日誌檔案轉換為Level 0級的SSTable
- 開始新的寫入請求重定向到新的日誌檔案中
檔案垃圾回收
在每次執行完大合併以及資料庫恢復後,會呼叫DeleteObsoleteFiles()
方法,該方法會檢索資料庫,獲取資料庫中中所有的檔名稱,自動刪除所有不是CURRENT檔案中的日誌(Log)檔案,另外,該方法也會刪除所有未被某個層級引用的,且不是某個大合併待輸出的日誌檔案。
LevelDB日誌(Log)格式
LevelDB日誌檔案是由一系列32KB檔案塊(Block)構成的,唯一例外的是日誌檔案中最後一個Block大小可能小於32KB。
每個檔案塊(Block)是由一系列記錄(Record)組成的,具體格式如下:
block := record* trailer? // 每個Block由一系列Record組成
record :=
checksum: uint32 // type和data[]的crc32校驗碼;小端模式儲存
length: uint16 // 小端模式儲存
type: uint8 // Record的型別,FULL, FIRST, MIDDLE, LAST
data: uint8[length]
注意:如果當前Block僅剩餘6位元組空間,則不會儲存新的Record,因為每個Record至少需要6位元組儲存校驗及長度資訊,對於這些剩餘的位元組,會使用全零進行填充,作為當前Block的尾巴。
注意:如果當前Block剩餘7位元組,且使用者追加了一個資料(data
)長度非零的Record,該Block會新增型別為FIRST的Record來填充剩餘的7個位元組,並在後續的Block中寫入使用者資料。
Record格式詳解
Record目前只有四種型別,分別用數字標識,後續會新增其他型別,例如,使用特定數字標識需要跳過的Record資料。
FULL == 1
FIRST == 2
MIDDLE == 3
LAST == 4
FULL型別Record標識該記錄包含使用者的整個資料記錄。
使用者記錄在Block邊界處儲存時,為了明確記錄是否被分割,使用FIRST,MIDDLE,LAST進行標識。
FIRST型別Record用來標識使用者資料記錄被切分的第一個Record。
LAST型別Record用來標識使用者資料記錄被切分的最後一個Record。
MIDDLE則用來標識使用者資料記錄被切分的中間Record。
例如,假設使用者寫入三條資料記錄,長度分別如下:
Record 1 Length | Record 2 Length | Record 3 Length |
---|---|---|
1000 | 97270 | 8000 |
Record 1將會以FULL型別儲存在第一個Block中;
Record 2的第一部分資料長度為31754位元組以FIRST型別儲存在第一個Block中,第二部分資料以長度為32761位元組的MIDDLE型別儲存在第二個Block中,最易一個長度為32761位元組資料以LAST型別儲存在第三個Block中;
第三個Block中剩餘的7個位元組以全零方式進行填充;
Record 3則將以Full型別儲存在第三個Block的開頭;
Block格式詳解
上述可以說是把Record格式的老底掀了個底掉,下面給出Block的資料格式到底是啥樣,小夥伴們不好奇嘛?趕快一起瞅一眼吧
通過上圖可以清晰的看到Block與Record之間的關係到底是啥樣?
- LevelDB的日誌檔案將使用者資料切分稱連續的大小為32KB的Block塊;
- 每個Block由連續的Log Record構成;
- 每個Log Record由CRC32,Length,Type,Content總共4部分構成;
Level日誌格式優缺點
人間事,十有八九不如意;人間情,難有白頭不相離。
LevelDB這種日誌格式也不可能完美咯,讓我們一起來掰扯掰扯其優缺點吧~
LevelDB日誌格式優點
- 在日誌資料重新同步時,只需要轉到下一個Block繼續掃描即可,如果Block存在有損壞,直接跳到下個Block處理即可。
- 當一個日誌檔案的部分內容作為記錄嵌入到另一個日誌檔案中時,不需要特殊處理即可使用。
- 對於需要在Block邊緣處進行拆分的應用程式(例如,MapReduce),處理時很簡單:找到下個Block邊界並跳過非FIRST/FULL型別記錄,直到找到FULL或FIRST型別記錄為止。
- 對於較大的記錄,不需要額外的緩衝區即可處理。
LevelDB日誌格式缺點
- 對於小的Record資料沒有進行打包處理,不過,這個問題可以通過新增Record型別進行處理。
- 資料沒有進行壓縮,不過,這個問題同樣可以通過新增Record型別進行處理。
額(⊙o⊙)…看起來,好像沒有啥缺點,O(∩_∩)O哈哈~
個人感覺哈,對於日誌來說,LevelDB的這種格式問題不大,畢竟,日誌(例如,WAL)等通常存在磁碟上,一般情況下,也會做定期清理,對系統來說,壓力不會太大,也還行,問題不大。
LevelDB Table Format
SSTable全稱Sorted String Table
,是BigTable,LevelDB及其衍生KV儲存系統的底層資料儲存格式。
SSTable儲存一系列有序的Key/Value鍵值對,Key/Value是任意長度的字串。Key/Value鍵值對根據給定的比較規則寫入檔案,檔案內部由一系列DataBlock構成,預設情況下,每個DataBlock大小為4KB,通常會配置為64KB,同時,SSTable儲存會必要的索引資訊。
每個SSTable的格式大概是下面下面這個樣子:
<beginning_of_file>
[data block 1]
[data block 1]
... ...
[data block N]
[meta block 1]
... ...
[meta block K] ===> 後設資料塊
[metaindex block] ===> 後設資料索引塊
[index block] ==> 索引塊
[Footer] ===> (固定大小,起始位置start_offset = filesize - sizeof(Footer))
<end_of_file>
SSTable檔案中包含檔案內部指標,每個檔案內部指標在LevelDB原始碼中稱為BlockHandle,包含以下資訊:
offset: varint64
size: varint64 # 注意,varint64是可變長64位整數,這裡,暫時不詳細描述該型別資料的實現方式,後續再說
- SSTable中的key/value鍵值對在底層檔案中以有序的方式儲存在一系列DataBlock中,這些DataBlock在檔案開頭處順序儲存,每個資料塊的實現格式對應LevelDB原始碼中的
block_builder.cc
檔案,每個資料塊可以以壓縮方式儲存 - DataBlock後面儲存了一系列後設資料塊(MetaBlock),後設資料塊格式化方式與DataBlock一致
- MetaIndex索引塊(MetaBlockIndex),該索引塊中每項對應一個後設資料塊的資訊,包括後設資料塊名稱及後設資料塊在檔案中的儲存位置資訊(即前面提到的BlockHandle)
- DataIndex索引塊(DataBlockIndex),該索引塊中每項(Entry)對應一個資料塊資訊,每項資訊中包含一個大於等於DataBlock中最大的Key且小於後續DataBlock中第一個Key的字串以及該DataBlock的BlockHandle資訊
- 每個SSTable檔案的尾部都是一個固定大小的Footer,該Footer包含MetaBlockIndex及DataBlockIndex的BlockHandle資訊以及尾部魔數,中間空餘位元組使用全零位元組進行填充
Footer的格式大概是下面這個樣子:
metaindex_handle: char[p]; // MetaDataIndex的BlockHanlde資訊
index_handle: char[q]; // DataBlockIndex的BlockHandle資訊
padding: char[40-q-p]; // 全零位元組填充
// (40==2*BlockHandle::kMaxEncodedLength)
magic: fixed64; // == 0xdb4775248b80fb57 (little-endian)
注意:metaindex_handle和index_handle最大佔用空間為40位元組,本質上就是varint64最大佔用位元組導致,後續,抽時間將varint64時再給大家好好掰扯掰扯~
SSTable格式圖文詳解
上面全是文字描述,有點不是特別好懂,這裡呢,給大家看下我畫的一張圖,可以說是非常的清晰明瞭~
每個SSTable檔案包含多個DataBlock,多個MetaBlock,一個MetaBlockIndex,一個DataBlockIndex,Footer。
Footer詳解:
Footer長度固定,48個位元組,位於SSTable尾部;
MetaBlockIndex的OffSet和Size及DataBlockIndex的OffSet和Size分別組成BlockHandle型別,用於在檔案中定址MetaBlockIndex與DataBlockIndex,為了節省磁碟空間,使用varint64編碼,OffSet與Size分別最少佔用1個位元組,最多佔用10個位元組,兩個BlockHandle佔用的位元組數量少於40時使用全零位元組進行填充,最後8個位元組放置SSTable魔數。
例如,DataBlockIndex.offset==64, DataBlockIndex.size=216,表示DataBlockIndex位於SSTable的第64位元組到第280位元組。
DataBlock詳解:
每個DataBlock預設配置4KB大小,通常推薦配置64KB大小。
每個DataBlock由多個RestartGroup,RestartOffSet集合及RestartOffSet總數,Type,CRC構成。
每個RestartGroup由K個RestartEntry組成,K可以通過options配置,預設值為16,每16個Key/Value鍵值對構成一個RestartGroup;
每個RestartEntry由共享位元組數,非共享位元組數,Value位元組數,Key非共享位元組陣列,Value位元組陣列構成;
DataBlockIndex詳解:
DataBlockIndex包含DataBlock索引資訊,用於快速定位到給定Key所在的DataBlock;
DataBlockIndex包含Key/Value,Type,CRC校驗三部分,Type標識是否使用壓縮演算法,CRC是Key/Value及Type的校驗資訊;Key的取值是大於等於其索引DataBlock的最大Key且小於下一個DataBlock的最小Key,Value是BlockHandle型別,由變長的OffSet和Size組成。
兩個有意思的問題
為什麼DataBlockIndex中Key不採用其索引的DataBlock的最大Key?
主要是為了節省儲存空間,假設該Key其索引的DataBlock的最大Key是"acknowledge",下一個block最小的key為"apple",如果DataBlockIndex的key採用其索引block的最大key,佔用長度為len("acknowledge");採用後一種方式,key值可以為"ad"("acknowledge" < "ad" < "apple"),長度僅為2,且檢索效果是一樣的。
為什麼BlockHandle的offset和size的單位是位元組數而不是DataBlock?
SSTable中的DataBlock大小是不固定的,儘管option中可以指定block_size引數,但SSTable中儲存資料時,並未嚴格按照block_size對齊,所以offset和size指的是偏移位元組數和長度位元組數;這與Innodb中的B+樹索引block偏移有區別。主要有兩個原因:
- LevelDB可以儲存任意長度的key和任意長度的value(不同於Innodb,限制每行資料的大小為16384個位元組),而同一個key/value鍵值對是不能跨DataBlock儲存的,極端情況下,比如我們的單 個 value 就很大,已經超過了 block_size,那麼這種情況,SSTable就無法進行儲存了。所以,通常情況下,實際的DataBlock的大小都是要略微大於options中配置的block_size的;
- 如果嚴格按照block_size對齊儲存資料,必然有很多DataBlock需要通過補0的方式進行對齊,肯定會浪費儲存空間;
SSTable檢索邏輯
基於以上實現邏輯,SSTable中的每個DataBlock主要支援兩種方式讀取儲存的Key/Value鍵值對:
- 支援順序讀取DataBlock中所有Key/Value鍵值對
- 支援給定Key定位其所在的DataBlock,從而實現提高檢索效率。
給定Key,SSTable檢索流程:
遺留問題
不行了,再寫下去,這篇文章字數又要破萬了,寫不動了,下篇文章再說吧、先打個Log,暫時有些問題還沒講清楚,如下:
- varint64到底是怎麼實現的?
- SSTable中的DataBlock到底是怎麼回事?
- LevelDB提供了哪些基礎的API?
上面這些問題下篇文章再說,另外,我的每篇文章都是自己親手敲滴,圖也是自己畫的,不允許轉載的呦,有問題請私信呦~
其實,感覺LevelDB裡面每個設計細節都可以好好學習學習的,歡迎各位小夥伴私信,一起討論呀~
另外,希望大家關注我的個人公眾號,更多高質量的技術文章等你來白嫖呦~~~