1. 程式人生 > >最近學習了HBase

最近學習了HBase

HBase是什麼

最近學習了HBase,正常來說寫這篇文章,應該從DB有什麼缺點,HBase如何彌補DB的缺點開始講會更有體感,但是本文這些暫時不講,只講HBase,把HBase相關原理和使用講清楚,後面有一篇文章會專門講DB與NoSql各自的優缺點以及使用場景。

HBase是谷歌Bigtable的開源版本,2006年穀歌釋出《Bigtable:A Distributed Storage System For Structured Data》論文之後,Powerset公司就宣佈HBase在Hadoop專案中成立,作為子專案存在。後來,在2010年左右逐漸成為Apache旗下的一個頂級專案,因此HBase名稱的由來就是由於其作為Hadoop Database存在的,用於儲存非結構化、半結構化的資料。

下圖展示了HBase在Hadoop生態中的位置:

可以看到HBase建立在HDFS上,HBase內部管理的檔案全部都是儲存在HDFS中,同時MapReduce這個計算框架在HBase之上又提供了高效能的計算能力來處理海量資料。

 

HBase的特點與不足

HBase的基本特點概括大致如下:

  • 海量資料儲存(PB)級別,在PB級別資料以及採用廉價PC儲存的情況下,資料能在幾十到百毫秒內返回資料
  • 高可用,WAL + Replication機制保證叢集異常不會導致寫入資料丟失與資料損壞,且HBase底層使用HDFS,HDFS本身也有備份
  • 資料寫入效能強勁
  • 列式儲存,和傳統資料庫行式儲存有本質的區別,這個在之後HBase儲存原理的時候詳細解讀
  • 半結構化或非結構化資料儲存
  • 儲存稀鬆靈活,列資料為空的情況下不佔據儲存空間
  • 同一份資料,可儲存多版本號資料,方便歷史資料回溯
  • 行級別事務,可以保證行級別資料的ACID特性
  • 擴容方便,無需資料遷移,及擴即用

當然事事不是完美的,HBase也存在著以下兩個最大的不足:

  • 無法做到條件查詢,這是最大的問題,假如你的程式碼中存在多個查詢條件,且每次使用哪個/哪組查詢條件不確定,那麼使用HBase是不合適的,除非資料冗餘,設計多份RowKey
  • 做不了分頁,資料總記錄數幾乎無法統計,因為HBase本身提供的錶行數統計功能是一個MapReduce任務,極為耗時,既然拿不到總記錄數,分頁總署也沒法確定,自然分頁也無法做了

總的來說,對於HBase需要了解以上的一些個性應該大致上就可以了,根據HBase的特點與不足,在合適的場景下選擇使用HBase,接下來針對HBase的一些知識點逐一解讀。

 

HBase的基本架構

下圖是HBase的基本架構:

從圖上可以看到,HBase中包含的一些元件如下:

  • Client----包含訪問HBase的介面
  • Zookeeper----通過選舉保證任何時候叢集中只有一個HMaster、HMaster與Region Server啟動時向註冊、儲存所有Region的定址入口、實時監控Region Server的上下線資訊並實時通知給HMaster、儲存HBase的Schema與Table原資料
  • HMaster----為Region Server分配Region、負責Region Server的負載均衡、發現失效的Region Server並重新分配其上的Region、管理使用者對Table的增刪改查
  • Region Server----維護Region並處理對Region的IO請求、切分在執行過程中變得過大的Region

其中,Region是分散式儲存和負載均衡中的最小單元,不過並不是儲存的最小單元。Region由一個或者多個Store組成,每個Store儲存一個列簇;每個Store又由一個memStore和0~N個StoreFile組成,StoreFile包含HFile,StoreFile只是對HFile做了輕量級封裝,底層就是HFile。

介於上圖元素有點多,我這邊畫了一張圖,把HBase架構中涉及的元素的關係理了一下:

 

HBase的基本概念

