前置问题与边界

HBase 的一个值不是由“第几行第几列”定位,而是由 (row, family, qualifier, timestamp) 这组坐标定位。row 是 uninterpreted bytes,按字典序排序;family 在建表时定义,是存储、压缩、缓存、版本数和 TTL 等配置的边界;qualifier 是 family 之内的列限定符,可以动态出现;timestamp 是版本维度。

本篇回答一个核心问题:(row, family, qualifier, timestamp) 如何唯一定位一个值。写入路径、MVCC、WAL 和 flush 放到第 04 篇;Delete tombstone 在 compaction 中真正清理的条件放到第 07 篇;行级原子性放到第 10 篇。

HBase 的“宽列”容易被误写成两种东西。它不是关系数据库里的稀疏宽表,因为 qualifier 不需要全表预定义,也不存在空列占位。它也不是分析型列存,因为物理按 column family 组织 Store,目标是按 row key 的在线读写和范围 scan。

Cell 坐标图

flowchart LR
  R[row key bytes] --> C[Column]
  C --> F[column family]
  C --> Q[column qualifier]
  Q --> T[timestamp/version]
  T --> V[value bytes]

官方 Data Model 将 column 描述为 family:qualifier。Cell 是 row、column family、column qualifier 的组合,并包含 value 与 timestamp。另一个更适合实现层的说法是 {row, column, version} 精确指定一个 Cell;其中 column 又由 family 和 qualifier 组成。

Row:排序键不是业务字段

row key 是字节数组,HBase 不解释它的业务含义,只按字典序排序。这个排序影响 Region 范围、scan 范围和热点分布。字符串、数字、时间戳、复合 key 都只是编码方案,最终都要落成 bytes。

如果业务把 row key 设计成 20260829#userId,所有同一时间前缀的写入会挤在相邻 key 空间。若设计成 userId#reverseTimestamp,同一用户的近期数据容易被连续 scan,但跨用户按时间聚合就不再天然连续。row key 设计的本质是选择哪类查询获得连续性,哪类查询承担二次索引或离线计算成本。

Column Family:物理组织和配置边界

column family 必须在 schema 定义时声明。所有属于同一 family 的 qualifier 物理上存放在一起,并共享压缩、BlockCache、Bloom Filter、TTL、版本数等存储属性。官方文档也建议同一 family 内的数据具有相近访问模式和大小特征。

这意味着 family 不是“业务模块分组”的装饰字段。把冷热数据、大小差异极大的字段、访问频率完全不同的字段塞进同一 family,会让缓存、压缩和 compaction 难以按真实访问模式工作。反过来,family 也不宜随意增多,因为每个 Region 下每个 family 都对应 Store,StoreFile 数量、flush 和 compaction 成本都会被放大。

Qualifier:动态列但不是无约束 schema

qualifier 可以在同一个 family 里动态出现。cf:emailcf:phonecf:lastLogin 不需要预先在建表语句中列出。这个特性常被说成 schema-less,但准确说法是:HBase 的 qualifier 维度灵活,column family、row key、版本策略和访问模式仍然构成 schema。

动态 qualifier 适合属性稀疏、列名本身带业务含义的场景,例如用户画像标签、倒排索引 posting、事件属性集合。它不适合把每个时间点、每个用户或每个大对象都塞进 qualifier;那会把排序能力从 row key 空间挪走,scan 和热点治理反而变难。

Timestamp:版本坐标

timestamp 是 long 类型版本号。未显式指定 timestamp 时,HBase 通常使用 RegionServer 写入时的时间;也可以在 Put 中显式写入 timestamp。读请求默认返回最新可见版本;如果需要多个版本,必须在 column family 版本数配置和 Get/Scan 的 readVersions 设置上同时满足条件。

