LevelDB是google開發的,一個速度很是快的KeyValue持久化存儲庫,key和value能夠是任意的byte數組,而且這種映射關係按key排序。git
計數器功能,更新很是頻繁,且數據不可丟失github
測試庫共100w行記錄,每條記錄16字節的key,100字節的value,壓縮後的value大概50字節算法
上述性能都是在沒有打開「壓縮」功能下的結果,若是打開「壓縮」選項,性能會有所提高,例如隨機讀性能會提高至11.602微秒,即8.5w次每秒數據庫
Google的leveldb是個很優秀的存儲引擎,但仍是有一些不盡人意的地方,好比leveldb不支持多線程合併,對key範圍查找的支持還很簡單,未作優化措施,等等。數組
而Facebook的RocksDB是個更彪悍的引擎,其實是在LevelDB之上作的改進,在用法上與LevelDB很是的類似。緩存
現代開源市場上有不少數據庫都在使用 RocksDB 做爲底層存儲引擎,好比TiDB。性能優化
在講述LevelDB的實現時,我想從數據存儲和檢索開始論述,討論不一樣結構的存儲方式的演進與區別。bash
在最基礎的層面,一個數據庫應該能作兩件事:微信
咱們嘗試實現一個最簡單的KeyValue持久化數據庫,支持如下功能:數據結構
新建項目SimpleDB,構建一個Server,支持
@GetMapping(path = "set")
public String set(@RequestParam("key") String key, @RequestParam("value") String value) {
if (StringUtils.isEmpty(key) || StringUtils.isEmpty(value)) return "FALSE";
String recordLine = buildRecordLine(key, value);
FileUtil.writeString(recordLine, PATH, UTF_8, true);
return "OK";
}
複製代碼
@GetMapping(path = "get")
public String get(String key) {
List<String> recordLines = FileUtil.readLines(PATH);
List<Record> targetRecords = recordLines.stream()
.map(line -> JsonUtil.decodeJson(line, Record.class))
.filter(record -> key.equals(record.getKey()))
.collect(Collectors.toList());
return CollectionUtils.isEmpty(targetRecords) ? null : targetRecords.get(targetRecords.size() - 1).getValue();
}
複製代碼
執行如下set請求
http://localhost:8080/set?key=name&value=yuming
http://localhost:8080/set?key=age&value=24
http://localhost:8080/set?key=name&value=yuming2
複製代碼
持久化文件內容
{"key":"name","value":"yuming"}
{"key":"age","value":"24"}
{"key":"name","value":"yuming2"}
複製代碼
執行如下get請求
http://localhost:8080/get?key=name
http://localhost:8080/get?key=age
複製代碼
響應爲
yuming2
24
複製代碼
set操做很是簡單,收到請求追加到持久化文件中,由於是順序寫因此效率會很高。
相似的不少內部基於日誌的數據庫也是將數據追加到文件中,固然真正的數據庫有更多的問題須要處理(如併發控制,回收磁盤空間以免日誌無限增加,處理錯誤與部分寫入的記錄),但基本原理是同樣的。
現有的set方法並不支持,不過只須要簡單改造一下,保證明際上的寫線程只有一個便可。
private static ExecutorService executorService = Executors.newSingleThreadExecutor();
/**
* set接口將收到的Key和Value拼接成KeyValue結構的JSON字符串追加到指定持久化文件中
*
* @param key
* @param value
* @return
*/
@GetMapping(path = "set")
public String set(@RequestParam("key") String key, @RequestParam("value") String value) {
if (StringUtils.isEmpty(key) || StringUtils.isEmpty(value)) return "FALSE";
String recordLine = buildRecordLine(key, value);
Future<?> future = executorService.submit(() -> FileUtil.writeString(recordLine, PATH, UTF_8, true));
try {
future.get();
return "OK";
} catch (Exception e) {
LOGGER.error("writeString error", e);
return "FALSE";
}
}
複製代碼
再來看Get操做,因爲每次查詢都須要遍歷整個日誌文件(O(n)),當日志文件愈來愈大時,Get操做的性能不容樂觀。
第一個思路是能不能加緩存,可是隨着數據量愈來愈多內存明顯是不夠用的。
若是熱數據比較多,大多數key很快被覆蓋,甚至後續咱們提供刪除方法(追加value爲null的行,後續壓縮線程處理)實際上的KeyValue對數沒那麼多,是否是就能使用緩存了呢?
對於這種狀況首先是須要處理緩存和文件內容的數據一致性問題。
第二點就是既然都是熱數據,實際的有效數據根本沒那麼多,咱們爲何要使用緩存,而不是把數據存放在內存中呢?
Redis直接將數據保存在內存中,爲了實現持久化功能也會將數據追加到日誌文件中,日誌文件只在啓動時恢復數據使用(順序讀,且只執行一次)。
這樣Redis就能保證:
因爲數據存儲在內存中,Redis也可以實現如下功能:
不是全部的場景都可以將數據全存儲在內存中,那麼咱們仍是須要探索一下有沒有其餘方案。
爲了高效查找數據庫中特定鍵的值,咱們須要一個數據結構:索引(index)。
索引背後的大體思想是,保存一些額外的元數據做爲路標,幫助你找到想要的數據。若是你想在同一份數據中以幾種不一樣的方式進行搜索,那麼你也許須要不一樣的索引,建在數據的不一樣部分上。
索引是從主數據衍生的附加結構,這種結構必然不是簡單的追加日誌。許多數據庫容許添加與刪除索引,這不會影響數據的內容,它隻影響查詢和插入的性能。維護額外的結構會產生開銷,特別是在寫入時。
寫入性能很難超過簡單地追加寫入文件,由於追加寫入是最簡單的寫入操做。任何類型的索引一般都會減慢寫入速度,由於每次寫入數據時都須要更新索引。
這是存儲系統中一個重要的權衡:精心選擇的索引加快了讀查詢的速度,可是每一個索引都會拖慢寫入速度。由於這個緣由,數據庫默認並不會索引全部的內容,而須要你經過對應用查詢模式的瞭解來手動選擇索引。你能夠選擇能爲應用帶來最大收益,同時又不會引入超出必要開銷的索引。
咱們繼續優化咱們的SimpleDB,以前有討論對讀操做加緩存,咱們提到對全部數據都加緩存內存是沒法承受的。
爲了減小緩存的大小,咱們能夠不全量緩存,只緩存部分熱數據,使用LRU淘汰不經常使用的緩存。
使用這種方案在緩存未命中的時候須要遍歷整個日誌文件,數據量很大的時候查找時間沒法接受,而且查詢的響應時間區別太大。
既然不能部分緩存,那麼咱們可否壓縮一下咱們的緩存。
咱們以前的緩存構建的是Map<key, value>結構,value的值是最耗費空間的部分,若是咱們將value由實際的值換成實際值在文件中的位置可以節省不少的空間。並且查詢時因爲知道位置因此能夠很快查到對應的數據。
咱們構建的這樣一個結構其實就是哈希索引。
這樣的存儲引擎很是適合每一個鍵的值常常更新的狀況。例如,鍵多是視頻的URL,值多是它播放的次數(每次有人點擊播放按鈕時遞增)。
在這種類型的工做負載中,有不少寫操做,可是沒有太多不一樣的鍵——每一個鍵有不少的寫操做,可是將全部鍵保存在內存中是可行的。
直到如今,咱們只是追加寫入一個文件 —— 因此如何避免最終用完磁盤空間?一種好的解決方案是,將日誌分爲特定大小的段,當日志增加到特定尺寸時關閉當前段文件,並開始寫入一個新的段文件。
而後,咱們就能夠對這些段進行壓縮,以下圖所示。壓縮意味着在日誌中丟棄重複的鍵,只保留每一個鍵的最近更新。
並且,因爲壓縮常常會使得段變得很小(假設在一個段內鍵被平均重寫了好幾回),咱們也能夠在執行壓縮的同時將多個段合併在一塊兒,以下圖所示。段被寫入後永遠不會被修改,因此合併的段被寫入一個新的文件。
凍結段的合併和壓縮能夠在後臺線程中完成,在進行時,咱們仍然能夠繼續使用舊的段文件來正常提供讀寫請求。合併過程完成後,咱們將讀取請求轉換爲使用新的合併段而不是舊段 —— 而後能夠簡單地刪除舊的段文件。
使用哈希索引當數據量不少時咱們沒法將全部索引數據都放入內存,那麼咱們可否只存儲部分索引呢?基於以前的文件結構顯然是沒法支持的。若是咱們的文件內容是有序排列的,是否是就能夠只存儲部分索引呢?
咱們能夠對段文件的格式作一個簡單的改變:咱們要求鍵值對的序列按鍵排序。乍一看,這個要求彷佛打破了咱們使用順序寫入的能力。
咱們把這個格式稱爲排序字符串表(Sorted String Table),簡稱SSTable。咱們還要求每一個鍵只在每一個合併的段文件中出現一次(壓縮過程已經保證)。與使用散列索引的日誌段相比,SSTable有幾個很大的優點:
若是在幾個輸入段中出現相同的鍵,該怎麼辦?請記住,每一個段都包含在一段時間內寫入數據庫的全部值。這意味着一個輸入段中的全部值必須比另外一個段中的全部值更新(假設咱們老是合併相鄰的段)。
當多個段包含相同的鍵時,咱們能夠保留最近段的值,並丟棄舊段中的值。 2. 爲了在文件中找到一個特定的鍵,你再也不須要保存內存中全部鍵的索引。如下圖爲例:假設你正在內存中尋找鍵 handiwork,可是你不知道段文件中該關鍵字的確切偏移量。
然而,你知道 handbag 和 handsome 的偏移,並且因爲排序特性,你知道 handiwork 必須出如今這二者之間。這意味着您能夠跳到 handbag 的偏移位置並從那裏掃描,直到您找到 handiwork(或沒找到)。
您仍然須要一個內存中索引來告訴您一些鍵的偏移量,但它可能很稀疏:每幾千字節的段文件就有一個鍵就足夠了,由於幾千字節能夠很快被掃描。 3. 因爲讀取請求不管如何都須要掃描所請求範圍內的多個鍵值對,所以能夠將這些記錄分組到塊中,並在將其寫入磁盤以前對其進行壓縮(如上圖中的陰影區域所示)。
稀疏內存中索引的每一個條目都指向壓縮塊的開始處。除了節省磁盤空間以外,壓縮還能夠減小IO帶寬的使用。
到目前爲止,可是如何讓你的數據首先被按鍵排序呢?咱們的傳入寫入能夠以任何順序發生。
在磁盤上維護有序結構是可能的(參考「B樹」),但在內存保存則要容易得多。有許多可使用的衆所周知的樹形數據結構,例如紅黑樹或AVL樹。使用這些數據結構,您能夠按任何順序插入鍵,並按排序順序讀取它們。如今咱們可使咱們的存儲引擎工做以下:
寫入時,先將其添加到內存中的平衡樹數據結構(例如,紅黑樹)。這個內存樹有時被稱爲內存表(memtable)。
當內存表大於某個閾值時,將其做爲SSTable文件寫入磁盤。這能夠高效地完成,由於樹已經維護了按鍵排序的鍵值對。新的SSTable文件成爲數據庫的最新部分。當SSTable被寫入磁盤時,寫入能夠繼續到一個新的內存表實例。
爲了提供讀取請求,首先嚐試在內存表中找到關鍵字,而後在最近的磁盤段中,而後在下一個較舊的段中找到該關鍵字。
在後臺會運行合併和壓縮過程以組合段文件並丟棄覆蓋或刪除的值。
這個方案效果很好。它只會遇到一個問題:若是數據庫崩潰,則最近的寫入(在內存表中,但還沒有寫入磁盤)將丟失。爲了不這個問題,咱們能夠在磁盤上保存一個單獨的日誌,每一個寫入都會當即被附加到磁盤上。
該日誌不是按排序順序,但這並不重要,由於它的惟一目的是在崩潰後恢復內存表。每當內存表寫出到SSTable時,相應的日誌均可以被丟棄。
從 LevelDB 中讀取數據其實並不複雜,memtable 和 imm 更像是兩級緩存,它們在內存中提供了更快的訪問速度,若是能直接從內存中的這兩處直接獲取到響應的值,那麼它們必定是最新的數據。
LevelDB 總會將新的鍵值對寫在最前面,並在數據壓縮時刪除歷史數據。
數據的讀取是按照 MemTable、Immutable MemTable 以及不一樣層級的 SSTable 的順序進行的,前二者都是在內存中,後面不一樣層級的 SSTable 都是以 *.ldb 文件的形式持久存儲在磁盤上,而正是由於有着不一樣層級的 SSTable,因此咱們的數據庫的名字叫作 LevelDB。
當 LevelDB 在內存中沒有找到對應的數據時,它纔會到磁盤中多個層級的 SSTable 中進行查找,這個過程就稍微有一點複雜了,LevelDB 會在多個層級中逐級進行查找,而且不會跳過其中的任何層級.
在查找的過程就涉及到一個很是重要的數據結構 FileMetaData:
FileMetaData 中包含了整個文件的所有信息,其中包括鍵的最大值和最小值、容許查找的次數、文件被引用的次數、文件的大小以及文件號,由於全部的 SSTable 都是以固定的形式存儲在同一目錄下的,因此咱們能夠經過文件號輕鬆查找到對應的文件。
查找的順序就是從低到高了,LevelDB 首先會在 Level0 中查找對應的鍵。可是,與其餘層級不一樣,Level0 中多個 SSTable 的鍵的範圍有重合部分的,在查找對應值的過程當中,會依次查找 Level0 中固定的 4 個 SSTable。
可是當涉及到更高層級的 SSTable 時,由於同一層級的 SSTable 都是沒有重疊部分的,因此咱們在查找時能夠利用已知的 SSTable 中的極值信息 smallest/largest 快速查找到對應的 SSTable,
再判斷當前的 SSTable 是否包含查詢的 key,若是不存在,就繼續查找下一個層級直到最後的一個層級 kNumLevels(默認爲 7 級)或者查詢到了對應的值。
這裏描述的算法本質上是LevelDB和RocksDB中使用的關鍵值存儲引擎庫,被設計嵌入到其餘應用程序中。除此以外,LevelDB能夠在Riak中用做Bitcask的替代品。在Cassandra和HBase中使用了相似的存儲引擎,這兩種引擎都受到了Google的Bigtable文檔(引入了SSTable和memtable)的啓發。
最初這種索引結構是由Patrick O'Neil等人描述的。在日誌結構合併樹(或LSM樹)的基礎上,創建在之前的工做上日誌結構的文件系統。基於這種合併和壓縮排序文件原理的存儲引擎一般被稱爲LSM存儲引擎。
Lucene是Elasticsearch和Solr使用的一種全文搜索的索引引擎,它使用相似的方法來存儲它的詞典。全文索引比鍵值索引複雜得多,可是基於相似的想法:在搜索查詢中給出一個單詞,找到說起單詞的全部文檔(網頁,產品描述等)。這是經過鍵值結構實現的,其中鍵是單詞(關鍵詞(term)),值是包含單詞(文章列表)的全部文檔的ID的列表。在Lucene中,從術語到發佈列表的這種映射保存在SSTable類的有序文件中,根據須要在後臺合併。
與往常同樣,大量的細節使得存儲引擎在實踐中表現良好。例如,當查找數據庫中不存在的鍵時,LSM樹算法可能會很慢:您必須檢查內存表,而後將這些段一直回到最老的(可能必須從磁盤讀取每個),而後才能肯定鍵不存在。
爲了優化這種訪問,存儲引擎一般使用額外的Bloom過濾器。 (布隆過濾器是用於近似集合內容的內存高效數據結構,它能夠告訴您數據庫中是否出現鍵,從而爲不存在的鍵節省許多沒必要要的磁盤讀取操做。
還有不一樣的策略來肯定SSTables如何被壓縮和合並的順序和時間。最多見的選擇是大小分層壓實。 LevelDB和RocksDB使用平坦壓縮(LevelDB所以得名),HBase使用大小分層,Cassandra同時支持。
在規模級別的調整中,更新和更小的SSTables前後被合併到更老的和更大的SSTable中。在水平壓實中,關鍵範圍被拆分紅更小的SSTables,而較舊的數據被移動到單獨的「水平」,這使得壓縮可以更加遞增地進行,而且使用更少的磁盤空間。
即便有許多微妙的東西,LSM樹的基本思想 —— 保存一系列在後臺合併的SSTables —— 簡單而有效。即便數據集比可用內存大得多,它仍能繼續正常工做。
因爲數據按排序順序存儲,所以能夠高效地執行範圍查詢(掃描全部高於某些最小值和最高值的全部鍵),而且由於磁盤寫入是連續的,因此LSM樹能夠支持很是高的寫入吞吐量。
剛纔討論的日誌結構索引正處在逐漸被接受的階段,但它們並非最多見的索引類型。使用最普遍的索引結構在1970年被引入,不到10年後變得「無處不在」,B樹經受了時間的考驗。在幾乎全部的關係數據庫中,它們是標準的索引實現。
像SSTables同樣,B樹保持按鍵排序的鍵值對,這容許高效的鍵值查找和範圍查詢。但這就是類似之處的結尾:B樹有着很是不一樣的設計理念。
咱們前面看到的日誌結構索引將數據庫分解爲可變大小的段,一般是幾兆字節或更大的大小,而且老是按順序編寫段。
相比之下,B樹將數據庫分解成固定大小的塊或頁面,傳統上大小爲4KB(有時會更大),而且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬件,由於磁盤也被安排在固定大小的塊中。
每一個頁面均可以使用地址或位置來標識,這容許一個頁面引用另外一個頁面 —— 相似於指針,但在磁盤而不是在內存中。咱們可使用這些頁面引用來構建一個頁面樹,如圖所示。
若是要更新B樹中現有鍵的值,則搜索包含該鍵的頁,更改該頁中的值,並將該頁寫回到磁盤(對該頁的任何引用保持有效)。
若是你想添加一個新的鍵,你須要找到其範圍包含新鍵的頁面,並將其添加到該頁面。若是頁面中沒有足夠的可用空間容納新鍵,則將其分紅兩個半滿頁面,並更新父頁面以解釋鍵範圍的新分區,如圖所示。
該算法確保樹保持平衡:具備 n 個鍵的B樹老是具備 O(log n) 的深度。大多數數據庫能夠放入一個三到四層的B樹,因此你不須要遵追蹤多頁面引用來找到你正在查找的頁面。(分支因子爲 500 的 4KB 頁面的四級樹能夠存儲多達 256TB 。)
B樹的基本底層寫操做是用新數據覆蓋磁盤上的頁面。假定覆蓋不改變頁面的位置;即,當頁面被覆蓋時,對該頁面的全部引用保持完整。這與日誌結構索引(如LSM樹)造成鮮明對比,後者只附加到文件(並最終刪除過期的文件),但從不修改文件。
爲了使數據庫對崩潰具備韌性,B樹實現一般會帶有一個額外的磁盤數據結構:預寫式日誌(WAL, write-ahead-log)(也稱爲重作日誌(redo log))。這是一個僅追加的文件,每一個B樹修改均可以應用到樹自己的頁面上。當數據庫在崩潰後恢復時,這個日誌被用來使B樹恢復到一致的狀態。
更新頁面的一個額外的複雜狀況是,若是多個線程要同時訪問B樹,則須要仔細的併發控制 —— 不然線程可能會看到樹處於不一致的狀態。這一般經過使用鎖存器(latches)(輕量級鎖)保護樹的數據結構來完成。日誌結構化的方法在這方面更簡單,由於它們在後臺進行全部的合併,而不會干擾傳入的查詢,而且不時地將舊的分段原子交換爲新的分段。
儘管B樹實現一般比LSM樹實現更成熟,但LSM樹因爲其性能特色也很是有趣。根據經驗,一般LSM樹的寫入速度更快,而B樹的讀取速度更快。 LSM樹上的讀取一般比較慢,由於它們必須在壓縮的不一樣階段檢查幾個不一樣的數據結構和SSTables。
B樹索引必須至少兩次寫入每一段數據:一次寫入預先寫入日誌,一次寫入樹頁面自己(也許再次分頁)。即便在該頁面中只有幾個字節發生了變化,也須要一次編寫整個頁面的開銷。有些存儲引擎甚至會覆蓋同一個頁面兩次,以避免在電源故障的狀況下致使頁面部分更新。
LSM樹能夠被壓縮得更好,所以常常比B樹在磁盤上產生更小的文件。 B樹存儲引擎會因爲分割而留下一些未使用的磁盤空間:當頁面被拆分或某行不能放入現有頁面時,頁面中的某些空間仍未被使用。因爲LSM樹不是面向頁面的,而且按期重寫SSTables以去除碎片,因此它們具備較低的存儲開銷,特別是當使用平坦壓縮時。
日誌結構存儲的缺點是壓縮過程有時會干擾正在進行的讀寫操做。儘管存儲引擎嘗試逐步執行壓縮而不影響併發訪問,可是磁盤資源有限,因此很容易發生請求須要等待而磁盤完成昂貴的壓縮操做。而B樹的行爲則相對更具可預測性。
壓縮的另外一個問題出如今高寫入吞吐量:磁盤的有限寫入帶寬須要在初始寫入(記錄和刷新內存表到磁盤)和在後臺運行的壓縮線程之間共享。寫入空數據庫時,可使用全磁盤帶寬進行初始寫入,但數據庫越大,壓縮所需的磁盤帶寬就越多。
若是寫入吞吐量很高,而且壓縮沒有仔細配置,壓縮跟不上寫入速率。在這種狀況下,磁盤上未合併段的數量不斷增長,直到磁盤空間用完,讀取速度也會減慢,由於它們須要檢查更多段文件。一般狀況下,即便壓縮沒法跟上,基於SSTable的存儲引擎也不會限制傳入寫入的速率,因此須要進行明確的監控來檢測這種狀況。
B樹的一個優勢是每一個鍵只存在於索引中的一個位置,而日誌結構化的存儲引擎可能在不一樣的段中有相同鍵的多個副本。
這個方面使得B樹在想要提供強大的事務語義的數據庫中頗有吸引力:在許多關係數據庫中,事務隔離是經過在鍵範圍上使用鎖來實現的,在B樹索引中,這些鎖能夠直接鏈接到樹。
B樹在數據庫體系結構中是很是根深蒂固的,爲許多工做負載提供始終如一的良好性能,因此它們不可能很快就會消失。
在新的數據存儲中,日誌結構化索引變得愈來愈流行。沒有快速和容易的規則來肯定哪一種類型的存儲引擎對你的場景更好,因此值得進行一些經驗上的測試。
阿里巴巴業務平臺事業部招聘Java開發:
社招:兩年以上開發經驗,熟悉經常使用框架和中間件,對負責的業務有本身的思考
校招:2021年畢業的同窗,計算機相關專業
聯繫方式:微信 fkx0703