接著看一下HBase的一些基本概念,HBase是以Table(表)組織資料的,一個Table中有著以下的一些元素:

  • RowKey(行鍵)----即關係型資料庫中的主鍵,它是唯一的,在HBase中這個主鍵可以是任意的字串,最大長度為64K,在內部儲存中會被儲存為位元組陣列,HBase表中的資料是按照RowKey的字典序排列的。例如1、2、3、4、5、10,按照自然數的順序是這樣的,但是在HBase中1後面跟的是10而不是2,因此在設計RowKey的時候一定要充分利用字典序這個特性,將一下經常讀取的行儲存到一起或者靠近,減少Scan耗時,提高讀取的效率
  • Column Family(列族)----表Schema的一部分,HBase表中的每個列都歸屬於某個列族,即列族是由一系列的列組成的,必須在建立表的時候就指定好。列明都以列族作為字首,例如courses:history、courses:math都屬於courses這個列族。列族不是越多越好,過多的列族會導致io增多及分裂時資料不均勻,官方推薦列族數量為1~3個。列族不僅能幫助開發者構建資料的語義邊界,還能有助於開發者設定某些特性,例如可以指定某個列族內的資料壓縮形式。訪問控制、磁碟和記憶體怒的使用統計都是在列族層面進行的,
  • Column(列)----一般從屬於某個列族,列的數量一般沒有強限制,一個列族中可以有數百萬列且這些列都可以動態新增
  • Version Number(版本號)----HBase中每一列的值或者說每個單元格的值都是具有版本號的,預設使用系統當前時間,精確到毫秒,也可以使用者顯式地設定。每個單元格中,不同版本的資料按照時間倒序排序,即最新的資料排在最前面。另外,為了避免資料存在過多版本造成的管理(儲存 + 索引)負擔,HBase提供了兩種資料版本回收的方式,一是儲存資料的最後n個版本,二是儲存最近一段時間內的版本,使用者可以針對每個列族進行設定
  • Cell(單元格)----一個單元格就是由RowKey、Column Family:Column、Version Number唯一確定的,Cell中的資料是沒有型別的,全部都是位元組碼

另外一個概念就是,訪問HBase Table中的行,只有三種方式:

  • 通過單個Row Key訪問
  • 通過Row Key的range
  • 全表掃描

這部分介紹的Table、RowKey、Column Family、Column等都屬於邏輯概念,而上部分中的Region Server、Region、Store等都屬於物理概念,下圖展示了邏輯概念與物理概念之間的關係:

即:table和region是一對多的關係,因為table的資料可能被打在多個region中;region和columnFamily是一對多的關係,一個store對應一個columnFamily,一個region可能對應多個store。

 

HBase的邏輯表檢視與物理表檢視

接著看一下HBase中的表邏輯檢視與物理檢視。首先是邏輯表檢視:

看到這裡定義了2個列族,一個Personal Info、一個Family Info,對應到資料庫中,相當於把兩張表合併到一個一起。

從邏輯檢視看,上圖由ZhangSan、LiSi兩行組成,但是在實際物理儲存上卻不是按照這種方式進行的儲存:

看到主要是有兩點差別:

  • 一行被拆開了,按照列進行儲存
  • 空列不會被儲存,例如LiSi在Peronal Info中沒有Provice與Phone,在Family Info中沒有Brother

 

HBase的增刪改查

光說不練假把式,不能光講理論,程式碼也是要有的,為了方便起見,我用的是阿里雲HBase,和HBase一樣,只是省去了運維成本。當然雖然本人是內部員工,但是工作之外的學習是不會佔用公司資源的^_^悄悄告訴大家,阿里雲HBase有個福利,第一個月免費試用,想同樣玩一下HBase的可以去阿里雲搞一個。

首先新增一下pom依賴,用阿里雲指定的HBase,使用上和原生的HBase API一模一樣:

<dependency>
    <groupId>com.aliyun.hbase</groupId>
    <artifactId>alihbase-client</artifactId>
    <version>2.0.3</version>
