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,大致如下:

  1. 獲取寫請求裏指定行的行鎖。由於這些批量寫請求之間是不保證一致性(只保證行一致性),因此每次只會嘗試阻塞獲取至少一個寫請求的行鎖,其它已被獲取的行鎖則跳過這次更新,等待下次迭代的繼續嘗試獲取
  2. 更新已經獲得行鎖的寫請求的時間戳爲當前時間
  3. 獲取HRegion的updatesLock的讀鎖。
  4. 獲取MVCC(Multi-Version Concurrency Control)的最新寫序號,和寫請求KeyValue數據一起寫入到MemStore。
  5. 構造WAL(Write-Ahead Logging) edit對象
  6. 把WAL edit對象異步添加到HLog中,獲取txid號
  7. 釋放第3步中的updatesLock的讀鎖以及第1步中獲得的行鎖
  8. 按照第6步中txid號同步HLog
  9. 提交事務,把MVCC的讀序號前移到第4步中獲取到的寫序號
  10. 如果以上步驟出現失敗,則回滾已經寫入MemStore的數據
  11. 如果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。

  1. HRegion調用FSHLog.appendNoSync,把修改記錄添加到本地buffer中,通知AsyncWriter有記錄插入,然後返回一個long型遞增的txid作爲這條修改記錄。注意到這是一個異步調用。
  2. HRegion之後會馬上釋放updatesLock的讀鎖以及獲得的行鎖,然後再調用FSHLog.sync(txid),來等待之前的修改記錄寫入到HLog中。
  3. AsyncWriter從本地buffer取出修改記錄,然後將記錄經過壓縮以及ProtoBuf序列化寫入到FSDataOutputStream的緩存中,然後再通知AsyncSyncer。由於AsyncSyncer的工作量較大,因此總共有5條線程,AsyncWriter會選擇其中一條進行喚醒。
  4. AsyncSyncer判斷是否有其它AsyncSyncer線程已經完成了同步任務,如果是則繼續等待AsyncWriter的同步請求。否則的話就把FSDataOutputStream的緩存寫入到HDFS中去,然後喚醒AsyncNotifier
  5. AsyncNotifier的任務較爲簡單,只是把所有正在等待同步的寫請求線程喚醒,不過事實上該過程同樣較爲耗時,因此另外分出AsyncNotifier線程,而不是在AsyncSyncer完成通知任務。
  6. 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也避免了讀寫請求之間的影響。

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