前置问题与边界

HBase schema 设计首先是 row key 设计,其次才是 column family 和 qualifier 的组织。row key 决定排序、Region 分布、范围 scan 和热点形态。column family 决定物理 Store、缓存、压缩、版本和 TTL 边界。qualifier 提供稀疏属性表达能力,但不替代 row key 的排序能力。

本篇回答一个核心问题:排序、前缀、salt、反转和时间桶如何影响热点与扫描。写入路径的 WAL/MemStore 状态放到第 04 篇;读取路径的 BlockCache、Bloom Filter 和 HFile block 放到第 05、06 篇;Region split 和 assignment 放到第 08 篇。

schema-less 不是没有 schema。HBase 不强制预定义 qualifier,但 row key 编码、family 数量、版本数、TTL、压缩、Bloom Filter 和访问模式都属于 schema 决策。

RowKey 决策图

flowchart TD
  Q[主要查询问题] --> A{是否需要连续范围 scan}
  A -->|是| R[把共同过滤维度放入 row key 前部]
  A -->|否| D[用散列或 salt 扩散写入]
  R --> H{是否单调递增写入}
  H -->|是| B[反转、salt 或时间桶]
  H -->|否| S[保持可读连续区间]
  D --> T[补二级索引或离线视图]
  B --> E[评估 scan fan-out]
  S --> E
  T --> E

这张图的核心不是某个具体格式,而是取舍:让一类查询变成连续 key range,通常会让另一类查询变成多段 scan 或外部索引。

排序决定局部性

HBase row key 是字节序排序。连续 key 会落在相邻 Region 中,也会被 scan 顺序访问。把用户 ID 放在前缀,会让单用户查询天然局部;把日期放在前缀,会让按日期扫描天然局部;把随机 salt 放在前缀,会让写入分散,但同一业务维度的查询需要 fan-out 到多个 salt 分区。

局部性不是越强越好。如果所有新数据都带同一个日期前缀,局部性会变成热点。分散性也不是越强越好。如果完全随机,范围 scan 会失去连续性,读路径要承担更多 RPC 和合并成本。

前缀:把最常见的过滤条件放到前面

前缀适合表达高频点查和范围查询。例如用户行为表常见查询是“按用户查最近 N 天”,row key 可以用 userId#reverseTimestamp#eventId。这样同一用户的数据聚在一起,近期数据按反向时间靠前,scan 可以从该用户前缀开始读一段。

如果主要查询是“按城市和品类查今日订单”,row key 前缀可能变成 city#category#date#orderId。这个设计会牺牲按单个用户回查订单的连续性;用户维度查询需要另一张索引表或离线视图。

Salt:扩散写入,代价是 fan-out

salt 在 row key 前面加一个有限取值的散列前缀,例如 03#userId#reverseTs。它把原本会落入同一 range 的写入分散到多个 range,减少单 Region 写热点。salt 不是 HBase 自动给出的万能配置,而是 schema 编码策略。

salt 的代价很具体:按 userId 查询时,需要计算或枚举 salt。如果 salt 只由 userId 决定,单用户仍落在固定 salt 下,可以保持点查;如果 salt 由事件或时间随机产生,查询同一用户的完整范围就要扫多个 salt 前缀。salt 数量越多,写入越分散,读取 fan-out 越大。

反转:改变单调增长方向

反转常见于域名、手机号、递增 ID 或时间戳。官方文档给过反转域名的例子:把 www.apache.org 变成 org.apache.www,让同一组织域名在排序上靠近。

反转也可用于避免递增 ID 把写入推向末端 Region。将用户 ID 或时间戳的一部分反转,会打散前缀相同的写入。但反转后的 key 往往不再符合自然范围查询。例如按原始时间从小到大 scan,可能需要额外解码和多段查询。

时间桶:给时间局部性加上边界

时间桶把时间维度压成小时、天、周等离散段,例如 userId#20260829#reverseTsbucket#reverseTs#id。它避免一个无限增长的时间序列集中在单个末端,同时给查询提供明确边界。

桶太大,热点仍会集中;桶太小,查询跨桶时 RPC 和结果合并增加。桶宽需要由写入速率、Region 大小、查询窗口和保留周期共同决定。不能用“按天一定好”这类经验替代实际负载分析。

Column Family 数量要克制

HBase 的 column family 是物理组织边界。每个 Region 下每个 family 都有 Store 和 StoreFile。family 太多,会放大 flush、compaction 和打开文件数量。family 太少,又可能把冷热、大小差异和 TTL 差异巨大的数据绑在一起。

