HBase BulkLoad异常生成千万HDFS文件问题排查
总 第17
篇
2019年 第13篇
一、背景
HBase BulkLoad通常分为两步骤
Step1. MR生成HFile文件
Step2. 将HFile文件导入集群
在Step1的reduce过程中,使用HFileOutputFormat2工具类生成HFile。
我们在Kylin的Merge任务中使用到了这一工具类,
也正是这个类异常生成了上千万个只有54B大小的HDFS文件,造成HDFS的namenode节点不可用,我们排查并修复了该问题提交至社区 HBASE-22887
本文将探究这个问题前世今生~ 揭开它的神秘面纱~
二、问题详解
在HBase2.0.0版本以前,
不同column family(以下简称CF)须同时进行flush,也就是说对于任意rowkeyA如果出现在F1的第一个文件中那也必须出现在F2的第一个文件中。MR任务调用
HFileOutputFormat2直接生成HFile文件时,
要求输入数据类型为HBase的Cell格式的有序数据集合(例如rowKey1-F1,rowKey1-F2,rowKey2-F1,rowKey2-F2),一般情况下每个 调用
HFileOutputFormat2
的reducer会为每个CF生成一个HFile。但由
于region的单个文件maxsize有限制
(默认10G,由参数hbase.hregion.max.filesize配置),当发现数据量超过maxsize时则会close当前writer,结束当前文件写入(这个过程简称为roll),再有新的数据需要写入时则new新的writer。那么当任意CF数据量超过maxsize,进行roll操作,其他CF必须同时roll,无论当时它们的数据量如何。
HFileOutputFormat2
的write函数实现的逻辑大致为:
-
首先解析传入的数据,得到他的rowKey和family
-
根据family找到对应family的writer
-
判定 writer
中数据量是否达到maxsize,若达到置标志位为true。 -
若 标志位为true
,判定当前是否为一个新rowKey,如果是,那么将roll所有writer。 -
若以上两条有一个为否,则将数据append进 writer
,记录当前rowKey。 -
重复以上操作,直至所有数据写入完毕。
也就是说在中途要想roll writer必须拿到俩个令牌:
-
令牌1: 某一个 writer中
数据量达到maxsize:
它可以由任意一个 writer
下发,并且统一发给所有 writer
。 -
令牌2: 为新的rowKey:
通常情况下只要rowKey-F1存在,那么一定是F1能拿到这个令牌。
一旦拿到两个令牌,所有 writer
必须都进入roll流程。
源码中两个令牌判定逻辑如下:
// 令牌1:If any of the HFiles for the column families has reached maxsize, we need to roll all the writers if (wl != null && wl.written + length >= maxsize) { this.rollRequested = true; } // 令牌2:This can only happen once a row is finished though if (rollRequested && Bytes.compareTo(this.previousRow, rowKey) != 0) { rollWriters();//roll所有writer&释放令牌1 }
这个过程中是因为同时roll原则,我们才设置了标志位(令牌1), writer
间两个令牌都是共享的,用于判定令牌2 previousRow也是共享的。
于是常常会因为一个 writer
达到最大值而所有writer都要roll再生成新writer,这会有什么问题呢?
碎文件过多。
如果我们有100个family,而只有F1比较大,其他都很小,F1每次写的时候可能其他 writer
都很小,造成的很大的浪费。
在HBase2.0.0 时这个问题发生了划时代的改变,同region的不同family的HFile不必再拥有相同的rowKey分割。
由此上面一个问题也可以得到解决,理论上哪个CF数据量达到maxsize限制,只需将该CF的writer roll即可,其他writer不受影响。
于是在2.0.0版本将上面的代码做如下更改,当拿到双令牌时只roll当前writer。
//原判定逻辑判定令牌1 if (wl != null && wl.written + length >= maxsize) { this.rollRequested = true; } //原判定逻辑判定令牌2 if (rollRequested && Bytes.compareTo(this.previousRow, rowKey) != 0) { rollWriters(wl);//仅将当前writer roll&释放令牌1 }
这样取消了writer之前的双通关令牌共享,当一个 writer
拿到两个令牌时,不会影响到其他的 writer
。
我们的问题就发生在这了。
此时,我们的令牌1-标志位this.rollRequested依旧是一块共享令牌,它可以由任意一个 writer
发放给其他所有 writer
,无论其他人的写入量是否达到了最大值,于此同时我们的令牌2只有F1可以获得。
试想一下以下情况,我们有两个CF F1&F2,经过一段时间的数据写入后:
-
写入rowKey1-F2:
F2的 writer
发现自己超过了maxsize,需要关闭当前writer,向所有 writer
发放了令牌1–
this.rollRequested
为
true
-
此时F2的rowKey因为和它的上一个rowKey一样,无法获得令牌2,只能继续将数据append进当前writer。
-
写入rowKey2-F1:
它是新的rowKey,因为令牌1共享,我们天然的拥有了令牌1,无论此时F1的 writer
多大,同时我们判定rowKey与上一个不同,执行了F1的 writer
的
rollWriters
,同时释放了令牌。 -
写入
rowKey2-F2:
F2的 writer
发现自己 超过了maxsize
,向所有 writer
再次发放了令牌1,且因为和它的上一个rowKey一样, 只能继续将数据append进当前writer
。
-
写入
rowKey3-F1:获得
双令牌,roll当前writer(只有一个值rowKey2-F1) -
写入
rowKey3-F2:
发令牌1…
…
无限循环
最终结果就是一直不该roll的F1- writer
,一直坚持着每个KeyValue进行一次roll;
而一直该roll的F2- writer
一直因为和上一个rowKey一样没办法roll。产生了大量
小文件导致namenode节点内存不足。
三、问题解决
从HBase进化的流程入手,也就不难理解这个问题的出现了,自然也就不难解决这个问题了,方案有两个:
-
PlanA:
退回到之前HBase1.x.x的版本,所有 writer
同进同出。
我们内部考虑使用这个方案,考虑到公司HBase集群多为1.x.x,方便HBase迁移回退,同时我们的CF的不会很多,CF之间的数据量几乎相同差距不大。 -
PlanB:
将共享变量独立出来。
令牌1本意是在HBase1.x.x时期用于不同的 writer
之间通信,但到了2.0.0时代, writer
独立后便不具备存在意义。
我们每次只需要现场判断是否达到maxsize,是否为新的rowKey即可(这里理论上在HBase不允许有相同的CF有相同的rowKey,但作为一个对外提供使用的包我们无法做保证所以需要判断rowKey)。
同时我们需要将preRowKey独立出来,每个 writer
保存一个。
一种实现方案如下:
(提交给社区的就是这个方案)
private final Map<byte[], byte[]> previousRows = new TreeMap(Bytes.BYTES_COMPARATOR); ... if (wl != null && wl.written + length >= maxsize && Bytes.compareTo(this.previousRows.get(family), rowKey) != 0) { rollWriters(wl); } ... previousRows.put(family,rowKey);
至此问题解决。
End.
四、彩蛋
1. 为什么是我们?
要复现这个问题,必须HBase2.0.0或以上+mr这样的组合,加上单HFile大小要超过10G双重禁制,并且还要恰巧先达到10G的不是第一个CF,在日常的任务中可以说是很难碰见了。
偏偏巧合的是我们的Kylin执行任务就是HBase2.0.0+mr,问题就出现了。
2. 怎么找到问题的?
问题排查几经周折,最终在理解了整个执行过程后, 重新编译源码
增加mr任务运行时日志,找到了问题根源。
:tada::tada::tada:
扫码关注