【HBase】從MiniBase學LSM算法

MiniBase學習筆記

https://github.com/pierre94/minibase

HBase相對複雜,想要快速啃下來比較困難。而MiniBase吸收了HBase最核心的引擎部分的精華,希望可以通過學習MiniBase以小見大,能夠對自己理解HBase這個龐然大物有所幫助。

  • 原項目: https://github.com/openinx/minibase

  • 資料: 《HBase原理與實踐#設計存儲引擎MiniBase》 https://weread.qq.com/web/reader/632326807192b335632d09ckc51323901dc51ce410c121b

接口

  • put/get/delete (點寫\查\刪)

  • scan(範圍查詢)

核心架構架構

MiniBase是一個標準的LSM樹索引結構,分內存部分和磁盤部分。

 

 

MemStore

  • 客戶端不斷地寫入數據,當MemStore的內存超過一定閾值時,MemStore會flush成一個磁盤文件。

  • MemStore分成MutableMemstore和ImmutableMemstore兩部分

    • MutableMemstore由一個ConcurrentSkipListMap組成,容許寫入

    • ImmutableMemstore也是一個ConcurrentSkipListMap,但是不容許寫入

  • 這裏設計兩個小的MemStore,是爲了防止在f lush的時候,MiniBase無法接收新的寫入。假設只有一個MutableMemstore,那麼一旦進入flush過程,MutableMemstore就無法寫入,而此時新的寫入就無法進行。

從源碼不難看出,其實就是2個ConcurrentSkipListMap kvMap和snapshot,flush的時候將kvMap賦值給snapshot,然後啓動一個新的ConcurrentSkipListMap

DiskStore

基本概念

  • DiskStore,由多個DiskFile組成,每一個DiskFile就是一個磁盤文件。

  • ImmutableMemstore執行完flush操作後,就會生成一個新的DiskFile,存放到DiskStore中.

  • 爲了有效控制DiskStore中的DiskFile個數,我們爲MiniBase設計了Compaction策略。目前的Compaction策略非常簡單——當DiskStore的DiskFile數量超過一個閾值時,就把所有的DiskFile進行Compact,最終合併成一個DiskFile。

核心問題與結構設計

  • DiskFile必須支持高效的寫入和讀取。

    • 由於MemStore的寫入是順序寫入,如果flush速度太慢,則可能會阻塞後續的寫入,影響寫入吞吐,因此flush操作最好也設計成順序寫。

    • LSM樹結構的劣勢就是讀取性能會有所犧牲,如果在DiskFile上能實現高效的數據索引,則可以大幅提升讀取性能,例如考慮布隆過濾器設計。

  • DiskFile的數據必須分成衆多小塊(內存小磁盤大)。

    • 一次IO操作只讀取一小部分的數據

 

DiskFile由3種類型的數據塊組成,分別是DataBlock、IndexBlock、MetaBlock。

DataBlock

  • 主要用來存儲有序的KeyValue集合——KeyValue-1,KeyValue-2,…,KeyValue-N

  • 一個DiskFile內可能有多個Block,具體的Block數量取決於文件內存儲的總KV數據量

IndexBlock

IndexBlock:一個DiskFile內有且僅有一個IndexBlock,它主要存儲多個DataBlock的索引數據。每個索引數據又包含4個字段:

  • lastKV :該DataBlock的最後一個KV。方便直接讀取這個DataBlock到內存。爲什麼不是第一個kv?

  • offset :該DataBlock在DiskFile中的偏移位置,查找時,用offset值去文件中Seek,並讀取DataBlock的數據。

  • size:該DataBlock佔用的字節長度。

  • bloomFilter:該DataBlock內所有KeyValue計算出的布隆過濾器字節數組。

MetaBlock

一個DiskFile中有且僅有一個MetaBlock;同時MetaBlock是定長的,因此可以直接通過定位diskf ile.f ilesize - len(MetaBlock)來讀取MetaBlock,而無需任何索引.

  • fileSize :該DiskFile的文件總長度,可以通過對比這個值和當前文件真實長度,判斷文件是否損壞

  • blockCount:該DiskFile擁有的Block數量

  • blockIndexOffset:該DiskFile內的IndexBlock的偏移位置,方便定位到IndexBlock。

  • blockIndexSize:IndexBlock的字節長度。

DiskStore讀取示例

假設用戶需要讀取指定DiskFile中key='abc'對應的value數據,那麼可以按照如下流程進行IO讀取

  • 因爲MetaBlock的長度是定長的,所以很容易定位到MetaBlock的位置並讀取信息

  • 根據MetaBlock.blockIndexOffset等信息讀取到IndexBlock信息

  • 由於IndexBlock中存儲着每一個DataBlock對應的數據區間,通過二分查找可以很方便定位到key='abc'在哪個DataBlock中需要key有序,假如key無序怎麼辦?ConcurrentSkipListMap保證了數據集合內是有序的

  • 再根據對應的DataBlock的offset和size,就能順利完成DataBlock的IO讀取