</dependency>
<dependency>
    <groupId>jdk.tools</groupId>
    <artifactId>jdk.tools</artifactId>
    <version>1.8</version>
    <scope>system</scope>
    <systemPath>${JAVA_HOME}/lib/tools.jar</systemPath>
</dependency>

注意一下第二個dependency,jdk.tools不新增pom檔案可能會報錯"Missing artifact jdk.tools:jdk.tools:jar:1.8",錯誤原因是tools.jar包是JDK自帶的,pom.xml中以來的包隱式依賴tools.jar包,而tools.jar並未在庫中,因此需要將tools.jar包新增到jdk庫中。

首先寫個HBaseUtil,用單例模式來寫,好久沒寫了,順便練習一下:

 1 /**
 2  * 五月的倉頡https://www.cnblogs.com/xrq730/p/11134806.html
 3  */
 4 public class HBaseUtil {
 5 
 6     private static HBaseUtil hBaseUtil;
 7     
 8     private Configuration config = null;
 9     
10     private Connection connection = null;
11     
12     private Map<String, Table> tableMap = new HashMap<String, Table>();
13     
14     private HBaseUtil() {
15         
16     }
17     
18     public static HBaseUtil getInstance() {
19         if (hBaseUtil == null) {
20             synchronized (HBaseUtil.class) {
21                 if (hBaseUtil == null) {
22                     hBaseUtil = new HBaseUtil();
23                 }
24             }
25         }
26         
27         return hBaseUtil;
28     }
29     
30     /**
31      * 初始化Configuration與Connection
32      */
33     public void init(String zkAddress) {
34         config = HBaseConfiguration.create();
35         config.set(HConstants.ZOOKEEPER_QUORUM, zkAddress);
36         
37         try {
38             connection = ConnectionFactory.createConnection(config);
39         } catch (IOException e) {
40             e.printStackTrace();
41             System.exit(0);
42         }
43     }
44     
45     /**
46      * 建立table
47      */
48     public void createTable(String tableName, byte[]... columnFamilies) {
49         // HBase建立表的時候必須建立指定列族
50         if (columnFamilies == null || columnFamilies.length == 0) {
51             return ;
52         }
53         
54         TableDescriptorBuilder tableDescriptorBuilder = TableDescriptorBuilder.newBuilder(TableName.valueOf(tableName));
55         for (byte[] columnFamily : columnFamilies) {
56             tableDescriptorBuilder.setColumnFamily(ColumnFamilyDescriptorBuilder.newBuilder(columnFamily).build());
57         }
58         
59         try {
60             Admin admin = connection.getAdmin();
61             admin.createTable(tableDescriptorBuilder.build());
62             // 這個Table連線存入記憶體中
63             tableMap.put(tableName, connection.getTable(TableName.valueOf(tableName)));
64         } catch (Exception e) {
65             e.printStackTrace();
66             System.exit(0);
67         }
68         
69     }
70     
71     public Table getTable(String tableName) {
72         Table table = tableMap.get(tableName);
73         if (table != null) {
74             return table;
75         }
76         
77         try {
78             table = connection.getTable(TableName.valueOf(tableName));
79             if (table != null) {
80                 // table物件存入記憶體
81                 tableMap.put(tableName, table);
82             }
83             
84             return table;
85         } catch (IOException e) {
86             e.printStackTrace();
87             return null;
88         }
89     }
90     
91 }

注意,HBase中的資料一切皆二進位制,因此從上面程式碼到後面程式碼,字串全部都轉換成了二進位制。

