如今的軟件開發其實大都是面向數據的開發,近些年,我們看到了數不勝數的各種存儲,眼花繚亂。MySQL、Redis、Kafka、HBase、MongoDB、ClickHouse、Elasticsearch、Druid等等,甚至在計算引擎中也會有存儲的出現。不禁感嘆,組件千變萬化!
是否疲於學習各種技術組件?
——夠了!
聽我一句勸,研究永恆的東西,才讓我們立於不敗之地。
不管任何的數據存儲,它做的事情在最根本的角度,只有兩個:
- 給它數據,就把數據存下來
- 隨時可以把數據取出來
可能你會說,
那是不是我們只需要研究組件的API,能夠把數據存下來、取出來就可以了?
No!!!!
雖然,大多數的開發人員不會自己去實現一個存儲引擎,但當今的存儲引擎像萬花筒一樣,RDB、NoSQL、全文檢索、緩存....。到底該選擇哪一種存儲引擎呢?所以,我們很有必要去了解。
最簡單的數據存儲
下面我用Java編寫一個最最簡單的數據存儲。
這里,我使用commons-cli以及commons-io來實現。
/**
* 最簡單的數據庫
*/
public class SimplestDB {
// 數據庫文件名
private static final String DB_FILE_NAME = "./db/db.dat";
public static void main(String[] args) throws ParseException, IOException {
Options options = new Options();
options.addOption("set", true, "插入數據,id和value使用|分隔");
options.addOption("get", true, "獲取數據");
options.addOption("help", false, "幫助");
CommandLineParser parser = new BasicParser();
CommandLine cmd = parser.parse(options, args);
if(cmd.hasOption("get")) {
String id = cmd.getOptionValue("get");
System.out.println(getData(id));
}
else if(cmd.hasOption("set")) {
String idAndValue = cmd.getOptionValue("set");
String[] split = idAndValue.split("\\|");
setData(split[0], split[1]);
}
else if(cmd.hasOption("help")) {
showHelp(options);
}
else {
showHelp(options);
}
}
private static void showHelp(Options options) {
HelpFormatter formatter = new HelpFormatter();
formatter.printHelp( "simple_db", options );
}
private static String getData(String id) throws IOException {
String content = FileUtils.readFileToString(new File(DB_FILE_NAME), "utf-8");
String[] lines = content.split("\n");
return Arrays.stream(lines)
.map(line -> line.split("\\|"))
.filter(pair -> pair[0].equals(id))
.map(pair -> String.format("id=%s,value=%s", pair[0], pair[1]))
.collect(Collectors.joining())
;
}
private static void setData(String id, String value) throws IOException {
FileUtils.writeStringToFile(new File(DB_FILE_NAME)
, String.format("%s|%s\n", id, value)
, StandardCharsets.UTF_8
, true
);
}
}
大家是不是覺得這很可笑?
但確實,它能夠將數據存儲下來,也可以根據id把數據取出來。
在數據量很少的情況下,它是能夠勝任工作的。
但如果數據量很大,getData的查詢性能是很低下的。因為它每次都要將文件讀取出來,然后逐行掃描。它的時間復雜度是:O(n)。
要讓查詢效率,數據存儲通常會使用索引技術來優化查詢。
索引,是通過保留一些額外的元數據,通過這些元數據來快速幫助我們定位數據。
那是不是索引越完善越好呢?
不然!
維護索引需要有額外的開銷,每次寫入操作,都需要更新索引。所以,它會讓寫入效率下降。
所以,存儲系統需要權衡,需要精心選擇、設計索引。而不是把所有的內容都做索引。
Hash索引
Hash與文件offset
我們前面說實現的數據存儲,其實是一種基於key-value的數據存儲。每次存儲或者查詢時,都需要提供一個key(上述是id)。看起來,它非常像哈希表。我們在Java開發中,也經常使用HashMap,而我們所經常使用的HashMap中的數據都是在內存中存儲着。
數據既然能在內存中存儲,是不是也可以在磁盤上存儲呢?
答案是肯定的,內存是一種存儲介質,磁盤也是一種存儲介質,為何不可呢。
我們所存儲的文本文件,都有偏移量的概念(其實,我們在文件操作的時候,很少會關注它)。
寫入數據時,將key與文件偏移量的映射保存下來。
讀取數據時,可以將每個key通過hash,然后映射到文件的偏移量。然后直接從偏移量位置,把數據快速讀取出來。
為了提升效率,將key與文件偏移量的映射加載到內存中(In-memory)。
segment存儲與合並
我們之前的實現,會不斷地追加到一個文件中。針對同一個key,可以會存儲多次。
1|this is a test
1|test
1|test123
...
隨着寫入的數據量越來越大,這個文件也將變得巨大無比。
我們需要想辦法來節省一些空間。
比較好的做法時,當文件大小達到一定大小時,或者寫入的數據條數超過一定大小時,可以新生成一個segment文件。並定期將segment文件進行合並處理。例如,針對上述例子,我們可以只存儲一份數據。
1|test123
看一下圖例:
假如以下是第一個數據文件(segment 1)
1:hadoop | 2:yarn | 3:hdfs | 2:spark | 2:hadoop |
---|---|---|---|---|
3:flink | 3:kylin | 4:hudi | 4:iceberg | 1:hive |
以下是第二個數據文件(segment 2)
2:hadoop | 2:hdfs | 2:spark | 2:flink | 3:hadoop |
---|---|---|---|---|
3:hive | 3:postgres | 4:datalake | 4:presto | 1:parquet |
合並(Compaction - Merg后)
可以看出來,合並后,明顯數據壓縮了很多。
這種做法可以在很多引擎中看到它的身影。
一些重要問題
-
文件格式
如果我們用純文本(CSV)格式存儲數據,它的效率並不是很高。而使用二進制方式存儲會更快。
-
刪除數據
當要刪除數據時,不能直接刪除,因為直接刪除會有大量的磁盤IO。而是設定一個刪除標記,在進行segment 合並時,再刪除。
-
容錯
如果數據庫程序突然crash,那么保存在內存中的映射都將丟失。我們需要重新讀取segment,構建出來Hash Mapping。但如果segment很大,將會需要很長時間的恢復動作,重啟會讓人很痛苦。
-
數據損壞
當在寫入數據時,數據庫突然宕機。此時,數據才寫了一半。需要對文件內容進行校驗和,並忽略掉損壞的數據。
-
並發控制
控制同時只有一個線程能夠寫入到segment。但可以由多個線程並行讀取。
Append-only log
這種設計其實就是不斷地往segment中追加內容,上述的操作IO都是順序執行的。如果需要更新數據,那么就會涉及到隨機寫入。而順序寫入要比隨機寫入快得很多。
另外,如果log是追加的,錯誤恢復起來也會容易很多。
Hash索引的限制
Hash映射需要存儲在內存中,而且Hash映射的數據量不能太大。
那如果數據量真的很大怎么辦呢?
將映射存儲在磁盤上唄!但考慮以下,如果每次都從磁盤讀取,這會有大量的磁盤隨機IO,降低系統效率。
基於范圍的查詢,效率低下。例如:我們想要查詢從1-10的數據。這種操作,必須要將整個Hash Mapping遍歷一遍。因為數據並沒有順序存儲下來。
排序表和LSM樹
排序表
之前在講解Hash索引時,我們提到了用segment存儲數據,這種方式是基於日志的存儲方式,是以Append-only方式存儲的。
而且,數據必須都是以key-value形式存儲的。
注意!
這些數據都是以出現的順序存儲的。
誰先到,就先寫入誰。
因為Hash Index是通過key的hash值來映射文件的offset,
所以,在實際數據存儲時,誰先存儲,誰后存儲。
無所謂!
SSTable是Sorted String Table的縮寫,我們這里就把它稱為排序表吧!
相比於之前segment存儲,它需要確保所有存入到segment文件的key都有字符串有序的。
慢着!
如果要確保key有序,那豈不是隨機存儲嗎?性能不是會大打折扣呢?
問得很好!這個我們在下個小節來聊!
先來看看SSTable的好處,這樣會讓我們更有動力去研究排序表。
1、因為數據是有序的,所以合並的效率特別高
![]() |
---|
我把《算法導論》中歸並排序的merge實現放在此處。有興趣的朋友可以看下。 |
2、因為數據是有序的,可以不用將索引數據全部都存儲在索引中。也就是存儲一部分索引就可以了(稀疏索引)。
![]() |
---|
示意圖 |
大家可以看到,上面只存儲了部分的鍵值。
如果要查詢3,能查到嗎?
有辦法!
因為所有key都是有序存儲的,雖然我們查詢不到3,但可以找到3是處於1-4之間,我們只需要搜索偏移量200-400之間的數據就可以了。通過這種方式,一樣可以很快地把數據查詢出來。
這種方式可以很大程度上減少內存中的索引值。
3、基於第2點好處,對key進行尋址時,都需要去掃描一定范圍的偏移量。那么可以對這個范圍內的數據放到一個組中,然后對該組進行壓縮。再讓key對應的文件offset指向壓縮后的組開始偏移量。這樣可以大大節省磁盤開銷、提升傳輸效率。
![]() |
---|
組壓縮效過更好! |
構建和維護排序表
因為需要將key值在segment中以有序的方式存儲,
但我們知道,如果每次插入一條數據都要去操作磁盤,這對於數據存儲引擎是無法接受的。會對效率大打折扣!
寫磁盤每次都是一次隨機寫入!
但有個更機智的玩法!
寫內存!
在內存中完成所有有序寫操作!
在內存中可以維護一個有序的數據結構,然后保證數據的寫入。
我們馬上想到了——跳表、紅黑樹、AVL樹等等。
這些結構可以任意地插入元素,且始終保證結構是有序的。
寫入排序表操作
假設內存中以紅黑樹實現,當寫入到排序表時:
- 將新寫入的元素新增到紅黑樹中。
- 當紅黑樹的內存大小達到某個閾值時,將紅黑樹中的數據刷入磁盤。——此時,數據都是順序寫入的
讀取數據操作
- 先從內存中的紅黑樹中讀取數據
- 如果沒有找到,再從磁盤segment中檢索數據
合並操作
為了提升磁盤利用效率,在后台運行線程不斷合並segment。
排序表的問題
上面的排序表有效地解決了Hash Index的問題,
是不是一切都OK了呢?
NO!
如果數據存儲引擎崩潰,存儲在內存中的紅黑樹就會徹底丟失!!!!
如何解決這個問題?
LSM樹
為了解決排序表丟失數據的問題,必須要保證在紅黑樹內存中的數據要進行持久化!
也就是寫磁盤!
靈魂發問時間!!!
什么時候寫磁盤?在寫內存之前,還是之后?
=> 當然是之前了!必須在寫內存之前,把數據持久化才不會丟!
寫磁盤不就速度慢了嗎?
=> 確實會比直接寫內存慢。但想想寫的磁盤是順序寫還是隨機寫?
呃...嗯....因為紅黑樹要保證key有序,當然是隨機寫了?
=> 錯!再問你個問題,當前寫日志的目的是什么?
呃...嗯...是解決排序表數據丟失問題?
=> 對!再問你,處理數據丟失需要確保數據有序嗎?
呃...嗯...好像不需要....
=> 哈哈!沒錯,因為只是出現故障時,將數據紅黑樹恢復出來。所以,我們只需要從崩潰的那一刻,回放容錯日志(預寫日志)就可以了!
這就是LSM樹!
了解一些存儲引擎的朋友,一定對LSM樹不會感到陌生!
HBase、Cassandra、RocksDB、LevelDB其實都是基於LSM樹的存儲引擎。
Elasticsearch和Solr底層都是基於Luence來存儲數據,而Lucene的詞條字典也是采用的類似的方法存儲數據。
LSM樹的縮寫為Log-Structured Merge-Tree。而基於LSM樹結構的存儲引擎通常稱為LSM引擎。
B+樹索引
介紹
其實,目前的數據存儲引擎,B樹索引應用最為廣泛。
![]() |
---|
數據來源於DBEngines。 |
絕大多數關系型數據庫、甚至一些非關系型數據庫都在使用B樹索引。
類似於我們前面介紹的排序表,B+樹也是按照key保證有序。因為要支持范圍查詢嘛!
是不是覺得B樹和之前的排序表很像啊?
錯!B+樹的設計理念和LSM樹有着完全不同的設計理念!
之前,我們說探討的日志結構的樹是分解為可變大小的segment存儲,一般一個segment至少幾MB,而B+樹將數據庫分為固定大小的塊(Block)或者頁(Page),一般為4KB,優勢會更大些,然后一次讀取一頁。
這種設計更貼近於操作系統底層,因為磁盤也是存儲在固定大小的塊中。
每個頁都有自己唯一的地址,一個頁可以引用其他頁,就像指針一樣。通過這種方式,可以構建出來一顆樹。這棵樹需要有一個頁稱為B+樹的root。每個頁都包含了幾個key,和對子頁的引用。
而檢索某個key值,其實就是B樹搜索的過程。
![]() |
---|
大家可以去學習下B+樹的檢索過程。 |
更新操作
搜索key,並找到其葉子節點所在的頁,修改葉子節點的值,並將該頁寫回磁盤。
此時,所有應用該頁的數據都將立刻生效。
添加操作
搜索key,找到key對應范圍的頁,並添加。如果頁的空間超過閾值,則拆分成兩個頁,繼續寫入。
為了保證搜索效率,B+樹不能太高,一般是3-4的深度。
而每個頁面說引用的頁面可以是100個以上。
B+樹可靠性
與LSM樹不一樣的是,B+樹索引是以較小的頁存儲的。所以每次寫入,都會用新的數據覆蓋之前的頁。它可以確保數據是完整的,也就是其他應用該頁都可以更新。而LSM索引是Append-Only。
這個操作是有成本的!
因為每次覆蓋都需要將磁盤的磁頭移動到對應的位置。
而如果一個頁寫滿之后,還需要將一個頁分割為兩個頁,然后再更新父頁。
如果這期間出現故障,將會導致索引數據丟失或者損壞!
那損壞后如何恢復呢?
預寫日志啊!
很聰明!
學習過LSM樹,我們已經有經驗了!
數據庫一般稱之為redo log。
每次B+樹的修改,都需要寫redo log,這樣數據庫崩潰之后,還可以通過redo log將數據恢復出來。
是不是以為這就完了?
並沒有!
還需要考慮並發的問題。
如果多個線程要同時寫入B+樹,需要確保數據是一致的!
所以,我們常聽說的鎖就出現了!
最后,對比下LSM樹和B+樹索引。
LSM其特性決定了,它寫入的速度是很快的,因為它都是直接寫入的內存結構,而無需刷盤。但讀取數據通常就不如B+樹了,因為LSM樹得在幾種數據結構、以及不同層次的結構中掃描查詢才行。