HBase点查流程:代码示例与数据结构

HBase 点查流程:从 Get 到 Cell 的完整链路与数据结构

本文以 HBase 2.x 的 Java API 和内部实现为主线,梳理一次点查从 table.get(Get) 到最终返回 Result 的完整路径。重点不在“如何使用”,而在“为什么这样设计”:RowKey 排序、hbase:meta 定位、MemStore、HFile Block、BloomFilter、BlockCache、多路 Scanner 合并,以及背后的数据结构。

一、先看一次点查的全链路

HBase 最擅长的不是复杂 SQL,而是“给定 RowKey,快速返回一行或一行中的若干列”。这个能力依赖下面这条链路。

HFile + BlockCacheMemStoreHStoreHRegionRegionServerhbase:metaConnection/RegionLocatorHTable/AsyncTableApplicationHFile + BlockCacheMemStoreHStoreHRegionRegionServerhbase:metaConnection/RegionLocatorHTable/AsyncTableApplicationget(Get)根据 RowKey 定位 Region读 hbase:meta 找到目标 RegionServerHRegionLocationRPC GetRequestregion.get(get)对涉及的每个 Column Family 创建 StoreScanner创建 MemStoreScannerBloomFilter + Block Index + BlockCache 定位内存中的匹配 Cell磁盘/缓存中的匹配 CellKeyValueHeap 合并去重ResultResult

一个点查从业务视角看只是一次 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:qualifieraddColumn 就不要 addFamily
maxVersions最多返回多少个历史版本无多版本需求时保持 1
cacheBlocks本次读取的 Block 是否进入 BlockCache点查默认 true
isolationLevelREAD_COMMITTEDREAD_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 的位置。

Get(row)

本地 MetaCache 命中?

缓存 HRegionLocation

Registry 找 hbase:meta Region

读 hbase:meta 行

按 startKey 二分定位目标 Region

RPC 到 RegionServer

从 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);
}

这里的关键对象是 RegionScannerStoreScanner

对象职责
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,而是借助三级结构缩小读取范围。

HFile

Data Blocks

Leaf Index Blocks

Root / Intermediate Index

BloomFilter Blocks

Trailer

Root Index Offset

BloomFilter Meta

Comparator / Encoding / Compression

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,适合多列点查;ROWCOLrow+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 时:

hit

miss

hit

miss

Data Block Request

L1 LRU BlockCache

返回 Block

L2 BucketCache

HDFS / 本地磁盘

校验 / 解压 / 解码

写入 L2/L1

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 在点查中要同时处理:

  1. 只选择 Get 中指定的 Column;
  2. 只保留时间范围内的版本;
  3. 只保留前 maxVersions 个版本;
  4. 遇到 Delete/DeleteColumn/DeleteFamily 时,把旧版本过滤掉;
  5. 判断是否已经找到足够结果,提前结束扫描。

这就是为什么即使底层有很多 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

理解它不需要死记源码类名,抓住五个关键词即可:

  1. 排序:RowKey 和 Cell 都有严格比较顺序;
  2. 索引:Meta 表负责 Region,HFile Index 负责 Block;
  3. 过滤:BloomFilter 和 ScanQueryMatcher 负责减少无效读取;
  4. 合并:MemStoreScanner 与 StoreFileScanner 通过 KeyValueHeap 归并;
  5. 缓存:BlockCache 和 MemStore 是点查低延迟的主要来源。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值