接著定義一個BaseHBaseUtilTest類,把一些基本的定義放在裡面,保持主測試類清晰:

 1 /**
 2  * 五月的倉頡https://www.cnblogs.com/xrq730/p/11134806.html
 3  */
 4 public class BaseHBaseUtilTest {
 5 
 6     protected static final String TABLE_NAME = "student";
 7     
 8     protected static final byte[] COLUMN_FAMILY_PERSONAL_INFO = "personalInfo".getBytes();
 9     
10     protected static final byte[] COLUMN_FAMILY_FAMILY_INFO = "familyInfo".getBytes();
11     
12     protected static final byte[] COLUMN_NAME = "name".getBytes();
13     
14     protected static final byte[] COLUMN_AGE = "age".getBytes();
15     
16     protected static final byte[] COLUMN_PHONE = "phone".getBytes();
17     
18     protected static final byte[] COLUMN_FATHER = "father".getBytes();
19     
20     protected static final byte[] COLUMN_MOTHER = "mother".getBytes();
21     
22     protected HBaseUtil hBaseUtil;
23     
24 }

第一件事情,建立Table,注意前面說的,HBase必須Table和列族一起建立:

 1 /**
 2  * 五月的倉頡https://www.cnblogs.com/xrq730/p/11134806.html
 3  */
 4 public class HBaseUtilTest extends BaseHBaseUtilTest {
 5 
 6     @Before
 7     public void init() {
 8         hBaseUtil = HBaseUtil.getInstance();
 9         hBaseUtil.init("xxx");
10     }
11     
12     /**
13      * 建立表
14      */
15     @Test
16     public void testCreateTable() {
17         hBaseUtil.createTable(TABLE_NAME, COLUMN_FAMILY_PERSONAL_INFO, COLUMN_FAMILY_FAMILY_INFO);
18     }
19     
20 }

我自己申請的HBase,zk地址就不給大家看啦,如果同樣申請了的,替換一下就好了。testCreateTable方法執行一下,就建立好了student表。接著利用put建立四條資料,多建立幾條,等下scan可以測試:

 1 /**
 2  * 新增資料
 3  */
 4 @Test
 5 public void testPut() throws Exception {
 6     Table table = hBaseUtil.getTable(TABLE_NAME);
 7     // 使用者1,使用者id:12345
 8     Put put1 = new Put("12345".getBytes());
 9     put1.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_NAME, "Lucy".getBytes());
10     put1.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_AGE, "18".getBytes());
11     put1.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_PHONE, "13511112222".getBytes());
12     put1.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_FATHER, "LucyFather".getBytes());
13     put1.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_MOTHER, "LucyMother".getBytes());
14     // 使用者2,使用者id:12346
15     Put put2 = new Put("12346".getBytes());
16     put2.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_NAME, "Lily".getBytes());
17     put2.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_AGE, "19".getBytes());
18     put2.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_PHONE, "13522223333".getBytes());
19     put2.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_FATHER, "LilyFather".getBytes());
20     put2.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_MOTHER, "LilyMother".getBytes());
21     // 使用者3,使用者id:12347
22     Put put3 = new Put("12347".getBytes());
23     put3.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_NAME, "James".getBytes());
24     put3.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_AGE, "22".getBytes());
25     put3.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_FATHER, "JamesFather".getBytes());
26     put3.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_MOTHER, "JamesMother".getBytes());
27     // 使用者4,使用者id:12447
28     Put put4 = new Put("12447".getBytes());
29     put4.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_NAME, "Micheal".getBytes());
30     put4.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_AGE, "22".getBytes());
31     put2.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_PHONE, "13533334444".getBytes());
32     put4.addColumn(COLUMN_FAMILY_FAMILY_INFO, COLUMN_MOTHER, "MichealMother".getBytes());
33     
34     table.put(Lists.newArrayList(put1, put2, put3, put4));
35 }

同樣的,執行一下testPut方法,四條資料就建立完畢了。注意為了提升處理效率,HBase的get、put這些API都提供的批量處理方式,這樣一次提交可以提交多條資料,發起一次請求即可,不用發起請求。