kv設計

在MiniBase中,只容許兩種更新操作:

  • Put操作

  • Delete操作

結構設計

  private KeyValue(byte[] key, byte[] value, Op op, long sequenceId) {
    assert key != null;
    assert value != null;
    assert op != null;
    assert sequenceId >= 0;
    this.key = key;
    this.value = value;
    this.op = op;
    this.sequenceId = sequenceId;
  }
  • Op有Put和Delete兩種操作類型

  • 每一次Put/Delete操作分配一個自增的唯一sequenceId. 讀取的時候,只能得到小於等於當前sequenceId的Put/Delete操作,這樣保證了本次讀取不會得到未來某個時間點的數據,實現了最簡單的Read Committed的事務隔離級別。

KeyValue在MemStore和DiskFile中都是有序存放的,所以需要爲KeyValue實現Comparable接口,如下所示:

  @Override
  public int compareTo(KeyValue kv) {
    if (kv == null) {
      throw new IllegalArgumentException("kv to compare should not be null");
    }
    int ret = Bytes.compare(this.key, kv.key);
    if (ret != 0) {
      return ret;
    }
    if (this.sequenceId != kv.sequenceId) {
      return this.sequenceId > kv.sequenceId ? -1 : 1;
    }
    if (this.op != kv.op) {
      return this.op.getCode() > kv.op.getCode() ? -1 : 1;
    }
    return 0;
  }
  • k小的在前面。後面讀取的時候就像二分查找了。

  • 注意在Key相同的情況下,sequenceId更大的KeyValue排在更前面,這是因爲sequenceId越大,說明這個Put/Delete操作版本越新,它更可能是用戶需要讀取的數據

  • 再比較op code (錦上添花,防止上游數據錯亂髮來一樣的sequenceId?)

寫入流程詳細剖析

寫入過程需要構造一個kv結構(put/delete),並加上一個自增的sequenceId.詳見MStore#put

  @Override
  public void put(byte[] key, byte[] value) throws IOException {
    this.memStore.add(KeyValue.createPut(key, value, sequenceId.incrementAndGet()));
  }

(MiniBase是本地生成,HBase應該要由服務端生成?)

kv數據是寫到kvMap中,其中kvMap就是ConcurrentSkipListMap結構。這裏我們需要關注:

  • dataSize更新問題 由於ConcurrentSkipListMap在put進數據後會返回相同key的舊value,所以需要考慮一下dataSize的更新(當前MemStore內存佔用字節數,用於判斷是否達到Flush閾值)。

  • 鎖 需要使用一個讀寫鎖updateLock來控制寫入操作和Flush操作的互斥

這裏詳見:MemStore#add

  public void add(KeyValue kv) throws IOException {
    // add前需要阻塞flush
    flushIfNeeded(true);
    updateLock.readLock().lock();
    try {
      KeyValue prevKeyValue;
      // ConcurrentSkipListMap特性 put後的返回值 the old value, or null if newly inserted
      if ((prevKeyValue = kvMap.put(kv, kv)) == null) {
        // 之前kv不存在則直接加
        dataSize.addAndGet(kv.getSerializeSize());
      } else {
        // 之前kv存在,需要計算差值(可能有更新)
        dataSize.addAndGet(kv.getSerializeSize() - prevKeyValue.getSerializeSize());
      }
    } finally {
      updateLock.readLock().unlock();
    }
    flushIfNeeded(false);
  }

寫入MemStore後,當數據量達到一定閾值就要將flush到DiskStore

讀取流程詳細剖析

讀取流程相對要複雜很多。

我們需要從多個有序集合中讀取數據:

  • MemStore

    • MutableMemstore:kvMap

    • ImmutableMemstore:snapshot

  • DiskStore:多個DiskFile

在Scan的時候,需要把多個有序集合通過多路歸併算法合併成一個有序集合,然後過濾掉不符合條件的版本,將正確的KV返回給用戶。

 

 

以上圖爲例,我們要將上面7個KV數據再次處理得到最終的結果。

對於同一個Key的不同版本,我們只關心最新的版本。假設用戶讀取時能看到的sequenceld≤101的數據,那麼讀取流程邏輯如下:

  • Key=A的版本 我們們只關注(A,Delete,100)這個版本,該版本是刪除操作,說明後面所有key=A的版本都不會被用戶看到。

  • Key=B的版本 我們只關心(B,Put,101)這個版本,該版本是Put操作,說明該Key沒有被刪除,可以被用戶看到。

  • Key=C的版本 我們只關心(C,Put,95)這個版本,該版本是Put操作,說明該Key沒有被刪除,可以被用戶看到。

對於全表掃描的scan操作,MiniBase將返回(B,Put,101)和(C,Put,95)這兩個KeyValue給用戶。

詳情見: MStore#ScanIter

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