從Bitcask存儲模型談超輕量級KV系統設計與實現

Bitcask介紹

Bitcask是一種“基於日誌結構的哈希表”(A Log-Structured Hash Table for Fast Key/Value Data)

Bitcask 最初作爲分佈式數據庫 Riak 的後端出現,Riak 中的每個節點都運行一個 Bitcask 實例,各自存儲其負責的數據。

拋開論文,我們先通過一篇博客 # Bitcask — a log-structured fast KV store 來了解bitcask的細節信息,下面是簡要的譯文。

Bitcask 設計

Bitcask 借鑑了大量來自日誌結構文件系統和涉及日誌文件合併的設計,例如 LSM 樹中的合併。它本質上是一個目錄,包含固定結構的追加日誌文件和一個內存索引。內存索引以哈希表的形式存儲所有鍵及其對應的值所在數據文件中的偏移量和其他必要信息,用於快速查找到對應的條目。

數據文件

數據文件是追加日誌文件,存儲鍵值對和一些元信息。一個 Bitcask 實例可以擁有多個數據文件,其中只有一個處於活動狀態,用於寫入,其他文件爲只讀文件。

數據文件中的每個條目都有固定的結構,我們可以用類似下面的數據結構來描述:

struct log_entry {
    uint32_t crc;
    uint32_t timestamp;
    uint32_t key_size;
    uint32_t value_size;
    char key[key_size];
    char value[value_size];
};

鍵目錄(KeyDir)

鍵目錄是一個內存哈希表,存儲 Bitcask 實例中所有鍵及其對應的值所在數據文件中的偏移量和一些元信息,例如時間戳,可以用類似下面的數據結構來描述:

struct key_entry {
    uint32_t file_id;
    uint32_t offset;
    uint32_t timestamp;
};

寫入數據

將新的鍵值對存儲到 Bitcask 時,引擎首先將其追加到活動數據文件中,然後在鍵目錄中創建一個新條目,指定值的存儲位置。這兩個動作都是原子性的,意味着條目要麼同時寫入兩個結構,要麼都不寫入。

更新現有鍵值對

Bitcask 直接支持完全替換值,但不支持部分更新。因此,更新操作與存儲新鍵值對非常相似,唯一的區別是不會在鍵目錄中創建新條目,而是更新現有條目的信息,可能指向新的數據文件中的新位置。

與舊值對應的條目現在處於“遊離狀態”,將在合併和壓縮過程中顯式地進行垃圾回收。

刪除鍵

刪除鍵是一個特殊的操作,引擎會原子性地將一個新的條目追加到活動數據文件中,其中值等於一個標誌刪除的特殊值,然後從內存鍵目錄中刪除該鍵的條目。該標誌值非常獨特,不會與現有值空間衝突。

讀取鍵值對

從存儲中讀取鍵值對需要引擎首先使用鍵目錄找到該鍵對應的數據文件和偏移量。然後,引擎從相應的偏移量處執行一次磁盤讀取,檢索日誌條目。檢索到的值與存儲的校驗碼進行正確性檢查,然後將值返回給客戶端。

該操作本身非常快速,只涉及一次磁盤讀取和幾次內存訪問,但可以使用文件系統預讀緩存進一步提高速度。

合併和壓縮

正如我們在更新和刪除操作中看到的,與鍵關聯的舊條目保持原樣,處於“遊離狀態”。這會導致 Bitcask 消耗大量磁盤空間。爲了提高磁盤利用率,引擎會定期將較舊的已關閉數據文件壓縮成一個或多個新數據文件,其結構與現有數據文件相同。

合併過程遍歷 Bitcask 中所有隻讀文件,生成一組數據文件,只包含每個存在的鍵的“最新”版本。

快速啓動

如果 Bitcask 發生故障並需要重啓,它必須讀取所有的數據文件並構建一個新的鍵目錄(KeyDir),如果沒有專門存儲,需要讀取所有文件重建。

其實上面的合併和壓縮操作可以部分緩解這個問題,一方面它們減少了需要讀取的最終會被廢棄的數據量,在合併的同事,可以生成一個hint提示文件,hint記錄了key和key指向的meta信息。 這樣讀取hint文件就可以快速重建鍵目錄(KeyDir)。

*Bitcask 評價

優點

  • 讀寫操作延遲低:Bitcask 的讀寫操作都非常快速,因爲它只需要一次磁盤查找即可檢索任何值。
  • 高寫入吞吐量:Bitcask 的寫入操作是追加式的,並且不需要進行磁盤尋道,因此可以實現高寫入吞吐量。
  • 可預測的查找和插入性能:由於其簡單的設計,Bitcask 的查找和插入性能非常可預測,這對於實時應用程序非常重要。
  • 崩潰恢復快:Bitcask 的崩潰恢復速度很快,因爲它只需要重建 KeyDir 即可。
  • 備份簡單:Bitcask 的備份非常簡單,只需複製數據文件目錄即可。

缺點

  • KeyDir 佔用內存:KeyDir 需要將所有鍵存儲在內存中,這對系統的 RAM 容量提出了較高的要求,尤其是在處理大型數據集時。

