HBase作爲分佈式NoSQL數據庫系統,不單支持寬列表,並且對於隨機讀寫來說也具有較高的性能。在高性能的隨機讀寫事務的同時,HBase也能保持事務的一致性。目前HBase只支持行級別的事務一致性。本文主要探討一下HBase的寫請求流程,主要基於0.98.8版本的實現。
客戶端寫請求
HBase提供的Java client API是以HTable爲主要接口,對應其中的HBase表。寫請求API主要爲HTable.put(write和update)、HTable.delete等。以HTable.put爲例子,首先來看看客戶端是怎麼把請求發送到HRegionServer的。
每個put請求表示一個KeyValue數據,考慮到客戶端有大量的數據需要寫入到HBase表,HTable.put默認是會把每個put請求都放到本地緩存中去,當本地緩存大小超過閥值(默認爲2MB)的時候,就要請求刷新,即把這些put請求發送到指定的HRegionServer中去,這裏是利用線程池併發發送多個put請求到不同的HRegionServer。但如果多個請求都是同一個HRegionServer,甚至是同一個HRegion,則可能造成對服務端造成壓力,爲了避免發生這種情況,客戶端API會對寫請求做了併發數限制,主要是針對put請求需要發送到的HRegionServer和HRegion來進行限制,具體實現在AsyncProcess中。主要參數設定爲:
- hbase.client.max.total.tasks 客戶端最大併發寫請求數,默認爲100
- hbase.client.max.perserver.tasks 客戶端每個HRegionServer的最大併發寫請求數,默認爲2
- hbase.client.max.perregion.tasks 客戶端每個HRegion最大併發寫請求數,默認爲1
爲了提高I/O效率,AsyncProcess會合並同一個HRegion對應的put請求,然後再一次把這些相同HRegion的put請求發送到指定HRegionServer上去。另外AsyncProcess也提供了各種同步的方法,如waitUntilDone等,方便某些場景下必須對請求進行同步處理。每個put和讀請求一樣,都是要通過訪問hbase:meta表來查找指定的HRegionServer和HRegion,這個流程和讀請求一致,可以參考文章的描述。
服務端寫請求
當客戶端把寫請求發送到服務端時,服務端就要開始執行寫請求操作。HRegionServer把寫請求轉發到指定的HRegion執行,HRegion每次操作都是以批量寫請求爲單位進行處理的。主要流程實現在HRegion.doMiniBatchMutation,大致如下:
- 獲取寫請求裏指定行的行鎖。由於這些批量寫請求之間是不保證一致性(只保證行一致性),因此每次只會嘗試阻塞獲取至少一個寫請求的行鎖,其它已被獲取的行鎖則跳過這次更新,等待下次迭代的繼續嘗試獲取
- 更新已經獲得行鎖的寫請求的時間戳爲當前時間
- 獲取HRegion的updatesLock的讀鎖。
- 獲取MVCC(Multi-Version Concurrency Control)的最新寫序號,和寫請求KeyValue數據一起寫入到MemStore。
- 構造WAL(Write-Ahead Logging) edit對象
- 把WAL edit對象異步添加到HLog中,獲取txid號
- 釋放第3步中的updatesLock的讀鎖以及第1步中獲得的行鎖
- 按照第6步中txid號同步HLog
- 提交事務,把MVCC的讀序號前移到第4步中獲取到的寫序號
- 如果以上步驟出現失敗,則回滾已經寫入MemStore的數據
- 如果MemStore緩存的大小超過閥值,則請求當前HRegion的MemStore刷新操作。
經過以上步驟後,寫請求就屬於被提交的事務,後面的讀請求就能讀取到寫請求的數據。這些步驟裏面都包含了HBase的各種特性,主要是爲了保證可觀的寫請求的性能的同時,也確保行級別的事務ACID特性。接下來就具體分析一下一些主要步驟的具體情況。
HRegion的updatesLock
步驟3中獲取HRegion的updatesLock,是爲了防止MemStore在flush過程中和寫請求事務發生線程衝突。
首先要知道MemStore在寫請求的作用。HBase爲了提高讀性能,因此保證存儲在HDFS上的數據必須是有序的,這樣就能使用各種特性,如二分查找,提升讀性能。但由於HDFS不支持修改,因此必須採用一種措施把隨機寫變爲順序寫。MemStore就是爲了解決這個問題。隨機寫的數據寫如MemStore中就能夠在內存中進行排序,當MemStore大小超過閥值就需要flush到HDFS上,以HFile格式進行存儲,顯然這個HFile的數據就是有序的,這樣就把隨機寫變爲順序寫。另外,MemStore也是HBase的LSM樹(Log-Structured Merge Tree)的實現部分之一。
在MemStore進行flush的時候,爲了避免對讀請求的影響,MemStore會對當前內存數據kvset創建snapshot,並清空kvset的內容,讀請求在查詢KeyValue的時候也會同時查詢snapshot,這樣就不會受到太大影響。但是要注意,寫請求是把數據寫入到kvset裏面,因此必須加鎖避免線程訪問發生衝突。由於可能有多個寫請求同時存在,因此寫請求獲取的是updatesLock的readLock,而snapshot同一時間只有一個,因此獲取的是updatesLock的writeLock。
獲取MVCC寫序號
MVCC是HBase爲了保證行級別的事務一致性的同時,提升讀請求的一種併發事務控制的機制。MVCC的機制不難理解,可以參考這裏。
MVCC的最大優勢在於,讀請求和寫請求之間不會互相阻塞衝突,因此讀請求一般不需要加鎖(只有兩個寫同一行數據的寫請求需要加鎖),只有當寫請求被提交了後,讀請求才能看到寫請求的數據,這樣就可以避免發生“髒讀”,保證了事務一致性。具體MVCC實現可以參考HBase的一位PMC Member的這篇文章。
WAL(Write-Ahead Logging) 與HLog
WAL是HBase爲了避免遇到節點故障無法服務的情況下,能讓其它節點進行數據恢復的機制。HBase進行寫請求操作的時候,默認都會把KeyValue數據寫入封裝成WALEdit對象,然後序列化到HLog中,在0.98.8版本里採用ProtoBuf格式進行序列化WAL。HLog是記錄HBase修改的日誌文件,和數據文件HFile一樣,也是存儲於HDFS上,因此保證了HLog文件的可靠性。這樣如果機器發生宕機,存儲在MemStore的KeyValue數據就會丟失,HBase就可以利用HLog裏面記錄的修改日誌進行數據恢復。
每個HRegionServer只有一個HLog對象,因此當前HRegionServer上所有的HRegion的修改都會記錄到同一個日誌文件中,在需要數據恢復的時候再慢慢按照HRegion分割HLog裏的修改日誌(Log Splitting)。
整個寫請求裏,WALEdit對象序列化寫入到HLog是唯一會發生I/O的步驟,這個會大大影響寫請求的性能。當然,如果業務場景對數據穩定性要求不高,關鍵是寫入請求,那麼可以調用Put.setDurability(Durability.SKIP_WAL),這樣就可以跳過這個步驟。
HBase爲了減輕寫入HLog產生I/O的影響,採用了較爲粒度較細的多線程併發模式(詳細可參考HBASE-8755)。HLog的實現爲FSHLog,主要過程涉及三個對象:AsyncWriter、AsyncSyncer和AsyncNotifier。整個寫入過程涉及步驟5-8。
- HRegion調用FSHLog.appendNoSync,把修改記錄添加到本地buffer中,通知AsyncWriter有記錄插入,然後返回一個long型遞增的txid作爲這條修改記錄。注意到這是一個異步調用。
- HRegion之後會馬上釋放updatesLock的讀鎖以及獲得的行鎖,然後再調用FSHLog.sync(txid),來等待之前的修改記錄寫入到HLog中。
- AsyncWriter從本地buffer取出修改記錄,然後將記錄經過壓縮以及ProtoBuf序列化寫入到FSDataOutputStream的緩存中,然後再通知AsyncSyncer。由於AsyncSyncer的工作量較大,因此總共有5條線程,AsyncWriter會選擇其中一條進行喚醒。
- AsyncSyncer判斷是否有其它AsyncSyncer線程已經完成了同步任務,如果是則繼續等待AsyncWriter的同步請求。否則的話就把FSDataOutputStream的緩存寫入到HDFS中去,然後喚醒AsyncNotifier
- AsyncNotifier的任務較爲簡單,只是把所有正在等待同步的寫請求線程喚醒,不過事實上該過程同樣較爲耗時,因此另外分出AsyncNotifier線程,而不是在AsyncSyncer完成通知任務。
- HRegion被喚醒,發現自己的txid已經得到同步,也就是修改記錄寫入到HLog中,於是接着其它操作。
在以上的寫入過程中,第2步裏HRegion先把記錄寫入HLog的buffer,然後再釋放之前獲得的鎖後才同步等待寫入完成,這樣可以有效降低鎖持有的時間,提高其它寫請求的併發。另外,AsyncWriter、AsyncSyncer和AsyncNotifier組成的新的寫模型主要負擔起HDFS寫操作的任務,對比起舊的寫模型(需要每個寫請求的線程來負責寫HDFS,大量的線程導致嚴重的鎖競爭),最主要是大大降低了線程同步過程中的鎖競爭,有效地提高了線程的吞吐量。這個寫過程對於大批量寫請求來說,能夠提高吞吐量,但對於寫請求併發量較小,線程競爭較低的環境下,由於每個寫請求必須等待Async*線程之間的同步,增加了線程上下文切換的開銷,會導致性能稍微下降(在0.99版本里採用了LMAX Disruptor同步模型,並把FSHLog進行了重構,HBASE-10156)。
MVCC讀序號前移
完成HLog的寫之後,整個寫請求事務就已經完成流程,因此就需要提交事務,讓其它讀請求可以看到這個寫請求的數據。前面已經略微介紹過MVCC的作用,這裏關注一下MVCC是如何處理讀序號前移。
MVCC在內部維持一個long型寫序號memstoreWrite,一個long型讀序號memstoreRead,還有一個隊列writeQueue。當HRegion調用beginMemStoreInsert要求分配一個寫序號的時候,就會把寫序號自增1,並返回,並同時把一個寫請求添加到writeQueue尾部。代碼如下:
public WriteEntry beginMemstoreInsert() {
synchronized (writeQueue) {
long nextWriteNumber = ++memstoreWrite;
WriteEntry e = new WriteEntry(nextWriteNumber);
writeQueue.add(e);
return e;
}
}
HRegion把這個寫序號和每個新插入的KeyValue數據進行關聯。當寫請求完成的時候,HRegion調用completeMemstoreInsert請求讀序號前移,MVCC首先把寫請求記錄爲完成,然後查看writeQueue隊列,從隊列頭部開始取出所有已經完成的寫請求,最後一個完成的寫請求的序號則會賦值給memstoreRead,表示這是當前最大可讀的讀序號,如果HRegion的寫請求的序號比讀序號要小,則完成了事務提交,否則HRegion會一直循環等待提交完成。相關代碼如下:
public void completeMemstoreInsert(WriteEntry e) {
advanceMemstore(e);
waitForRead(e);
}
boolean advanceMemstore(WriteEntry e) {
synchronized (writeQueue) {
e.markCompleted();
long nextReadValue = -1;
while (!writeQueue.isEmpty()) {
ranOnce=true;
WriteEntry queueFirst = writeQueue.getFirst();
//...
if (queueFirst.isCompleted()) {
nextReadValue = queueFirst.getWriteNumber();
writeQueue.removeFirst();
} else {
break;
}
}
if (nextReadValue > 0) {
synchronized (readWaiters) {
memstoreRead = nextReadValue;
readWaiters.notifyAll();
}
}
if (memstoreRead >= e.getWriteNumber()) {
return true;
}
return false;
}
}
public void waitForRead(WriteEntry e) {
boolean interrupted = false;
synchronized (readWaiters) {
while (memstoreRead < e.getWriteNumber()) {
try {
readWaiters.wait(0);
} catch (InterruptedException ie) {
//...
}
}
}
}
由此可見,MVCC保證了事務提交的串行順序性,如果有某個寫請求提交成功,則任何寫序號小於這個寫序號的寫請求必然提交成功。因此在讀請求的時候,只要獲取MVCC的讀請求序號則可以讀取任何最新提交成功寫請求的寫數據。另外,MVCC只限制在事務提交的這個過程的串行,在實際的寫請求過程中,其它步驟都是允許併發的,因此不會對性能造成太大的影響。
至此,HBase的一個寫請求的事務提交過程就完成。在整個寫過程裏,都採用了大量的方法去避免鎖競爭、縮短獲取鎖的時間以及保證事務一致性等措施。由於MemStore在內存的緩存上始終有大小限制,因此當MemStore超過閥值的時候,HBase就要刷新數據到HDFS上,形成新的HFile。接下來看看這個過程。
MemStore的flush
當大量的寫請求數據添加到MemStore上,MemStore超過閥值,HRegion就會請求把MemStore的數據flush到HDFS上。另外要注意到的是,這裏flush的單位是單個HRegion,也就是說如果有多個HStore,只要有一個MemStore超過閥值,這個HRegion所屬的所有HStore都要執行flush操作。
- HRegion首先要獲取updatesLock的寫鎖,這樣就防止有新的寫請求到來
- 請求獲取MVCC的寫序號
- 請求MemStore生成snapshot
- 釋放updatesLock的寫鎖
- 提交之前獲取的MVCC寫序號,等待之前的事務完成,防止回滾事務寫入HFile
- 把snapshot的KeyValue數據寫入到HFile裏
主要集中來看看把snapshot的KeyValue數據寫入HFile部分。先來看看HFile的格式:
之前在讀請求文章裏已經介紹個HFile的這個格式。HFile要保證每個HBlock大小約爲64KB,採用DataBlock多級索引和BloomFilter一級索引的方法來構成HFile結構。整個寫過程比較簡單,在循環裏便利獲取MemStore的snapshot的KeyValue數據,然後不斷寫DataBlock裏,如果當前DataBlock的總大小超過64KB,則DataBlock就停止添加數據(設置了壓縮會進行壓縮處理),同時計算DataBlock的索引,並添加到內存中,另外如果開啓了BloomFilter屬性也要寫入對應的BloomBlock,這個過程中會注意保存未壓縮大小等FileInfo數據。
當所有的snapshot數據都寫入完DataBlock中後,就要開始寫入DataBlock的多級索引了。HBase會根據之前保存的索引計算多級索引的級數,如果索引數量不多,則有可能只有RootIndexBlock一個級別。同時也會根據RookIndexBlock獲得MidKey的數據。最後就按照順序寫入FileInfo以及BloomFilter的索引,還有Trailer。
總結
HBase採用了MemStore把隨機寫變爲順序寫,這樣有助於提高讀請求的效率。另外也爲了避免數據丟失使用HLog來記錄修改日誌。在整個寫過程中,使用了多種手段減輕了鎖競爭,提高了線程吞吐量,也注意縮短鎖獲取的時間,儘可能地提高併發。通過利用MVCC也避免了讀寫請求之間的影響。