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