解決方案

  • 分片:可以將鍵進行分片,將數據分佈到多個 Bitcask 實例中,從而水平擴展系統並降低對內存的需求。這種方法不會影響基本的 CRUD(Create、Read、Update、Delete)操作。

爲何要考慮自研輕量級KV系統

我們線上的搜索系統,檢索到match的doc後,需要通過id獲取doc的詳情,考慮到數據量級很大,redis首先排除,我們最初的選型是mongodb,在十億級別的數據量時,整體問題不大,但是面向未來更大的數據量級,我們需要考慮更容易維護的方案。

當前mongodb的問題:

  • mongodb的存儲滿了後,擴容較難
  • 每天增量數據寫入,影響讀取性能
  • 三地的集羣,數據的一致性保障並非一件簡單的事情
  • 最重要的,我們的使用場景僅僅是kv查詢,mongodb在這個場景有點大材小用了

爲了解決上面的問題,我們考慮一種數據分版本的方案。

具體來說,對於KV場景,將每個版本的數據,根據特定的hash規則將數據分成多片,每片離線按照Bitcask的思路,生成好hint文件和數據文件,上接一個分佈式服務提供查詢即可。對於增量的數據,只需要按同樣的hash規則,先生成好數據,將數據文件加載即可,這樣可以確保數據的一致。同一組的slot文件,如果一臺機器加載不下,可以多臺機器加載,分佈式服務做好控制即可。查詢時,像對key做hash,然後併發去查詢對應slot的服務即可。

輕量級KV系統設計

實際系統中,數據的key都是int64數據,value是json string,我們來設計hint和data文件格式。在不考慮校驗的情況下,我們可以用最簡單的文件格式來存儲。

離線寫入

hint格式,按照 key,value length,offset 依次寫入。

|  int64  key | int32 value length |  int64 value offset |  ... | int64  key | int32 value length |  int64 value offset |

data 格式,直接append 壓縮後的數據。

|  compressed value bytes | ... |  compressed value bytes |

簡單的java代碼實現:


FileOutputStream dataFileWriter = new FileOutputStream (outputFile);  
FileOutputStream indexFileWriter = new FileOutputStream(outPutIndex);  
long offset = 0;  
int count = 0;  
while (dataList.hasNext()) {  
    Document doc = dataList.next();  
    Long id = doc.getLong("_id");  
    byte[] value = compress(doc.toJson(),"utf-8");  
    byte[] length = intToByteLittle(value.length);  
  
    dataFileWriter.write(value);  
    offset +=  value.length;  
  
    indexFileWriter.write(longToBytesLittle(id));  
    indexFileWriter.write(length);  
    indexFileWriter.write(longToBytesLittle(offset));  
  
    count++;  
    if (count % 10000 == 0) {  
        log.info("already load {} items", count);  
        indexFileWriter.flush();  
        dataFileWriter.flush();  
    }  
}  
  
indexFileWriter.close();  
dataFileWriter.close();

在線讀取

讀取,需要先讀取hint索引文件,加載到內存。

// read index  
FileInputStream indexReader = new FileInputStream(indexFile);  
  
// id 8字節, offset 4字節  
byte[] entry = new byte[8+4+8];  
byte[] idBytes = new byte[8];  
byte[] lengthBytes = new byte[4];  
byte[] offsetBytes = new byte[8];  
Map<Long, Pair<Long, Integer>> id2Offset = new HashMap<>();  
// 這裏直接加載到map裏,可以優化  
while(indexReader.read(entry, 0, entry.length) > 0) {  
  
    System.arraycopy(entry,0, idBytes, 0, 8);  
    System.arraycopy(entry,8, lengthBytes, 0, 4);  
    System.arraycopy(entry,12, offsetBytes, 0, 8);  
    
    long id = EncodingUtils.bytesToLongLittle(idBytes);  
    int dataLength =  EncodingUtils.bytes2IntLittle(lengthBytes);  
    long offset =  EncodingUtils.bytesToLongLittle(offsetBytes);  
    id2Offset.put(id, Pair.of(offset, dataLength));  
}

從數據文件讀取數據也會比較簡單,先從hint數據獲取到key對應的offset和dataLength,然後讀取數據解壓即可。

RandomAccessFile dataFileReader = new RandomAccessFile(dataFile, "r");  
while(true) {  
    System.out.print("請輸入查詢key:");  
    String key =  System.console().readLine();  
    long id = Long.parseLong(key);  
    if (id2Offset.containsKey(id)) {  
        long offset = id2Offset.get(id).getFirst();  
        int dataSize =  id2Offset.get(id).getSecond();  
        dataFileReader.seek(offset);  
        byte[] data = new byte[dataSize];  
        dataFileReader.read(data, 0, data.length);  
        byte[] decodeData = uncompress(data);  
        System.out.println(new String(decodeData));  
    }  
}

上面僅僅是demo性質的代碼,實際過程中還要考慮數據的完整性檢驗,以及LRU緩存等。

總結

沒有最好的K-V系統,只有最適合應用業務實際場景的系統,做任何的方案選擇,要結合業務當前的實際情況綜合權衡,有所取有所舍。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章