從0開始:500行程式碼實現 LSM 資料庫
簡介:LSM-Tree 是很多 NoSQL 資料庫引擎的底層實現,例如 LevelDB,Hbase 等。本文基於《資料密集型應用系統設計》中對 LSM-Tree 資料庫的設計思路,結合程式碼實現完整地闡述了一個迷你資料庫,核心程式碼 500 行左右,通過理論結合實踐來更好地理解資料庫的原理。
作者 | 蕭愷
來源 | 阿里技術公眾號
前言
LSM-Tree 是很多 NoSQL 資料庫引擎的底層實現,例如 LevelDB,Hbase 等。本文基於《資料密集型應用系統設計》中對 LSM-Tree 資料庫的設計思路,結合程式碼實現完整地闡述了一個迷你資料庫,核心程式碼 500 行左右,通過理論結合實踐來更好地理解資料庫的原理。
一 SSTable(排序字串表)
之前基於雜湊索引實現了一個數據庫,它的侷限性是雜湊表需要整個放入到記憶體,並且區間查詢效率不高。
在雜湊索引資料庫的日誌中,key 的儲存順序就是它的寫入順序,並且對於同一個 key 後出現的 key 優先於之前的 key,因此日誌中的 key 順序並不重要。這樣的好處是寫入很簡單,但沒有控制 key 重複帶來的問題是浪費了儲存空間,初始化載入的耗時會增加。
現在簡單地改變一下日誌的寫入要求:要求寫入的 key 有序,並且同一個 key 在一個日誌中只能出現一次。這種日誌就叫做 SSTable,相比雜湊索引的日誌有以下優點:
1)合併多個日誌檔案更加簡單高效。
因為日誌是有序的,所以可以用檔案歸併排序演算法,即併發讀取多個輸入檔案,比較每個檔案的第一個 key,按照順序拷貝到輸出檔案。如果有重複的 key,那就只保留最新的日誌中的 key 的值,老的丟棄。
2)查詢 key 時,不需要在記憶體中儲存所有 key 的索引。
如下圖所示,假設需要查詢 handiwork,且記憶體中沒有記錄該 key 的位置,但因為 SSTable 是有序的,所以我們可以知道 handiwork 如果存在一定是在 handbag 和 handsome 的中間,然後從 handbag 開始掃描日誌一直到 handsome 結束。這樣的好處是有三個:
- 記憶體中只需要記錄稀疏索引,減少了記憶體索引的大小。
- 查詢操作不需要讀取整個日誌,減少了檔案 IO。
- 可以支援區間查詢。
二 構建和維護 SSTable
我們知道寫入時 key 會按照任意順序出現,那麼如何保證 SSTable 中的 key 是有序的呢?一個簡單方便的方式就是先儲存到記憶體的紅黑樹中,紅黑樹是有序的,然後再寫入到日誌檔案裡面。
儲存引擎的基本工作流程如下:
- 當寫入時,先將其新增到記憶體的紅黑樹中,這個記憶體中的樹稱為記憶體表。
-
當記憶體表大於某個閾值時,將其作為 SSTable 檔案寫入到磁碟,因為樹是有序的,所以寫磁碟的時候直接按順序寫入就行。
- 為了避免記憶體表未寫入檔案時資料庫崩潰,可以在儲存到記憶體表的同時將資料也寫入到另一個日誌中(WAL),這樣即使資料庫崩潰也能從 WAL 中恢復。這個日誌寫入就類似雜湊索引的日誌,不需要保證順序,因為是用來恢復資料的。
- 處理讀請求時,首先嚐試在記憶體表中查詢 key,然後從新到舊依次查詢 SSTable 日誌,直到找到資料或者為空。
- 後臺程序週期性地執行日誌合併與壓縮過程,丟棄掉已經被覆蓋或刪除的值。
以上的演算法就是 LSM-Tree(基於日誌結構的合併樹 Log-Structured Merge-Tree) 的實現,基於合併和壓縮排序檔案原理的儲存引擎通常就被稱為 LSM 儲存引擎,這也是 Hbase、LevelDB 等資料庫的底層原理。
三 實現一個基於 LSM 的資料庫
前面我們已經知道了 LSM-Tree 的實現演算法,在具體實現的時候還有很多設計的問題需要考慮,下面我挑一些關鍵設計進行分析。
1 記憶體表儲存結構
記憶體表的 value 儲存什麼?直接儲存原始資料嗎?還是儲存寫命令(包括 set 和 rm )?這是我們面臨的第一個設計問題。這裡我們先不做判斷,先看下一個問題。
記憶體表達到一定大小之後就要寫入到日誌檔案中持久化。這個過程如果直接禁寫處理起來就很簡單。但如果要保證記憶體表在寫入檔案的同時,還能正常處理讀寫請求呢?
一個解決思路是:在持久化記憶體表 A 的同時,可以將當前的記憶體表指標切換到新的記憶體表例項 B,此時我們要保證切換之後 A 是隻讀,只有 B 是可寫的,否則我們無法保證記憶體表 A 持久化的過程是原子操作。
- get 請求:先查詢 B,再查詢 A,最後查 SSTable。
- set 請求:直接寫入 A
- rm 請求:假設 rm 的 key1 只在 A 裡面出現了,B 裡面沒有。這裡如果記憶體表儲存的是原始資料,那麼 rm 請求是沒法處理的,因為 A 是隻讀的,會導致 rm 失敗。如果我們在記憶體表裡面儲存的是命令的話,這個問題就是可解的,在 B 裡面寫入 rm 命令,這樣查詢 key1 的時候在 B 裡面就能查到 key1 已經被刪除了。
因此,假設我們持久化記憶體表時做禁寫,那麼 value 是可以直接儲存原始資料的,但是如果我們希望持久化記憶體表時不禁寫,那麼 value 值就必須要儲存命令。我們肯定是要追求高效能不禁寫的,所以 value 值需要儲存的是命令, Hbase 也是這樣設計的,背後的原因也是這個。
另外,當記憶體表已經超過閾值要持久化的時候,發現前一次持久化還沒有做完,那麼就需要等待前一次持久化完成才能進行本次持久化。換句話說,記憶體表持久化只能序列進行。
2 SSTable 的檔案格式
為了實現高效的檔案讀取,我們需要好好設計一下檔案格式。
以下是我設計的 SSTable 日誌格式:
- 資料區:資料區主要是儲存寫入的命令,同時為了方便分段讀取,是按照一定的數量大小分段的。
- 稀疏索引區:稀疏索引儲存的是資料段每一段在檔案中的位置索引,讀取 SSTable 時候只會載入稀疏索引到記憶體,查詢的時候根據稀疏索引載入對應資料段進行查詢。
- 檔案索引區:儲存資料區域的位置。
以上的日誌格式是迷你的實現,相比 Hbase 的日誌格式是比較簡單的,這樣方便理解原理。同時我也使用了 JSON 格式寫入檔案,目的是方便閱讀。而生產實現是效率優先的,為了節省儲存會做壓縮。
四 程式碼實現分析
我寫的程式碼實現在:TinyKvStore,下面分析一下關鍵的程式碼。程式碼比較多,也比較細碎,如果只關心原理的話可以跳過這部分,如果想了解程式碼實現可以繼續往下讀。
1 SSTable
記憶體表持久化
記憶體表持久化到 SSTable 就是把記憶體表的資料按照前面我們提到的日誌格式寫入到檔案。對於 SSTable 來說,寫入的資料就是資料命令,包括 set 和 rm,只要我們能知道 key 的最新命令是什麼,就能知道 key 在資料庫中的狀態。
/**
* 從記憶體錶轉化為ssTable
* @param index
*/
private void initFromIndex(TreeMap< String, Command> index) {
try {
JSONObject partData = new JSONObject(true);
tableMetaInfo.setDataStart(tableFile.getFilePointer());
for (Command command : index.values()) {
//處理set命令
if (command instanceof SetCommand) {
SetCommand set = (SetCommand) command;
partData.put(set.getKey(), set);
}
//處理RM命令
if (command instanceof RmCommand) {
RmCommand rm = (RmCommand) command;
partData.put(rm.getKey(), rm);
}
//達到分段數量,開始寫入資料段
if (partData.size() >= tableMetaInfo.getPartSize()) {
writeDataPart(partData);
}
}
//遍歷完之後如果有剩餘的資料(尾部資料不一定達到分段條件)寫入檔案
if (partData.size() > 0) {
writeDataPart(partData);
}
long dataPartLen = tableFile.getFilePointer() - tableMetaInfo.getDataStart();
tableMetaInfo.setDataLen(dataPartLen);
//儲存稀疏索引
byte[] indexBytes = JSONObject.toJSONString(sparseIndex).getBytes(StandardCharsets.UTF_8);
tableMetaInfo.setIndexStart(tableFile.getFilePointer());
tableFile.write(indexBytes);
tableMetaInfo.setIndexLen(indexBytes.length);
LoggerUtil.debug(LOGGER, "[SsTable][initFromIndex][sparseIndex]: {}", sparseIndex);
//儲存檔案索引
tableMetaInfo.writeToFile(tableFile);
LoggerUtil.info(LOGGER, "[SsTable][initFromIndex]: {},{}", filePath, tableMetaInfo);
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
寫入的格式是基於讀取倒推的,主要是為了方便讀取。例如 tableMetaInfo 寫入是從前往後寫的,那麼讀取的時候就要從後往前讀。這也是為什麼 version 要放到最後寫入,因為讀取的時候是第一個讀取到的,方便對日誌格式做升級。這些 trick 如果沒有動手嘗試,光看是很難理解為什麼這麼幹的。
/**
* 把資料寫入到檔案中
* @param file
*/
public void writeToFile(RandomAccessFile file) {
try {
file.writeLong(partSize);
file.writeLong(dataStart);
file.writeLong(dataLen);
file.writeLong(indexStart);
file.writeLong(indexLen);
file.writeLong(version);
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
/**
* 從檔案中讀取元資訊,按照寫入的順序倒著讀取出來
* @param file
* @return
*/
public static TableMetaInfo readFromFile(RandomAccessFile file) {
try {
TableMetaInfo tableMetaInfo = new TableMetaInfo();
long fileLen = file.length();
file.seek(fileLen - 8);
tableMetaInfo.setVersion(file.readLong());
file.seek(fileLen - 8 * 2);
tableMetaInfo.setIndexLen(file.readLong());
file.seek(fileLen - 8 * 3);
tableMetaInfo.setIndexStart(file.readLong());
file.seek(fileLen - 8 * 4);
tableMetaInfo.setDataLen(file.readLong());
file.seek(fileLen - 8 * 5);
tableMetaInfo.setDataStart(file.readLong());
file.seek(fileLen - 8 * 6);
tableMetaInfo.setPartSize(file.readLong());
return tableMetaInfo;
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
從檔案中載入 SSTable
從檔案中載入 SSTable 時只需要載入稀疏索引,這樣能節省記憶體。資料區等查詢的時候按需讀取就行。
/**
* 從檔案中恢復ssTable到記憶體
*/
private void restoreFromFile() {
try {
//先讀取索引
TableMetaInfo tableMetaInfo = TableMetaInfo.readFromFile(tableFile);
LoggerUtil.debug(LOGGER, "[SsTable][restoreFromFile][tableMetaInfo]: {}", tableMetaInfo);
//讀取稀疏索引
byte[] indexBytes = new byte[(int) tableMetaInfo.getIndexLen()];
tableFile.seek(tableMetaInfo.getIndexStart());
tableFile.read(indexBytes);
String indexStr = new String(indexBytes, StandardCharsets.UTF_8);
LoggerUtil.debug(LOGGER, "[SsTable][restoreFromFile][indexStr]: {}", indexStr);
sparseIndex = JSONObject.parseObject(indexStr,
new TypeReference< TreeMap< String, Position>>() {
});
this.tableMetaInfo = tableMetaInfo;
LoggerUtil.debug(LOGGER, "[SsTable][restoreFromFile][sparseIndex]: {}", sparseIndex);
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
SSTable 查詢
從 SSTable 查詢資料首先是要從稀疏索引中找到 key 所在的區間,找到區間之後根據索引記錄的位置讀取區間的資料,然後進行查詢,如果有資料就返回,沒有就返回 null。
/**
* 從ssTable中查詢資料
* @param key
* @return
*/
public Command query(String key) {
try {
LinkedList< Position> sparseKeyPositionList = new LinkedList<>();
Position lastSmallPosition = null;
Position firstBigPosition = null;
//從稀疏索引中找到最後一個小於key的位置,以及第一個大於key的位置
for (String k : sparseIndex.keySet()) {
if (k.compareTo(key) <= 0) {
lastSmallPosition = sparseIndex.get(k);
} else {
firstBigPosition = sparseIndex.get(k);
break;
}
}
if (lastSmallPosition != null) {
sparseKeyPositionList.add(lastSmallPosition);
}
if (firstBigPosition != null) {
sparseKeyPositionList.add(firstBigPosition);
}
if (sparseKeyPositionList.size() == 0) {
return null;
}
LoggerUtil.debug(LOGGER, "[SsTable][restoreFromFile][sparseKeyPositionList]: {}", sparseKeyPositionList);
Position firstKeyPosition = sparseKeyPositionList.getFirst();
Position lastKeyPosition = sparseKeyPositionList.getLast();
long start = 0;
long len = 0;
start = firstKeyPosition.getStart();
if (firstKeyPosition.equals(lastKeyPosition)) {
len = firstKeyPosition.getLen();
} else {
len = lastKeyPosition.getStart() + lastKeyPosition.getLen() - start;
}
//key如果存在必定位於區間內,所以只需要讀取區間內的資料,減少io
byte[] dataPart = new byte[(int) len];
tableFile.seek(start);
tableFile.read(dataPart);
int pStart = 0;
//讀取分割槽資料
for (Position position : sparseKeyPositionList) {
JSONObject dataPartJson = JSONObject.parseObject(new String(dataPart, pStart, (int) position.getLen()));
LoggerUtil.debug(LOGGER, "[SsTable][restoreFromFile][dataPartJson]: {}", dataPartJson);
if (dataPartJson.containsKey(key)) {
JSONObject value = dataPartJson.getJSONObject(key);
return ConvertUtil.jsonToCommand(value);
}
pStart += (int) position.getLen();
}
return null;
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
2 LsmKvStore
初始化載入
- dataDir:資料目錄儲存了日誌資料,所以啟動的時候需要從目錄中讀取之前的持久化資料。
- storeThreshold:持久化閾值,當記憶體表超過一定大小之後要進行持久化。
- partSize:SSTable 的資料分割槽閾值。
- indexLock:記憶體表的讀寫鎖。
- ssTables:SSTable 的有序列表,按照從新到舊排序。
- wal:順序寫入日誌,用於儲存記憶體表的資料,用作資料恢復。
啟動的過程很簡單,就是載入資料配置,初始化內容,如果需要做資料恢復就將資料恢復到記憶體表。
/**
* 初始化
* @param dataDir 資料目錄
* @param storeThreshold 持久化閾值
* @param partSize 資料分割槽大小
*/
public LsmKvStore(String dataDir, int storeThreshold, int partSize) {
try {
this.dataDir = dataDir;
this.storeThreshold = storeThreshold;
this.partSize = partSize;
this.indexLock = new ReentrantReadWriteLock();
File dir = new File(dataDir);
File[] files = dir.listFiles();
ssTables = new LinkedList<>();
index = new TreeMap<>();
//目錄為空無需載入ssTable
if (files == null || files.length == 0) {
walFile = new File(dataDir + WAL);
wal = new RandomAccessFile(walFile, RW_MODE);
return;
}
//從大到小載入ssTable
TreeMap< Long, SsTable> ssTableTreeMap = new TreeMap<>(Comparator.reverseOrder());
for (File file : files) {
String fileName = file.getName();
//從暫存的WAL中恢復資料,一般是持久化ssTable過程中異常才會留下walTmp
if (file.isFile() && fileName.equals(WAL_TMP)) {
restoreFromWal(new RandomAccessFile(file, RW_MODE));
}
//載入ssTable
if (file.isFile() && fileName.endsWith(TABLE)) {
int dotIndex = fileName.indexOf(".");
Long time = Long.parseLong(fileName.substring(0, dotIndex));
ssTableTreeMap.put(time, SsTable.createFromFile(file.getAbsolutePath()));
} else if (file.isFile() && fileName.equals(WAL)) {
//載入WAL
walFile = file;
wal = new RandomAccessFile(file, RW_MODE);
restoreFromWal(wal);
}
}
ssTables.addAll(ssTableTreeMap.values());
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
寫入操作
寫入操作先加寫鎖,然後把資料儲存到記憶體表以及 WAL 中,另外還要做判斷:如果超過閾值進行持久化。這裡為了簡單起見我直接序列執行了,沒有使用執行緒池執行,但不影響整體邏輯。set 和 rm 的程式碼是類似,這裡就不重複了。
@Override
public void set(String key, String value) {
try {
SetCommand command = new SetCommand(key, value);
byte[] commandBytes = JSONObject.toJSONBytes(command);
indexLock.writeLock().lock();
//先儲存資料到WAL中
wal.writeInt(commandBytes.length);
wal.write(commandBytes);
index.put(key, command);
//記憶體表大小超過閾值進行持久化
if (index.size() > storeThreshold) {
switchIndex();
storeToSsTable();
}
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
indexLock.writeLock().unlock();
}
}
記憶體表持久化過程
切換記憶體表及其關聯的 WAL:先對記憶體表加鎖,然後新建一個記憶體表和 WAL,把老的記憶體表和 WAL 暫存起來,釋放鎖。這樣新的記憶體表就可以開始寫入,老的記憶體表變成只讀。
執行持久化過程:把老記憶體表有序寫入到一個新的 ssTable 中,然後刪除暫存記憶體表和臨時儲存的 WAL。
/**
* 切換記憶體表,新建一個記憶體表,老的暫存起來
*/
private void switchIndex() {
try {
indexLock.writeLock().lock();
//切換記憶體表
immutableIndex = index;
index = new TreeMap<>();
wal.close();
//切換記憶體表後也要切換WAL
File tmpWal = new File(dataDir + WAL_TMP);
if (tmpWal.exists()) {
if (!tmpWal.delete()) {
throw new RuntimeException("刪除檔案失敗: walTmp");
}
}
if (!walFile.renameTo(tmpWal)) {
throw new RuntimeException("重新命名檔案失敗: walTmp");
}
walFile = new File(dataDir + WAL);
wal = new RandomAccessFile(walFile, RW_MODE);
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
indexLock.writeLock().unlock();
}
}
/**
* 儲存資料到ssTable
*/
private void storeToSsTable() {
try {
//ssTable按照時間命名,這樣可以保證名稱遞增
SsTable ssTable = SsTable.createFromIndex(dataDir + System.currentTimeMillis() + TABLE, partSize, immutableIndex);
ssTables.addFirst(ssTable);
//持久化完成刪除暫存的記憶體表和WAL_TMP
immutableIndex = null;
File tmpWal = new File(dataDir + WAL_TMP);
if (tmpWal.exists()) {
if (!tmpWal.delete()) {
throw new RuntimeException("刪除檔案失敗: walTmp");
}
}
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
查詢操作
查詢的操作就跟演算法中描述的一樣:
- 先從記憶體表中取,如果取不到並且存在不可變記憶體表就從不可變記憶體表中取。
- 記憶體表中查詢不到就從新到舊的 SSTable 中依次查詢。
@Override
public String get(String key) {
try {
indexLock.readLock().lock();
//先從索引中取
Command command = index.get(key);
//再嘗試從不可變索引中取,此時可能處於持久化sstable的過程中
if (command == null && immutableIndex != null) {
command = immutableIndex.get(key);
}
if (command == null) {
//索引中沒有嘗試從ssTable中獲取,從新的ssTable找到老的
for (SsTable ssTable : ssTables) {
command = ssTable.query(key);
if (command != null) {
break;
}
}
}
if (command instanceof SetCommand) {
return ((SetCommand) command).getValue();
}
if (command instanceof RmCommand) {
return null;
}
//找不到說明不存在
return null;
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
indexLock.readLock().unlock();
}
}
總結
知行合一,方得真知。如果我們不動手實現一個數據庫,就很難理解為什麼這麼設計。例如日誌格式為什麼這樣設計,為什麼資料庫儲存的是資料操作而不是資料本身等等。
本文實現的資料庫功能比較簡單,有很多地方可以優化,例如資料持久化非同步化,日誌檔案壓縮,查詢使用布隆過濾器先過濾一下。有興趣的讀者可以繼續深入研究。
參考資料
《資料密集型應用系統設計》
原文連結
本文為阿里雲原創內容,未經允許不得轉載。