HBase寫請求分析,hbase寫請求
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也避免了讀寫請求之間的影響。
著作權聲明:本文為博主原創文章,未經博主允許不得轉載。