接著看一下利用Get API查詢資料:

 1 /**
 2  * 獲取資料
 3  */
 4 @Test
 5 public void testGet() throws Exception {
 6     Table table = hBaseUtil.getTable(TABLE_NAME);
 7     // get1,拿到全部資料
 8     Get get1 = new Get("12345".getBytes());
 9     // get2,只拿personalInfo資料
10     Get get2 = new Get("12346".getBytes());
11     get2.addFamily(COLUMN_FAMILY_PERSONAL_INFO);
12         
13     Result[] results = table.get(Lists.newArrayList(get1, get2));
14     if (results == null || results.length == 0) {
15         return ;
16     }
17         
18     for (Result result : results) {
19         printResult(result);
20     }
21 }
22 
23 private void printResult(Result result) {
24     System.out.println("====================分隔符====================");
25     printBytes(result.getValue(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_NAME));
26     printBytes(result.getValue(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_AGE));
27     printBytes(result.getValue(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_PHONE));
28     printBytes(result.getValue(COLUMN_FAMILY_FAMILY_INFO, COLUMN_FATHER));
29     printBytes(result.getValue(COLUMN_FAMILY_FAMILY_INFO, COLUMN_MOTHER));
30 }
31     
32 private void printBytes(byte[] bytes) {
33     if (bytes != null && bytes.length != 0) {
34         System.out.println(new String(bytes));
35     }
36 }

HBase查詢資料比較靈活的是,可以查詢RowKey下對應的所有資料、可以按照RowKey-Column Family的維度查詢資料、可以按照RowKey-Column Family-Column的維度查詢資料,也可以按照RowKey-Column Family-Column-Timestamp的維度查詢資料,可以查詢Timestamp區間內的資料,也可以查詢RowKey-Column Family-Column下所有Timestamp資料。上面的程式碼執行結果為:

====================分隔符====================
Lucy
18
13511112222
LucyFather
LucyMother
====================分隔符====================
Lily
19
13533334444

和我們的預期相符,即"12345"這個RowKey查詢出了所有資料,"12346"這個RowKey只查了personalInfo這個列族的資料。

最後這一部分我們看一下更新,更新的API和新增的API都是一樣的,都是Put:

@Test
public void testUpdate() throws Exception {
    Table table = hBaseUtil.getTable(TABLE_NAME);
    // 使用者1,使用者id:12345
    Put put = new Put("12346".getBytes());
    put.addColumn(COLUMN_FAMILY_PERSONAL_INFO, COLUMN_AGE, 1, "22".getBytes());
    table.put(put);
}

Get看一下執行12346這條資料的值:

Lily
19
13533334444

看到12346對應的資料,原本Age是19,更新到22,依然是19,這就是一個值得注意的點了。HBase的更新其實是往Table裡面新增一條記錄,按照Timestamp進行排序,最新的資料在前面,每次Get的時候將第一條資料取出來。在這裡我們指定的Timestamp=1,這個值落後於先前插入的Timestamp,自然就排在後面,因此讀取出來的Age依然是原值19,這個細節特別注意一下。

 

HBase的Scan

感覺前面篇幅有點大,所以這裡專門抽一個篇幅出來寫一下Scan,Scan是HBase掃描資料的方式。

首先可以看一下最基本的Scan:

 1 /**
 2  * 掃描
 3  */
 4 @Test
 5 public void testScan() throws Exception {
 6     Table table = hBaseUtil.getTable(TABLE_NAME);
 7     Scan scan = new Scan().withStartRow("12345".getBytes(), true).withStopRow("12347".getBytes(), true);
 8         
 9     ResultScanner rs = table.getScanner(scan);
10     if (rs != null) {
11         for (Result result : rs) {
12             printResult(result);
13         }
14     }
15 }

執行結果為:

====================分隔符====================
Lucy
19
13511112222
LucyFather
LucyMother
====================分隔符====================
Lily
19
13533334444
LilyFather
LilyMother
====================分隔符====================
James
22
JamesFather
JamesMother

表示查詢12345~12347這個範圍內的所有RowKey,withStartRow的第二個引數true表示包含,如果為false那麼12345這個RowKey就查不出來了。

