Apache HBase 读写路径深度工程实战:从 WAL 与 MemStore 跳表、HFile 块格式与布隆过滤、BlockCache 分层到 Region 分裂与 Compaction 策略的全链路解析
执行摘要
在 NewSQL 与实时湖仓大行其道的今天,HBase 常被简化成一句"Google BigTable 的开源实现"。但对任何真正扛过 PB 级在线随机读写的人来说,HBase 的价值不在于它是一个 LSM-Tree KV 存储,而在于它把随机写转化为顺序写、把随机读转化为多层索引过滤这一整套工程取舍做得极其克制而完整。
本文拆的不是 API,而是数据落盘的每一层结构:一次 Put 从客户端到 WAL 再到 MemStore 的完整链路、MemStore 用跳表加 Chunk 池对抗 GC 与内存碎片的双重设计、HFile 的块级布局与三级跳过策略、读路径上 KeyValueHeap 的 k 路归并、BlockCache 的 L1/L2 分层,以及 Region 分裂与 Compaction 这两条最容易把集群拖垮的后台管线。
一、写路径:一次 Put 的三段式提交
HBase 的写路径可以概括成一句话:先落 WAL,再写内存,最后异步刷盘。这段代码看起来平淡无奇:
try (Table table = conn.getTable(TableName.valueOf("events"))) {
Put put = new Put(Bytes.toBytes("user#10086"));
put.addColumn(Bytes.toBytes("cf"),
Bytes.toBytes("ts"),
Bytes.toBytes(System.currentTimeMillis()));
table.put(put);
}
但从 put() 返回到数据真正可见,中间跨了三个不同的并发阶段:
- 追加 WAL:RegionServer 把这条
Put序列化成WALEdit,以 protobuf 形式 append 到 HDFS 上的 WAL 文件。AsyncFSWAL用RingBuffer(LMAX Disruptor)把并发写请求聚合成批,再由一个 consumer 线程做hflush/hsync。 - 写入 MemStore:WAL 成功后,KV 插入该列族对应的
MemStore(本质是一个按 key 有序的ConcurrentSkipListMap)。 - 推进 read point:HBase 用
MultiVersionConsistencyControl维护一个全局自增的memstoreReadPoint。写入分配的writeNumber在写入完成前不可见,完成后才把 read point 推过去——这就是 HBase 的 MVCC,它避免了读事务看到"半个 Put"。
关键工程点在第 1 步的取舍:hflush 只保证数据到达 DataNode 的 OS page cache,不保证落盘;hsync 保证落盘但代价高得多。因此:
<property>
<name>hbase.wal.hsync</name>
<value>false</value>
</property>
默认 false 意味着整机掉电可能丢最后几毫秒的 WAL,但 RegionServer 进程被 kill 不会丢(数据已在 DN 内存中,由 HDFS 副本保证)。生产上绝大多数集群接受这个风险换取写吞吐;对金融类场景才打开 hsync,代价通常是写延迟翻倍。
真正的坑在于:WAL 是按 RegionServer 共享的,不是按 Region。一台 RS 上几百个 Region 的所有写都串到同一份 WAL 上滚动,所以单个 Region 的热点写会污染同一台 RS 上所有 Region 的刷盘节奏。这也是为什么 rowkey 设计一旦打散失败,故障表现是"整台 RS 变慢"而不是"单个 Region 变慢"。
二、MemStore:跳表 + MSLAB 的双重设计
MemStore 的核心数据结构是并发跳表,但真正值得学的是它配套的 MSLAB(MemStore-Local Allocation Buffer)。
朴素实现下,一个 128MB 的 MemStore 里塞满几十万个 KeyValue 对象,每次 flush 后整体丢弃,会在 CMS/G1 里制造大量朝生夕死的中年对象,最终表现为长时间的 concurrent mode failure。HBase 的解法是把小的 KeyValue 分配到一个预分配的 2MB Chunk 里:
// 简化后的 MemStoreLAB 分配逻辑
public ByteBuffer allocateBytes(int size) {
if (curChunk == null || !curChunk.hasRemaining(size)) {
curChunk = chunkPool.getChunk(); // 从池里拿,不足再向堆申请
}
ByteBuffer bb = curChunk.slice(size); // 纯指针切分,无对象分配
return bb;
}
这样一来:KV 数据本身是 byte[] 切片而非独立对象,flush 后只需把整个 Chunk 归还池子,几乎不产生垃圾;代价是 Chunk 内部碎片——HBase 用 hbase.hregion.memstore.chunkpool.maxsize 限制池子总大小,并让不同列族共享 ChunkPool。
一个容易踩的坑是:MSLAB 只对 offheap 之外的堆内 MemStore 有意义。如果你开启了 hbase.hregion.memstore.offheap.global.memstore.size(把 MemStore 放到堆外),Chunk 就直接走 NIO DirectByteBuffer,JVM GC 压力归零,但内存上限必须自己卡死,否则 RegionServer 会被 OS OOM-killer 直接带走。
三、HFile:块级布局与三级跳过
MemStore flush 后落地的文件叫 HFile,逻辑上是一棵多层 B+ 树式的块索引结构:
HFile v3 布局(简化)
┌──────────────────────────────────────┐
│ Scanned block section │
│ Data Block (64KB,默认,含 KV 序列) │
│ Leaf Index Block / Bloom Block │
├──────────────────────────────────────┤
│ Non-scanned block section │
│ Meta Block(Bloom 元数据) │
├──────────────────────────────────────┤
│ Load-on-open section │
│ Root Data Index / File Info │
├──────────────────────────────────────┤
│ Trailer(定长,指向以上各段偏移) │
└──────────────────────────────────────┘
读一个 key 时,HBase 用三级机制避免全文件扫描:
- Bloom Filter:先问布隆过滤器"这个 rowkey 一定不在本文件吗?"不在就直接跳过整个 HFile。ROWCOL 级别的 Bloom 能把 Get 的 I/O 降低一个数量级,代价是每个文件多几个 MB 元数据。
- 块索引:根索引常驻内存(load-on-open 段),二级索引按需加载,定位到目标 Data Block 的偏移。
- 块内扫描:只在 64KB 的块内做二分查找。
这三层决定了 HBase 读放大的量级:一次 Get 最坏情况下的磁盘 I/O = 涉及的 HFile 数 × 索引层数 + 1。所以"文件太多"是读性能的头号杀手,这直接引出第七节的 Compaction。
四、读路径:KeyValueHeap 的 k 路归并
一次 Scan 要同时看到 MemStore 里的最新数据和磁盘上所有 HFile 的历史数据,而它们各自有序。HBase 的解法与 MapReduce 的 reduce 端归并同构——优先级队列做 k 路归并:
// StoreScanner 内部(简化)
KeyValueHeap heap = new KeyValueHeap(scanners, comparator);
// scanners = [MemStoreScanner, StoreFileScanner × N]
while (heap.next() != null) {
Cell c = heap.peek();
// 1. 跳过被 Delete / DeleteFamily 标记屏蔽的版本
// 2. 应用 Scan 的 TimeRange / 版本数 / Filter
// 3. 吐给上层 RegionScanner
}
这里有两个值得注意的工程细节:
- Scan 的 read point 在打开时确定,整个扫描期间不变。这意味着长时间运行的 Scan 不会看到扫描开始后写入的新数据——这是刻意的快照语义,不是 bug。
hbase.client.scanner.caching(默认 100)决定每次 RPC 拉回多少行。设太大会让单次 RPC 超时并意外拉高 RegionServer 堆占用;设太小则 RPC 次数爆炸。对宽行场景,务必同时调小hbase.client.scanner.max.result.size(默认 2MB),否则一次 RPC 拽回整行几十 MB 直接把客户端打爆。
五、BlockCache:L1/L2 分层
HBase 的缓存不是单层 LRU。默认配置下是 LruBlockCache(L1,堆内)+ BucketCache(L2,堆外或直接文件) 的组合:
<property>
<name>hfile.block.cache.size</name>
<value>0.4</value> <!-- L1 占 RegionServer 堆的比例 -->
</property>
<property>
<name>hbase.bucketcache.ioengine</name>
<value>offheap</value> <!-- 或 file:/mnt/nvme/bucketcache -->
</property>
<property>
<name>hbase.bucketcache.size</name>
<value>16384</value> <!-- 单位 MB -->
</property>
L1 里再按访问频率切成 single-access / multi-access / in-memory 三区,用类似 SLRU 的方式给"被反复访问的块"更高的留存优先级。这个设计的现实动因是:HBase 的缓存污染主要来自 Compaction 和全表 Scan,LRU 会把真正的热数据冲掉,而分层 + 频率感知能把这种一次性的顺序访问隔离出去。
生产建议:写多读少(如时序写入)场景把 hfile.block.cache.size 降到 0.2,把省下的内存给 MemStore;在线点查为主的场景反过来,并给关键列族设 IN_MEMORY => 'true',让它常驻 L1 的 in-memory 区。
六、Region 分裂:为什么它不是"搬家"
Region 变大后自动分裂(默认 10GB 触发,2.x 后由 IncreasingToUpperBoundRegionSplitPolicy 动态计算)。分裂的关键设计是不搬数据:
- RegionServer 在父 Region 目录下为两个子 Region 各生成两个引用文件(Reference File),内容只是"父文件 + 起始 key + 后半/前半"的指针。
- 更新
hbase:meta表,把两个 daughter region 上线,父 Region 下线。 - 真正的物理切分推迟到这两个子 Region 下次 Compaction 时顺带完成。
分裂前: region_a/ [hfile1, hfile2, hfile3]
分裂后: region_a/ [hfile1, hfile2, hfile3] (父,已下线)
region_b/ [hfile1.top, hfile2.top, hfile3.top]
region_c/ [hfile1.bottom, hfile2.bottom, hfile3.bottom]
好处是分裂是秒级操作,坏处是引用文件让 Store 的文件清单在一段时间内翻倍,读放大飙升。所以预分区(pre-splitting)永远优于线上自动分裂——建表时就按 rowkey 分布切好:
byte[][] splits = new byte[][] {
Bytes.toBytes("user#2000"), Bytes.toBytes("user#4000"),
Bytes.toBytes("user#6000"), Bytes.toBytes("user#8000")
};
admin.createTable(desc, splits);
七、Compaction:最贵也最容易被误配的后台管线
Compaction 分两类:Minor(挑若干相邻小文件合并)与 Major(把一个 Store 的所有文件合并成一个,同时物理清除过期版本与 Delete 标记)。
选择哪些文件参与 Minor Compaction 由策略决定,2.x 默认 ExploringCompactionPolicy:
候选集合选择(简化):
遍历所有连续文件窗口,对每个窗口计算:
fileSizeRatio = 窗口总大小 / Region 总大小
nonPeakRatio = 窗口内非最大文件大小之和 / 窗口总大小
选出"文件数最多、且总大小尽量小"的窗口 → 最小代价最大收益
几个血泪经验:
- Major Compaction 默认每 7 天自动跑一次(
hbase.hregion.majorcompaction),它会把整个 Region 的数据重写一遍,I/O 峰值足以打垮线上业务。生产集群一律设为 0,改成业务低峰期手动触发。 - Compaction 的吞吐量限制必须开:
hbase.hstore.compaction.throughput.lower.bound/upper.bound(按 MB/s 限流),否则后台合并会吃满磁盘带宽。 - StripeCompactionPolicy 把 Region 按 key range 切成多个 stripe,每个 stripe 独立合并,能显著降低大 Region 单次合并的 I/O 峰值,适合 key 分布均匀、Region 超过 20GB 的场景。
八、生产故障模式与排查清单
| 现象 | 根因 | 排查与处置 |
|---|---|---|
写入延迟毛刺、RS 日志出现 Blocking updates | MemStore 达到 hbase.hregion.memstore.flush.size 的水位,触发 flush 并阻塞写 | 看 memstoreSize 与 globalMemStoreSize 水位;加大堆或调低 block.multiplier;检查 rowkey 是否写热点 |
| 点查 P99 突然恶化 | Store 文件数过多(>30)导致读放大 | hbase hfile -m 看文件数;紧急触发 Major Compaction;长期调大 flush.size 减少文件产出 |
| RegionServer 反复 OOM | offheap MemStore / BucketCache 超限,或单行过宽 | 控制 hbase.bucketcache.size;限制单行 cell 数量与 value 大小 |
| 分裂风暴,meta 表膨胀 | 预分区不足 + 写入曲线陡增 | 建表预分区;调大 hbase.hregion.max.filesize;清理 hbase:meta 过期条目 |
| Compaction 队列长期积压 | 磁盘带宽不足或 compaction 线程太少 | 调 hbase.regionserver.thread.compaction.large/small;开吞吐量限流避免与前台争抢 |
一条总纲:HBase 80% 的性能问题不是参数问题,而是 rowkey 与列族建模问题。所有缓存、压缩、合并参数的优化,都建立在"数据均匀分布、访问局部性良好"这个前提上。热点 rowkey 会让上述所有优化归零,因为整个集群的吞吐等于最热那台 RegionServer 的吞吐。
九、结论:HBase 留下的设计遗产
回看 HBase 的取舍,可以提炼出四条至今仍被沿用进 RocksDB、TiKV、Paimon 里的原则:
- 写路径用顺序 I/O 换随机 I/O:WAL 追加 + MemStore 有序缓冲,把随机写变成顺序写与内存排序。
- 读路径用多级跳过换全量扫描:Bloom → 块索引 → 块内二分,层层剪枝。
- 用延迟物化推迟代价:Region 分裂先写引用文件,把物理切分推迟到下次 Compaction。
- 用池化对抗 GC:MSLAB 与 BucketCache 的本质,是把"对象生命周期"问题转化成"字节缓冲区复用"问题。
真正理解 HBase,不是为了调优某个参数,而是理解当数据规模大到内存装不下时,系统该如何用结构化的代价分层来维持可预测的性能。这套思路,在今天的湖仓 Upsert 引擎与流式存储里依然一字未改。

发表评论 取消回复