直接生成hfile的目的是跨过使用hbase客户端,减小客户端,服务器压力。面对每天要往hbase写大量数据的情况的时候非常有优势。
因为 hfile的生成这一步 可以完全不跟HBase打交道,不像使用put请求,我们要不断地向hbase服务器发送RPC请求,然后需经过WAL预写,再刷新。
这不仅会造成写入速度慢,也会增加hbase的压力,从而对客户的读请求造成压力
当生成hfile文件之后,我们再把hfile load进hbase中。这一步是非常快的。
本文将会描述HFile生成这一过程,对涉及到的相关知识点也会做简要阐述。
Spark有直接保存textFile,Parquet,sequenceFile等方法。但是hfile是一个特定格式的输出,我们调用RDD.saveAsNewAPIHadoopFile方法。同时我们需要指定outPutFormat。
outPutFormat是一个对map_reduce job输出格式的描述,主要是三个方法:
关键的应是getWriter这个方法了,他返回一个RecordWriter的类,这个类可以自己去实现。当然这个writer是针对K V类型,也就是说这个writer写的是一个键值对(mapreduce是需要指定KV的)。这个writer有两个方法需要实现:
这里我们采用HBase提供的HFileOutputFormat。
key=ImmutableBytesWritable 一个byte的序列。
value=KeyValue
getWriter返回的是
public RecordWriter getRecordWriter(
final TaskAttemptContext context) throws IOException, InterruptedException {
return HFileOutputFormat2.createRecordWriter(context);
}
其实用的也是HFileOutputFormat2的writer,HFileOutputFormat已经不推荐使用了。
在HFileOutputFormat2.createRecordWriter方法中:
// 维护了一个 map key是列族,value是WriterLength 有两个属性 long written ;StoreFile.Writer
// 就是说每一个列族都会有一个writer
private final Map writers =
new TreeMap(Bytes.BYTES_COMPARATOR
//每一个KeyValue都会包含列族 列 值 等信息
byte [] rowKey = CellUtil.cloneRow(kv);
long length = kv.getLength();
byte [] family = CellUtil.cloneFamily(kv);
WriterLength wl = this.writers.get(family);
if (wl == null) {
fs.mkdirs(new Path(outputdir, Bytes.toString(family)));
}
// 可以发现 每一个列族对应的writer 都对应了一个文件目录
// 这也符合我们的理解 因为不同列族对应不同的Store 存在不同的目录下 同时在bulkLoad的时候,我们也会说到多列族的问题
// 在这里也可以看出 要同时生成多列族的hfile是可行的
kv.updateLatestStamp(this.now); // 每一个KeyValue 会在这里附带一个timeStamp
// 接下来再来看: hbase在建表的时候 会指定压缩 和 布隆过滤器
// 比如:
//create 'table', { NAME => '0', DATA_BLOCK_ENCODING => 'PREFIX', BLOOMFILTER => 'ROWCOL', COMPRESSION => 'SNAPPY'}, {NUMREGIONS => 1000, SPLITALGO => 'HexStringSplit'}
// 我们还需要考虑 使用这种hfile的方式 是不是 也能保持上面的格式
// HFileOutputFormat维护了三个map 如下
final Map compressionMap = createFamilyCompressionMap(conf);
final Map bloomTypeMap = createFamilyBloomTypeMap(conf);
final Map blockSizeMap = createFamilyBlockSizeMap(conf);
// 这些都是从conf中去获取 压缩算法 布隆过滤器类型
// 这个conf 是在getWriter方法传入的一个 TaskAttemptContext context
这个conf是可以传的 如果不传的话 默认是取 self.context.hadoopConfiguration
我们也是可以传conf进去的
conf里面是一个map:map的key是属性名,例如压缩算法,value是一个字符串,字符串的内容应该是:列族名=值&列族名=值......
解析的代码是这样的:
Map confValMap = new TreeMap(Bytes.BYTES_COMPARATOR);
String confVal = conf.get(confName, "");
for (String familyConf : confVal.split("&")) {
String[] familySplit = familyConf.split("=");
if (familySplit.length != 2) {
continue;
}
try {
confValMap.put(URLDecoder.decode(familySplit[0], "UTF-8").getBytes(),
URLDecoder.decode(familySplit[1], "UTF-8"));
} catch (UnsupportedEncodingException e) {
// will not happen with UTF-8 encoding
throw new AssertionError(e);
}
}
return confValMap;
// 所以我们可以通过以上的方式进行赋值
// 或者我们可以显示调用 HFileOutPutFormat.configureIncrementalLoad 方法
// HFileOutputFormat.configureIncrementalLoad(job,hTable)
// 这个方法会根据table去获取布隆过滤器,压缩算法等等
// 并将其添加到conf里面
// 添加方式 和 上面提到的 方式一样 列族名=值&列族名=值
// configureIncrementalLoad这个方法会更新job的Configuration 所以我们需要把这个job的con传入,如下:
saveAsNewAPIHadoopFile(outputPath.toString, classOf[ImmutableBytesWritable], classOf[KeyValue], classOf[HFileOutputFormat], job.getConfiguration)
至此,我们完全阐述了HFileOutPutFormat
saveAsNewAPIHadoopFile
在上文已经阐述了调用saveAsNewAPIHadoopFile方法,并传入HFileOutputFormat,会生成我们指定格式的文件。而这个方法是一个mapReduce任务,严格来说它是一个没有reduce的map任务。在写MapReduce任务的时候,我们需要给一个InputFormat。这个InputFormat其实和上文讲的OutputFormat的功能相似。它描述的是任务输入的格式。
在InputFormat方法中,关键两个方法:getSplits和createRecordReader。
一个分片的任务交给一个map任务,传统的一个map任务一个输出(这也完全取决于map任务的outPutFormat,例如上面我们提到的HFileOutputFormat,也可参考https://blog.csdn.net/searcher_recommeder/article/details/53035788一个map输出多个文件).
但是对于saveAsNewAPIHadoopFile这给方法,他不需要指定inputFormat,为什么呢?这里我认为是调用这个方法的是一个RDD,这里一个RDD的partition将会作为一个map任务的输入。
分区器
在(2)中提到了,saveAsNewAPIHadoopFile方法,一个partition可以理解为一个map的输入,同时,我们传入的是HFileOutputFormat,在(1)中我们提到了HFileOutputFormt的write是列族不同则会有不同的writer,对应不同的输出。
那么也就是 一个partition的数据,将会有多少个列族,就会有多少个文件。
那么这就对分区有要求了。
我们知道,hbase建表的时候,是有预分区的。rowkey在一个给的区间里的数据将会在一个region里面。一个region对应多个HStore(一个列族一个HStore),每个HStore下有多个hfile文件(这也可以理解HFileOutputFormt中为什么一个列族的数据要写到一个目录下了)。
基于这个原因,如果我们的RDD的partition中的rowkey是乱的,也就是说本应该在一个region的数据却分散在了不同的partition里面,最终导致生成的一个hfile文件却要属于不同的region。
这也不是说不可以,但是这会增加bulk load时的计算压力(后面会阐述bulk load的原理)。bulk load的时候需要保证一个hfile文件只属于一个region,否则就要进行拆分。
所以为了减小bulk load时的压力(因为load的时候,就需要调用habse了)我们在save之前就对RDD进行分区,使得属于同一个region的数据在一个partition里面。以下这种方式一般只对具有预分区的表有效。
// 要保证处于同一个region的数据在同一个partition里面,那么首先我们需要得到table的startkeys
// 再根据startKey建立一个分区器
// 分区器有两个关键的方法需要去实现
// 1. numPartitions 多少个分区
// 2. getPartition给一个key,返回其应该在的分区 分区器如下:
private class HFilePartitioner(conf: Configuration, splits: Array[Array[Byte]], numFilesPerRegion: Int) extends Partitioner {
val fraction = 1 max numFilesPerRegion min 128
override def getPartition(key: Any): Int = {
def bytes(n: Any) = n match {
case s: String => Bytes.toBytes(s)
case s: Long => Bytes.toBytes(s)
case s:Int=>Bytes.toBytes(s)
}
val h = (key.hashCode() & Int.MaxValue) % fraction
for (i <- 1 until splits.length)
if (Bytes.compareTo(bytes(key), splits(i)) < 0) return (i - 1) * fraction + h
(splits.length - 1) * fraction + h
}
override def numPartitions: Int = splits.length * fraction
}
// 参数splits为table的startKeys
// 参数numFilesPerRegion为一个region想要生成多少个hfile,便于理解 先将其设置为1 即一个region生成一个hfile
// h可以理解为它在这个region中的第几个hfile(当需要一个region有多个hfile的时候)
// 因为startKeys是递增的,所以找到第一个大于key的region,那么其上一个region,这是这个key所在的region
进行分区
利用所写的分区器进行分区。
根据上面的分区器,我们可以实现位于同一个region的数据都划分到一起。但是还有一个问题。hfile中的数据都是有序的(参见 hfile解析)。排序方式应该是:rowkey,列族,列名。
这里我们使用一个算子repartitionAndSortWithinPartitions。他会按照给定的分区器进行分区,并且在一个分区内数据是按key有序的。同时我们应该还需要一个比较器,如下:
implicit val bytesOrdering = new Ordering[Int] {
override def compare(a: Int, b: Int) = {
val ord = Bytes.compareTo(Bytes.toBytes(a), Bytes.toBytes(b))
// if (ord == 0) throw KeyDuplicatedException(a.toString)
ord
}
} // 是按bytes比较
// 模拟一个rdd生成 map是列名和列值 还没有指定列族
val rdd=sc.parallelize((1 to 500).map(rowkey=>{
rowkey->Map("column1"->(rowkey.toString+"column"),"column2"->(rowkey+"column2"))
}),50)
rdd.repartitionAndSortWithinPartitions(new HFilePartitioner(hbaseconf, hTable.getStartKeys, 1)
// 但是这里是按照在一个partition里面按照key,也就是数据的rowkey进行了排序。如果我们一个rowkey有多列,或是有多个列族,还需要进行如下操作。
rdd.repartitionAndSortWithinPartitions(new HFilePartitioner(hbaseconf, hTable.getStartKeys, 1))
.flatMap{
case (key,columns)=>
val rowkey= new ImmutableBytesWritable()
rowkey.set( Bytes.toBytes(key)) //设置rowkey
val kvs = new TreeSet[KeyValue](KeyValue.COMPARATOR)
columns.foreach(ele=>{
val (column,value)=ele // 每一条数据两个列族 对应map里面的两列
kvs.add(new KeyValue(rowkey.get(),Bytes.toBytes("family1"),Bytes.toBytes(column), Bytes.toBytes(value)))
kvs.add(new KeyValue(rowkey.get(),Bytes.toBytes("family2"),Bytes.toBytes(column), Bytes.toBytes(value)))
})
kvs.toSeq.map(kv => (rowkey, kv))
}.saveAsNewAPIHadoopFile(outPut, classOf[ImmutableBytesWritable], classOf[KeyValue], classOf[HFileOutputFormat])
//在上述我们TreeSet[KeyValue](KeyValue.COMPARATOR)再次进行排序
// 现在每一个分区是严格有序的了
以上的代码会生成两个目录:family1,family2.
bulk load 见 https://blog.csdn.net/yulin_Hu/article/details/82314503