進階的,HBase為我們提供了帶過濾器的Scan,一共有十來種,我這邊只演示兩種以及組合的情況,其他的查詢一下HBase API文件即可,2.1版本的API文件地址為http://hbase.apache.org/2.1/apidocs/index.html。演示程式碼如下:

 1 @Test
 2 public void testScanFilter() throws Exception {
 3     Table table = hBaseUtil.getTable(TABLE_NAME);
 4         
 5     System.out.println("********************RowFilter測試********************");
 6     Scan scan0 = new Scan().withStartRow("12345".getBytes(), true);
 7     scan0.setFilter(new RowFilter(CompareOperator.EQUAL, new BinaryComparator("12346".getBytes())));
 8     ResultScanner rs0 = table.getScanner(scan0);
 9     printResultScanner(rs0);
10         
11     System.out.println("********************PrefixFilter測試********************");
12     Scan scan1 = new Scan().withStartRow("12345".getBytes(), true);
13     scan1.setFilter(new PrefixFilter("124".getBytes()));
14     ResultScanner rs1 = table.getScanner(scan1);
15     printResultScanner(rs1);
16         
17     System.out.println("********************兩種Filter同時滿足測試********************");
18     Scan scan2 = new Scan().withStartRow("12345".getBytes(), true);
19     Filter filter0 = new RowFilter(CompareOperator.EQUAL, new BinaryComparator("12447".getBytes()));
20     Filter filter1 = new PrefixFilter("124".getBytes());
21     FilterList filterList = new FilterList(FilterList.Operator.MUST_PASS_ALL, filter0, filter1);
22     scan2.setFilter(filterList);
23     ResultScanner rs2 = table.getScanner(scan2);
24     printResultScanner(rs2);
25 }

執行結果為:

********************RowFilter測試********************
====================分隔符====================
Lily
19
13533334444
LilyFather
LilyMother
********************PrefixFilter測試********************
====================分隔符====================
Micheal
22
MichealMother
********************兩種Filter同時滿足測試********************
====================分隔符====================
Micheal
22
MichealMother

總的來說,HBase本質上是KV型NoSql,根據Key查詢Value是最高效的,Scan這個API還是慎用,範圍裡面的資料量小倒無所謂,一旦RowKey設計不合理,StartRow和EndRow沒有指定好,可能會造成大範圍的掃描,降低HBase整體能力。

 

HBase和KV型快取的區別

看了上面的程式碼演示,不知道大家有沒有和我一開始有一樣的疑問:HBase看上去也是K-V形式的,那麼它和支援KV型資料的快取(例如Redis、MemCache、Tair)有什麼區別?

我用一張表格總結一下二者的區別:

總的來說,同樣作為資料庫的NoSql替代方案,HBase更加適合用於海量資料的持久化場景,KV型快取更加適合用於對資料的高效能讀寫上。

 

HBase的Region分裂及會導致的熱點問題

經典問題,首先看一下什麼是Region分裂,只把Region分裂講清楚,不講具體Region分裂的實現方式,理由也很簡單,Region分裂細節學得再清楚,對工作中的幫助也不大,沒必要太過於追根究底。

Region分裂是HBase能夠擁有良好擴張性的最重要因素之一,也必然是所有分散式系統追求無限擴充套件性的一副良藥。通過前面的部分我們知道HBase的資料以行為單位儲存在HBase表中,HBase表按照多行被分割為多個Region,這個Region分佈在HBase叢集中,並且由Region Server程序負責講這些Region提供給Client訪問。一個Region中,RowKey是一個連續的範圍,也就是說表中的記錄在Region中是按照startKey到endKey的範圍為RowKey進行排序儲存的。通常一個表由多個Region構成,這些Region分佈在多個Region Server上,也就是說,Region是在Region Server中插入和查詢資料時負載均衡的物理機制。一張HBase表在剛剛建立的時候預設只有一個Region,所以關於這張表的請求都被路由到同一個Region Server,無論叢集中有多少Region Server,而一旦某個Region的大小達到一定值,就會自動分裂為兩個Region,這也就是為什麼HBase表在剛剛建立的階段不能充分利用整個叢集吞吐量的原因。

