:從建表到性能調優(yōu)的完整指南)
1. 為什么要在Java里直接操作HBase而不是繞道走很多剛接觸大數據存儲層的人會有一個疑問HBase本身已經提供了Shell客戶端敲幾行命令就能建表、插數據、查記錄為什么還要費勁用Java寫代碼去操作它這個問題我在帶新人的時候被問過不下十次。答案其實不復雜——Shell適合調試和臨時驗證但真正落到生產系統(tǒng)里數據寫入、讀取、掃描這些動作必須嵌入到業(yè)務邏輯中由程序自動完成不可能靠人工敲命令。你總不能讓運維同事半夜爬起來手動往HBase里插一條記錄。HBase作為構建在HDFS之上的分布式列式存儲系統(tǒng)天然適合海量數據的隨機讀寫。它的數據模型和傳統(tǒng)關系型數據庫完全不同沒有固定的列定義每一行可以有任意多的列族和列限定符同一列族下的數據物理上存儲在一起。這種設計讓它在稀疏數據、寬表場景下表現(xiàn)非常出色。而Java API是HBase最原生、最完整的操作接口官方提供的客戶端就是Java寫的其他語言的客戶端本質上也是通過Thrift網關做了一層封裝。所以如果你想真正吃透HBaseJava API是繞不開的一關。這篇文章面向的是已經了解HBase基本概念表、行鍵、列族、時間戳但還沒動手用Java寫過操作代碼的開發(fā)者。我會從環(huán)境依賴開始一步步把建表、寫入、查詢、掃描、刪除這些核心操作講清楚同時把我在實際項目中踩過的坑和總結的經驗一并分享出來。代碼可以直接復制到你的工程里跑參數和配置我會解釋清楚為什么這么設。2. 動手之前依賴、配置與連接池的取舍2.1 Maven依賴到底該引哪些包用Java操作HBase第一步是把客戶端依賴引入工程。HBase的客戶端依賴并不算輕量因為它會連帶拉入Hadoop相關的若干jar包。以HBase 2.x版本為例核心依賴是hbase-client但實際使用中往往還需要hbase-common和hadoop-common。如果你用的是Maven可以這樣配置dependency groupIdorg.apache.hbase/groupId artifactIdhbase-client/artifactId version2.4.11/version /dependency dependency groupIdorg.apache.hbase/groupId artifactIdhbase-common/artifactId version2.4.11/version /dependency這里有個容易踩的坑hbase-client的版本必須和服務端HBase的版本保持兼容。HBase 1.x和2.x的API差異不小比如Connection接口在1.x里還是HConnection到了2.x才統(tǒng)一成Connection。如果你拿2.x的客戶端去連1.x的服務端編譯能過但運行時可能報各種奇怪的錯。我的建議是客戶端版本和服務端大版本保持一致小版本盡量對齊。另外很多教程會讓你引入hbase-server這是不對的。hbase-server包含了RegionServer的實現(xiàn)體積巨大客戶端根本不需要。引入它只會讓你的工程打包出來多出幾十兆的無用依賴還可能引起類沖突。2.2 Configuration對象的正確構造方式連接HBase需要一個Configuration對象它承載了ZooKeeper地址、端口等關鍵信息。最直接的方式是手動設置Configuration conf HBaseConfiguration.create(); conf.set(hbase.zookeeper.quorum, zk-host-1,zk-host-2,zk-host-3); conf.set(hbase.zookeeper.property.clientPort, 2181);HBaseConfiguration.create()會自動加載classpath下的hbase-site.xml如果這個文件存在里面的配置會覆蓋你手動設置的值。這一點要特別注意有時候你代碼里明明設了正確的ZooKeeper地址但連上去發(fā)現(xiàn)是另一套集群八成就是hbase-site.xml在作怪。排查的時候可以先打印一下conf.get(hbase.zookeeper.quorum)看看實際生效的值是什么。ZooKeeper的地址建議寫全所有節(jié)點用逗號分隔。只寫一個節(jié)點雖然也能連上但那個節(jié)點掛了客戶端就找不到集群了。HBase客戶端會從ZooKeeper上讀取hbase:meta表的位置信息所以ZooKeeper的穩(wěn)定性直接決定了客戶端能否正常連接。2.3 Connection是重量級對象千萬別每次new這是新手最容易犯的錯誤也是性能殺手。Connection對象的創(chuàng)建涉及與ZooKeeper的握手、元數據表的讀取等一系列網絡交互開銷很大。如果你每次操作都ConnectionFactory.createConnection(conf)在高并發(fā)場景下會把ZooKeeper壓垮連接數暴漲最終導致整個集群響應變慢。正確的做法是把Connection做成單例整個應用生命周期內只創(chuàng)建一次所有線程共享。Connection本身是線程安全的可以放心地在多線程環(huán)境下使用。但要注意從Connection獲取的Table對象不是線程安全的每個線程應該自己從Connection里拿一個Table實例。public class HBaseConnector { private static volatile Connection connection; public static Connection getConnection() throws IOException { if (connection null || connection.isClosed()) { synchronized (HBaseConnector.class) { if (connection null || connection.isClosed()) { Configuration conf HBaseConfiguration.create(); conf.set(hbase.zookeeper.quorum, zk-host-1,zk-host-2,zk-host-3); connection ConnectionFactory.createConnection(conf); } } } return connection; } }這段代碼用了雙重檢查鎖來保證線程安全。volatile關鍵字不能省否則可能出現(xiàn)指令重排序導致拿到未初始化完全的對象。雖然看起來有點啰嗦但這是經過驗證的可靠寫法。3. 建表這件事表描述符與預分區(qū)的實際寫法3.1 TableDescriptor和ColumnFamilyDescriptor的構建邏輯在HBase 2.x里建表用的是Admin對象配合TableDescriptorBuilder和ColumnFamilyDescriptorBuilder。這套Builder模式比1.x時代的HTableDescriptor要清晰不少但初次接觸的人容易被繞暈。核心邏輯是先構建列族描述符再把列族加進表描述符最后通過Admin創(chuàng)建表。Admin admin connection.getAdmin(); ColumnFamilyDescriptor cfDescriptor ColumnFamilyDescriptorBuilder .newBuilder(Bytes.toBytes(info)) .setMaxVersions(3) .setTimeToLive(7 * 24 * 60 * 60) .build(); TableDescriptor tableDescriptor TableDescriptorBuilder .newBuilder(TableName.valueOf(user_behavior)) .setColumnFamily(cfDescriptor) .build(); if (!admin.tableExists(TableName.valueOf(user_behavior))) { admin.createTable(tableDescriptor); }setMaxVersions(3)表示每個單元格保留最近3個版本的數據。HBase的版本控制是按時間戳來的每次寫入都會帶上時間戳不指定的話默認用當前時間。如果你只需要最新值設成1就行能省不少存儲空間。setTimeToLive設置的是數據的存活時間單位是秒上面設的是7天。超過這個時間的數據會在Major Compaction時被清理掉。3.2 預分區(qū)別讓所有數據擠在一個Region里新建的表默認只有一個Region所有寫入都會落到這一個Region上。在數據量小的時候沒問題但當寫入量上來之后單個Region會成為瓶頸而且自動分裂的過程會引起短暫的不可用。所以生產環(huán)境建表時一定要做預分區(qū)。預分區(qū)有兩種常用方式。第一種是直接指定分割點byte[][] splitKeys new byte[][] { Bytes.toBytes(1), Bytes.toBytes(2), Bytes.toBytes(3), Bytes.toBytes(4), Bytes.toBytes(5), Bytes.toBytes(6), Bytes.toBytes(7), Bytes.toBytes(8), Bytes.toBytes(9) }; admin.createTable(tableDescriptor, splitKeys);這樣會創(chuàng)建10個Region行鍵以0開頭的落在第一個Region以1開頭的落在第二個以此類推。分割點的選擇取決于你的行鍵分布特征。如果行鍵是用戶ID的哈希值那均勻取模分割就行如果行鍵有時間前綴那就要小心熱點問題。第二種方式是用createTable的重載方法指定Region數量和分割算法但實際用得最多的還是手動指定分割點因為可控性最強。注意預分區(qū)的分割點必須是有序的而且不能重復。如果傳入無序的數組創(chuàng)建表時會直接拋異常。另外分割點的數量決定了Region的數量Region數量等于分割點數量加一。3.3 列族數量不是越多越好HBase的列族在物理上是分開存儲的每個列族對應一組HFile。列族越多寫入時的flush和compaction開銷就越大。官方建議一張表的列族數量控制在2到3個以內。我見過有人把每個字段都設成一個列族結果寫入性能慘不忍睹。列族的命名也有講究。用簡短的英文單詞比如info、data、meta不要用中文或者特殊字符。雖然HBase底層用字節(jié)數組存儲理論上支持任意字符但列族名會出現(xiàn)在HFile路徑和監(jiān)控指標里用奇怪的字符會給運維帶來麻煩。4. 寫入與讀取Put、Get和批量操作的性能差異4.1 單條Put的寫法與時間戳控制寫入數據用Put對象指定行鍵、列族、列限定符和值Table table connection.getTable(TableName.valueOf(user_behavior)); Put put new Put(Bytes.toBytes(user_001)); put.addColumn(Bytes.toBytes(info), Bytes.toBytes(name), Bytes.toBytes(張三)); put.addColumn(Bytes.toBytes(info), Bytes.toBytes(age), Bytes.toBytes(28)); put.addColumn(Bytes.toBytes(info), Bytes.toBytes(city), Bytes.toBytes(北京)); table.put(put);addColumn有多個重載版本可以指定時間戳。如果不指定HBase會用RegionServer的當前時間。在需要回放歷史數據或者做數據修正的場景下手動指定時間戳很有用。但要注意時間戳是long類型單位是毫秒。如果你寫入的時間戳比已有數據的時間戳小那這條數據雖然寫進去了但查詢時默認返回的是時間戳最大的版本你可能看不到剛寫的數據。table.put(put)每次調用都是一次RPC請求。如果你要寫入大量數據一條一條put效率極低。這時候應該用BufferedMutator或者批量put(ListPut)。4.2 批量寫入BufferedMutator與List 的選擇table.put(ListPut)會把一批Put打包成一個RPC請求發(fā)給RegionServer減少了網絡往返次數。但它的缺點是同步的調用線程會阻塞直到所有數據寫入完成。而且如果一批數據太大會導致單次RPC超時。BufferedMutator則是異步的它內部維護了一個緩沖區(qū)數據先寫到緩沖區(qū)達到閾值后自動批量發(fā)送。寫入線程不需要等待服務端確認吞吐量更高。BufferedMutatorParams params new BufferedMutatorParams(TableName.valueOf(user_behavior)) .writeBufferSize(4 * 1024 * 1024); BufferedMutator mutator connection.getBufferedMutator(params); for (int i 0; i 100000; i) { Put put new Put(Bytes.toBytes(user_ i)); put.addColumn(Bytes.toBytes(info), Bytes.toBytes(age), Bytes.toBytes(String.valueOf(i % 100))); mutator.mutate(put); } mutator.flush(); mutator.close();writeBufferSize設的是緩沖區(qū)大小默認是2MB。設大一點能提高吞吐量但也會增加內存占用和丟數據的風險——如果客戶端進程崩潰緩沖區(qū)里還沒發(fā)送的數據就丟了。所以BufferedMutator適合對可靠性要求不是極端高的場景比如日志采集。如果數據一條都不能丟還是老老實實用同步批量寫。4.3 Get查詢指定列族和列限定符的粒度控制讀取單行數據用GetGet get new Get(Bytes.toBytes(user_001)); get.addFamily(Bytes.toBytes(info)); get.addColumn(Bytes.toBytes(info), Bytes.toBytes(name)); Result result table.get(get); byte[] nameBytes result.getValue(Bytes.toBytes(info), Bytes.toBytes(name)); String name Bytes.toString(nameBytes);addFamily和addColumn是可選的。如果不指定Get會返回該行所有列族的所有列。在寬表場景下一行可能有上百個列全查出來既浪費網絡帶寬又浪費內存。所以精確指定需要的列是一個好習慣。Result對象還提供了rawCells()方法可以拿到所有單元格的詳細信息包括時間戳。如果你需要做版本對比或者數據審計這個方法很有用。4.4 Scan掃描setStartRow和setStopRow的邊界陷阱Scan用于范圍查詢是HBase最強大的功能之一Scan scan new Scan(); scan.setStartRow(Bytes.toBytes(user_001)); scan.setStopRow(Bytes.toBytes(user_100)); scan.addFamily(Bytes.toBytes(info)); scan.setCaching(500); scan.setBatch(100); ResultScanner scanner table.getScanner(scan); for (Result result : scanner) { // 處理每一行 } scanner.close();這里有幾個關鍵點。setStartRow是包含的setStopRow是不包含的。也就是說上面的Scan會返回行鍵從user_001到user_099的數據user_100不會被返回。這個左閉右開的區(qū)間設計經常讓人搞混我第一次用的時候就多查了一條數據。setCaching(500)控制的是每次RPC從服務端拉取多少行到客戶端。默認值很小好像是100在掃描大量數據時會導致頻繁的RPC調用。設大一點能顯著提高掃描速度但也會增加客戶端內存壓力。500到1000是一個比較平衡的值。setBatch(100)控制的是每行返回多少個單元格。這個參數只在行特別寬列非常多的時候才需要設置。如果一行有幾千個列一次性返回會導致單次RPC響應過大甚至OOM。設成100表示每行分多次返回。注意ResultScanner使用完畢后必須關閉否則會泄漏連接資源。建議用try-with-resources或者try-finally保證關閉。5. 刪除操作與過濾器那些文檔里不會細說的細節(jié)5.1 Delete的三種粒度行、列族、列刪除數據用Delete對象粒度可以細到列// 刪除整行 Delete deleteRow new Delete(Bytes.toBytes(user_001)); // 刪除整個列族 Delete deleteFamily new Delete(Bytes.toBytes(user_001)); deleteFamily.addFamily(Bytes.toBytes(info)); // 刪除指定列 Delete deleteColumn new Delete(Bytes.toBytes(user_001)); deleteColumn.addColumn(Bytes.toBytes(info), Bytes.toBytes(city)); table.delete(deleteColumn);HBase的刪除并不是立即物理刪除數據而是寫入一個墓碑標記tombstone。真正的數據清理發(fā)生在Major Compaction時。所以刪除操作本身很快但不會立刻釋放存儲空間。如果你刪了大量數據后發(fā)現(xiàn)磁盤沒降不要慌等compaction跑完就好了。還有一個容易忽略的點刪除操作也會帶上時間戳。如果你刪除時指定的時間戳小于數據的時間戳那刪除標記不會生效數據依然能查到。默認情況下Delete使用當前時間戳所以一般不會有問題。但如果你在做數據修正手動指定了舊時間戳就要小心這個坑。5.2 Filter的實戰(zhàn)用法PrefixFilter和RowFilter過濾器是HBase Scan的利器可以在服務端過濾數據減少網絡傳輸。常用的有PrefixFilter、RowFilter、SingleColumnValueFilter等。// 前綴過濾只返回行鍵以user_1開頭的行 PrefixFilter prefixFilter new PrefixFilter(Bytes.toBytes(user_1)); Scan scan new Scan(); scan.setFilter(prefixFilter); // 行鍵正則過濾 RowFilter rowFilter new RowFilter( CompareFilter.CompareOp.EQUAL, new RegexStringComparator(user_\\d{3}) ); scan.setFilter(rowFilter);PrefixFilter的效率很高因為它可以直接定位到起始行然后順序掃描到不滿足前綴為止。RowFilter配合正則表達式則靈活得多但正則是逐行匹配的性能不如PrefixFilter。如果前綴能滿足需求優(yōu)先用PrefixFilter。SingleColumnValueFilter用于按列值過濾但要注意它的一個特性如果某行沒有指定的列默認行為是跳過這行。如果你希望沒有該列的行也被返回需要調用setFilterIfMissing(false)。這個默認行為坑過不少人。5.3 過濾器組合FilterList的AND與OR多個過濾器可以組合使用FilterList filterList new FilterList(FilterList.Operator.MUST_PASS_ALL); filterList.addFilter(new PrefixFilter(Bytes.toBytes(user_))); filterList.addFilter(new RowFilter( CompareFilter.CompareOp.EQUAL, new RegexStringComparator(.*_00[1-9]) )); scan.setFilter(filterList);MUST_PASS_ALL相當于AND所有過濾器都通過才返回MUST_PASS_ANY相當于OR任意一個通過就返回。過濾器組合的順序會影響性能把過濾力度大的放在前面能減少后續(xù)過濾器的計算量。6. 連接管理與異常處理生產環(huán)境必須考慮的事6.1 連接泄漏的常見原因與排查方法Connection和Table都是需要關閉的資源。雖然Connection建議做成單例長期持有但Table對象在用完后應該及時關閉。如果你在代碼里頻繁connection.getTable()卻不關閉RegionServer上的連接數會持續(xù)增長最終達到上限后拒絕新連接。排查連接泄漏的一個實用方法是看RegionServer的Web UI里面有當前活躍的RPC連接數。如果這個數字只增不減基本可以確定有泄漏。另一個方法是在代碼里加日志記錄每次getTable和close的調用對比數量是否匹配。6.2 RetriesExhaustedException的觸發(fā)條件與應對RetriesExhaustedException是HBase客戶端最常見的異常之一通常意味著客戶端重試了多次仍然無法完成操作。觸發(fā)原因可能有很多RegionServer宕機、網絡抖動、Region正在分裂、ZooKeeper會話超時等。應對這個異常首先要看異常信息里的詳細描述它會告訴你重試了多少次、每次失敗的原因是什么。如果是短暫的網絡問題客戶端會自動重試并恢復如果是RegionServer真的掛了那需要等Master把Region重新分配后才能恢復。調整重試參數可以改變客戶端的行為conf.set(hbase.client.retries.number, 5); conf.set(hbase.client.pause, 1000); conf.set(hbase.client.operation.timeout, 30000);hbase.client.retries.number是重試次數默認是15次設小一點可以讓失敗更快暴露出來。hbase.client.pause是重試間隔的基數實際間隔會在這個基礎上做指數退避。hbase.client.operation.timeout是單次操作的超時時間單位毫秒。6.3 批量寫入時的部分失敗處理用table.put(ListPut)批量寫入時如果部分數據失敗HBase會拋出RetriesExhaustedWithDetailsException。這個異常里包含了成功和失敗的記錄列表try { table.put(putList); } catch (RetriesExhaustedWithDetailsException e) { ListThrowable exceptions e.getCauses(); ListRow failedRows e.getFailedRows(); // 對失敗的行做重試或記錄 }拿到失敗的行之后可以根據異常類型決定是重試還是丟棄。如果是NotServingRegionException說明Region正在遷移等一會兒重試通常能成功如果是DoNotRetryIOException那重試也沒用需要檢查數據或表結構。7. 我踩過的那些坑與性能調優(yōu)的實戰(zhàn)心得7.1 行鍵設計不當導致的熱點問題行鍵設計是HBase使用中最重要的一環(huán)沒有之一。我見過一個項目用時間戳作為行鍵前綴結果所有寫入都集中在最后一個Region上其他Region閑著集群資源嚴重浪費。這就是典型的熱點問題。解決熱點問題的常用方法是給行鍵加鹽或者做哈希。加鹽就是在行鍵前面拼一個隨機前綴比如(hash(userId) % 10) _ userId。這樣數據會均勻分布到10個Region上。但加鹽之后按userId范圍查詢就變得困難了因為相同userId的數據可能在不同的Region上。所以加鹽適合隨機讀寫的場景不適合范圍掃描。另一種方法是反轉行鍵。如果行鍵是遞增的ID反轉之后高位變化低位不變也能起到分散寫入的效果。比如user_0001反轉成1000_resu。這種方法適合ID長度固定的場景。7.2 Scan時setCaching設太大反而變慢setCaching設大一點能減少RPC次數但并不是越大越好。如果設成10000客戶端一次要從服務端拉10000行數據這會導致單次RPC響應非常大網絡傳輸時間長客戶端內存占用高。而且如果掃描到一半客戶端處理不過來服務端可能已經超時了。我的經驗值是500到1000。在這個范圍內RPC次數和單次數據量比較平衡。當然具體值還要看單行數據的大小如果每行數據很大比如幾KB那caching設小一點更合適。7.3 客戶端版本與服務端不匹配的詭異報錯前面提過版本兼容的問題這里再展開說一下。HBase 2.x的客戶端連1.x的服務端最常見的報錯是NoSuchMethodError或者ClassNotFoundException。因為2.x的API里有些方法在1.x里不存在編譯時用的是2.x的jar所以能過運行時加載的是1.x的類就找不到了。還有一種情況是Hadoop版本不匹配。HBase依賴Hadoop的Configuration類如果客戶端引入的Hadoop版本和服務端的差異太大Configuration的序列化格式可能不兼容導致連接建立后讀取配置出錯。排查這類問題先確認客戶端和服務端的版本矩陣然后看異常堆棧里是哪個類出的問題。7.4 關于Bytes工具類的使用建議HBase提供了Bytes工具類來做byte[]和基本類型之間的轉換。用的時候要注意Bytes.toBytes(String)用的是UTF-8編碼Bytes.toString(byte[])也是按UTF-8解碼。如果數據里包含非UTF-8的字節(jié)轉換會出問題。對于二進制數據直接用byte[]操作不要經過String轉換。另外Bytes.toBytes(int)和Bytes.toBytes(long)生成的字節(jié)數組長度不同比較的時候要注意。行鍵比較是按字節(jié)序進行的不是按數值大小。比如Bytes.toBytes(10)和Bytes.toBytes(9)比較結果是10小于9因為字符1的ASCII碼小于9。如果行鍵是數值建議統(tǒng)一用固定長度編碼或者用Bytes.toBytes(long)保證長度一致。7.5 監(jiān)控與調優(yōu)的日常檢查項在生產環(huán)境跑HBase客戶端有幾個指標需要定期關注。RegionServer的blockCacheHitRatio反映讀緩存命中率如果低于0.8說明緩存不夠或者訪問模式太隨機。compactionQueueSize如果持續(xù)大于0說明compaction跟不上寫入速度需要調整compaction策略或者增加RegionServer資源??蛻舳说膔pcQueueSize如果經常滿說明寫入壓力超過了RegionServer的處理能力需要考慮擴容或者優(yōu)化行鍵設計。這些指標可以通過HBase自帶的Web UI查看也可以接入監(jiān)控系統(tǒng)做告警。我的習慣是每天早上花五分鐘掃一眼關鍵指標有異常趨勢就提前處理不要等到用戶反饋慢了才去查。7.6 一個實用的調試技巧用Shell驗證Java代碼的結果當你用Java代碼寫入數據后不確定是否成功最快的驗證方式是用HBase Shell查一下scan user_behavior, {LIMIT 5} get user_behavior, user_001Shell的結果和Java API的結果應該一致。如果不一致先檢查是不是連到了不同的集群ZooKeeper地址配錯了再檢查表名和列族名是否拼寫正確。這個技巧在調試階段能省很多時間。代碼寫多了會發(fā)現(xiàn)HBase的Java API本身并不復雜難的是對數據模型的理解和對集群行為的把握。同樣的API調用在不同的行鍵設計和表結構下性能可能差幾十倍。所以不要只盯著代碼怎么寫多想想數據怎么組織、訪問模式是什么、集群能不能扛住。這些才是決定項目成敗的關鍵。