欢迎光临
我们一直在努力

HBase入门

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 Keypersonal_info:namepersonal_info:cityoffice_info:tel
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,是客户端定位数据的"路由表"。


四、写流程与读流程:理解性能来源

写流程

  • 客户端向 ZooKeeper 请求,拿到 hbase:meta 表所在的 RegionServer
  • 读取 meta 表,缓存到连接对象中(这也是为什么 HBase 连接是"重量级"的,官方推荐一个进程只创建一个 Connection)
  • 调用 Table.put(),解析 RowKey,对照缓存的 meta 信息定位到具体 RegionServer
  • 数据先顺序追加写入 WAL(预写日志),直接落盘
  • 再写入对应的 MemStore(写缓存)并排序
  • 向客户端返回 ack——注意,这一步在数据落 HFile 之前就已经返回成功了
  • 等达到刷写时机后,MemStore 数据才批量刷写成 HFile
  • 为什么先写 WAL 再写 MemStore? 因为 MemStore 是内存结构,机器宕机数据就没了。写 WAL 保证了即使宕机,也能通过日志文件重建数据——这本质上和 MySQL 的 redo log 是同一个思路。

    读流程

  • 优先查 BlockCache(读缓存),如果之前读过,可能命中
  • 不管缓存是否命中,都需要再去读 MemStore 和对应的 HFile 文件——因为最新写入的数据可能还没刷写到 HFile,只在 MemStore 里
  • 把所有读到的数据合并版本后返回
  • 每次读取要碰三个地方(BlockCache、MemStore、HFile),效率天然比单点查询低,所以 HBase 做了三层优化:

    • HFile 自带索引:定位 RowKey 比全文件扫描快
    • BlockCache 缓存元数据:如果 HFile 没变化(记录在文件尾信息里),不用重复读
    • 布隆过滤器:每个 HFile 维护一个布隆过滤器,能快速判断某个 RowKey"大概率不存在"于这个文件里,从而跳过整个文件的扫描。它用 Hash 算法实现,不是绝对准确的(可能误判为存在),但绝不会漏判,所以不影响查询结果正确性,只影响要多扫一个文件的概率。

    五、RowKey 设计:全篇最重要的部分

    前面反复强调"查询只能按 RowKey 检索",这一节就是讲怎么设计。

    三种基础手法

  • 生成随机数/hash/散列值:打散写入,避免热点
  • 时间戳反转:让最新数据排在前面,适合"查最近数据"的场景
  • 字符串拼接:多字段拼接成一个 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 的设计能不能让目标数据在字典序上聚在一起。


    七、写在最后:入门阶段该抓哪些重点

    回顾全文,几个建议供参考:

  • RowKey 设计是重中之重——上手前先把业务会用到的查询模式列清楚(会按哪些字段查、要不要范围扫描),再决定 RowKey 怎么拼。这一步一旦定了表结构,后期改动成本很高,值得多花时间推敲。
  • 连接管理遵循单例模式——Connection 是重量级对象,一个进程创建一次、全局复用即可,不要在每次读写时都新建。
  • 优先用 Get,少用 Scan——只要 RowKey 设计合理,大多数点查场景 Get 就能高效命中;Scan 适合范围查询,但要靠 startRow/stopRow 收窄范围,避免全表扫描。
  • 列族数量尽量精简(官方建议 1-2 个)——列族越多,flush 和 compaction 的开销越大,对写入性能影响明显。
  • 至于更底层的架构原理(Master/RegionServer 内部分工、MemStore Flush 触发条件、Compaction 策略、Region Split 版本演进),适合在有了实际使用经验之后再回头深入,面试准备阶段也是重点复习对象。

    赞(0)
    未经允许不得转载:171主机测评 » HBase入门
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址