在HBase管理介面可以檢視每個Region,startKey與endKey的範圍,例如(圖片來自網路):

這裡特別注意一個點,RowKey是按照Key的字元自然順序進行排序的,因此RowKey=9的Key,會落在最後一個Region Server中而不是第一個Region Server中。

那麼什麼是熱點問題應該也很好理解了:

雖然HBase的單機讀寫效能強勁,但是當叢集中成千上萬的請求RowKey都落在aaaaa-ddddd之間,那麼這成千上萬請求最終落到Region Server1這臺伺服器上,一旦超出伺服器自身承受能力,那麼必然導致伺服器不可用甚至宕機。因此我們說設計RowKey的時候千萬把時間戳或者id自增的方式作為RowKey方案就是這個道理,時間戳或者id自增的方式,雖然最終可以讓RowKey落到不同的Region中,但是在當下或者當下往後的一段時間內,RowKey一定是會落到同一個Region中的,資料熱點問題將嚴重影響HBase叢集能力。

解決熱點問題通常有兩個方案,最初級的方案是設定預分割槽,即在Table建立的時候就先設定幾個Region,為每個Region劃分不同的startKey與endKey,但這麼做有以下兩個缺點:

  • 高度依賴RowKey,必須事先知道插入資料的RowKey的分佈
  • 即使事先知道插入資料的RowKey分佈,但是如果資料分佈不均勻或者存在熱點行,依然無法均勻分攤負載

但是無論如何,設定預分割槽依然是一種解決熱點問題的方案。

第二個解決方案是一勞永逸的解決方案也是使用HBase最核心的一個點:合理設計RowKey。即讓RowKey均勻分佈在Region中,大致有以下幾個方案可供參考:

  • 倒序。例如手機號碼135ABCD、135EFGH、135IJKL這種,字首沒有區分度,非常容易落到相同的Region中,此時做倒序即DCBA531、HGFE531、LKJI531,將有區分度的部分放在前面,就非常容易將資料散落在不同的Region中
  • 原資料加密,例如做MD5,因為MD5的隨機性是非常強的,因此做了MD5後,資料將會非常分散
  • 加隨機字首,比如ASCII碼中隨機選5位作為資料字首,同樣可以達到分散RowKey的效果,但是缺點是必須記住每個原資料對應的字首

無論如何,還是那句話,合理設計RowKey是HBase使用的核心。

 

WAL機制

最後講一下前面提到的WAL機制,WAL的全稱為Write Ahead Log,它是HBase的RegionServer在處理資料插入和刪除的過程中用來記錄操作內容的一種日誌,是用來做災難恢復的。

其實WAL並不是什麼新鮮思想,在分散式領域很常見:

  • mysql有binlog,記錄每一次資料變更
  • redis有aof,在開啟aof的情況下,每隔短暫時間,將這段時間產生的操作記錄檔案

其核心都是,變更資料前先寫磁碟日誌檔案,在系統發生異常的時候,重放日誌檔案對資料進行恢復,HBase的WAL機制也是一樣的思想,資料變更步驟為:

  • 首先從之前的圖上可以看到有HLog,HLog是實現WAL的類,一個RegionServer對應一個HLog,多個Region共享一個HLog,不過從HBase1.0版本開始可以定義多個HLog以提高吞吐量
  • 客戶端的一次資料提交先寫HLog,這個是告知客戶端資料提交成功的前提
  • HLog寫入成功後寫入MemStore
  • 當MemStore的值達到一定程度後,flush到hdfs,形成一個一個的StoreFile(HFile)
  • flush過後,HLog中對應的資料就沒用了,刪除

因為有了HLog,即使在MemStore中的資料還沒有flush到hdfs的時候系統發生了宕機或者重啟,資料都不會出現丟失。

&n