HBase 按 timestamp 降序组织版本,因此读取最新版本时可以先遇到较新的 Cell。多个写入使用相同 row、family、qualifier、timestamp 时,官方文档说明最后写入的值是可读取的值。这个“last write wins”不是跨行事务,也不是跨集群冲突解决策略,只是同一 Cell 版本坐标内的覆盖语义。

Delete:先写标记,再等清理机会

Delete 不会原地删除 HFile 中的旧值。HBase 使用 tombstone 标记删除语义,读路径在合并 Cell 时根据 tombstone 过滤旧值。真正移除 tombstone 和被覆盖旧值,需要等到满足条件的 compaction。TTL、版本数、快照引用和 compaction 类型都会影响何时能物理清理。

本篇只讲模型边界。第 07 篇会展开 minor compaction、major compaction、TTL、版本和 Delete marker 之间的细节。

关键对象和状态

对象 定义 状态变化 约束
Table row 的有序集合 split 成多个 Region schema 定义 family,不定义每个 qualifier
Row row key 与若干 Cell 按 row key 排序 row 内 mutation 是原子边界
Column Family qualifier 集合的物理前缀 Store、StoreFile、cache 配置随 family 生效 不能当作无限业务分组
Qualifier family 内列名 可以动态出现 列名过长会进入每个 Cell 的存储成本
Timestamp 版本号 读默认取最新版本 显式 timestamp 会影响可见版本顺序
Cell 坐标和值 Put 写入,Delete 标记过滤 value 是 uninterpreted bytes
Tombstone 删除标记 compaction 条件满足后清理 不是立即物理删除

最小实验

UNVERIFIED_RUNTIME:当前任务环境未运行 HBase 2.6.6、Hadoop 3.4.3、JDK 17 组合。以下实验用于复现版本与 Delete 语义,不提供伪造输出。

1
hbase shell
1
create_namespace 'lab'
1
create 'lab:t01_cell_version', {NAME => 'cf', VERSIONS => 3}
1
put 'lab:t01_cell_version', 'row-1', 'cf:name', 'v1', 1000
1
put 'lab:t01_cell_version', 'row-1', 'cf:name', 'v2', 2000
1
put 'lab:t01_cell_version', 'row-1', 'cf:name', 'v3', 3000
1
get 'lab:t01_cell_version', 'row-1', {COLUMN => 'cf:name'}
1
get 'lab:t01_cell_version', 'row-1', {COLUMN => 'cf:name', VERSIONS => 3}
1
delete 'lab:t01_cell_version', 'row-1', 'cf:name', 2000
1
get 'lab:t01_cell_version', 'row-1', {COLUMN => 'cf:name', VERSIONS => 3}

这组步骤观察四件事:同一 row/family/qualifier 可以有多个 timestamp;默认 get 只取最新可见版本;VERSIONS => 3 让多个版本可被读取;删除某个版本后,读路径会按删除标记过滤,而不是立即改写旧 HFile。

清理命令:

1
disable 'lab:t01_cell_version'
1
drop 'lab:t01_cell_version'
1
drop_namespace 'lab'

Java Public API 示例

UNVERIFIED_RUNTIME:以下代码只使用 HBase 2.6 公共 client API,未在本任务环境连接真实集群执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.nio.charset.StandardCharsets;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.client.Connection;
import org.apache.hadoop.hbase.client.ConnectionFactory;
import org.apache.hadoop.hbase.client.Get;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.Table;

public final class CellVersionExample {
public static void main(String[] args) throws Exception {
Configuration conf = HBaseConfiguration.create();
try (Connection connection = ConnectionFactory.createConnection(conf);
Table table = connection.getTable(TableName.valueOf("lab:t01_cell_version"))) {
byte[] row = "row-1".getBytes(StandardCharsets.UTF_8);
byte[] family = "cf".getBytes(StandardCharsets.UTF_8);
byte[] qualifier = "name".getBytes(StandardCharsets.UTF_8);

Put put = new Put(row);
put.addColumn(family, qualifier, 3000L, "v3".getBytes(StandardCharsets.UTF_8));
table.put(put);

Get get = new Get(row);
get.addColumn(family, qualifier);
get.readVersions(3);
Result result = table.get(get);
System.out.println(result.size());
}
}
}

