3.3 读路径:三层合并 本节摘要:HBase 的 get 本质是长度为 1 的 scan。一次读要跨 BlockCache、MemStore 与多个 HFile 做键值归并(LSM-Tree 的"读放大"),按时间戳取最新版本、滤掉墓碑。本节把读路径逐层拆开,量化每个环节的开销,并解释"为什么 HBase 读比写贵"以及优化从哪里下手。 读路径全景 以 为例,完整路径分四段: 三个数据源各自提供一个有序游标(KeyValueScanner),归并引擎在游标间做小顶堆归并——和多路归并排序一模一样。这就是"三层合并"的准确含义:缓存层、内存层、文件层各出一个(或多个)游标,按行键列序归并,时间戳定胜负。 逐层看开销 第一层 BlockCache。
本节摘要:HBase 的 get 本质是长度为 1 的 scan。一次读要跨 BlockCache、MemStore 与多个 HFile 做键值归并(LSM-Tree 的"读放大"),按时间戳取最新版本、滤掉墓碑。本节把读路径逐层拆开,量化每个环节的开销,并解释"为什么 HBase 读比写贵"以及优化从哪里下手。
以 get 'orders', 'u1001-1724055123' 为例,完整路径分四段:
三个数据源各自提供一个有序游标(KeyValueScanner),归并引擎在游标间做小顶堆归并——和多路归并排序一模一样。这就是"三层合并"的准确含义:缓存层、内存层、文件层各出一个(或多个)游标,按行键列序归并,时间戳定胜负。
第一层 BlockCache。 RegionServer 堆内存的默认 40% 拨给读缓存,按 64 KB 数据块为粒度缓存 HFile 内容。查 HFile 前先看缓存,命中则免一次磁盘 IO。BlockCache 有两种实现:堆内 LruBlockCache(对象开销大、GC 压力大)与堆外 BucketCache(内存利用率高,推荐生产使用)。缓存命中率是读性能的第一指标——第 6 章会专门展开。
第二层 MemStore。 直接扫跳表(2.2 节),纯内存操作,快但必须有——最新写入还没落盘呢。
第三层 HFile 集合。 这是读放大的主战场。单个 HFile 内查找 = 布隆过滤器先问"这行键可能在本文件吗"(第 6 章)→ 根/叶索引定位数据块(2.3 节)→ 读块(可能已在 BlockCache)→ 块内二分。单文件一次点查通常 1–3 次 IO,但 Store 里若有 10 个文件,最坏就是 10 倍。Compaction(3.2 节)存在的意义就是把文件数压住,读路径的宽度完全由它决定。
游标们吐出的候选 KeyValue 按全字段字典序排列(2.3 节),归并引擎对同一坐标(行键+列)的一串记录按时间戳从大到小取用:
用一个实验验证"覆盖与删除"在合并视角下的行为:
hbase:050:0> put 'orders', 'k1', 'cf:v', 'a' -- ts=t1 hbase:051:0> put 'orders', 'k1', 'cf:v', 'b' -- ts=t2 最新 hbase:052:0> delete 'orders', 'k1', 'cf:v' -- ts=t3 墓碑 hbase:053:0> get 'orders', 'k1', {VERSIONS => 3} COLUMN CELL 0 row(s)
返回空。但用反向扫描指定旧时间戳区间仍能看到 a 与 b:
hbase:054:0> scan 'orders', {RAW => true, VERSIONS => 3} ROW COLUMN+CELL k1 column=cf:v, timestamp=t3, type=DeleteColumn k1 column=cf:v, timestamp=t2, value=b k1 column=cf:v, timestamp=t1, value=a
RAW => true 绕过滤除逻辑,把墓碑与旧版本一并吐出——这是排查"数据怎么没了"的利器,也直观展示了读路径"先归并再过滤"的两段式结构。等 Major Compaction 跑过之后,同样的 RAW 扫描只剩墓碑前的最后状态,旧值被物理清除。
HBase 内部 get 就是 STARTROW=STOPROW=行键 的 scan,共享同一套归并引擎。scan 的额外机制有二:
区间多 Region 联动。 扫描区间横跨多个 Region 时,scanner 依次定位每个 Region 所在的 RegionServer,逐段推进——本质是把 3.1 节的落点计算循环了一遍。
惰性与预取。 Scan 不是一次拉全量,而是客户端缓存一批(默认 caching=100 行,可调),服务端游标按需拉块(batch 粒度)。大扫描务必设置 setCaching 与 setBatch 组合,否则要么客户端内存爆、要么 RPC 次数爆炸(第 4 章客户端优化给参数表)。
现在可以完整回答第 2 章埋下的问题了。写路径只追加一两个顺序目标(WAL、MemStore),读路径要:
所以 HBase 的典型画像:写吞吐极高、点查延迟毫秒级但 P99 抖动明显(取决于缓存命中与文件数)。优化的三板斧全部从本节推导:压文件数(Compaction 策略)、提命中率(BlockCache 容量与布隆)、缩扫描宽度(RowKey 设计与过滤条件下推)。
⚠️ 常见坑:以为
scan加了FILTER => ...就万事大吉。值过滤器要等数据读到服务端内存才能判断,省的是网络不省 IO;能转成行键区间(STARTROW/STOPROW)的条件永远优先——又回到第 5 章的主题:一切查询皆行键算术。
💡 关键直觉:读路径是写路径的"还款",每一分写时的偷懒(小文件、多版本、墓碑)都由读时的归并来偿还。看到读延迟高,先数 Store 文件数与缓存命中率,再谈别的。
读写机制齐了。下一章回到开发者视角:用 Java API 把这些机制用起来,并学会让客户端少犯蠢。