LevelDB,你好~


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中。

后台主要完成的工作:

  1. 將之前內存中的MemTable寫入到SSTable中
  2. 丟棄掉舊的MemTable
  3. 刪除舊的日志(Log)文件和MemTable
  4. 添加新的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數據庫重啟流程

  1. 讀取CURRENT文件中存儲的最近提交的MANIFEST文件名稱
  2. 讀取MANIFEST文件
  3. 清理過期文件
  4. 這一步可以打開所有SSTable,不過,最好使用懶加載,避免內存占用過高
  5. 將日志文件轉換為Level 0級的SSTable
  6. 開始新的寫入請求重定向到新的日志文件中

文件垃圾回收

在每次執行完大合並以及數據庫恢復后,會調用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會添加類型為FIRSTRecord來填充剩余的7個字節,並在后續的Block中寫入用戶數據。

Record格式詳解

Record目前只有四種類型,分別用數字標識,后續會新增其他類型,例如,使用特定數字標識需要跳過的Record數據。

FULL == 1
FIRST == 2
MIDDLE == 3
LAST == 4

FULL類型Record標識該記錄包含用戶的整個數據記錄。

用戶記錄在Block邊界處存儲時,為了明確記錄是否被分割,使用FIRSTMIDDLELAST進行標識。

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的數據格式到底是啥樣,小伙伴們不好奇嘛?趕快一起瞅一眼吧

通過上圖可以清晰的看到BlockRecord之間的關系到底是啥樣?

  1. LevelDB的日志文件將用戶數據切分稱連續的大小為32KB的Block塊;
  2. 每個Block由連續的Log Record構成;
  3. 每個Log Record由CRC32,Length,Type,Content總共4部分構成;

Level日志格式優缺點

人間事,十有八九不如意;人間情,難有白頭不相離。

LevelDB這種日志格式也不可能完美咯,讓我們一起來掰扯掰扯其優缺點吧~

LevelDB日志格式優點

  1. 在日志數據重新同步時,只需要轉到下一個Block繼續掃描即可,如果Block存在有損壞,直接跳到下個Block處理即可。
  2. 當一個日志文件的部分內容作為記錄嵌入到另一個日志文件中時,不需要特殊處理即可使用。
  3. 對於需要在Block邊緣處進行拆分的應用程序(例如,MapReduce),處理時很簡單:找到下個Block邊界並跳過非FIRST/FULL類型記錄,直到找到FULLFIRST類型記錄為止。
  4. 對於較大的記錄,不需要額外的緩沖區即可處理。

LevelDB日志格式缺點

  1. 對於小的Record數據沒有進行打包處理,不過,這個問題可以通過添加Record類型進行處理。
  2. 數據沒有進行壓縮,不過,這個問題同樣可以通過添加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的字符串以及該DataBlockBlockHandle信息
  • 每個SSTable文件的尾部都是一個固定大小的Footer,該Footer包含MetaBlockIndexDataBlockIndexBlockHandle信息以及尾部魔數,中間空余字節使用全零字節進行填充

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偏移有區別。主要有兩個原因:

  1. LevelDB可以存儲任意長度的key和任意長度的value(不同於Innodb,限制每行數據的大小為16384個字節),而同一個key/value鍵值對是不能跨DataBlock存儲的,極端情況下,比如我們的單 個 value 就很大,已經超過了 block_size,那么這種情況,SSTable就無法進行存儲了。所以,通常情況下,實際的DataBlock的大小都是要略微大於options中配置的block_size的;
  2. 如果嚴格按照block_size對齊存儲數據,必然有很多DataBlock需要通過補0的方式進行對齊,肯定會浪費存儲空間;

SSTable檢索邏輯

基於以上實現邏輯,SSTable中的每個DataBlock主要支持兩種方式讀取存儲的Key/Value鍵值對:

  1. 支持順序讀取DataBlock中所有Key/Value鍵值對
  2. 支持給定Key定位其所在的DataBlock,從而實現提高檢索效率。

給定Key,SSTable檢索流程:

遺留問題

不行了,再寫下去,這篇文章字數又要破萬了,寫不動了,下篇文章再說吧、先打個Log,暫時有些問題還沒講清楚,如下:

  • varint64到底是怎么實現的?
  • SSTable中的DataBlock到底是怎么回事?
  • LevelDB提供了哪些基礎的API?

上面這些問題下篇文章再說,另外,我的每篇文章都是自己親手敲滴,圖也是自己畫的,不允許轉載的呦,有問題請私信呦~

其實,感覺LevelDB里面每個設計細節都可以好好學習學習的,歡迎各位小伙伴私信,一起討論呀~

另外,希望大家關注我的個人公眾號,更多高質量的技術文章等你來白嫖呦~~~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM