一條數據的HBase之旅,簡明HBase入門教程-Write全流程
本文基于提供的樣例數據,介紹了寫數據的接口,RowKey定義,數據在客戶端的組裝,數據路由,打包分發,以及RegionServer側將數據寫入到Region中的全部流程。

NoSQL漫談
本文整體思路
-
前文內容回顧
-
示例數據
-
HBase可選接口介紹
-
表服務接口介紹
-
介紹幾種寫數據的模式
-
如何構建Put對象(包含RowKey定義以及列定義)
-
數據路由
-
Client側的分組打包
-
Client發RPC請求到RegionServer
-
安全訪問控制
-
RegionServer側處理:Region分發
-
Region內部處理:寫WAL
-
Region內部處理:寫MemStore
為了保證"故事 " 的完整性,導致本文篇幅過長,非常抱歉,讀者可以按需跳過不感興趣的內容。
前文回顧
上篇文章《 一條數據的HBase之旅,簡明HBase入門教程-開篇 》主要介紹了如下內容:
-
HBase項目概況(搜索引擎熱度/社區開發活躍度)
-
HBase數據模型(RowKey,稀疏矩陣,Region,Column Family,KeyValue)
-
基于HBase的數據模型,介紹了HBase的適合場景(以 實體/事件 為中心的簡單結構的數據)
-
介紹了HBase與HDFS的關系,集群關鍵角色以及部署建議
-
寫數據前的準備工作:建立連接,建表
示例數據
(上篇文章已經提及,這里再復制一次的原因,一是為了讓下文內容更容易理解,二是個別字段名稱做了調整)
給出一份我們日常都可以接觸到的數據樣例,先簡單給出示例數據的字段定義:

示例數據字段定義
本文力求簡潔,僅給出了最簡單的幾個字段定義。如下是”虛構”的樣例數據:

示例數據
在本文大部分內容中所涉及的一條數據,是上面加粗的最后一行 " Mobile1 " 為 " 13400006666 " 這行記錄。在下面的流程圖中,我們使用下面這樣一個紅色小圖標來表示該數據所在的位置:

數據位置標記
可選接口
HBase中提供了如下幾種主要的接口:
-
Java Client API
HBase的基礎API,應用最為廣泛。
-
HBase Shell
基于Shell的命令行操作接口,基于Java Client API實現。
-
Restful API
Rest Server側基于Java Client API實現。
-
Thrift API
Thrift Server側基于Java Client API實現。
-
MapReduce Based Batch Manipulation API
基于MapReduce的批量數據讀寫API。
除了上述主要的API,HBase還提供了 基于Spark的批量操作接口 以及 C++ Client 接口,但這兩個特性都被規劃在了3.0版本中,當前尚在開發中。
無論是HBase Shell/Restful API還是Thrift API,都是基于Java Client API實現的。因此,接下來關于流程的介紹,都是基于Java Client API的調用流程展開的。
關于表服務接口的抽象
同步連接與異步連接,分別提供了不同的表服務接口抽象:
-
Table 同步連接中的表服務接口定義
-
AsyncTable 異步連接中的表服務接口定義
異步連接AsyncConnection獲取AsyncTable實例的接口默認實現:

Create AsyncTable
同步連接ClusterConnection的實現類ConnectionImplementation中獲取Table實例的接口實現:

Create Table
寫數據的幾種方式
-
Single Put
單條記錄單條記錄的隨機put操作。Single Put所對應的接口定義如下:
在AsyncTable接口中的定義:
CompletableFuture<Void> put(Put put);
在Table接口中的定義:
void put(Put put) throws IOException;
-
Batch Put
匯聚了幾十條甚至是幾百上千條記錄之后的 小批次 隨機put操作。
Batch Put只是本文對該類型操作的稱法,實際的接口名稱如下所示:
在AsyncTable接口中的定義:
List<CompletableFuture<Void>> put(List<Put> puts);
在Table接口中的定義:
void put(List<Put> puts) throws IOException;
-
Bulkload
基于MapReduce API提供的數據批量導入能力,導入數據量通常在GB級別以上,Bulkload能夠繞過Java Client API直接生成HBase的底層數據文件(HFile)。
構建Put對象
設計合理的RowKey
RowKey通常是一個或若干個字段的直接組合或經一定處理后的信息,因為一個表中所有的數據都是基于RowKey排序的,RowKey的設計對讀寫都會有直接的性能影響。
我們基于本文的樣例數據,先給出兩種RowKey的設計,并簡單討論各自的優缺點:
樣例數據:

示例數據
RowKey Format 1 : Mobile1 + StartTime
為了方便讀者理解,我們在兩個字段之間添加了連接符”^”。如下是RowKey以及相關排序結果:

RowKey Format 1
RowKey Format 2 : StartTime + Mobile1

RowKey Format 2
從上面兩個表格可以看出來,不同的字段組合順序設計,帶來截然不同的排序結果,我們將RowKey中的第一個字段稱之為“ 先導字段 ”。第一種設計,有利于查詢”手機號碼XXX的在某時間范圍內的數據記錄”,但不利于查詢”某段時間范圍內有哪些手機號碼撥出了電話?”,而第二種設計卻恰好相反。
上面是兩種設計都是兩個字段的直接組合,這種設計在實際應用中,會帶來讀寫 熱點 問題,難以保障數據讀寫請求在所有Regions之間的負載均衡。避免熱點的常見方法有如下幾種:
Reversing
如果先導字段本身會帶來熱點問題,但該字段尾部的信息卻具備良好的隨機性,此時,可以考慮將先導字段做反轉處理,將尾部幾位直接提前到前面,或者直接將整個字段完全反轉。
將 先導字段 Mobile1翻轉后,就具備非常好的隨機性。
例如:
13400001111^201803010800
將先導字段Mobile1反轉后的RowKey變為:
11110000431^201803010800
Salting
Salting的原理是在RowKey的前面添加固定長度的隨機Bytes,隨機Bytes能保障數據在所有Regions間的負載均衡。

RowKey With Salting
Salting能很好的保障寫入時將數據均勻分散到各個Region中,但對于讀取卻是不友好的,例如,如果讀取Mobile1為”13400001111″在20180301這一天的數據記錄時,因為Salting Bytes信息是隨機選擇添加的,查詢時并不知道前面添加的Salting Bytes是”A”,因此{“A”, “B”, “C”}所關聯的Regions都得去查看一下。
Hashing
Hashing是將一個RowKey通過一個Hash函數生成一組固定長度的bytes,Hash函數能保障所生成的隨機bytes具備良好的離散度,從而也能夠均勻打散到各個Region中。Hashing既有利于隨機寫入,又利于基于知道RowKey各字段的確切信息之后的隨機讀取操作,但如果是基于RowKey范圍的Scan或者是RowKey的模糊信息進行查詢的話,就會帶來顯著的性能問題,因為原來在字典順序相鄰的RowKey列表,通過Hashing打散后導致這些數據被分散到了多個Region中。
因此, RowKey的設計,需要充分考慮業務的讀寫特點 。
本文內容假設RowKey設計: reversing(Mobile1) +StartTime
也就是說,RowKey由反轉處理后的Mobile1與StartTime組成。對于我們所關注的這行數據:

關注的數據記錄
RowKey應該為: 66660000431^201803011300
因為創建表時預設的Region與RowKey強相關,我們現在才可以給出本文樣例所需要創建的表的” Region分割點 “信息:
假設,Region分割點為“1,2,3,4,5,6,7,8,9”,基于這9個分割點,可以預先創建10個Region,這10個Region的StartKey和StopKey如下所示:

Region劃分信息
-
第一個Region的StartKey為空,最后一個Region的StopKey為空
-
每一個Region區間,都包含StartKey本身,但不包含StopKey
-
由于Mobile1字段的最后一位是0~9之間的隨機數字,因此,可以均勻打散到這10個Region中
定義列
每一個列在HBase中體現為一個KeyValue,而每一個KeyValue擁有特定的組成結構,這一點在上一篇文章的數據模型章節已經提到過。
所謂的定義列,就是需要定義出每一個列要存放的列族(Column Family)以及列標識(Qualifier)信息。
我們假設,存放樣例數據的這個表名稱為” TelRecords ” ,為了簡單起見,僅僅設置了1個名為”I”的列族。

Column Family以及列標識定義
因為Mobile1與StartTime都已經被包含在RowKey中,所以,不需要再在列中存儲一份。關于列族名稱與列標識名稱,建議應該簡短一些,因為這些信息都會被包含在KeyValue里面,過長的名稱會導致數據膨脹。
基于RowKey和列定義信息,就可以組建HBase的Put對象, 一個Put對象用來描述待寫入的一行數據 ,一個Put可以理解成與某個RowKey關聯的1個或多個KeyValue的集合。
至此,這條數據已經轉變成了Put對象,如下圖所示:

