深入 HBase 03 - Schema 与 row key 设计
前置问题与边界
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#reverseTs 或 bucket#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 | |
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |
第一条 scan 观察无 salt 的连续用户前缀。后两条 scan 观察 salt 后的 fan-out:同一用户的数据如果分散到 00# 和 01# 两个前缀,查询需要分别扫描再合并。
清理命令:
1 | |
1 | |
1 | |
失败恢复
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 只能阶段性缓解。
练习
-
为“按用户查最近 100 条订单”设计 row key,并说明是否需要反向时间戳。
-
为“按城市、品类、日期查订单”设计 row key,并说明用户维度查询如何补。
-
选一个 salt 数量,写出选择依据和读取 fan-out 成本。
-
判断一个大字段是否应该从高频 family 拆出。
-
解释为什么
timestamp#userId和userId#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 与设计边界 |
参考资料
- Apache HBase Data Model:https://hbase.apache.org/docs/datamodel/
- Apache HBase and Schema Design:https://hbase.apache.org/docs/schema-design/
- Apache HBase Regions:https://hbase.apache.org/docs/architecture/regions/
- Apache HBase Performance Tuning:https://hbase.apache.org/docs/performance/
- Apache HBase 2.6 Client API:https://hbase.apache.org/2.6/apidocs/org/apache/hadoop/hbase/client/package-summary.html
