分析hbase的split流程,同样地我们先从regionserver中的相应线程作为突破口,依次分析split的触发条件和split的执行实现。
split的触发条件:
我们在compact流程分析中讲解过,hbase的regionserver中维护着一个CompactSplitThread类型的变量,所有的compact/split请求都会交给该变量处理,前面我们分析了compact在CompactSplitThread中的处理,同样的,我们接下来将会分析split是如何在CompactSplitThread中被处理的。首先还是看CompactSplitThread中定义的几个与split相关的变量。
private final ThreadPoolExecutor longCompactions; //long合并线程池
private final ThreadPoolExecutor shortCompactions; //short合并线程池
private final ThreadPoolExecutor splits; //split线程池
private final ThreadPoolExecutor mergePool; //merge线程池
private int regionSplitLimit;
需要注意的是最后一个整型变量regionSplitLimit,它定义了一个regionserver所持有的最大region总数,如果region的数量超过了它的限制,则split不会再发生。该变量的值由hbase.regionserver.regionSplitLimit配置,默认是1000;splits是该regionserver用于参与split的线程池,线程池中的线程数量由“hbase.regionserver.thread.split”配置,默认是1。
region发生split时,调用方会调用CompactSplitThread中的requestSplit方法,该方案将split请求包装成一个Runnable的SplitRequest对象放入前文所说的线程池split中去执行。
this.splits.execute(new SplitRequest(r, midKey, this.server));
分析requestSplit方法的调用时机就可以理出region发生split行为的时间。我们直接列出结果如下:
第一种方式是region发生compact之后,此时会形成比较大的文件,split会在这个时候被调用;
第二种方式是region发生flush的时候,这一部分的代码如下:
lock.readLock().lock();
try {
notifyFlushRequest(region, emergencyFlush);
FlushResult flushResult = region.flush(forceFlushAllStores);
boolean shouldCompact = flushResult.isCompactionNeeded();
// We just want to check the size
boolean shouldSplit = ((HRegion)region).checkSplit() != null;
if (shouldSplit) {
this.server.compactSplitThread.requestSplit(region);
} else if (shouldCompact) {
server.compactSplitThread.requestSystemCompaction(
region, Thread.currentThread().getName());
}
if (flushResult.isFlushSucceeded()) {
long endTime = EnvironmentEdgeManager.currentTime();
server.metricsRegionServer.updateFlushTime(endTime - startTime); //将本次flush的时间记录下来
}
} catch (DroppedSnapshotException ex) {
..........
} catch (IOException ex) {
..........
} finally {
lock.readLock().unlock();
wakeUpIfBlocking(); //唤醒所有等待的线程
}
可以看到这里调用checkSplit并检查其返回的结果是否是null来确认是否可split,checkSplit调用splitPolicy做判断,在1.X.Xhbase中的默认split策略实现是IncreasingToUpperBoundingRegionSplitPolicy。这种策略取region所属的table在该region所在的regionserver上的region总数记为tableRegionsCount,采用以下公式计算阈值:tableRegionsCount*tableRegionsCount*tableRegionsCount*Memstoreflushsize*2,如果region中某个store的大小大于上述值,则标记该region需要被split。紧接着就是获取split的切分点,split point一般被指定为最大store上最大storefile的中心点。
这里多说一句,region上的memstore发生flush的时候会获取readLock。readLock是读锁,读写锁是一种多线程同步的方案,所谓读锁其实就是共享锁,所谓写锁就是排他锁,当一个线程获取读锁时,所有其他以读模式访问的线程都可以获得访问权,而已写模式对它进行加锁的线程都会被阻塞。而当一个线程获取写锁的时候,所有其他试图获取锁的线程都会被阻塞。这里获取了readLock,所以flush时对region的更新都会被阻塞。
第三种调用是在RSRpcServices的splitRegion函数中,表示的是用户对region发出的split请求。
split的执行实现:
接上文,摸清了split的请求发起时机之后,接下来分析split是如何实现的,前面说过spilt请求会包装成SplitRequest类型的对象交由splits线程处理。所以具体的split实现是在SplitRequest的run方法中,下面我们筛选出run方法的重点流程以分析split的实现:
public void run() {
.......
SplitTransactionImpl st = new SplitTransactionImpl(parent, midKey);
try {
tableLock = server.getTableLockManager().readLock(.........);
try {
tableLock.acquire();
} catch (IOException ex) {
.......
}
if (!st.prepare()) return;
try {
st.execute(this.server, this.server);
} catch (Exception e) {
.......
}
} catch (Exception e) {
server.checkFileSystem();
} finally {
//处理post coprocessor
releaseTableLock();
//更新metrics
}
}
ok,除了一些异常处理和回滚,run方法的主要逻辑已经梳理出来了,可以看出split前首先获取了table的共享锁,这样做的目的是防止其它并发的table修改行为修改当前table的状态或者schema等。然后初始化一个SplitTransactionImpl类型的变量,依次调用它的prepare和execute方法完成region切割。
perpare用以完成split的前期准备,包括构造两个子HRegionInfo,分别是hri_a和hri_b,其中hri_a的startKey胃parent的startKey,endKey为midKey;hri_b的startKey为midKey,endKey为parent的endKey。
接下来在execute方法中,主要代码包括以下三步:
PairOfSameType<Region> regions = createDaughters(server, services);
if (this.parent.getCoprocessorHost() != null) {
this.parent.getCoprocessorHost().preSplitAfterPONR();
}
regions = stepsAfterPONR(server, services, regions);
transition(SplitTransactionPhase.COMPLETED);
从createDaughters方法进去,我们一点点分析split的实现,其中比较关键的步骤是createDaughters,我们把该方法的主要逻辑列出在下面:
PairOfSameType<Region> daughterRegions = stepsBeforePONR(server, services, testing);
List<Mutation> metaEntries = new ArrayList<>;
if (!testing && useZKForAssignment) {
if (metaEntries == null || metaEntries.isEmpty()) {
MetaTableAccessor.splitRegion(server.getConnection(),
parent.getRegionInfo(), daughterRegions.getFirst().getRegionInfo(),
daughterRegions.getSecond().getRegionInfo(), server.getServerName(),
parent.getTableDesc().getRegionReplication());
} else {
offlineParentInMetaAndputMetaEntries(server.getConnection(),
parent.getRegionInfo(), daughterRegions.getFirst().getRegionInfo(), daughterRegions
.getSecond().getRegionInfo(), server.getServerName(), metaEntries,
parent.getTableDesc().getRegionReplication());
}
} else if (services != null && !useZKForAssignment) {
..........
}
createDaughters返回的regions实际上是由stepsBeforePONR返回的,下面我们列出stepsBeforePONR中的主要逻辑:
this.parent.getRegionFileSystem().createSplitsDir();
try {
hstoreFilesToSplit = this.parent.close(false);
} catch (Exception e) {
.........
}
Pari<Integer,Integer> expectedReferences = splitStoreFiles(hstoreFilesToSplit);
Region a = this.parent.createDaughterRegionFromSplits(this.hri_a);
Region b = this.parent.createDaughterRegionFromSplits(this.hri_b);
return new PairOfSametype<Region>(a,b);
上面我们列出了几个主要步骤,穿插在这些主要步骤之间的是将当前split的状态更新到zookeeper的节点上,hbase的region在发生split的同时,会在zookeeper的region-in-transition目录下创建一个节点,供master同步监听新region的上线和老region的下线这些信息,此外如果split中间发生错误,也需要zookeeper上的状态信息同步以协调region之间的变化。
首先createSplitsDir在parent region的目录下创建了一个.split目录,接着在.split目录下创建daughter region A和region B两个子目录,然后调用close将parent关掉,调用是传入的参数false表示在关掉parent region前先强制执行一次flush,将region memstore中的数据刷写到磁盘。关闭region后region server将region标记为offline状态。
region的关闭在实现上是提交了该region拥有的store到一个线程池中,然后每个store的close方法进行关闭的,store的关闭结果异步获取。hbase在实现这一步中使用了CompletionService,返回结果通过CompletionService的take方法获取,使用这种方式的优势就是当多线程启动了多个Callable之后,每个callable都会返回一个future,CompletionService自己维护了一个线程安全的list,保证先完成的future一定先返回。
现在回来继续讲解我们的split流程,进入splitStoreFiles方法,该方法实际上是为parent中的每个storeFile创建了两个索引文件,核心代码在下面的片段中:
ThreadFactoryBuilder builder = new ThreadFactoryBuilder();
builder.setNameFormat("StoreFileSplitter-%1$d");
ThreadFactory factory = builder.build();
ThreadPoolExecutor threadPool =
(ThreadPoolExecutor) Executors.newFixedThreadPool(maxThreads, factory);
List<Future<Pair<Path,Path>>> futures = new ArrayList<Future<Pair<Path,Path>>> (nbFiles);
// Split each store file.
for (Map.Entry<byte[], List<StoreFile>> entry: hstoreFilesToSplit.entrySet()) {
for (StoreFile sf: entry.getValue()) {
StoreFileSplitter sfs = new StoreFileSplitter(entry.getKey(), sf);
futures.add(threadPool.submit(sfs)); //storefile被提交执行split了
}
}
StoreFileSpliitter最终调用HRegionFileSystem中的下面一句代码完成索引文件的创建:
Reference r =
top ? Reference.createTopReference(splitRow): Reference.createBottomReference(splitRow);
回到stepsBeforePONR方法,最后两步根据子region的元信息创建了HRegion A和B,实际上就是创建了A/B的实际存储目录。完成这些后stepsBeforePONR方法返回,然后调用如下的代码修改meta表中parent region和分裂出的region A和region B的信息。
MetaTableAccessor.splitRegion
splitRegion是一个原子方法,它将父region的信息置为offline,并写入子region的信息,但是此时的子region A和B还不能对外提供服务。需要等待regionServer打开该子region才可以,带着这个疑问,我们进入split流程的最后一个方法——stepsAfterPORN,在该方法中调用openDaughters将子RegionA和RegionB打开以接受写请求,regionsrver打开A和B之后会补充上述两个子region在.META.表中的信息,此时客户端才能够发现两个子region并向该两个region发送请求。
负责open region的线程是继承了HasThread接口的DaughterOpener类,主要包括了下面两个步骤:
1> 调用openHRegion函数的initilize,主要步骤如下:
a、向hdfs上写入.regionInfo文件以便meta挂掉时恢复;
b、初始化其下的HStore,主要是调LoadStoreFiles函数;
2> 将子Region添加到rs的online region列表上,并添加到meta表中;
最后更新zk节点上/hbase/region-in-transition/region-name节点的状态为COMPLETED,指示split结束。
Split过程结束后,HDFS和META中还会保留着指向parent region索引文件的信息,这些索引文件会在子region执行major compact重写的时候被删除掉。master的Garbage Collection任务会周期性地检查子region中是否还包含指向parents Region的索引文件,如果不再包含,此时master会将parent Region删除掉。