Put
數據路由
初始化ZooKeeper Session
因為meta Region存放于ZooKeeper中,在第一次從ZooKeeper中讀取META Region的地址時,需要先初始化一個ZooKeeper Session。ZooKeeper Session是ZooKeeper Client與ZooKeeper Server端所建立的一個會話,通過心跳機制保持長連接。
獲取Region路由信息
通過前面建立的連接,從ZooKeeper中讀取meta Region所在的RegionServer,這個讀取流程,當前已經是異步的。獲取了meta Region的路由信息以后,再從meta Region中定位要讀寫的RowKey所關聯的Region信息。如下圖所示:

Region Routing
因為每一個用戶表Region都是一個RowKey Range,meta Region中記錄了每一個用戶表Region的路由以及狀態信息,以RegionName(包含表名,Region StartKey ,Region ID,副本ID等信息)作為RowKey。基于一條用戶數據RowKey,快速查詢該RowKey所屬的Region的方法其實很簡單:只需要基于表名以及該用戶數據RowKey,構建一個虛擬的Region Key,然后通過Reverse Scan的方式,讀到的第一條Region記錄就是該數據所關聯的Region。如下圖所示:

Location User Region
Region只要不被遷移,那么獲取的該Region的路由信息就是一直有效的,因此,HBase Client有一個Cache機制來緩存Region的路由信息,避免每次讀寫都要去訪問ZooKeeper或者meta Region。
進階內容1:meta Region究竟在哪里?
meta Region的路由信息存放在ZooKeeper中,但meta Region究竟在哪個RegionServer中提供讀寫服務?
在1.0版本中,引入了一個新特性,使得Master可以”兼任”一個RegionServer角色(可參考HBASE-5487, HBASE-10569),從而可以將一些系統表的Region分配到Master的這個RegionServer中,這種設計的初衷是為了簡化/優化Region Assign的流程,但這依然帶來了一系列復雜的問題,尤其是Master初始化和RegionServer初始化之間的Race,因此,在2.0版本中將這個特性暫時關閉了。詳細信息可以參考:HBASE-16367,HBASE-18511,HBASE-19694,HBASE-19785,HBASE-19828
客戶端側的數據分組“打包”
如果這條待寫入的數據采用的是Single Put的方式,那么,該步驟可以略過(事實上,單條Put操作的流程相對簡單,就是先定位該RowKey所對應的Region以及RegionServer信息后,Client直接發送寫請求到RegionServer側即可)。
但如果這條數據被混雜在其它的數據列表中,采用Batch Put的方式,那么,客戶端在將所有的數據寫到對應的RegionServer之前,會先分組”打包”,流程如下:
-
按Region分組 :遍歷每一條數據的RowKey,然后,依據meta表中記錄的Region信息,確定每一條數據所屬的Region。此步驟可以獲取到Region到RowKey列表的映射關系。
-
按RegionServer”打包” :因為Region一定歸屬于某一個RegionServer(注:本文內容中如無特殊說明,都未考慮Region Replica特性),那屬于同一個RegionServer的多個Regions的寫入請求,被打包成一個MultiAction對象,這樣可以一并發送到每一個RegionServer中。

數據分組與打包
Client發送寫數據請求到RegionServer
類似于Client發送建表到Master的流程,Client發送寫數據請求到RegionServer,也是通過RPC的方式。只是,Client到Master以及Client到RegionServer,采用了不同的RPC服務接口。

Client Send Request To RegionServer
single put請求與batch put請求,兩者所調用的RPC服務接口方法是不同的,如下是Client.proto中的定義:

Client Proto定義
安全訪問控制
如何保障UserA只能寫數據到UserA的表中,以及禁止UserA改寫其它User的表的數據,HBase提供了ACL機制。ACL通常需要與Kerberos認證配合一起使用,Kerberos能夠確保一個用戶的合法性,而ACL確保該用戶僅能執行權限范圍內的操作。
HBase將權限分為如下幾類:
-
READ(‘R’)
-
WRITE(‘W’)
-
EXEC(‘X’)
-
CREATE(‘C’)
-
ADMIN(‘A’)
可以為一個用戶/用戶組定義整庫級別的權限集合,也可以定義Namespace、表、列族甚至是列級別的權限集合。
RegionServer端處理:Region分發
RegionServer的RPC Server側,接收到來自Client端的RPC請求以后,將該請求交給Handler線程處理。
如果是single put,則該步驟比較簡單,因為在發送過來的請求參數MutateRequest中,已經攜帶了這條記錄所關聯的Region,那么直接將該請求轉發給對應的Region即可。
如果是batch puts,則接收到的請求參數為MultiRequest,在MultiRequest中,混合了這個RegionServer所持有的多個Region的寫入請求,每一個Region的寫入請求都被包裝成了一個RegionAction對象。RegionServer接收到MultiRequest請求以后,遍歷所有的RegionAction,而后寫入到每一個Region中,此過程是 串行 的:

