HBase 点查流程:从 Get 到 Cell 的完整链路与数据结构
本文以 HBase 2.x 的 Java API 和内部实现为主线,梳理一次点查从
table.get(Get)到最终返回Result的完整路径。重点不在“如何使用”,而在“为什么这样设计”:RowKey 排序、hbase:meta定位、MemStore、HFile Block、BloomFilter、BlockCache、多路 Scanner 合并,以及背后的数据结构。
一、先看一次点查的全链路
HBase 最擅长的不是复杂 SQL,而是“给定 RowKey,快速返回一行或一行中的若干列”。这个能力依赖下面这条链路。
一个点查从业务视角看只是一次 get,但在底层会同时完成“定位 Region”和“读取 Region 内数据”两件独立的事情。前者主要和 RowKey 排序有关,后者主要和 LSM-Tree 的读写路径有关。
二、Get 对象:点查请求本身就是一个数据结构
客户端写一个点查通常只需要三行:
Configuration conf = HBaseConfiguration.create();
try (Connection connection = ConnectionFactory.createConnection(conf);
Table table = connection.getTable(TableName.valueOf("ods_order"))) {
Get get = new Get(Bytes.toBytes("u-1001_20260903120000"));
get.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("status"));
get.readVersions(1);
get.setTimeRange(0L, Long.MAX_VALUE);
Result result = table.get(get);
}
Get 并不是一个简单的 RowKey 包装器。它内部表达了“我想要哪些列、哪个时间范围、最多几个版本、是否缓存 Block”。
public class Get {
private byte[] row;
private NavigableMap<byte[], NavigableSet<byte[]>> familyMap;
private int maxVersions = 1;
private TimeRange tr = TimeRange.allTime();
private boolean cacheBlocks = true;
private IsolationLevel isolationLevel = IsolationLevel.READ_COMMITTED;
private boolean checkExistenceOnly = false;
public Get addFamily(byte[] family) { /* ... */ }
public Get addColumn(byte[] family, byte[] qualifier) { /* ... */ }
public Get readVersions(int versions) { /* ... */ }
}
这里有几个容易影响点查性能的字段:
| 字段 | 作用 | 点查建议 |
|---|---|---|
row | 唯一决定目标 RowKey | 尽量贴近业务自然主键 |
familyMap | 精确指定 cf:qualifier | 能 addColumn 就不要 addFamily |
maxVersions | 最多返回多少个历史版本 | 无多版本需求时保持 1 |
cacheBlocks | 本次读取的 Block 是否进入 BlockCache | 点查默认 true |
isolationLevel | READ_COMMITTED 或 READ_UNCOMMITTED | 默认即可 |
addColumn 的意义不只是代码简洁,它会传导到底层扫描器,使 HFile 可以利用 BloomFilter 跳过无关 Block。
三、RowKey 排序与 Region 定位
1. HBase 的行键不是随机哈希
HBase 的 RowKey 本质上是按字节序排序的一段数据。系统只会根据 [startKey, endKey) 划分 Region:
Region 1: [ "", "u-1003" )
Region 2: [ "u-1003", "u-2000" )
Region 3: [ "u-2000", "" )
因此 u-1001_20260903120000 会被定位到 Region 1。HBase 没有全局二级索引,也没有自动跨 Region 查询,这正是 RowKey 设计如此重要的原因。
2. 客户端如何找到 Region
早期 HBase 有 -ROOT- 和 .META. 两层表,现在的版本统一收敛到 hbase:meta。客户端通常不会每次请求都访问 ZooKeeper 或 Master,而是先查本地缓存,缓存失效后再通过 Registry 找到 hbase:meta 的位置。
从 API 层可以直接拿到定位结果:
try (RegionLocator locator = connection.getRegionLocator(TableName.valueOf("ods_order"))) {
HRegionLocation location = locator.getRegionLocation(Bytes.toBytes("u-1001_20260903120000"));
RegionInfo regionInfo = location.getRegion();
ServerName serverName = location.getServerName();
System.out.println("RegionStart=" + Bytes.toStringBinary(regionInfo.getStartKey()));
System.out.println("RegionServer=" + serverName.getHostAndPort());
}
hbase:meta 的行键可以简单理解为:
tableName,startKey,timestamp,encodedRegionName
因为行键有序,客户端可以用一次 seek 或二分查找找到当前 RowKey 所属的 Region,而不需要扫描整张 meta 表。
四、RegionServer 收到请求后做什么
RegionServer 的 RPC 层收到 GetRequest 后,先从在线 Region 列表中找到目标 HRegion,再交给 HRegion 执行读取。
// 简化的 RegionServer 处理路径
public Result get(byte[] regionName, GetRequest request) throws IOException {
HRegion region = getOnlineRegion(regionName);
Get get = request.getGet();
return region.get(get);
}
HRegion 内部不会直接打开所有 HFile,而是为涉及的 Column Family 创建扫描器:
// 简化:HRegion 点查主流程
public Result get(Get get) throws IOException {
checkRow(get.getRow());
List<Cell> cells = new ArrayList<>();
try (RegionScanner scanner = getScanner(new Scan(get))) {
while (scanner.next(cells)) {
if (cells.isEmpty()) {
continue;
}
// 多版本、删除标记、列过滤都在 scanner 层处理
}
}
return Result.create(cells);
}
这里的关键对象是 RegionScanner 和 StoreScanner:
| 对象 | 职责 |
|---|---|
HRegion | 管理一行所属的所有 Store,协调读流程 |
HStore | 管理一个 Column Family 的 MemStore 与 StoreFile |
StoreScanner | 把一个 Store 内的内存/磁盘 Scanner 合并成一个有序流 |
RegionScanner | 把多个 StoreScanner 合并成跨列族的结果 |
KeyValueHeap | 多路归并的核心优先级队列 |
五、点查涉及的核心数据结构
1. Cell:最小数据单元
Cell 是 HBase 中比“行”更小的数据单位。一行可能由多个 Cell 组成:
RowKey: u-1001_20260903120000
cf:order : amount = 29900 timestamp=20260903120000000 type=Put
cf:order : status = PAID timestamp=20260903120000000 type=Put
cf:user : vip_level = 3 timestamp=20260801000000000 type=Put
Cell 接口主要字段可以简化为:
public interface Cell {
byte[] getRowArray();
int getRowOffset();
int getRowLength();
byte[] getFamilyArray();
int getFamilyOffset();
int getFamilyLength();
byte[] getQualifierArray();
int getQualifierOffset();
int getQualifierLength();
long getTimestamp();
Type getType();
byte[] getValueArray();
long getSequenceId();
}
其中 Type 对读路径非常重要:
Put
Delete
DeleteColumn
DeleteFamily
DeleteFamilyVersion
一个点查最终返回的 Result 只是若干满足条件的 Cell 的集合。删除不一定物理删除,而是写入一个带删除标记的 Cell,在读取时过滤旧版本。
2. KeyValue:有序比较的关键
底层最经典的 Cell 实现是 KeyValue。它把 RowKey、Column Family、Qualifier、Timestamp、Type 拼成一段可比较字节:
Key 结构:
row + family + qualifier + timestamp(逆序) + type
比较顺序为:
row 升序
-> family 升序
-> qualifier 升序
-> timestamp 降序
-> type 降序/按类型顺序
Timestamp 采用降序是关键设计。同一个 row/family/qualifier 的多个版本会物理相邻,点查时能够快速定位到最新版本。
// 简化:判断两个 KeyValue 的顺序
public int compare(KeyValue left, KeyValue right) {
int c = Bytes.compareTo(left.row, right.row);
if (c != 0) return c;
c = Bytes.compareTo(left.family, right.family);
if (c != 0) return c;
c = Bytes.compareTo(left.qualifier, right.qualifier);
if (c != 0) return c;
// timestamp 越大越新,排序时更靠前
c = Long.compare(right.timestamp, left.timestamp);
if (c != 0) return c;
return left.type.ordinal() - right.type.ordinal();
}
这个比较器是 HBase 读写路径的共同底座。MemStore、HFile、BloomFilter、Block Index 和 Scanner 合并最终都依赖它。
六、MemStore 点查:内存中的有序结构
HBase 写入数据时先写 WAL 和 MemStore。MemStore 内部不是普通的 HashMap,而是一个按 CellComparator 排序的有序集合。
DefaultMemStore
└── CellSkipListSet
└── ConcurrentSkipListSet<Cell>
点查进入某个 Store 后,首先要扫描 MemStore:
// 简化:StoreScanner 初始化时加入 MemStoreScanner
List<KeyValueScanner> scanners = new ArrayList<>();
for (KeyValueScanner memStoreScanner : memStore.getScanners(readPoint)) {
scanners.add(memStoreScanner);
}
for (StoreFile storeFile : storeFiles) {
scanners.add(storeFile.getScanner(cacheBlocks));
}
KeyValueHeap heap = new KeyValueHeap(scanners, comparator);
因为 ConcurrentSkipListSet 本身有序,MemStoreScanner 可以用 seekTo(row/family/qualifier) 直接跳转到目标位置,复杂度接近 O(log n)。
MemStore 还会有 active 和 immutable snapshot 两类结构。flush 时 active 被切换为 immutable,随后异步写入 HFile,避免阻塞写入。读取时多个 MemStoreScanner 会同时参与合并。
七、HFile 磁盘结构:BloomFilter、Block Index、BlockCache
数据落盘后变成 HFile。点查不会顺序扫描整个 HFile,而是借助三级结构缩小读取范围。
1. BloomFilter:先判断“可能不存在”
BloomFilter 不会直接返回数据,它回答的是:
这个 RowKey 或
row+qualifier是否可能存在于这个 HFile?
如果返回 false,可以直接跳过这个 HFile,避免读取 Block Index 和 Data Block。
// 简化:StoreFileScanner 在 seek 前先检查 BloomFilter
public boolean shouldSeek(StoreFile storeFile, Cell target) {
if (storeFile.isBulkLoaded()) {
return true;
}
BloomFilter bloomFilter = storeFile.getReader().getBloomFilter();
if (bloomFilter == null) {
return true;
}
BloomType type = storeFile.getBloomFilterType();
byte[] key = target.getRowArray();
int length = target.getRowLength();
if (type == BloomType.ROWCOL) {
key = buildRowColKey(target);
length = key.length;
}
return bloomFilter.contains(key, 0, length, null);
}
建表时通常会设置:
BLOOMFILTER => 'ROW'
# 或
BLOOMFILTER => 'ROWCOL'
ROW 只对 RowKey 建 BloomFilter,适合多列点查;ROWCOL 对 row+qualifier 建 BloomFilter,过滤更精确,但占用更多内存。点查如果只取少量列,ROWCOL 通常更有优势。
2. Block Index:从 RowKey 定位到 Data Block
HFile 默认 Data Block 大小为 64KB。Data Block 有起始 Key 和偏移量,这些元信息构成 Leaf Index;Leaf Index 又通过 Root Index/Intermediate Index 被快速定位。
Trailer
-> Root Index
-> Intermediate Index
-> Leaf Index
-> Data Block Offset / Size / First Key
点查流程可以简化成下面这段伪代码:
public List<Cell> readRowFromHFile(HFile.Reader reader, Cell searchKey) {
// 1. 读 Trailer,获取 Root Index、BloomFilter 元信息
HFileBlock trailerBlock = reader.readTrailer();
long rootIndexOffset = trailerBlock.getRootIndexOffset();
// 2. 读 Root Index,二分找到可能包含目标 Key 的 Leaf Index
HFileBlock rootIndex = loadBlock(rootIndexOffset);
HFileBlock leafIndex = locateLeafIndex(rootIndex, searchKey);
// 3. 读 Leaf Index,二分找到 Data Block
BlockWithScanInfo targetBlock = locateDataBlock(leafIndex, searchKey);
// 4. 从 BlockCache 或磁盘读取目标 Data Block
HFileBlock dataBlock = blockCache.getBlock(targetBlock.getBlockKey());
if (dataBlock == null) {
dataBlock = reader.readBlock(targetBlock.getOffset(), targetBlock.getOnDiskSize());
blockCache.cacheBlock(targetBlock.getBlockKey(), dataBlock);
}
// 5. 在 Data Block 内做二分 seek
HFileScanner scanner = dataBlock.getScanner(reader, false, false, false);
if (scanner.seekTo(searchKey) != 0) {
return Collections.emptyList();
}
// 6. 顺序读取同一个 RowKey 的 Cell,直到 Key 越过目标 Row
List<Cell> cells = new ArrayList<>();
Cell cell = scanner.getCell();
while (cell != null && Bytes.equals(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
searchKey.getRowArray(), searchKey.getRowOffset(), searchKey.getRowLength())) {
cells.add(cell);
if (!scanner.next()) {
break;
}
cell = scanner.getCell();
}
return cells;
}
真正的 HFile 读取会更复杂,还需要处理压缩、编码、校验和、扫描器缓存以及不同 HFile 版本。上面代码保留的是点查最核心的骨架。
3. BlockCache:用内存换磁盘 IO
BlockCache 处于 Data Block 读取路径上。常见的 HBase 2.x 配置是 L1 + L2:
L1: LruBlockCache -> 堆内,快速淘汰
L2: BucketCache -> 堆外,容量更大
读取一个 Block 时:
BlockCache 的 Key 通常包含 HFile 文件名、Block 偏移量和编码信息,避免不同 HFile 的 Block 互相覆盖:
public class BlockCacheKey {
private final String hfileName;
private final long offset;
private final BlockType blockType;
@Override
public boolean equals(Object obj) { /* hfileName + offset + blockType */ }
}
Get 对象中的 cacheBlocks 就是控制本次读取的 Data Block 是否进入这个缓存。点查默认开启,适合热点行或热点 Block 场景。
八、StoreScanner 与 ScanQueryMatcher:把多个来源合成一个答案
一个 Store 的读取可能来自:
active MemStoreScanner
immutable MemStoreScanner 1
immutable MemStoreScanner 2
StoreFileScanner 1
StoreFileScanner 2
StoreFileScanner 3
StoreScanner 负责把它们组织成一个 KeyValueHeap。这个堆会按照 CellComparator 弹出当前最小的 Cell,再根据 ScanQueryMatcher 判断是否返回、跳过还是结束。
// 简化:StoreScanner 多路归并
while (heap.next()) {
Cell current = heap.peek();
MatchCode code = queryMatcher.match(current);
switch (code) {
case INCLUDE:
resultCells.add(current);
heap.next();
break;
case SEEK_NEXT_ROW:
heap.seekToNextRow(current);
break;
case SEEK_NEXT_COL:
heap.seekToNextColumn(current);
break;
case DONE:
return resultCells;
case SKIP:
heap.next();
break;
}
}
ScanQueryMatcher 在点查中要同时处理:
- 只选择
Get中指定的 Column; - 只保留时间范围内的版本;
- 只保留前
maxVersions个版本; - 遇到
Delete/DeleteColumn/DeleteFamily时,把旧版本过滤掉; - 判断是否已经找到足够结果,提前结束扫描。
这就是为什么即使底层有很多 Scanner,客户端拿到的仍然是一个干净的 Result。
九、MVCC 与读取一致性
HBase 写入每个 Cell 时会分配单调递增的 sequenceId。读取开始时记录一个 readPoint,扫描器只会返回 sequenceId <= readPoint 的 Cell。
WAL -> MemStore -> Cell(sequenceId)
|
+-> MVCC readPoint 过滤
这样可以避免一次读取看到“写了一半”的数据。对于普通点查,默认 READ_COMMITTED 会保证不读到未提交 Cell;特殊场景才使用 READ_UNCOMMITTED。
十、一次点查的完整简化实现
把客户端定位、RegionServer 调用、StoreScanner 合并、Matcher 过滤串起来,可以写成一个“解释版”点查流程:
public Result pointGet(
Connection connection,
TableName tableName,
byte[] row,
byte[] family,
byte[] qualifier) throws IOException {
Get get = new Get(row)
.addColumn(family, qualifier)
.readVersions(1);
try (Table table = connection.getTable(tableName)) {
return table.get(get);
}
}
如果把上面这段调用展开,它的底层等价流程是:
1. 客户端:把 Get 编码为 GetRequest。
2. 定位层:由 RowKey 找到 RegionInfo 与 RegionServer。
3. RPC 层:RegionServer 接收请求并交给 HRegion。
4. Store 层:为每个 Column Family 创建 StoreScanner。
5. MemStore:跳表定位内存数据。
6. HFile:BloomFilter 过滤 -> Block Index 定位 -> BlockCache 命中或读盘。
7. Merge:KeyValueHeap 按 CellComparator 合并。
8. Filter:ScanQueryMatcher 处理列、版本、时间和删除标记。
9. MVCC:用 readPoint 过滤未提交 Cell。
10. 返回:多个 Cell 打包为 Result。
十一、点查性能优化的落地建议
1. RowKey 设计优先
点查性能的第一变量是 RowKey 是否能精确定位到少量 Region 和少量 Block。避免把时间戳放在 RowKey 最前面,否则单用户历史数据会散落到不同 Region。
推荐:userId_timestamp
不推荐:timestamp_userId
2. 建表时开启 BloomFilter
create 'ods_order',
{NAME => 'cf', BLOOMFILTER => 'ROWCOL', COMPRESSION => 'SNAPPY',
DATA_BLOCK_ENCODING => 'FAST_DIFF'}
ROWCOL 对“只取某一列”的点查过滤更准确;如果一行会取很多列,ROW 会更通用。
3. 查询时显式指定列族和列
get.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("status"));
只读一列时,这个选择会传导到 StoreScanner,避免无关列的数据参与合并和网络传输。
4. 控制版本数
get.readVersions(1);
没有多版本需求时,不扩大 maxVersions。版本越多,合并和过滤成本越高。
5. 关注 BlockCache 命中率
点查热点行时,BlockCache 命中率比磁盘吞吐更重要。可以在 RegionServer 指标中关注:
blockCacheHitRatio
blockCacheHitCount
blockCacheMissCount
blockCacheEvictionCount
堆内 L1 + 堆外 BucketCache 的组合,适合容量大且不希望频繁 GC 的场景。
小结
HBase 点查的本质是:用有序 RowKey 快速定位 Region,用 BloomFilter 和 Block Index 快速定位 Data Block,用 MemStore 和 BlockCache 吸收内存读取,再用多路归并和 Matcher 合并出一个确定性的 Result。
理解它不需要死记源码类名,抓住五个关键词即可:
- 排序:RowKey 和 Cell 都有严格比较顺序;
- 索引:Meta 表负责 Region,HFile Index 负责 Block;
- 过滤:BloomFilter 和 ScanQueryMatcher 负责减少无效读取;
- 合并:MemStoreScanner 与 StoreFileScanner 通过 KeyValueHeap 归并;
- 缓存:BlockCache 和 MemStore 是点查低延迟的主要来源。

257

被折叠的 条评论
为什么被折叠?



