HBase 入门:从数据模型到实战
摘要:本文梳理 HBase 的核心数据模型(稀疏、分布式、多维、排序)、Master/RegionServer/ZooKeeper 架构、读写全流程,重点拆解 RowKey 设计方法论,并结合物流实时数仓项目给出 Java API(Put/Get/Scan)实战要点与优先级建议。本文整理自尚硅谷《大数据技术之HBase》课程。
一、HBase 是什么,为什么长这样
HBase 是以 HDFS 为底层存储的分布式、可扩展 NoSQL 数据库,设计理念直接照搬 Google 的 BigTable 论文。论文开篇那句话是理解 HBase 一切设计的钥匙:
Bigtable 是一个稀疏的、分布式的、持久的多维排序 map。
拆开看这四个词,HBase 的数据模型就理清了:
- 稀疏:同一张表里不同行可以有完全不同的列,没有值的列不占存储空间
- 分布式:数据切成 Region 分散在多台机器上
- 多维:定位一个值需要 行键(RowKey) + 列键(列族:列限定符) + 时间戳
- 排序:数据整体按 RowKey 的字典序存储
最后这个"排序"是全篇最重要的一句话——HBase 查询只能靠 RowKey 检索,不像 MySQL 可以对任意字段建索引。这意味着 RowKey 设计的好坏,直接决定了你的查询能不能跑得动。这也是本篇后半部分的重点。
二、数据模型:六个概念的关系
以官方逻辑视图为例:
| row_key1 | 张三 | 北京 | 010-1111111 |
| row_key2 | 王五 | 广州 | (空) |
- Name Space(命名空间):相当于关系型数据库的 database。HBase 自带两个:hbase(存内置系统表)和 default(用户默认命名空间)。
- Table:建表时只需要声明列族,不需要声明具体的列。这是和 MySQL 最大的区别——字段可以动态、按需写入,不需要提前定义 schema,天然适合字段易变的场景。
- Row:每行由一个 RowKey + 多个 Column 组成,数据按 RowKey 字典序存储,查询只能按 RowKey 检索。
- Column:由 Column Family(列族) + Column Qualifier(列限定符) 组成,比如 info:name。建表只需指定列族,列限定符可以随写随建。
- Time Stamp:标识数据版本,每次写入系统自动打上时间戳,默认读取最新版本。
- Cell:由 {rowkey, column family:column qualifier, timestamp} 唯一确定的存储单元,数据以字节数组形式存储。
物理存储结构和逻辑视图是两回事。 逻辑视图里的空单元格,底层根本不存储——每个非空 Cell 都单独存成一条 {RowKey, ColumnFamily, ColumnQualifier, Timestamp, Type, Value} 记录。这就是"稀疏"的物理体现:没有数据的地方不占空间,不像关系型数据库的 NULL 还得占一个字段位。
三、基本架构:四个角色
HBase 集群由四部分组成,理解各自职责就理解了整个读写链路:
- Master(HMaster):通常部署在 NameNode 上,负责管理元数据表 hbase:meta(接收表的创建/修改/删除)、监控 Region 是否需要负载均衡、故障转移、拆分。不直接参与数据的读写。
- RegionServer(HRegionServer):通常部署在 DataNode 上,真正干活的角色——负责 Cell 级别的读写(put/get),以及 Region 拆分合并的实际执行。
- ZooKeeper:做 Master 高可用、记录 RegionServer 部署信息、存储 hbase:meta 表的位置信息。客户端读写数据时直接连 ZooKeeper 拿 meta 表位置,不用连 Master——这样设计是为了让 Master 专注管理,减轻它的压力。
- HDFS:提供最终的底层存储和高容错支持。
有个细节值得记住:hbase:meta 本质上和其他 HBase 表没区别,只是在 list 命令里被过滤掉了,它记录了每个 Region 的起始位置和所在的 RegionServer,是客户端定位数据的"路由表"。
四、写流程与读流程:理解性能来源
写流程
为什么先写 WAL 再写 MemStore? 因为 MemStore 是内存结构,机器宕机数据就没了。写 WAL 保证了即使宕机,也能通过日志文件重建数据——这本质上和 MySQL 的 redo log 是同一个思路。
读流程
每次读取要碰三个地方(BlockCache、MemStore、HFile),效率天然比单点查询低,所以 HBase 做了三层优化:
- HFile 自带索引:定位 RowKey 比全文件扫描快
- BlockCache 缓存元数据:如果 HFile 没变化(记录在文件尾信息里),不用重复读
- 布隆过滤器:每个 HFile 维护一个布隆过滤器,能快速判断某个 RowKey"大概率不存在"于这个文件里,从而跳过整个文件的扫描。它用 Hash 算法实现,不是绝对准确的(可能误判为存在),但绝不会漏判,所以不影响查询结果正确性,只影响要多扫一个文件的概率。
五、RowKey 设计:全篇最重要的部分
前面反复强调"查询只能按 RowKey 检索",这一节就是讲怎么设计。
三种基础手法
一个完整案例,展示设计思维
需求:存储消费记录,要同时支持"查张三 2021年12月的消费总额"和"查所有人 2021年12月的消费总额"。
第一次尝试——RowKey 设计为 username_date:
scan: startRow -> zhangsan2021-12
stopRow -> zhangsan2021-12. (末尾用略大于 '-' 的字符卡边界)
这能满足"查张三"的需求,但完全没法满足"查所有人"——因为不同用户名开头,字典序上根本不连续,没法用一个 startRow/stopRow 区间圈出"所有人的12月数据"。
这暴露了 HBase RowKey 设计的核心特点:适用性强、泛用性差,一个 RowKey 设计通常只能完美服务一种查询模式。
第二次调整——把可枚举的字段往前放。时间是可枚举的(总共12个月),用户名不可枚举,所以时间应该在前:
RowKey 格式: date(yyyy-MM) + userdate(-dd hh:mm:ss ms)
查张三12月: startRow -> 2021-12_zhangsan stopRow -> 2021-12_zhangsan.
查所有人12月: startRow -> 2021-12 stopRow -> 2021-12.
两个需求都能用一次 scan 解决——这就是 RowKey 设计的方法论:分析所有查询模式,把可枚举、会作为过滤条件的字段往前排。
加上预分区之后的坑
如果给这张表做了预分区(比如按 hash(user+date) % 120 分成 120 个分区),会引入新问题:12月的数据分散在全部 120 个分区里,"查所有人12月"这个需求就得扫描全部 120 个分区,性能反而变差了。
解决方法是让分区号和月份产生对应关系:比如把 0~9 号分区固定只存1月数据,10~19号只存2月数据,以此类推。这样查某个月,只需要扫描对应的 10 个分区,而不是全部 120 个。
这个案例的价值不在于记住这个具体方案,而在于理解:RowKey 设计和分区设计要放在一起考虑,不能割裂——你的物流数仓如果给 HBase 维度表做了预分区,也要提前想清楚查询模式,不要等上线后发现某类查询要扫全表。
六、Java API:项目里真正要写的部分
连接管理
HBase 连接是重量级对象,官方明确建议一个进程只创建一个 Connection,用单例模式管理:
public class HBaseConnect {
public static Connection connection = null;
static {
try {
connection = ConnectionFactory.createConnection();
} catch (IOException e) {
e.printStackTrace();
}
}
public static void closeConnection() throws IOException {
if (connection != null) connection.close();
}
}
配置文件放在 resources/hbase-site.xml,只需要写 ZooKeeper 地址:
<configuration>
<property>
<name>hbase.zookeeper.quorum</name>
<value>hadoop102,hadoop103,hadoop104</value>
</property>
</configuration>
这一点直接对应你的 Flink Async I/O 场景:维度查询是高频操作,如果每次查询都新建一个 HBase 连接,性能会被连接创建开销拖垮。正确做法是在 RichAsyncFunction 的 open() 方法里初始化一次连接,在整个算子生命周期内复用。
写入(Put)
public static void putCell(String namespace, String tableName, String rowKey,
String columnFamily, String columnName, String value) throws IOException {
Table table = connection.getTable(TableName.valueOf(namespace, tableName));
Put put = new Put(Bytes.toBytes(rowKey));
put.addColumn(Bytes.toBytes(columnFamily), Bytes.toBytes(columnName), Bytes.toBytes(value));
table.put(put);
table.close();
}
注意:重复写入相同 RowKey + 相同列的数据,不会报错也不会覆盖丢失——会写入多个版本,默认读取时只返回最新版本。这一点在 CDC 场景下要小心:如果不做去重清理,同一维度多次更新会在 HBase 里堆积多个历史版本,占用存储空间。
读取(Get)——对应 Flink Async I/O 查维度表
public static void getCells(String namespace, String tableName, String rowKey,
String columnFamily, String columnName) throws IOException {
Table table = connection.getTable(TableName.valueOf(namespace, tableName));
Get get = new Get(Bytes.toBytes(rowKey));
get.addColumn(Bytes.toBytes(columnFamily), Bytes.toBytes(columnName));
Result result = table.get(get);
Cell[] cells = result.rawCells();
for (Cell cell : cells) {
String value = new String(CellUtil.cloneValue(cell));
System.out.println(value);
}
table.close();
}
这就是你在 Async I/O 里查维度表的核心逻辑:根据业务主键(比如运单号、商品ID)拼出 RowKey,用 Get 请求拿到对应的维度信息,再和主流数据关联。性能瓶颈往往不在这段代码本身,而在 RowKey 设计得好不好——如果 RowKey 设计合理(比如维度表按查询主键直接做 RowKey),Get 是 O(1) 级别的高效点查;如果设计不合理需要 Scan 扫描一个区间再过滤,性能会明显下降。
扫描(Scan)
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(startRow)); // 默认包含
scan.withStopRow(Bytes.toBytes(stopRow)); // 默认不包含
ResultScanner scanner = table.getScanner(scan);
for (Result result : scanner) {
// 处理每一行
}
Scan 用于范围查询,官方不建议扫描过多数据,务必用 startRow/stopRow 收窄范围——这再次印证了前面 RowKey 设计部分的结论:范围查询的效率完全取决于 RowKey 的设计能不能让目标数据在字典序上聚在一起。
七、写在最后:入门阶段该抓哪些重点
回顾全文,几个建议供参考:
至于更底层的架构原理(Master/RegionServer 内部分工、MemStore Flush 触发条件、Compaction 策略、Region Split 版本演进),适合在有了实际使用经验之后再回头深入,面试准备阶段也是重点复习对象。