Write Per Region
從這里可以看出來,并不是一個batch越大越好,大的batch size甚至可能導致吞吐量下降。
Region內部處理:寫WAL
HBase也采用了LSM-Tree的架構設計:LSM-Tree利用了傳統機械硬盤的“ 順序讀寫速度遠高于隨機讀寫速度 ”的特點。隨機寫入的數據,如果直接去改寫每一個Region上的數據文件,那么吞吐量是非常差的。因此,每一個Region中隨機寫入的數據,都暫時先緩存在內存中(HBase中存放這部分內存數據的模塊稱之為 MemStore ,這里僅僅引出概念,下一章節詳細介紹),為了保障數據可靠性,將這些隨機寫入的數據 順序寫入 到一個稱之為WAL(Write-Ahead-Log)的日志文件中,WAL中的數據按時間順序組織:

MemStore And WAL
如果位于內存中的數據尚未持久化,而且突然遇到了機器斷電,只需要將WAL中的數據回放到Region中即可:

WAL Replay
在HBase中,默認一個RegionServer只有一個可寫的WAL文件。WAL中寫入的記錄,以 Entry 為基本單元,而一個Entry中,包含:
-
WALKey 包含{Encoded Region Name,Table Name,Sequence ID,Timestamp}等關鍵信息,其中,Sequence ID在維持數據一致性方面起到了關鍵作用,可以理解為一個事務ID。
-
WALEdit WALEdit中直接保存待寫入數據的所有的KeyValues,而這些KeyValues可能來自一個Region中的多行數據。
也就是說,通常,一個Region中的一個batch put請求,會被組裝成一個Entry,寫入到WAL中:

Write into WAL
將Entry寫到文件中時是支持壓縮的,但該特性默認未開啟。
WAL進階內容
WAL Roll and Archive
當正在寫的WAL文件達到一定大小以后,會創建一個新的WAL文件,上一個WAL文件依然需要被保留,因為這個WAL文件中所關聯的Region中的數據,尚未被持久化存儲,因此,該WAL可能會被用來回放數據。

Roll WAL
如果一個WAL中所關聯的所有的Region中的數據,都已經被持久化存儲了,那么,這個WAL文件會被暫時歸檔到另外一個目錄中:

WAL Archive
注意,這里不是直接將WAL文件刪除掉,這是一種穩妥且合理的做法,原因如下:
-
避免因為邏輯實現上的問題導致WAL被誤刪,暫時歸檔到另外一個目錄,為錯誤發現預留了一定的時間窗口
-
按時間維度組織的WAL數據文件還可以被用于其它用途,如增量備份,跨集群容災等等,因此,這些WAL文件通常不允許直接被刪除,至于何時可以被清理,還需要額外的控制邏輯
另外,如果對寫入HBase中的數據的可靠性要求不高,那么,HBase允許通過配置跳過寫WAL操作。
思考:put與batch put的性能為何差別巨大?
在網絡分發上,batch put已經具備一定的優勢,因為batch put是打包分發的。
而從寫WAL這塊,看的出來,batch put寫入的一小批次Put對象,可以通過一次sync就持久化到WAL文件中了,有效減少了IOPS。
但前面也提到了,batch size并不是越大越好,因為每一個batch在RegionServer端是被串行處理的。
利用Disruptor提升寫并發性能
在高并發隨機寫入場景下,會帶來大量的WAL Sync操作,HBase中采用了Disruptor的RingBuffer來減少競爭,思路是這樣:如果將瞬間并發寫入WAL中的數據,合并執行Sync操作,可以有效降低Sync操作的次數,來提升寫吞吐量。
Multi-WAL
默認情形下,一個RegionServer只有一個被寫入的WAL Writer,盡管WAL Writer依靠順序寫提升寫吞吐量,在基于普通機械硬盤的配置下,此時只能有單塊盤發揮作用,其它盤的IOPS能力并沒有被充分利用起來,這是Multi-WAL設計的初衷。Multi-WAL可以在一個RegionServer中同時啟動幾個WAL Writer,可按照一定的策略,將一個Region與其中某一個WAL Writer綁定,這樣可以充分發揮多塊盤的性能優勢。
關于WAL的未來
WAL是基于機械硬盤的IO模型設計的,而對于新興的非易失性介質,如3D XPoint,WAL未來可能會失去存在的意義,關于這部分內容,請參考文章《從HBase中移除WAL?3D XPoint技術帶來的變革》。
Region內部處理:寫MemStore
每一個Column Family,在Region內部被抽象為了一個HStore對象,而每一個HStore擁有自身的MemStore,用來緩存一批最近被隨機寫入的數據,這是LSM-Tree核心設計的一部分。
MemStore中用來存放所有的KeyValue的數據結構,稱之為CellSet,而CellSet的核心是一個ConcurrentSkipListMap,我們知道,ConcurrentSkipListMap是Java的跳表實現,數據按照Key值有序存放,而且在高并發寫入時,性能遠高于ConcurrentHashMap。
因此,寫MemStore的過程,事實上是將batch put提交過來的所有的KeyValue列表,寫入到MemStore的以ConcurrentSkipListMap為組成核心的CellSet中:

