客户端会先通过Zookeeper查找Meta表在的RegionServer(Meta表和普通表存储上没有差别),通过Meta表查找 key所在的Region 对应的 RegionServer,然后找到对应的HRegion,在每个Region进行查找。
客户端会缓存Meta表的信息(如Region位置),这能显著减少对Zookeeper和Meta表的访问压力,提升查询效率只有当缓存失效(如Region被迁移)时,才需要重新查询。
hbase:meta表记录了所有用户Region的详细信息(如Region所属的表、起始和结束RowKey、所在的RegionServer地址等),是客户端定位数据的“地图”Region的定位:每个Region负责存储表中一段连续的RowKey范围的数据。因此,只要知道RowKey,就可以通过
hbase:meta表找到它所属的Region及其所在的RegionServer
HRegion响应
getInternal 是 HBase Region Server 处理单个 Get 请求的核心实现(最终调用这个函数进行实际的get)。首先看一下这个函数的签名和整体结构:
private List<Cell> getInternal(Get get, boolean withCoprocessor, long nonceGroup, long nonce)
throws IOException {
List<Cell> results = new ArrayList<>();
// 1. pre-get 协处理器钩子
if (withCoprocessor && (coprocessorHost != null)) {
if (coprocessorHost.preGet(get, results)) {
metricsUpdateForGet();
return results;
}
}
// 2. 将 Get 请求转换为 Scan
Scan scan = new Scan(get);
if (scan.getLoadColumnFamiliesOnDemandValue() == null) {
scan.setLoadColumnFamiliesOnDemand(isLoadingCfsOnDemandDefault());
}
// 3. 获取 RegionScanner 并读取数据
try (RegionScanner scanner = getScanner(scan, null, nonceGroup, nonce)) {
List<Cell> tmp = new ArrayList<>();
scanner.next(tmp);
// 4. 克隆 Cell,确保数据安全
for (Cell cell : tmp) {
results.add(CellUtil.cloneIfNecessary(cell));
}
}
// 5. post-get 协处理器钩子
if (withCoprocessor && (coprocessorHost != null)) {
coprocessorHost.postGet(get, results);
}
// 6. 更新统计指标
metricsUpdateForGet();
return results;
}
函数签名分析:
private List<Cell> getInternal(...):私有方法,是HRegion内部处理Get请求的具体实现。它返回一个Cell列表,每个Cell代表一个单元格(即一个列的值)。Get get: 客户端发起的Get请求对象,包含了要查询的行键(Row Key)、列族(Column Family)、列(Qualifier)等信息。boolean withCoprocessor: 一个标志,用于控制是否触发协处理器(Coprocessor)的钩子方法。long nonceGroup, long nonce: 用于实现操作的幂等性,防止因重试导致同一操作被执行多次。
详细执行流程分析
1. 协处理器 preGet 钩子
// ...
if (withCoprocessor && (coprocessorHost != null)) {
if (coprocessorHost.preGet(get, results)) {
metricsUpdateForGet();
return results;
}
}
// ...
这是 Get 操作的第一个扩展点。在真正执行数据查找之前,HBase 会检查是否加载了协处理器。
coprocessorHost.preGet(): 如果存在协处理器,则会调用其preGet方法。- 作用: 协处理器可以在此阶段完全接管
Get请求。例如,它可以进行权限校验,如果发现用户没有权限,可以直接抛出异常。或者,它可以实现自定义的缓存逻辑,如果数据在自定义缓存中命中,就可以直接填充results列表,并返回true来短路(Bypass)后续的HBase标准读取流程。 - 如果
preGet返回true,意味着协处理器已经处理了这次请求,getInternal方法会立即更新统计信息并返回结果。
2. 将 Get 转换为 Scan
// ...
Scan scan = new Scan(get);
if (scan.getLoadColumnFamiliesOnDemandValue() == null) {
scan.setLoadColumnFamiliesOnDemand(isLoadingCfsOnDemandDefault());
}
// ...
在HBase内部,Get 操作被视为一种特殊的 Scan 操作,即只扫描一行数据。
new Scan(get): 这个构造函数会将Get对象的所有属性(如行键、列族、时间戳范围等)转换成一个Scan对象。这个Scan的startRow和stopRow都被设置为Get的行键,从而将扫描范围限定在这一行。setLoadColumnFamiliesOnDemand: 这是一个重要的性能优化。如果开启,HBase在扫描时只会加载那些在查询中明确需要的列族的数据。如果一个表有多个列族,而查询只涉及其中一两个,这个特性可以显著减少I/O。这里会设置一个Region级别的默认值。
3. 获取 RegionScanner 并读取数据
// ...
try (RegionScanner scanner = getScanner(scan, null, nonceGroup, nonce)) {
List<Cell> tmp = new ArrayList<>();
scanner.next(tmp);
// ...
}
// ...
这是整个 Get 操作的核心。
getScanner(scan, ...): 此方法会创建一个RegionScanner实例,它是用于在单个Region内扫描数据的迭代器。scanner.next(tmp): 调用next()方法来获取下一行数据。对于由Get转换来的Scan,这个方法最多只会返回一行数据。所有符合条件的Cell都会被添加到tmp列表中。
子函数分析: getScanner()
getScanner 的作用是构建一个复杂的扫描器体系,这个体系能够统一地、有序地访问存储在不同位置(内存和磁盘文件)的数据。其内部结构大致如下:
RegionScannerImpl:getScanner方法最终会返回一个RegionScannerImpl对象。这是RegionScanner接口的实现类。StoreScanner: 在RegionScannerImpl内部,会为Scan请求中涉及的每一个列族(Store)创建一个StoreScanner。StoreScanner负责扫描一个列族下的所有数据。KeyValueScanner合并:StoreScanner本身是一个合并扫描器,它内部包含了一个KeyValueScanner列表,并将它们合并成一个有序的视图。这个列表通常包括:- 一个
MemStoreScanner: 用于扫描内存中(MemStore)还未刷写到磁盘的数据。 - 多个
StoreFileScanner: 每个HFile(在代码中称为StoreFile) 对应一个StoreFileScanner,用于扫描持久化在磁盘上的数据文件。
- 一个
- 数据合并:
StoreScanner会使用一个最小堆(Min-Heap)的数据结构,将来自MemStoreScanner和所有StoreFileScanner的数据进行归并排序,确保Cell按照行键、列、时间戳(降序)的顺序返回。 - 最终视图:
RegionScannerImpl再将来自不同StoreScanner(不同列族)的数据进行合并,最终呈现给调用者一个完整的、有序的行视图。
这个分层的扫描器设计是HBase读取路径的核心,它屏蔽了数据存储在内存还是磁盘的复杂性。
4. 克隆 Cell,确保数据安全
// ...
// Copy EC to heap, then close the scanner.
// This can be an EXPENSIVE call. It may make an extra copy from offheap to onheap buffers.
// See more details in HBASE-26036.
for (Cell cell : tmp) {
results.add(CellUtil.cloneIfNecessary(cell));
}
// ...
scanner.next() 返回的 Cell 对象可能指向不稳定的内存区域。
- 背景: 为了性能,HBase广泛使用堆外内存(Off-heap memory),例如
BucketCache。scanner返回的Cell可能只是一个指向堆外ByteBuffer的引用。 try-with-resources: 这个语法块会在执行完毕后自动调用scanner.close()。close()方法会释放扫描器占用的所有资源,包括它引用的堆外内存块。CellUtil.cloneIfNecessary(cell): 这个方法的关键作用是:检查Cell的数据是否在即将被释放的内存中。如果是,它会把这个Cell的内容(行键、列族、列、时间戳、值)完整地复制到一个新的、位于Java堆内存(On-heap)的Cell对象中。这样,即使扫描器关闭,返回给上层应用的数据依然是安全有效的。- 性能: 正如注释所说,这个克隆操作可能涉及内存拷贝,是有性能开销的。
5. 协处理器 postGet 钩子
// ...
if (withCoprocessor && (coprocessorHost != null)) {
coprocessorHost.postGet(get, results);
}
// ...
在数据被成功检索并克隆后,返回给客户端之前,HBase提供了第二个协处理器扩展点。
coprocessorHost.postGet(): 调用协处理器的postGet方法。- 作用: 协处理器可以在此阶段对
results列表进行检查、过滤或修改。例如,一个数据脱敏的协处理器可以在这里将结果中的敏感信息替换为*号。
6. 更新统计指标
// ...
metricsUpdateForGet();
// ...
最后,调用 metricsUpdateForGet() 方法来更新与 Get 操作相关的性能监控指标。
子函数分析: metricsUpdateForGet()
void metricsUpdateForGet() {
if (this.metricsRegion != null) {
this.metricsRegion.updateGet();
}
if (this.rsServices != null && this.rsServices.getMetrics() != null) {
rsServices.getMetrics().updateReadQueryMeter(this, 1);
}
}
这个方法会更新两个层面的统计:
- Region层面:
metricsRegion.updateGet()会增加该Region的get操作计数器。 - RegionServer层面:
rsServices.getMetrics().updateReadQueryMeter()会更新整个RegionServer的读请求计数器。
这些指标对于监控系统健康状况和性能调优至关重要。
总结
HRegion.getInternal 方法是HBase单行读取操作的“指挥中心”。它的执行流程可以概括为:
请求 -> preGet (可短路) -> Get转Scan
-> 构建扫描器体系(RegionScanner -> StoreScanner -> MemStore/HFile Scanners)
-> 数据检索与合并 -> 数据克隆(保证安全) -> postGet (可修改) -> 更新Metrics -> 返回结果
HRegion.getScanner
这个方法是 HBase Region Server 处理所有扫描类型请求(包括 Get 和 Scan)的入口点,其核心职责是构建一个能够遍历 Region 数据的迭代器。
首先看 HRegion 中的 getScanner 方法。它有几个重载版本,但最终都调用到下面这个私有方法:
private RegionScannerImpl getScanner(Scan scan, List<KeyValueScanner> additionalScanners,
long nonceGroup, long nonce) throws IOException {
return TraceUtil.trace(() -> {
// 1. 开始一个 Region 操作
startRegionOperation(Operation.SCAN);
try {
// 2. 校验和准备 Scan 对象中的列族
if (!scan.hasFamilies()) {
// 如果 Scan 未指定列族,则默认扫描所有列族
for (byte[] family : this.htableDescriptor.getColumnFamilyNames()) {
scan.addFamily(family);
}
} else {
// 如果指定了列族,则逐一校验是否存在
for (byte[] family : scan.getFamilyMap().keySet()) {
checkFamily(family);
}
}
// 3. 实例化真正的 Scanner
return instantiateRegionScanner(scan, additionalScanners, nonceGroup, nonce);
} finally {
// 4. 结束 Region 操作
closeRegionOperation(Operation.SCAN);
}
}, () -> createRegionSpan("Region.getScanner"));
}
函数逻辑分析:
startRegionOperation: 这是一个重要的生命周期管理方法。它会检查 Region 是否正在关闭(closing),如果是则抛出异常。这确保了在扫描期间,Region 不会被关闭或分裂,保证了扫描过程的稳定性。- 校验和准备列族: 代码确保
Scan对象是合法的。如果客户端没有指定要扫描哪些列族,它会默认扫描表的所有列族。如果指定了,它会检查这些列族是否真实存在于表定义中。 instantiateRegionScanner: 这是创建扫描器的核心步骤,我们将重点分析它。closeRegionOperation: 在finally块中调用,与startRegionOperation配对,表示扫描操作的生命周期结束。TraceUtil.trace: 整个逻辑被包裹在追踪工具中,用于分布式追踪系统(如 OpenTelemetry/Zipkin)记录该操作的耗时和跨度。
子函数分析: instantiateRegionScanner
这个方法是实际创建 RegionScanner 实例的地方。
// ... existing code ...
protected RegionScannerImpl instantiateRegionScanner(Scan scan,
List<KeyValueScanner> additionalScanners, long nonceGroup, long nonce) throws IOException {
if (scan.isReversed()) {
if (scan.getFilter() != null) {
scan.getFilter().setReversed(true);
}
return new ReversedRegionScannerImpl(scan, additionalScanners, this, nonceGroup, nonce);
}
return new RegionScannerImpl(scan, additionalScanners, this, nonceGroup, nonce);
}
// ... existing code ...
逻辑分析:
- 该方法的主要作用是根据
Scan对象是否是反向扫描(isReversed())来决定实例化哪种扫描器。 - 正向扫描: 创建
RegionScannerImpl实例。 - 反向扫描: 创建
ReversedRegionScannerImpl实例。ReversedRegionScannerImpl继承自RegionScannerImpl,并重写了部分逻辑以支持从后往前的扫描。
为了理解核心机制,我们接下来深入分析 RegionScannerImpl 的构造和初始化过程。
RegionScannerImpl 的构造与初始化
RegionScannerImpl 的构造函数是整个扫描器体系建立的核心。它调用了一系列初始化方法来构建一个分层的扫描器结构。
RegionScannerImpl(Scan scan, List<KeyValueScanner> additionalScanners, HRegion region,
long nonceGroup, long nonce) throws IOException {
// ...
// 1. 确定读快照点 (Read Point)
if (mvccReadPoint > 0) {
this.readPt = mvccReadPoint;
} else {
this.readPt = region.getReadPoint(isolationLevel);
}
// ...
// 2. 初始化所有底层的 KeyValueScanner
initializeScanners(scan, additionalScanners);
}
1. 确定读快照点 (Read Point)
readPt (Read Point) 是一个非常关键的概念,它代表了本次扫描可见数据的“快照”版本。所有序列号(Sequence ID)大于 readPt 的数据变更(Put/Delete)对本次扫描都是不可见的。这保证了扫描操作的读一致性。
2. initializeScanners - 构建扫描器金字塔
这是最核心的初始化方法,它构建了一个“扫描器金字塔”来统一访问来自不同列族、不同文件以及内存中的数据。
private void initializeScanners(Scan scan, List<KeyValueScanner> additionalScanners)
throws IOException {
// ...
List<KeyValueScanner> scanners = new ArrayList<>(scan.getFamilyMap().size());
List<KeyValueScanner> joinedScanners = new ArrayList<>(scan.getFamilyMap().size());
// ...
try {
// a. 为每个列族创建一个 StoreScanner
for (Map.Entry<byte[], NavigableSet<byte[]>> entry : scan.getFamilyMap().entrySet()) {
HStore store = region.getStore(entry.getKey());
KeyValueScanner scanner = store.getScanner(scan, entry.getValue(), this.readPt);
// ...
// b. 根据是否"按需加载"决定放入哪个列表
if (
this.filter == null || !scan.doLoadColumnFamiliesOnDemand()
|| this.filter.isFamilyEssential(entry.getKey())
) {
scanners.add(scanner);
} else {
joinedScanners.add(scanner);
}
}
// ...
// c. 初始化顶层合并堆
initializeKVHeap(scanners, joinedScanners, scan);
}
// ...
}
逻辑分析:
-
a. 获取
StoreScanner: 循环遍历Scan请求中涉及的每个列族(Column Family)。region.getStore(family): 获取代表该列族的HStore对象。store.getScanner(...): 这是又一层关键的递归调用。HStore的getScanner方法会创建一个StoreScanner。StoreScanner负责扫描单个列族的所有数据,它内部会再次创建一个合并堆(KeyValueHeap),用于合并来自该列族 内存(MemStore)和所有磁盘文件(HFile) 的数据流。
-
b. 按需加载列族 (Load Column Families On Demand): 这是一个性能优化。如果开启了此功能,并且
Filter认为某个列族不是“必需”的(isFamilyEssential返回false),那么这个列族的StoreScanner会被放入joinedScanners列表。这些列族的数据只有在确定某一行需要被返回时,才会去读取,从而避免了不必要的 I/O。 -
c.
initializeKVHeap: 这个方法负责构建“金字塔”的顶端。它接收来自所有“必需”列族的StoreScanner列表(scanners),并使用一个顶层的KeyValueHeap(在RegionScannerImpl中名为storeHeap)将它们合并成一个统一的、跨所有列族的、有序的KeyValue流。
扫描器体系结构总结
HRegion.getScanner 的调用链最终构建了一个层次化的扫描器结构,可以总结如下:
-
RegionScannerImpl(顶层): 客户端直接交互的对象。它管理着整个扫描过程的生命周期和状态。- 内部包含一个
KeyValueHeap(名为storeHeap)。这个堆的作用是跨列族合并数据。它从每个StoreScanner中取出最小的KeyValue(Cell),从而保证RegionScannerImpl对外总是返回全局有序的数据。
- 内部包含一个
-
StoreScanner(中间层,每个列族一个): 负责扫描单个列族的数据。- 内部也包含一个
KeyValueHeap。这个堆的作用是合并单个列族内部的数据源。
- 内部也包含一个
-
MemStoreScanner和StoreFileScanner(底层,每个数据源一个):MemStoreScanner: 扫描内存(MemStore)中的数据。StoreFileScanner: 扫描单个 HFile 磁盘文件中的数据。
这个金字塔形的结构,通过两层 KeyValueHeap 的归并排序,优雅地解决了从多个物理位置(内存、多个HFile)和逻辑单元(多个列族)中读取数据,并最终呈现给用户一个统一、有序的数据视图的问题。
StoreScanner
StoreScanner 是 HBase 扫描器体系中非常关键的一环,它位于 RegionScanner 之下,负责扫描 单个列族(Store) 的数据。一个 Store 的数据分布在两处:内存中的 MemStore 和磁盘上的多个 HFile(在代码中称为 StoreFile)。StoreScanner 的核心职责就是将这些来源的数据聚合成一个统一、有序的 KeyValue(Cell)流。
我们来分析构造函数,它是为 用户扫描(User Scan) 创建 StoreScanner 的主要入口。
// ... existing code ...
/**
* Opens a scanner across memstore, snapshot, and all StoreFiles. Assumes we are not in a
* compaction.
* @param store who we scan
* @param scan the spec
* @param columns which columns we are scanning
*/
public StoreScanner(HStore store, ScanInfo scanInfo, Scan scan, NavigableSet<byte[]> columns,
long readPt) throws IOException {
// 1. 调用私有构造函数,初始化基本成员变量
this(store, scan, scanInfo, columns != null ? columns.size() : 0, readPt, scan.getCacheBlocks(),
ScanType.USER_SCAN);
if (columns != null && scan.isRaw()) {
throw new DoNotRetryIOException("Cannot specify any column for a raw scan");
}
// 2. 创建查询匹配器 (Query Matcher)
matcher = UserScanQueryMatcher.create(scan, scanInfo, columns, oldestUnexpiredTS, now,
store.getCoprocessorHost());
// 3. 注册为观察者,用于处理 flush
store.addChangedReaderObserver(this);
List<KeyValueScanner> scanners = null;
try {
// 4. 从 Store 获取并筛选底层的 KeyValueScanner
// Pass columns to try to filter out unnecessary StoreFiles.
scanners = selectScannersFrom(store,
store.getScanners(cacheBlocks, scanUsePread, false, matcher, scan.getStartRow(),
scan.includeStartRow(), scan.getStopRow(), scan.includeStopRow(), this.readPt,
isOnlyLatestVersionScan(scan)));
// 5. 对所有底层 Scanner 执行 seek 操作
// Seek all scanners to the start of the Row (or if the exact matching row
// key does not exist, then to the start of the next matching Row).
// Always check bloom filter to optimize the top row seek for delete
// family marker.
seekScanners(scanners, matcher.getStartKey(), explicitColumnQuery && lazySeekEnabledGlobally,
parallelSeekEnabled);
// 6. 设置 per-family 的 limit 和 offset
// set storeLimit
this.storeLimit = scan.getMaxResultsPerColumnFamily();
// set rowOffset
this.storeOffset = scan.getRowOffsetPerColumnFamily();
addCurrentScanners(scanners);
// 7. 创建 KeyValueHeap,合并所有 Scanner
// Combine all seeked scanners with a heap
resetKVHeap(scanners, comparator);
} catch (IOException e) {
clearAndClose(scanners);
// remove us from the HStore#changedReaderObservers here or we'll have no chance to
// and might cause memory leak
store.deleteChangedReaderObserver(this);
throw e;
}
}
// ... existing code ...
详细执行流程解析:
-
调用私有构造函数:
this(...)调用会初始化一些基础属性,如comparator、scan对象、readPt(读快照点)、oldestUnexpiredTS(最老的未过期时间戳)等。它还会根据扫描类型(Get/Scan)和配置决定是使用PREAD还是STREAM模式读取 HFile。 -
创建
UserScanQueryMatcher:matcher是一个非常重要的组件。它封装了所有查询条件,包括行键范围、列、时间范围、版本数以及过滤器(Filter)。在后续的扫描过程中,StoreScanner会用matcher来判断一个KeyValue是否符合用户查询,以及是否可以跳过某些数据。 -
注册观察者:
store.addChangedReaderObserver(this)将自己注册到HStore。当MemStore发生flush时,HStore会通知所有观察者(调用updateReaders方法),StoreScanner就可以动态地将新生成的HFile的Scanner加入到当前的扫描中,保证扫描能看到 flush 后的数据。 -
获取并筛选
KeyValueScanner:store.getScanners(...): 这是关键的一步。它会返回代表当前Store中所有数据源的Scanner列表,这通常包括:- 一个或多个
MemStoreScanner(用于扫描内存数据)。 - 多个
StoreFileScanner(每个 HFile 对应一个)。
- 一个或多个
selectScannersFrom(...): 这是一个优化步骤。它会遍历store.getScanners返回的所有Scanner,并根据Scan的条件(如时间范围、TTL)和 HFile 的元数据(如布隆过滤器、时间范围)来提前排除掉那些肯定不包含所需数据的 HFile。例如,如果一个 HFile 的时间范围与查询的时间范围完全没有交集,那么对应的StoreFileScanner就会被直接关闭,根本不会参与后续的扫描。
-
执行
seek操作:seekScanners(...)会在所有通过筛选的Scanner上执行seek操作,将它们的内部指针定位到scan的起始行键(startRow)。这也是一个优化点,避免了从头扫描每个 HFile。 -
设置 Limit/Offset: 从
Scan对象中获取针对该列族的行数限制和偏移量。 -
创建
KeyValueHeap:resetKVHeap(...)会创建一个KeyValueHeap实例。KeyValueHeap是一个最小堆(Min-Heap),它将所有底层的Scanner(来自 MemStore 和 HFiles)作为输入源。通过反复调用堆的peek()和next()方法,StoreScanner可以高效地以正确的顺序(按 KeyValue 排序)从所有数据源中取出最小的KeyValue,从而实现归并排序的效果。
UserScanQueryMatcher类详细分析
UserScanQueryMatcher是HBase中专门处理用户扫描请求的查询匹配器,它继承自ScanQueryMatcher抽象基类,负责决定每个Cell是否应该被包含在扫描结果中。与StoreScanner配合,它构成了HBase扫描操作的核心决策引擎。
protected final boolean hasNullColumn; // 是否查询空列
protected final Filter filter; // 用户指定的过滤器
protected final byte[] stopRow; // 扫描终止行
protected final TimeRange tr; // 时间范围约束
private final int versionsAfterFilter; // 过滤后的版本数限制
private int count = 0; // 当前列的计数器
private ExtendedCell curColCell = null; // 当前列的引用
核心方法实现
列匹配逻辑 (matchColumn方法)
这是UserScanQueryMatcher最核心的方法,它实现了完整的列匹配决策链:
protected final MatchCode matchColumn(ExtendedCell cell, long timestamp, byte typeByte) throws IOException {
// 步骤1: 时间范围检查
int tsCmp = tr.compare(timestamp);
if (tsCmp > 0) return MatchCode.SKIP; // 时间戳太大,跳过
if (tsCmp < 0) return columns.getNextRowOrNextColumn(cell); // 时间戳太小,跳到下一行/列
// 步骤2: 检查是否为用户请求的列
MatchCode matchCode = columns.checkColumn(cell, typeByte);
if (matchCode != MatchCode.INCLUDE) {
return matchCode;
}
// 步骤3: 版本数量检查
matchCode = columns.checkVersions(cell, timestamp, typeByte, false);
// 步骤4: 如果有过滤器,合并过滤器响应
return filter == null ? matchCode : mergeFilterResponse(cell, matchCode, filter.filterCell(cell));
}
过滤器响应合并 (mergeFilterResponse方法)
这个方法实现了复杂的过滤器与列检查器的决策合并逻辑,通过一个完整的决策矩阵来处理各种组合情况:
private final MatchCode mergeFilterResponse(ExtendedCell cell, MatchCode matchCode, ReturnCode filterResponse) {
switch (filterResponse) {
case SKIP:
if (matchCode == MatchCode.INCLUDE) return MatchCode.SKIP;
if (matchCode == MatchCode.INCLUDE_AND_SEEK_NEXT_COL) return MatchCode.SEEK_NEXT_COL;
if (matchCode == MatchCode.INCLUDE_AND_SEEK_NEXT_ROW) return MatchCode.SEEK_NEXT_ROW;
break;
case NEXT_COL:
// 根据列跟踪器决定跳到下一列还是下一行
if (matchCode == MatchCode.INCLUDE || matchCode == MatchCode.INCLUDE_AND_SEEK_NEXT_COL) {
return columns.getNextRowOrNextColumn(cell);
}
break;
case INCLUDE_AND_SEEK_NEXT_ROW:
matchCode = MatchCode.INCLUDE_AND_SEEK_NEXT_ROW;
break;
}
// 版本数控制:确保不超过扫描设置的最大版本数
if (curColCell == null || !CellUtil.matchingRowColumn(cell, curColCell)) {
count = 0;
curColCell = cell;
}
count++;
if (count > versionsAfterFilter) {
// 超过版本限制,优化跳到下一行或列
if (matchCode == MatchCode.INCLUDE_AND_SEEK_NEXT_ROW) {
matchCode = MatchCode.SEEK_NEXT_ROW;
} else {
matchCode = MatchCode.SEEK_NEXT_COL;
}
}
return matchCode;
}
StoreScanner与UserScanQueryMatcher的互动机制
StoreScanner和UserScanQueryMatcher形成了一个生产者-消费者模式:
- StoreScanner:负责从多个数据源(MemStore、StoreFile)读取Cell数据
- UserScanQueryMatcher:负责决定每个Cell是否应该被包含在最终结果中
在StoreScanner的next()方法中,交互流程如下:
// 1. StoreScanner从heap中获取下一个cell
cell = heap.peek();
// 2. 调用UserScanQueryMatcher进行匹配决策
ScanQueryMatcher.MatchCode qcode = matcher.match(cell);
// 3. 根据匹配结果决定后续操作
switch (qcode) {
case INCLUDE:
case INCLUDE_AND_SEEK_NEXT_ROW:
case INCLUDE_AND_SEEK_NEXT_COL:
// 包含该cell,添加到结果中
outResult.add(cell);
// 根据具体情况决定是否跳到下一行/列
if (qcode == ScanQueryMatcher.MatchCode.INCLUDE_AND_SEEK_NEXT_ROW) {
seekOrSkipToNextRow(cell);
} else if (qcode == ScanQueryMatcher.MatchCode.INCLUDE_AND_SEEK_NEXT_COL) {
seekOrSkipToNextColumn(cell);
}
break;
case SEEK_NEXT_ROW:
// 需要跳到下一行
seekOrSkipToNextRow(cell);
break;
case SEEK_NEXT_COL:
// 需要跳到下一列
seekOrSkipToNextColumn(cell);
break;
case SKIP:
// 跳过该cell,继续下一个
this.heap.next();
break;
case DONE:
case DONE_SCAN:
// 扫描结束
close(false);
return scannerContext.setScannerState(NextState.NO_MORE_VALUES).hasMoreValues();
}
智能跳转优化
UserScanQueryMatcher不仅告诉StoreScanner是否包含某个Cell,还会提供跳转建议,让StoreScanner能够智能地跳过大量不必要的数据:
行级跳转 (SEEK_NEXT_ROW)
当UserScanQueryMatcher确定当前行已经处理完毕时,会建议StoreScanner直接跳到下一行,避免扫描当前行的剩余列族和列。
列级跳转 (SEEK_NEXT_COL)
当确定当前列已经处理完毕时,建议直接跳到下一列,避免扫描当前列的更多版本。
智能提示跳转 (SEEK_NEXT_USING_HINT)
当过滤器提供了下一个可能匹配的Key提示时,UserScanQueryMatcher会建议StoreScanner直接跳转到该位置,实现最大幅度的跳过优化。
性能优化协作
批量控制
StoreScanner会根据UserScanQueryMatcher的决策结果,动态调整批量大小,确保在达到批量限制时及时返回:
if (scannerContext.checkBatchLimit(LimitScope.BETWEEN_CELLS)) {
break LOOP; // 达到批量限制,提前返回
}
内存控制
通过UserScanQueryMatcher的包含决策,StoreScanner能够精确控制内存使用,避免加载过多不必要的数据:
if (matcher.isUserScan() && totalBytesRead > maxRowSize) {
throw new RowTooBigException("Max row size allowed: " + maxRowSize);
}
版本控制
UserScanQueryMatcher通过内部的count和versionsAfterFilter机制,确保StoreScanner不会返回超过用户指定版本数的Cell,实现精确的版本控制。
设计亮点总结
- 职责分离:StoreScanner专注数据获取,UserScanQueryMatcher专注决策判断
- 智能跳转:通过MatchCode系统实现多级跳转优化,大幅提升扫描效率
- 过滤器集成:完美支持用户自定义过滤器,提供灵活的过滤能力
- 版本精确控制:确保返回结果严格符合用户的版本数要求
- 内存友好:通过精确的包含/排除决策,最小化内存占用
trySkipToNextRow 方法
在 HBase 中,数据是按块存储的,每个块通常包含多个 KeyValue。每个块都有一个索引键,表示该块中的第一个 KeyValue。
当 trySkipToNextRow 方法发现下一个索引键(即下一个数据块的起始键)已经大于当前要查找的目标行时,说明:
- 当前数据块中不可能有目标行的数据
- 目标行的数据在下一个数据块或更后面的块中
例子:
- 当前在 BLOCK 1 的 r1/c3
- 下一个索引键是 BLOCK 2 的 r1/c4
- 但我们要找的是 r2(下一行),r1/c4 < r2,所以继续找
- 发现下一个索引键已经是 r2/c1 或更大 → 直接 SEEK 到 r2
当发现下一个索引键不大于目标行时,说明:
- 目标行的数据可能在当前数据块中
- 只需要逐条跳过当前块中的数据即可找到目标
例子:
| BLOCK 1 | BLOCK 2 |
| r1/c1/t5, r1/c1/t4, r1/c1/t3 | r1/c1/t2, r1/c1/T1, r1/c2/T3 |
- 当前在 r1/c1/t5,要找下一个列(比如 c2)
- 下一个索引键是 r1/c1/t4,还在同一个块中
- 所以逐条 SKIP 即可:r1/c1/t4 → r1/c1/t3 → r1/c1/t2 → r1/c2/T3
核心代码逻辑
protected boolean trySkipToNextRow(ExtendedCell cell) throws IOException {
ExtendedCell nextIndexedKey = getNextIndexedKey(); // 获取下一个索引键
if (nextIndexedKey != null &&
matcher.compareKeyForNextRow(nextIndexedKey, cell) >= 0) { // 关键判断!
// 如果下一个索引键 >= 目标行键,说明目标在下一个块中
// 继续在当前块中 SKIP
this.heap.next(); // 逐条跳过
++kvsScanned;
} else {
// 下一个索引键 < 目标行键,目标可能在更后面的块中
// 直接返回 false,让调用者执行 SEEK
return false;
}
}
为什么这样优化?
- SEEK 操作代价高:需要重新定位文件指针,可能涉及磁盘 I/O
- SKIP 操作代价低:只是在当前已加载的内存块中移动指针
- 智能选择:根据索引键预测哪种方式更高效,避免不必要的昂贵操作
每次都需要 scan 所有文件吗?
不是。 HBase 在 StoreScanner 层面做了大量优化来避免扫描所有数据:
-
HFile 筛选 (
selectScannersFrom): 这是最重要的优化。在扫描开始前,HBase 会利用 HFile 的元数据(Metadata)进行预筛选。- 时间范围(Time Range): 如果 HFile 的时间范围与查询的时间范围不重叠,则该文件被跳过。
- 布隆过滤器(Bloom Filter): 对于
Get请求或指定了具体行键的Scan,HBase 会使用行布隆过滤器(ROW)或行列布隆过滤器(ROWCOL)来快速判断一个 HFile 是否可能包含目标行/单元格。如果布隆过滤器判断肯定不存在,则该文件被直接跳过。 - 行键范围(Key Range): HFile 也存储了最小和最大行键。如果查询的行键范围与 HFile 的行键范围没有交集,文件也会被跳过。
-
Seek 操作: 对于通过筛选的 HFile,扫描器不会从头读到尾。它会执行
seek操作,直接将文件指针定位到查询的startRow,然后从那里开始顺序读取。 -
Lazy Seek: 对于多列查询,
lazySeekEnabledGlobally开启时,seek操作可能被推迟,进一步减少不必要的 I/O。
因此,一个典型的 Scan 或 Get 请求,只会打开和扫描那些可能包含所需数据的 HFile 的相关部分。
如果内存有,还要扫描出所有 key 对应的 value 吗?
这个问题的核心是 HBase 如何处理同一行、同一列在不同地方(MemStore、多个 HFile)存在多个版本的数据。
答案是:需要扫描,但会高效地合并和过滤。
StoreScanner 的工作机制正是为了解决这个问题:
-
统一视图:
KeyValueHeap将来自MemStoreScanner和所有相关StoreFileScanner的KeyValue放入同一个最小堆中进行排序。这意味着无论数据来自内存还是磁盘,都会被统一处理。 -
版本合并与过滤: 当
StoreScanner从KeyValueHeap中取出KeyValue时,ScanQueryMatcher会发挥作用:- 版本数控制:
matcher会跟踪当前行已经返回了多少个版本的KeyValue。如果超过了scan指定的最大版本数,后续出现的更旧的版本就会被跳过。 - Delete Marker 处理: 如果
matcher看到了一个删除标记(Delete Marker),它会“遮蔽”掉所有时间戳比该标记更旧的、位于同一行或同一列的KeyValue。这些被遮蔽的KeyValue也会被跳过。 - TTL 过期:
matcher也会检查KeyValue的时间戳,如果它已经因为超出了 TTL 而过期,也会被跳过。
- 版本数控制:
举个例子: 假设你要 get 一行 row1,列 cf:q1 的数据,最大版本数为 1。
MemStore中有{row1, cf:q1, ts=10, value='new'}。- 一个
HFile中有{row1, cf:q1, ts=5, value='old'}。
StoreScanner 的处理流程如下:
MemStoreScanner和StoreFileScanner都被创建并seek到row1。KeyValueHeap会同时包含来自内存和文件的KeyValue。由于ts=10的KeyValue更“小”(在 HBase 排序中,时间戳降序排列),它会先从堆顶弹出。matcher接收到ts=10的KeyValue,发现符合条件,版本数计数变为 1。- 接下来,
ts=5的KeyValue从堆顶弹出。 matcher接收到ts=5的KeyValue,但发现row1, cf:q1的版本数已经达到上限(1),于是它会跳过这个KeyValue。
最终,只有内存中的新版本数据会返回给上层。虽然磁盘上的数据也被 seek 到了,但它在合并阶段就被高效地过滤掉了,避免了不必要的数据处理和返回。
内存里的数据不一定是“最终”的
直觉上认为:“内存(MemStore)是最新写入的,磁盘(HFile)是过去写入的,所以内存有结果直接返回不就行了吗?”
在大多数情况下这个逻辑是对的,但 HBase 作为一个通用的分布式数据库,必须处理以下三种情况,导致它不敢只看内存:
1. 时间戳(Timestamp)的灵活性
HBase 允许客户端在写入数据时自定义时间戳。
- 场景:如果一个客户端在 10:00 写入了
RowA到磁盘(HFile),然后另一个客户端在 10:05 写入了一个带有9:00时间戳的RowA到内存(MemStore)。 - 结果:虽然内存里的数据是“后写”的,但由于它的时间戳更小,在 HBase 的逻辑里,磁盘上的数据才是“最新”的。HBase 必须比较所有 Scanner 的时间戳才能决定谁排在前面。
2. 删除标记(Tombstone)的覆盖范围
删除在 HBase 中也是一次写入(写入一个 Delete 类型的 Cell)。
- 场景:如果你发起一个
Get请求,内存中确实有一个Value=A。但是,在某个尚未合并(Compaction)的磁盘 HFile 中,可能存在一个 Family Delete 或 Row Delete 标记,其时间戳大于内存中这条记录的时间戳。 - 结果:只有把内存和所有 HFile 的 Scanner 结果放在一起“对质”(归并排序),HBase 才能确定这条记录是不是真的由于某种原因被标记删除了。
3. 多版本请求(Max Versions)
- 场景:用户查询时可能指定了
VERSIONS => 3。 - 结果:即使内存里有最新的 1 个版本,剩下的 2 个版本极有可能散落在磁盘的不同 HFile 中。为了凑齐这 3 个版本,系统必须扫描磁盘。
总结:内存里的数据是“最近写入”的,但不一定是“逻辑上最新”或“逻辑上完整”的。只有通过 KeyValueHeap 将所有来源的数据按 RowKey -> Column -> Timestamp 排序,才能得到最终真相。
HBase StoreScanner 初始化流程的性能瓶颈
在 HBase 的 StoreScanner 初始化流程 里,执行顺序如下:
-
预加载所有 Scanner 并 Seek 到起始位置
-
对每个 StoreFile 构造 StoreFileScanner,并立即执行
seekToStartKV()→ 触发HFileReaderImpl.seek→ 读取 block index → 可能读取 data block -
对 MemStore 构造 MemStoreScanner,并立即执行
seekToFirstInRange()→ 跳转到 SkipList 的第一个合法 Cell
-
-
构建最小堆结构
将上述所有 Scanner 塞进以 KeyValueHeap 为底层结构的“最小堆”。
-
延迟过滤
最后通过 matcher(ScanQueryMatcher) 做行级/列级过滤。
关键问题:此时“已经发生的磁盘 seek”成为沉没成本——即使后续 matcher 快速判定“这一行/这一列不需要”,也只能将该 Scanner 标记为“已耗尽”或“跳过”,无法撤销之前已付出的 I/O。
关键路径拆解(HBase 2.x/3.x 主干)
1. StoreScanner.<init>
├─ 对每个 StoreFile 构造 StoreFileScanner
│ └─ 立即执行 seekToStartKV()
│ → HFileReaderImpl.seek
│ → 读 block index
│ → 可能读 data block
├─ 对 MemStore 构造 MemStoreScanner
│ └─ 立即执行 seekToFirstInRange()
│ → 跳到 SkipList 第一个合法 Cell
├─ 把所有 scanner 塞进 KeyValueHeap(最小堆)
└─ 之后每次 next() 时
├─ KeyValueHeap.peek 返回最小 KV
└─ 用 ScanQueryMatcher.match(KV) 决定是否返回给上层
—— 若 matcher 返回 SKIP、DELETE、NEXT_COL 等,就继续 heap.next() 再试
核心矛盾:seek 到第一个合法 KV发生在 matcher 之前。若表包含大量版本/列,但 Scan 仅取最新版本或指定列,则第一次 seek 读入的 block 中大部分 KV 会被 matcher 跳过,但磁盘 I/O 已不可逆。
社区已有的缓解手段
1. 延迟 Seek(Lazy-seek)
-
机制:早在 0.96 版本引入,但仅针对文件级别。
-
StoreFileScanner 的
seekToStartKV()不会真正读取 data block,仅将 block index 加载到内存,真正的 data block I/O 延迟到第一次next()被调用时。
-
-
收益:若某个文件在 matcher 第一轮即被判定“整文件无需读取”(例如该文件最大时间戳 < scan 的 time range),则后续永不触发 data block 读,节省磁盘 I/O。
-
局限:
-
文件内第一个 block 只要被 matcher 判为“可能有用”,仍需读取一次;
-
MemStore 的 seek 是实打实的跳表定位,无 lazy 机制。
-
2. 列族级布隆/时间范围过滤
-
机制:HFile 的行级布隆、行+列布隆、时间范围索引(time-range meta block)在文件打开时已加载。
-
StoreFileScanner 在 seek 之前,会根据 scan 的 row/column/time 条件计算“是否可能命中”的掩码。若掩码为全不命中,整个文件直接跳过(连 index 都不读)。
-
-
收益:可过滤大量无关文件。
-
局限:只要掩码判断“有可能”,仍需走后续 lazy-seek 流程。
3. Scanner-level Timeout + Row-level Timeout
-
机制:若单次
next()耗时过长,RegionServer 会主动标记该 scanner 超时,客户端重开新 scanner。 -
作用:虽无法减少初始 I/O,但可避免因读取过多无用 KV 导致的长尾延迟。
还能再优化吗?
方案 A:将 Matcher 的“粗过滤”再往前挪
-
背景:当前 lazy-seek 仅实现“文件级”跳过,列/版本级过滤仍需等待第一次
next()。 -
思路:在 StoreFileScanner.lazySeek() 中,先读取 trailer + index + bloom + time-range,结合 matcher 的“最小列集合/版本号/时间戳”倒推出“第一个可能命中的 data block 编号”。若不存在此类 block,则直接返回
EMPTY_SEEK,连第一个 data block 都不读。 -
实现:相当于将 matcher 的“粗逻辑”复制一份到 StoreFileScanner 内部(复杂度较高)。
-
收益:
-
对“只 scan 最新版本”场景,可跳过所有老版本 block;
-
对“只 scan 少量列”场景,可跳过不含这些列的 block。
-
方案 B:MemStore 也做“延迟定位”
-
背景:当前 MemStoreScanner 在构造时直接跳到 SkipList 第一个合法节点。
-
思路:若 MemStore 体积大且 scan 范围极小(如单行+单列),可仅记录“起始 key + 是否可能命中”的布尔值,第一次
next()时再定位。 -
收益:减少初期 CPU 开销与 cache-miss,但对热行场景收益有限。
方案 C:Top-N 优化(HBASE-24808 方向)
-
背景:若 SQL/Scan 仅需 limit N 条结果。
-
思路:在 StoreScanner 中维护一个最大堆(或计数器),一旦获取 N 条结果,提前关闭剩余未发生 lazy-seek 的 scanner,避免无效 I/O。
-
收益:对“分页/最新 N 条”场景极其有效,已在 Phoenix 层完成 POC,社区合并回内核的呼声高。
结论
-
“先 seek 后过滤”确实是主要性能损耗点,但社区已通过 lazy-seek + 布隆/时间索引 基本消除了“整文件”级别的无用 I/O。
-
剩余优化空间:
-
“文件内第一个 block” 的 I/O 仍难以避免,除非将 matcher 的过滤逻辑下沉到 StoreFileScanner 内部(方案 A),或查询语义本身为 Top-N(方案 C)。
-
在多数“随机分布” workload 下,第一个 block 命中率较高,因此单纯“跳过剩余堆”收益有限;真正的优化方向是 连第一个 block 都不读,这需要更精细的索引/布隆与时间范围联动,或通过 limit 语义提前终止 scanner。
-
StoreFileScanner seek延迟IO
在StoreFileScanner.java中:
// 延迟seek的关键变量
private boolean realSeekDone;
private boolean delayedReseek;
private ExtendedCell delayedSeekKV;
requestSeek方法实现了延迟seek逻辑:
- 首先通过布隆过滤器判断是否需要真正的seek
- 如果需要seek,创建"假"的KeyValue作为当前值,不实际执行seek
- 设置
realSeekDone = false标记延迟seek状态
在KeyValueHeap.java中的pollRealKV()方法:
while (kvScanner != null && !kvScanner.realSeekDone()) {
if (kvScanner.peek() != null) {
kvScanner.enforceSeek(); // 真正执行延迟的seek
}
}
只有当scanner需要成为堆顶元素时,才会真正执行seek操作。
延迟IO时 怎么构建正确最小堆?
在StoreScanner.java的seekScanners方法:
if (isLazy) {
for (KeyValueScanner scanner : scanners) {
scanner.requestSeek(seekKey, false, true); // 延迟seek
}
}
构建最小堆时,所有scanner都执行延迟seek,不实际读取数据。
在KeyValueHeap.java的构造函数:
for (KeyValueScanner scanner : scanners) {
if (scanner.peek() != null) {
this.heap.add(scanner); // 使用peek()获取的"假"key进行比较
}
}
比较的是各个scanner通过peek()返回的"假"key,这些key是在requestSeek中创建的,代表了该scanner可能包含的最小key。
当调用next()或需要获取真实数据时:
- 通过
pollRealKV()检测到realSeekDone() == false - 调用
enforceSeek()执行真正的seek操作 - 更新scanner的实际当前值
- 重新参与堆排序
假key的核心特性是"不超越真实数据":
- createFirstOnRowColTS:创建的假key时间戳 ≤ 文件中的真实最大时间戳
- createLastOnRowCol:创建的假key是行+列+qualifier的最大可能值
时间戳是降序排列的,时间戳越大,排序越靠前。
在StoreFileScanner.requestSeek()方法中,当seekTimestamp > maxTimestampInFile时:
if (seekTimestamp > maxTimestampInFile) {
// Create a fake key that is not greater than the real next key.
// (Lower timestamps correspond to higher KVs.)
setCurrentCell(PrivateCellUtil.createFirstOnRowColTS(kv, maxTimestampInFile));
}
这里的语义是:
- 我们要找一个不小于目标key的位置
- 但目标key的时间戳比文件中任何时间都大
- 所以我们创建一个假key,它使用文件最大时间戳,但类型为Maximum
通过PrivateCellUtil.createFirstOnRowColTS(kv, maxTimestampInFile)创建的假key:
public static ExtendedCell createFirstOnRowColTS(Cell cell, long ts) {
return new FirstOnRowColTSCell(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
cell.getFamilyArray(), cell.getFamilyOffset(), cell.getFamilyLength(),
cell.getQualifierArray(), cell.getQualifierOffset(), cell.getQualifierLength(), ts);
}
关键特性:
- 时间戳:使用文件最大时间戳
- 类型字节:继承自
FirstOnRowColCell,而FirstOnRowColCell继承自FirstOnRowCell - FirstOnRowCell的getTypeByte()返回KeyValue.Type.Maximum.getCode()
KeyValueHeap.java中的pollRealKV方法是保证正确性的核心:
protected KeyValueScanner pollRealKV() throws IOException {
KeyValueScanner kvScanner = heap.poll();
if (kvScanner == null) {
return null;
}
while (kvScanner != null && !kvScanner.realSeekDone()) {
if (kvScanner.peek() != null) {
kvScanner.enforceSeek(); // 强制执行真实seek
Cell curKV = kvScanner.peek();
// 关键:与堆中的下一个scanner比较
KeyValueScanner nextEarliestScanner = heap.peek();
if (nextEarliestScanner == null) {
return kvScanner; // 堆中只剩这一个scanner
}
Cell nextKV = nextEarliestScanner.peek();
if (nextKV == null || comparator.compare(curKV, nextKV) < 0) {
// 当前scanner已经有最小的key,直接返回
return kvScanner;
}
// 当前scanner的key不是最小的,放回堆中重新竞争
heap.add(kvScanner);
}
kvScanner = heap.poll();
}
return kvScanner;
}
假key的排序保证
- 假key ≤ 真实key(时间戳、类型等维度)
- 假key之间可以正确比较
- 假key与真实key比较时,真实key总是"更大"或相等
延迟执行的正确性
- 只有在scanner可能成为堆顶时才执行真实seek
- 真实seek后,假key被真实key替换
- 如果真实seek后发现key不是最小的,scanner被放回堆中继续竞争
对于普通 Get,默认是几个版本?
对于一个普通的 Get 操作,如果不显式调用 get.readVersions(int versions) 或 get.readAllVersions(),默认只获取最新的一个版本。
@InterfaceAudience.Public
public class Get extends Query implements Row {
private static final Logger LOG = LoggerFactory.getLogger(Get.class);
private byte[] row = null;
private int maxVersions = 1;
9711

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



