hadoop1.x中,map task分为4种,分别是 job-setup task、job-cleanup task、task-cleanup task和map task。其中,job-setup task 和job-cleanup task分别是作业运行时启动的第一个任务和最后一个任务,主要工作分别是进行一些作业初始化和收尾工作,比如创建和删除作业临时输出目录;而task-cleanup task则是任务失败或者被杀死后,用于清理已写入临时目录中数据的任务,本文中介绍第四种map task,它需要处理数据
map task 整体流程
map task的整体计算流程可以分为5个阶段
1、read 阶段:map task通过用户编写的RecordReader,从输入InputSplit中解析出一个个key/value
2、map 阶段:该阶段主要是将解析出的key/value交给用户编写的map()函数处理,并产生一系列新的key/value
3、collect阶段:在用户编写的map()函数中,当数据处理完成后,一般会调用outputCollector.collect()输出结果,在该函数内部,它会将生成的key/value分片(通过调用partitioner),并写入一个环形内存缓冲区中。
4、split阶段:即 “溢写”,当环形缓冲区满后,mapreduce会将数据写到本地磁盘上,生成一个临时文件,需要注意的是,将数据写入本地磁盘前,先要对数据进行一次本地排序,并在必要时对数据进行合并、压缩等操作
5、combine阶段:当所有数据处理完成以后,map task对所有临时文件进行一次合并,以确保最终只会生成一个数据文件
map task 计算流程
在map task中,最重要的部分是输出的结果在内存中和磁盘中的组织方式,具体涉及collect、spill和combine三个阶段,也就是用户调用OutputCollector.collect()函数之后依次经历的几个阶段,
collect过程分析
待map()函数处理完一对key/value,并产生新的key/value后,会调用OutputCollector.collect()函数输出结果,
跟踪进入map task的入口函数run(),可发现,如果用户选用旧API,则会调用runOldMapper函数处理数据,该函数根据实际的配置创建合适的MapRunnable以迭代调用用户编写的map()函数,而map()函数的参数OutputCollector正是MapRunnable传入的OldOutputCollector对象
OldOutputCollector根据作业是否包含Reduce task封装了不同的MapOutputCollector实现,如果Reduce Task数目为0,则封装DirectMapOutputCollector对象直接将结果写入HDFS中作为最终结果,否则封装MapOutputBuffer对象暂时将结果写入本地磁盘上以供Reduce Task进一步处理,接下来我们介绍reduce task数目非0的情况
用户在map()函数中调用OldOutputCollector.collect(key,value)后,在该函数内部,首先会调用Partitioner.getPartition()函数获取记录的分区号partition,然后将三元组
private int bufindex = 0; // marks end of collected
private int bufmark = 0; // marks end of record
private byte[] kvbuffer; // main output buffer
private static final int PARTITION = 0; // partition offset in acct
private static final int KEYSTART = 1; // key offset in acct
private static final int VALSTART = 2; // val offset in acct
private static final int ACCTSIZE = 3; // 每对key/value占用kvindices中的三项
private static final int RECSIZE = (ACCTSIZE + 1) * 4; // 每对key/value共占用kvoffsets和kvindices中的4个字节
final float spillper = job.getFloat("io.sort.spill.percent",(float)0.8);
final float recper = job.getFloat("io.sort.record.percent",(float)0.05);
final int sortmb = job.getInt("io.sort.mb", 100);
int maxMemUsage = sortmb << 20;//将内存单位转化为字节
int recordCapacity = (int)(maxMemUsage * recper);
recordCapacity -= recordCapacity % RECSIZE;//保证recordCapacity是4*4的整数倍
recordCapacity /= RECSIZE;//计算内存中最多保存key/value数目
kvoffsets = new int[recordCapacity];//kvoffsets占用1:3的1
kvindices = new int[recordCapacity * ACCTSIZE];//kvoffsets占用1:3的3
kvindices
kvindices即位置索引数组,用于保存key/value值在数据缓冲区kvbuffer中的起始位置
kvbuffer
kvbuffer即数据缓冲区,用于保存实际的key/value值,默认情况下最多可使用io.sort.mb中的95%,当该缓冲区使用率超过io.sort.spill.percent后,便会触发线程SpillThread将数据写入磁盘
内存io.sort.mb的分配方式
以上几个缓冲区读写采用了典型的单生产者消费者模型,其中,MapoutputBuffer的collect方法和MapoutputBuffer.buffer的write方法是生产者,spillThread线程是消费者,他们之间同步是通过可重入的互斥锁spillLock和spillLock上的两个条件变量(spillDone和spillReady)完成后。生产者主要的伪代码是
spillLock.lock();
try {
boolean kvfull;
do {
if (sortSpillException != null) {
throw (IOException)new IOException("Spill failed"
).initCause(sortSpillException);
}
// sufficient acct space
kvfull = kvnext == kvstart;
final boolean kvsoftlimit = ((kvnext > kvend)
? kvnext - kvend > softRecordLimit
: kvend - kvnext <= kvoffsets.length - softRecordLimit);
if (kvstart == kvend && kvsoftlimit) {
LOG.info("Spilling map output: record full = " + kvsoftlimit);
startSpill();
}
if (kvfull) {
try {
while (kvstart != kvend) {
reporter.progress();
spillDone.await();
}
} catch (InterruptedException e) {
throw (IOException)new IOException(
"Collector interrupted while waiting for the writer"
).initCause(e);
}
}
} while (kvfull);
} finally {
spillLock.unlock();
}
通常用一个线性缓冲区模拟实现环形缓冲区,并通过取模操作实现循环数据存储,下面介绍环形缓冲区kvoffsets的写数据过程,该过程由指针kvstart/kvend/kvindex控制,其中kvstart表示存有数据的内存段初始位置,kvindices表示未存储数据的内存段初始位置,而在正常写入情况下,kvend=kvstart,一旦满足溢写条件,则kvend=kvindex,此时指针区间[kvstart,kvend]为有效数据区间,
操作1 写入缓冲区
直接将数据写入kvindex指针指向的内存空间,同时移动kvindex指向下一个可写入的内存空间首地址,kvindex移动公式为:kvindex=(kvindex+1)%kvoffsets.length由于kvoffsets为环形缓冲区,因此可能涉及两种写入情况
**情况1 **kvindex>kvend,这种情况下,指针kvindex在指针kvend后面,如果向缓冲区写入一个字符串,则kvindex指针后移一位
环形缓冲区在kvindex>kvend情况下写入数据
情况2 kvindex < kvend,在这种情况下,指针kvindex位于指针kvend前面,如果向缓冲区中写入一个字符串,这kvindex指针后移一位
环形缓冲区在kvindex<=kvend情况下写入数据
操作2 溢写到磁盘
当kvoffsets内存空间使用率超过io.sort.spill.percent(默认是80%)后,需要将内存中数据写到磁盘上,为了判断是否满足该条件,需要求出kvoffsets已使用内存。如果kvindex>kvend,则已使用内存大小为kvindex>kvend;否则,已使用内存大小为kvoffsets.length-(kvend-kvindex)
环形缓冲区kvbuffer
环形缓冲区kvbuffer的读写操作过程由指针bufstart/bufend/bufvoid/bufindex/bufmark控制,其中,bufstart/bufend/bufindex含义与kvindex/kvend/kvstart相同,而bufvoid指向kvbuffer中有效内存结束位置,bufmark表示最后写入的一个完整key/value结束位置,具体写入过程中涉及的状态和操作如下
情况1:初始状态
初始状态下,bufstart=bufend=bufindex=bufmark=0,bufvoid=kvbuffer.length,
情况2:写入一个key
写入一个key后,需移动bufindex指针到可写入内存初始位置,
情况3:写入一个value
写入key对应的value后,除移动bufindex指针外,还要移动bufmark指针,表示已经写入一个完整的key/value
情况4:不断写入key/value,直到满足溢写条件,即kvoffsets或者kvbuffer空间使用率超过io.sort.spill.percent(默认值是80%),此时需要将数据写到磁盘上,
情况5:溢写
如果达到溢写条件,则令bufend < - bufindex,并将缓冲区[bufstart,bufend)直接的数据写到磁盘上
溢写完成以后,恢复正常写入状态,令bufstart < -bufend
在溢写的同时,map task仍可向kvbuffer中写入数据
情况6:写入key时,发生跨界现象
当写入某个key时,缓冲区尾部剩余空间不足以容纳整个key值,此时需要将key值分开存储,其中一部分存到缓冲区末尾,另一部分存到缓冲区的首部
情况7:调整key位置,防止key跨界现象
由于key是排序的关键字,通常需交给RawComparator进行排序,而它要求排序关键字必须在内存中连续存储,因此不允许key跨界存储,为解决该问题,hadoop将跨界的key值重新存储到缓冲区的首位置,通常分为以下2种情况
bufindex+(bufvoid-bufmark) < bufstart:此时缓冲区前半段有足够的空间容纳整个key值,因此可通过两次内存复制解决跨行问题,
int headbytelen = bufvoid - bufmark;
bufvoid = bufmark;
if (bufindex + headbytelen < bufstart) {
System.arraycopy(kvbuffer, 0, kvbuffer, headbytelen, bufindex);
System.arraycopy(kvbuffer, bufvoid, kvbuffer, 0, headbytelen);
bufindex += headbytelen;
}
bufindex+(bufvoid-bufmark)>=bufstart:此时缓冲区前半段没有足够的空间容纳整个key值,将key值移到缓冲区开始位置时将触发一次spill操作,可以通过3次内存复制解决跨行问题
byte[] keytmp = new byte[bufindex];
System.arraycopy(kvbuffer, 0, keytmp, 0, bufindex);
bufindex = 0;
out.write(kvbuffer, bufmark, headbytelen);
out.write(keytmp);
情况8:某个key或者value太大,以至于整个缓冲区不能容纳它。如果一条记录的key或者value太大,整个缓冲区都不能容纳它,则map task会抛出MapbufferTooSmallException异常,并将该记录单独输出到一个文件中。
环形缓冲区优化
在hadoop1.x版本中,当满足一下两个条件之一时,map task会发生溢写现象
1、缓冲区kvindices或者kvbuffer的空间使用率达到io.sort.spill.percent(默认值是80%)
2、出现一条kvbuffer无法容纳的超大记录
前面提到,map task将可用的缓冲区空间io.sort.mb按照一定比例静态分配给kvoffsets、kvindices、kvbuffer三个缓冲区,而正如条件1所述,只要任何一个缓冲区的使用率达到一定比例,就会发生溢写现象,即使另外的缓冲区使用率非常低,因此设置合理的io.sort.spill.percent参数,对于充分利用缓冲区空间和减少溢写次数,是十分必要的,考虑到每条数据需要占用索引大小为16B,因此建议用户采用以下设置io.sort.spill.percent
io.sort.spill.percent=16/(16+R)
其中R为平均每条记录的长度
实例:假设一个作业的map task输入的数据量和输出数据量相同,每个map task输入数据量大小为128MB,且共有1342177条记录,每条记录大小约为100B,则需要索引大小为16*1342177=20.9MB,根据这些信息,可设置参数如下
io.sort.mb:128MB+20.9MB=148.9MB
io.sort.spill.percent:16/(16+100)=0.138
io.sort.spill.percent:1.0
这样配置可保证数据只落一次地,效率最高,当然,实际使用时可能很难达到这种情况,比如每个map task 输出数据量非常大,缓冲区难以全部容纳它们,但你至少可以设置合理的io.sort.spill.percent以更充分地利用io.sort.mb并尽可能减少中间文件数目
尽管用户可以通过经验公式设置一个较优的io.sort.spill.percent参数,但在实际应用中,估算一个非常合理的R值仍是较麻烦的,为了从根本上解决这个问题,hadoop0.21采用共享环形缓冲区对map task输出数据的组织方式进行了优化,这样,用户无须再为自己的作业设置io.sort.spill.percent参数。hadoop0.21主要有2个修改点
1、不在将索引和记录分别放到不同的环形缓冲区中,而是让它们共用一个环形缓冲区
2、引入一个新的指针equator,该指针界定了索引和数据的共同起始存放位置,从该位置开发,索引和数据分别沿相反的方向增长内存使用空间
通过让索引和记录共享一个环形缓冲区,可舍弃io.sort.spill.percent参数,这样不仅解决了用户设置参数的苦恼,也使得map task能够最大限度地利用io.sort.mb空间,进而减少磁盘溢写次数,提高效率
spill过程分析
spill过程由SpillThread线程完成,前面我们说过SpillThread线程实际上是缓冲区kvbuffer的消费者,其主要代码
spillLock.lock();
spillThreadRunning = true;
try {
while (true) {
spillDone.signal();
while (kvstart == kvend) {
spillReady.await();
}
try {
spillLock.unlock();
sortAndSpill();
} catch (Exception e) {
sortSpillException = e;
} catch (Throwable t) {
sortSpillException = t;
String logMsg = "Task " + getTaskID() + " failed : "
+ StringUtils.stringifyException(t);
reportFatalError(getTaskID(), t, logMsg);
} finally {
spillLock.lock();
if (bufend < bufindex && bufindex < bufstart) {
bufvoid = kvbuffer.length;
}
kvstart = kvend;
bufstart = bufend;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
spillLock.unlock();
spillThreadRunning = false;
}
}
}
线程SpillThread调用函数sortAndSpill()将环形缓冲区kvbuffer中区间[bufstart,bufend)内的数据写到磁盘上,函数sortAndSpill()内部工作流程如下
步骤1:利用快速排序算法对缓冲区kvbuffer中区间[bufstart,bufend)内的数据进行排序,排序的方式是,先按照分区编号partition进行排序,然后按照key进行排序,这样,经过排序后,数据以分区为单位聚集在一起,且同一分区内所有数据按照key有序
步骤2:按照分区编号由小到大依次将每个分区中的数据写入任务工作目录下的临时文件output/spillN.out(N表示当前溢写次数)中,如果用户设置了combiner,则写入文件之前,对每个分区中的数据进行一次聚集操作
步骤3:将分区数据的元信息写到内存索引数据结构SpillRecord中,其中每个分区的元信息包括在临时文件中的偏移量、压缩前数据大小和压缩后数据大小,如果当前内存中索引大小超过1MB,则将内存索引写到文件output/spillN.out.index中
combine过程分析
当所有数据处理完后,map task会将所有临时文件合并成一个大文件,并保存到文件output/file.out中,同时生成相应的索引文件output/file.out.index
在进行文件合并过程中,map task以分区为单位进行合并,对于某个分区,它将采用多轮递归合并的方式:每轮合并io.sort.factor(默认为100)个文件,并将产生的文件重新加入待合并列表中,对文件排序后,重复以上过程,直到最终得到一个大文件
让每个map task最终只生成一个数据文件,可避免同时打开大量文件和同时读取大量小文件产生的随机读取带来的开销