Write Into MemStore
MemStore因為涉及到大量的隨機寫入操作,會帶來大量Java小對象的創建與消亡,會導致大量的內存碎片,給GC帶來比較重的壓力,HBase為了優化這里的機制,借鑒了操作系統的內存分頁的技術,增加了一個名為MSLab的特性,通過分配一些固定大小的Chunk,來存儲MemStore中的數據,這樣可以有效減少內存碎片問題,降低GC的壓力。當然,ConcurrentSkipListMap本身也會創建大量的對象,這里也有很大的優化空間,去年阿里的一篇文章透露了阿里如何通過優化ConcurrentSkipListMap的結構來有效減少GC時間。
進階內容2:先寫WAL還是先寫MemStore?
在0.94版本之前,Region中的寫入順序是先寫WAL再寫MemStore,這與WAL的定義也相符。
但在0.94版本中,將這兩者的順序顛倒了,當時顛倒的初衷,是為了使得行鎖能夠在WAL sync之前先釋放,從而可以提升針對單行數據的更新性能。詳細問題單,請參考HBASE-4528。
在2.0版本中,這一行為又被改回去了,原因在于修改了行鎖機制以后(下面章節將講到),發現了一些性能下降,而HBASE-4528中的優化卻無法再發揮作用,詳情請參考HBASE-15158。改動之后的邏輯也更簡潔了。
進階內容3:關于行級別的ACID
在之前的版本中,行級別的任何并發寫入/更新都是互斥的,由一個行鎖控制。但在2.0版本中,這一點行為發生了變化,多個線程可以同時更新一行數據,這里的考慮點為:
-
如果多個線程寫入同一行的不同列族,是不需要互斥的
-
多個線程寫同一行的相同列族,也不需要互斥,即使是寫相同的列,也完全可以通過HBase的MVCC機制來控制數據的一致性
-
當然,CAS操作(如checkAndPut)或increment操作,依然需要獨占的行鎖
更多詳細信息,可以參考HBASE-12751。
至此,這條數據已經被同時成功寫到了WAL以及MemStore中:

Data Written In HBase
總結
本文主要內容總結如下:
-
介紹HBase寫數據可選接口以及接口定義。
-
通過一個樣例,介紹了RowKey定義以及列定義的一些方法,以及如何組裝Put對象
-
數據路由,數據分發、打包,以及Client通過RPC發送寫數據請求至RegionServer
-
RegionServer接收數據以后,將數據寫到每一個Region中。寫數據流程先寫WAL再寫MemStore,這里展開了一些技術細節
-
簡單介紹了HBase權限控制模型
需要說明的一點,本文所講到的MemStore其實是一種"簡化 " 后的模型,在2.0版本中,這里已經變的更加復雜,這些內容將在下一篇介紹Flush與Compaction的流程中詳細介紹。
本文作者:畢杰山
本文首發于:NoSQL漫談(nosqlnotes.com),歡迎關注"NoSQL漫談"。
來自:http://blog.csdn.net/nosqlnotes/article/details/79682656