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里面每個設計細節都可以好好學習學習的,歡迎各位小伙伴私信,一起討論呀~
另外,希望大家關注我的個人公眾號,更多高質量的技術文章等你來白嫖呦~~~