一个实用原则是按访问模式和生命周期分 family,而不是按业务领域分 family。经常一起读、大小相近、TTL 相近的数据适合放在同一 family;大对象、低频字段、短 TTL 字段通常不该和高频小字段混在一个 family。

关键对象和状态

设计项 影响对象 收益 代价
业务前缀 Region、Scan 查询局部性 前缀低基数会热点
Salt Region 分布 写入扩散 查询 fan-out
Reverse RowKey 排序 打散单调增长 自然范围顺序丢失
Time bucket Region、TTL、Scan 限定时间窗口 跨桶合并
Column family Store、HFile、Cache 物理策略独立 StoreFile 和 compaction 放大
Qualifier Cell 稀疏属性灵活 列名进入存储成本

最小实验

UNVERIFIED_RUNTIME:当前任务环境未运行目标集群。以下实验用于观察 row key 排序和 scan fan-out,不提供伪造输出。

1
hbase shell
1
create_namespace 'lab'
1
create 'lab:t03_rowkey_design', 'cf'
1
put 'lab:t03_rowkey_design', 'u001#20260829#0003', 'cf:v', 'a'
1
put 'lab:t03_rowkey_design', 'u001#20260829#0001', 'cf:v', 'b'
1
put 'lab:t03_rowkey_design', '00#u001#20260829#0002', 'cf:v', 'c'
1
put 'lab:t03_rowkey_design', '01#u001#20260829#0004', 'cf:v', 'd'
1
scan 'lab:t03_rowkey_design', {STARTROW => 'u001#', STOPROW => 'u001$'}
1
scan 'lab:t03_rowkey_design', {STARTROW => '00#u001#', STOPROW => '00#u001$'}
1
scan 'lab:t03_rowkey_design', {STARTROW => '01#u001#', STOPROW => '01#u001$'}

第一条 scan 观察无 salt 的连续用户前缀。后两条 scan 观察 salt 后的 fan-out:同一用户的数据如果分散到 00#01# 两个前缀,查询需要分别扫描再合并。

清理命令:

1
disable 'lab:t03_rowkey_design'
1
drop 'lab:t03_rowkey_design'
1
drop_namespace 'lab'

失败恢复

row key 设计不直接改变 WAL 恢复协议,但会改变故障影响面。如果写入集中在一个热点 Region,该 RegionServer 崩溃会影响更大比例的新写入;WAL replay 也会集中处理同一个 Region 的 edit。分散良好的 row key 能把负载和恢复压力摊到更多 Region,但过度 salt 会让读请求在故障后同时命中更多 Region。

Region split 之后,设计不良的递增 key 仍可能把新写入推向最后一个 Region。split 解决的是已有 key range 过大,不自动解决未来 key 的单调热点。

工程迁移

场景 HBase 决策 可迁移模式
用户时间线 userId#reverseTs 查询维度前缀化
高写入计数 salt#entity#ts 写扩散换读合并
域名索引 org.apache.www 反转公共后缀
日志保留 bucket#service#reverseTs 时间桶控制范围
冷热字段 多 family 生命周期分组

[PATTERN] 有序 key-value 系统的 schema 设计,本质是在“写入分布”和“读取连续性”之间选边界。每加一层打散,就要补一层读合并。

常见误解

误解一:salt 越多越好。salt 扩散写入,但增加查询 fan-out 和结果合并。

误解二:row key 可读性最重要。row key 先服务排序、定位和 scan,可读性只是在运维排查时有价值。

误解三:column family 可以按业务模块随便拆。family 是物理 Store 边界,过多会放大文件与后台任务。

误解四:动态 qualifier 等于任意字段都适合放 qualifier。访问路径如果需要排序和范围,字段可能应该进入 row key。

误解五:Region split 能修复所有热点。单调写入会继续打到末端 Region,split 只能阶段性缓解。

练习

  1. 为“按用户查最近 100 条订单”设计 row key,并说明是否需要反向时间戳。

  2. 为“按城市、品类、日期查订单”设计 row key,并说明用户维度查询如何补。

  3. 选一个 salt 数量,写出选择依据和读取 fan-out 成本。

  4. 判断一个大字段是否应该从高频 family 拆出。

  5. 解释为什么 timestamp#userIduserId#timestamp 的热点与 scan 行为不同。

系列导航

篇号 主题 状态
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 与设计边界

参考资料