示例只说明坐标表达方式:Put 面向单行,addColumn 指定 family、qualifier、timestamp 和 value;Get 指定同一行与列,并用 readVersions 请求多个版本。是否能读到多个版本,还取决于表的 column family 版本配置。

失败恢复

数据模型本身不负责恢复,但它决定 WAL replay 和读路径如何合并结果。WAL edit 记录的是 mutation;重放后仍然按照 row、family、qualifier、timestamp 形成 Cell。Delete 也是一种 marker,恢复后同样参与可见性判断。

因此,故障恢复不能写成“把文件恢复回来就结束”。HDFS 保住 WAL 和 HFile 副本后,HBase 还要把 mutation、版本、Delete marker 和 MVCC 可见性还原成表语义。

工程迁移

HBase 模型 迁移时要保留的问题 不能直接迁移的结论
RowKey 排序 查询是否能变成连续 key range 所有数据库都适合复合字符串 key
Column Family 物理组织是否按访问模式分组 family 越多越灵活
Qualifier 动态 稀疏属性如何编码 schema-less 等于没有 schema
Timestamp 版本 多版本读取和保留策略 版本等于事务时间
Tombstone 删除和物理清理分离 delete 后立刻释放空间

[PATTERN] 宽列模型的关键是把“逻辑列灵活”与“物理边界稳定”分开。动态 qualifier 提供表达能力,column family 和 row key 负责控制存储成本。

常见误解

误解一:HBase 没有 schema。HBase 不要求预定义 qualifier,但 family、row key、版本和 TTL 都是 schema。

误解二:空列会占空间。官方文档明确空 Cell 不存在,也不占用存储空间。

误解三:timestamp 总是物理写入时间。未指定时通常使用 RegionServer 写入时间;显式 timestamp 可以由客户端指定。

误解四:Delete 立即删除旧值。Delete 先产生 tombstone,物理清理依赖 compaction 条件。

误解五:同一行像关系数据库记录一样有固定列。HBase row 可以拥有不同 qualifier 集合。

练习

  1. 给用户画像表设计两个 column family,并说明每个 family 的访问频率和 TTL 是否相近。

  2. 解释为什么把每一天写成 qualifier 可能让按时间范围 scan 变困难。

  3. 对同一 Cell 写入三个 timestamp,分别说明默认 get、指定版本数 get、指定时间范围 get 的语义差异。

  4. 判断一个“动态字段很多”的业务是适合 qualifier,还是更适合把字段名编码进 row key。

  5. 说明 Delete marker 为什么必须等 compaction 才可能物理清理。

系列导航

篇号 主题 状态
00 导读:row key 决定数据位置 上一篇
01 数据模型:Cell、Column Family 与版本 本篇
02 集群架构:HMaster、RegionServer、ZooKeeper 与 hbase:meta 下一篇
03 Schema 与 row key 设计
04 写入路径:WAL、MVCC、MemStore 与 flush
05 读取路径:BlockCache、Bloom Filter 与 HFile block
06 HFile 内部结构
07 Compaction、TTL、版本与 Delete
08 Region 生命周期:split、merge 与 assignment
09 Client 路由与重试
10 行级原子性与一致性边界
11 RegionServer 崩溃与 WAL 恢复
12 跨集群 Replication
13 Snapshot、Backup 与恢复
14 Get、Scan、Filter 与分页
15 BufferedMutator 与 Bulk Load
16 Coprocessor、Endpoint 与 Phoenix 边界
17 Kerberos、RPC 保护与 ACL
18 Metrics、hbtop、Compaction 与性能调优
19 HBase 3.0 与设计边界

参考资料