2.3 HFile:落盘的格式与多版本的物理真相 本节摘要:HFile 是 HBase 的磁盘存储格式,本质是按行键排序的 KeyValue 序列加上多级索引与可选布隆过滤器。本节拆解 HFile 的分层结构,展示用工具查看真实文件的方法,并讲清多版本、墓碑、TTL 在文件内的物理形态——这直接决定第 3 章读路径为什么要"合并"。 从 KeyValue 说起 HFile 里没有"行"这个容器,只有一条条 KeyValue 记录。1.2 节的四维坐标,在物理上被编码进每条 KeyValue 的键部分: 同一个行键的多列数据,就是多条 RowKey 前缀相同的 KeyValue 连续存放;多版本则是 RowKey、列均相同、仅时间戳不同的多条记录。
本节摘要:HFile 是 HBase 的磁盘存储格式,本质是按行键排序的 KeyValue 序列加上多级索引与可选布隆过滤器。本节拆解 HFile 的分层结构,展示用工具查看真实文件的方法,并讲清多版本、墓碑、TTL 在文件内的物理形态——这直接决定第 3 章读路径为什么要"合并"。
HFile 里没有"行"这个容器,只有一条条 KeyValue 记录。1.2 节的四维坐标,在物理上被编码进每条 KeyValue 的键部分:
KeyValue 布局(概念示意) +------------------------------------------------------------+ | key length | value length | key | +------------------------------------------------------------+ key = rowkey + column family + qualifier + timestamp + type type ∈ { Put, DeleteColumn, DeleteFamily ... } value = 原始字节
同一个行键的多列数据,就是多条 RowKey 前缀相同的 KeyValue 连续存放;多版本则是 RowKey、列均相同、仅时间戳不同的多条记录。排序规则是全字段字典序:先比行键,再比列族、限定符,最后时间戳大的排前面——最新版本永远在同键记录的头部,读取时拿第一条即可。2.2 节"MemStore 是跳表、Flush 天然有序"到这里闭环:HFile 就是有序 KeyValue 流的容器。
一个 HFile 自上而下分块组织,块大小默认 64 KB(建表时 BLOCKSIZE 可调,与 HDFS 块无关,别混淆):

要点解读:
分层结构里有两个列族级参数值得单独算账。
BLOCKSIZE 的权衡。默认 64 KB 是随机点查与顺序扫描的折中:块越小,点查搬运的无关数据越少、索引越密(占内存越多);块越大,顺序扫描吞吐越高、索引越稀。两个极端场景各有最优解——随机点查为主的表调小到 16 或 32 KB(索引项虽多但每次 IO 更省),大范围扫描为主的表调大到 128 KB。改它要慎重:块大小决定了已落盘文件的物理形态,改参数只影响之后 Flush 的新文件,想让全表生效得靠一次 Major Compaction 重写。
COMPRESSION 的选择。常用三档:SNAPPY 压缩率中等但编解码极快,CPU 便宜 IO 贵的在线集群几乎无脑选它;GZ 压缩率高但慢,适合写一次读多次的归档数据(配合冷表很划算);LZ4 与 SNAPPY 同级、具体谁快看硬件。一个常被忽略的联动:压缩发生在块级别,块内相邻 KeyValue 的行键前缀高度重复,所以大块压缩率更高——块大小与压缩选择不是两个独立决策。另外布隆过滤器与索引不参与数据块压缩,它们本来就是为省 IO 设计的紧凑结构。
💡 线上经验:多数业务用 SNAPPY 加默认块大小就到头了。先按默认建表,等监控(第 7 章)显示 IO 成为瓶颈再回头调这两个参数——有指标之后再调,与拍脑袋调,是两种工程。
灌完数据后(2.2 节的实验接着用),可以离线检视 HFile。用 HBase 自带的 HFile 工具(在 HBase 目录下执行):
$ bin/hbase hfile -v -p -m \ file:///opt/hbase-data/hbase/data/default/flush_demo/<region-hash>/cf/<hfile-hash> ... Scanning -> u1001-1724055123/cf:status/1724055600/Put/vlen=4/seqid=12 value = PAID ... Bloom filter: Bloom filter type: ROW Number of keys: 200000 Max keys: 218971 ... Block index: Number of blocks: 1543 Total size: 522 KB
输出逐条列出了 KeyValue(注意 Put 类型与 seqid,正好对应 2.1 节的 sequence id),以及布隆过滤器条目数与块索引规模。给同一条目做"更新",再 Flush,新 HFile 里会出现同键不同时间戳的新记录,旧记录仍在旧文件——更新与删除在 HFile 层面都只是追加新记录。
删除的物理实现是写入一条 type 为 Delete 系列的记录(墓碑)。它比同键的旧 Put 排得靠前(排序规则里删除标记优先),读路径扫描时碰到墓碑就知道后面的旧值作废。但旧值仍然躺在旧文件里,要等 Compaction 重写文件时才被物理清除(第 3 章)。
TTL 同理:列族可设生命周期(单位秒),到期的 Cell 在 Compaction 时被丢弃。一个典型陷阱:
hbase:030:0> alter 'flush_demo', {NAME => 'cf', TTL => '86400'}
设完 TTL 观察磁盘占用并不下降——因为清理发生在下一次 Major Compaction。想立刻见效需手动触发 major_compaction。同样的道理,"疯狂 delete 后磁盘不缩水"也是 Compaction 时机问题,不是 bug。
⚠️ 常见坑:把大量短生命周期的数据写进同一张表又靠 delete 清理,墓碑本身也占空间,还拖慢读路径扫描。正确做法是按时间分表或靠 TTL 加 Major Compaction 兜底。
| 旋钮 | 参数 | 效果 |
|---|---|---|
| 保留版本数 | VERSIONS | 读取与 Compaction 时最多留几个时间戳 |
| 最少版本数 | MIN_VERSIONS | 配合 TTL,过期也保底留几版 |
| 生命周期 | TTL | Compaction 时清除超龄 Cell |
默认 VERSIONS=1 意味着读路径只需关心"最新一条",行为最简单。把 VERSIONS 调大(如 5)后,get 要返回多条、Scan 语义也变复杂,第 3 章读路径的多版本合并会用到这些设定。
💡 关键直觉:HFile 是"只追加、不修改"的,一切更新删除都是新记录加标记,真正的整理发生在 Compaction——把这句话记住,第 3 章的读放大与合并开销都会顺理成章。
写路径三站到此走完。但 Store 里已经攒下了好几个 HFile,同一条数据散落多处——下一章先讲 Region 的分裂与 Compaction 怎么收拾局面,再把读路径的三层合并补全。