Hadoop源代码分析(三五)
除了对外提供的接口,NameNode上还有一系列的线程,不断检查系统的状态,下面是这些线程的功能分析。
在NameNode中,定义了如下线程:
hbthread = null; // HeartbeatMonitor thread
public Daemon lmthread = null; // LeaseMonitor thread
smmthread = null; // SafeModeMonitor thread
public Daemon replthread = null; // Replication thread
private Daemon dnthread = null;
PendingReplicationBlocks中也有一个线程:
timerThread = null;
NameNode内嵌的HTTP服务器中自然也有线程,这块我们就不分析啦。
infoServer;
心跳线程用于对DataNode的心态进行检查,以间隔heartbeatRecheckInterval运行heartbeatCheck方法。如果在一定时间内没收到DataNode的心跳信息,我们就认为该节点已经死掉,调用removeDatanode(前面分析过)将DataNode标记为无效。
租约lmthread用于检查租约的硬超时,如果租约硬超时,调用前面分析过的internalReleaseLease,释放租约。
smmthread运行的SafeModeMonitor我们前面已经分析过了。
replthread运行ReplicationMonitor,这个线程会定期调用computeDatanodeWork和processPendingReplications。
computeDatanodeWork会执行computeDatanodeWork或computeInvalidateWork。computeDatanodeWork从neededReplications中扫描,取出需要复制的项,然后:
l 检查文件不存在或者处于构造状态;如果是,从队列中删除复制项,退出对复制项的处理(接着处理下一个);
l 得到当前数据块副本数并选择复制的源DataNode,如果空,退出对复制项的处理;
l 再次检查副本数(很可能有DataNode从故障中恢复),如果发现不需要复制,从队列中删除复制项,退出对复制项的处理;
l 选择复制的目标,如果目标空,退出对复制项的处理;
l 将复制的信息(数据块和目标DataNode)加入到源目标DataNode中;在目标DataNode中记录复制请求;
l 从队列中将复制项移动到pendingReplications。
可见,这个方法执行后,复制项从neededReplications挪到pendingReplications中。DataNode在某次心跳的应答中,可以拿到相应的信息,执行复制操作。
computeInvalidateWork当然是用于删除无效的数据块。它的主要工作在invalidateWorkForOneNode中完成。和上面computeDatanodeWork类似,不过它的处理更简单,将recentInvalidateSets的数据通过DatanodeDescriptor.addBlocksToBeInvalidated挪到DataNode中。
dnthread执行的是DecommissionedMonitor,它的run方法周期调用decommissionedDatanodeCheck,再到checkDecommissionStateInternal,定期将完成Decommission任务的DataNode状态从DECOMMISSION_INPROGRESS改为DECOMMISSIONED。
PendingReplicationMonitor中的线程用于对处在等待复制状态的数据块进行检查。如果发现长时间该数据块没被复制,那么会将它挪到timedOutItems中。请参考PendingReplicationBlocks的讨论。
infoServer的相关线程我们就不分析了,它们都用于处理HTTP请求。
上面已经总结了NameNode上的一些为特殊任务启动的线程,除了这些线程,NameNode上还运行着RPC服务器的相关线程,具体可以看前面章节。
在我们开始分析Secondary NameNode前,我们给出了以NameNode上一些状态转移图,大家可以通过这个图,更好理解NameNode。
NameNode:
DataNode:
文件:
Block,比较复杂:
上面的图不是很严格,只是用于帮助大家理解NameNode对Block复杂的处理过程。
稍微说明一下,“Block in inited DataNode”表明这个数据块在一个刚初始化的DataNode上。“Block in INodeFile”是数据块属于某个文件,“Block in INodeFileUnderConstruction” 表明这数据块属于一个正在构建的文件,当然,处于这个状态的Block可能因为租约恢复而转移到“Block in Recover”。右上方描述了需要复制的数据块的状态,UnderReplicatedBlocks和PendingReplicationBlocks的区别在于Block是否被插入到某一个DatanodeDescriptor中。Corrupt和Invalidate的就好理解啦。
Hadoop源代码分析(三六)
转战进入Secondary NameNode,前面的分析我们有事也把它称为从NameNode,从NameNode在HDFS里是个小配角。
跟Secondary NameNode有关系的类不是很多,如下图:
首先要讨论的是NameNode和Secondary NameNode间的通信。NameNode上实现了接口NamenodeProtocol(如下图),就是用于NameNode和Secondary NameNode间的命令通信。
NameNode和Secondary NameNode间数据的通信,使用的是HTTP协议,HTTP的容器用的是jetty,TransferFsImage是文件传输的辅助类。
GetImageServlet的doGet方法目前支持取FSImage(getimage),取日志(getedit)和存FSImage(putimage)。例如:
http://localhost:50070/getimage?getimage
可以获取FSImage。
http://localhost:50070/getimage?getedit
可以获取日志文件。
保存FSImage需要更多的参数,它的流程很好玩,Secondary NameNode发送一个HTTP请求到NameNode,启动NameNode上一个HTTP客户端到Secondary NameNode上去下载FSImage,下载需要的一些信息,都放在从NameNode的HTTP请求中。
我们先来考察Secondary NameNode持久化保存的信息:
[hadoop@localhost namesecondary]$ ls –R
.:
current image in_use.lock previous.checkpoint
./current:
edits fsimage fstime VERSION
./image:
fsimage
./previous.checkpoint:
edits fsimage fstime VERSION
in_use.lock的用法和前面NameNode,DataNode的是一样的。对比NameNode保存的信息,我们可以发现Secondary NameNode上保存多了一个previous.checkpoint。CheckpointStorage就是应用于Secondary NameNode的存储类,它继承自FSImage,只添加了很少的方法。
previous.checkpoint目录保存了上一个checkpoint的信息(current里的永远是最新的),临时目录用于创建新checkpoint,成功后,老的checkpoint保存在previous.checkpoint目录中。状态图如下(基类FSImage用的是黑色):
至于上面目录下文件的内容,和FSImage是一样的。
CheckpointStorage除了上面图中的startCheckpoint和endCheckpoint方法(上图给出了正常流程),还有:
void
throws
和FSImage.coverTransitionRead类似,用于分析现有目录,创建目录(如果不存在)并从可能的错误中恢复。
private void doMerge(CheckpointSignature sig) throws
doMerge被类SecondaryNameNode的同名方法调用,我们后面再分析。
Hadoop源代码分析(三七)
Secondary NameNode的成员变量很少,主要的有:
private CheckpointStorage checkpointImage;
Secondary NameNode使用的Storage
private NamenodeProtocol namenode;
和NameNode通信的接口
private HttpServer infoServer;
传输文件用的HTTP服务器
main方法是Secondary NameNode的入口,它最终启动线程,执行SecondaryNameNode的run。启动前的对SecondaryNameNode的构造过程也很简单,主要是创建和NameNode通信的接口和启动HTTP服务器。
SecondaryNameNode的run方法每隔一段时间执行doCheckpoint(),从NameNode的主要工作都在这一个方法里。这个方法,总的来说,会从NameNode上取下FSImage和日志,然后再本地合并,再上传回NameNode。这个过程结束后,从NameNode上保持了NameNode上持久化信息的一个备份,同时,NameNode上已经完成合并到FSImage的日志可以抛弃,一箭双雕。
具体的的流程是:
1:调用startCheckpoint,为接下来的工作准备空间。startCheckpoint会在内部做一系列的检查,然后调用CheckpointStorage的startCheckpoint方法,创建目录。
2:调用namenode的rollEditLog方法,开始一次新的检查点过程。调用会返回一个CheckpointSignature(检查点签名),在上传合并完的FSImage时,会使用这个签名。
Namenode的rollEditLog方法最终调用的是FSImage的同名方法,前面提到过这个方法,作用是关闭往edits上写的日志,打开日志到edits.new。明显,在Secondary NameNode下载fsimage和日志的时候,对命名空间的修改,将保持在edits.new的日志中。
注意,如果FSImage这个时候的状态(看下面的状态机,前面出现过一次)不是出于CheckpointStates.ROLLED_EDITS,将抛异常结束这个过程。
3:通过downloadCheckpointFiles下载fsimage和日志,并设置本地检查点状态为CheckpointStates.UPLOAD_DONE。
4:合并日志的内容到fsimage中。过程很简单,CheckpointStorage利用继承自FSImage的loadFSImage加载fsimage,loadFSEdits应用日志,然后通过saveFSImage保存。很明显,现在保存在硬盘上的fsimage是合并日志的内容以后的文件。
5:使用putFSImage上传合并日志后的fsimage(让NameNode通过HTTP到从NameNode取文件)。这个过程中,NameNode会:
调用NameNode的FSImage.validateCheckpointUpload,检查现在的状态;
利用HTTP,从Secondary NameNode获取新的fsimage;
更新结束后设置新状态。
6:调用NameNode的rollFsImage,最终调用FSImage的rollFsImage方法,前面我们已经分析过了。
7:调用本地endCheckpoint方法,结束一次doCheckpoint流程。
其实前面在分析FSImage的时候,我们在不了解Secondary NameNode的情况下,分析了很多和Checkpoint相关的方法,现在我们终于可以有一个比较统一的了解了,下面给出NameNode和Secondary NameNode的存储系统在这个流程中的状态转移图,方便大家理解。
图中右侧的状态转移图:
文件系统上的目录的变化(三六中出现):
Hadoop源代码分析(三八)
我们可以开始从系统的外部来了解HDFS了,DFSClient提供了连接到HDFS系统并执行文件操作的基本功能。DFSClient也是个大家伙,我们先分析它的一些内部类。我们先看LeaseChecker。租约是客户端对文件写操作时需要获取的一个凭证,前面分析NameNode时,已经了解了租约,INodeFileUnderConstruction的关系,INodeFileUnderConstruction只有在文件写的时候存在。客户端的租约管理很简单,包括了增加的put和删除的remove方法,run方法会定期执行,并通过ClientProtocl的renewLease,自动延长租约。
接下来我们来分析内部为文件读引入的类。
InputStream是系统的虚类,提供了3个read方法,一个skip(跳过数据)方法,一个available方法(目前流中可读的字节数),一个close方法和几个在输入流中做标记的方法(mark:标记,reset:回到标记点和markSupported:能力查询)。
FSInputStream也是一个虚类,它将接口Seekable和PositionedReadable混插到类中。Seekable提供了可以在流中定位的能力(seek,getPos和seekToNewSource),而PositionedReadable提高了从某个位置开始读的方法(一个read方法和两个readFully方法)。
FSInputChecker在FSInputStream的基础上,加入了HDFS中需要的校验功能。校验在readChecksumChunk中实现,并在内部的read1方法中调用。所有的read调用,最终都是使用read1读数据并做校验。如果校验出错,抛出异常ChecksumException。
有了支持校验功能的输入流,就可以开始构建基于Block的输入流了。我们先回顾前面提到的读数据块的请求协议:
然后我们来分析一下创建BlockReader需要的参数,newBlockReader最复杂的请求如下:
public static
long
long
long startOffset, long
int bufferSize, boolean
String clientName)
throws
其中,sock为到DataNode的socket连接,file是文件名(只是用于日志输出),其它的参数含义都很清楚,和协议基本是一一对应的。该方法会和DataNode进行对话,发送上面的读数据块的请求,处理应答并构造BlockReader对象(BlockReader的构造函数基本上只有赋值操作)。
BlockReader的readChunk用于处理DataNode送过来的数据,格式前面我们已经讨论过了,如下图。
读数据用的read,会调用父类FSInputChecker的read,最后调用readChunk,如下:
read如果发现读到正确的校验码,则用过checksumOk方法,向DataNode发送成功应达。
BlockReader的主要流程就介绍完了,接下来分析DFSInputStream,它封装了DFSClient读文件内容的功能。在它的内部,不但要处理和NameNode的通信,同时通过BlockReader,处理和DataNode的交互。
DFSInputStream记录Block的成员变量是:
private LocatedBlocks locatedBlocks = null;
它不但保持了文件对应的Block序列,还保持了管理Block的DataNode的信息,是DFSInputStream中最重要的成员变量。DFSInputStream的构造函数,通过类内部的openInfo方法,获取这个变量的值。openInfo间接调用了NameNode的getBlockLocations,获取LocatedBlocks。
DFSInputStream中处理数据块位置的还有下面一些函数:
synchronized List<LocatedBlock> getAllBlocks() throws
private LocatedBlock getBlockAt(long offset) throws
private synchronized List<LocatedBlock> getBlockRange(long
long
private synchronized DatanodeInfo blockSeekTo(long target) throws
它们的功能都很清楚,需要注意的是他们处理过程中可能会调用再次调用NameNode的getBlockLocations,使得流程比较复杂。blockSeekTo还会创建对应的BlockReader对象,它被几个重要的方法调用(如下图)。在打开到DataNode之前,blockSeekTo会调用chooseDataNode,选择一个现在活着的DataNode。
通过上面的分析,我们已经知道了在什么时候会连接NameNode,什么时候会打开到DataNode的连接。下面我们来看读数据。read方法定义如下:
public int read(long position, byte[] buffer, int offset, int
该方法会从流的position位置开始,读取最多length个byte到buffer中offset开始的空间中。参数检测完以后,通过getBlockRange获取要读取的数据块对应的block范围,然后,利用fetchBlockByteRange方法,读取需要的数据。
fetchBlockByteRange从某一个数据块中读取一段数据,定义如下:
private void fetchBlockByteRange(LocatedBlock block, long
long end, byte[] buf, int
由于读取的内容都在一个数据块内部,这个方法会创建BlockReader,然后利用BlockReader的readAll方法,读取数据。读的过程中如果发生校验错,那么,还会通过reportBadBlocks,向NameNode报告校验错。
另一个读方法是:
public synchronized int read(byte buf[], int off, int len) throws
它在流的当前位置(可以通过seek方法调整)读取数据。首先它会判断当前流的位置,如果已经越过了对象现在的blockReader能读取的范围(当上次read读到数据块的尾部时,会发生这中情况),那么通过blockSeekTo打开到下一个数据块的blockReader。然后,read在当前的这个数据块中通过readBuffer读数据。主要,这个read方法只在一块数据块中读取数据,就是说,如果还有空间可以存放数据但已经到了数据块的尾部,它不会打开到下一个数据块的BlockReader继续读,而是返回,返回值包含了以读取数据的长度。
DFSDataInputStream是一个Wrapper(DFSInputStream),我们就不讨论了。
Hadoop源代码分析(三九)
接下来当然是分析输出流了。
处于继承体系的最上方是OutputStream,它实现了Closeable(方法close)和Flushable(方法flush)接口,提供了3个不同形式的write方法,这些方法的含义都很清楚。接下来的是FSOutputSummer,它引入了HDFS写数据时需要的计算校验和的功能。FSOutputSummer的write方法会调用write1,write1中计算校验和并将用户输入的数据拷贝到对象的缓冲区中,缓冲区满了以后会调用flushBuffer,flushBuffer最终调用还是虚方法的writeChunk,这个时候,缓冲区对应的校验和缓冲区对的内容都已经准备好了。通过这个类,HDFS可以把一个流转换成为DataNode数据接口上的包格式(前面我们讨论过这个包的格式,如下)。
DFSOutputStream继承自FSOutputSummer,是一个非常复杂的类,它包含了几个内部类。我们先分析Packet,其实它对应了上面的数据包,有了上面的图,这个类就很好理解了,它的成员变量和上面数据块包含的信息基本一一对应。构造函数需要的参数有pktSize,包的大小,chunksPerPkt,chunk的数目(chunk是一个校验单元)和该包在Block中的偏移量offsetInBlock。writeData和writeChecksum用于往缓冲区里写数据/校验和。getBuffer用户获得整个包,包括包头和数据。
DataStreamer和ResponseProcessor用于写包/读应答,和我们前面讨论DataNode的Pipe写时类似,客户端写数据也需要两个线程,下图扩展了我们在讨论DataNode处理写时的示意图,包含了客户端:
DataStreamer启动后进入一个循环,在没有错误和关闭标记为false的情况下,该循环首先调用processDatanodeError,处理可能的IO错误,这个过程比较复杂,我们在后面再讨论。
接着DataStreamer会在dataQueue(数据队列)上等待,直到有数据出现在队列上。DataStreamer获取一个数据包,然后判断到DataNode的连接是否是打开的,如果不是,通过DFSOutputStream.nextBlockOutputStream打开到DataNode的连接,并启动ResponseProcessor线程。
DataNode的连接准备好以后,DataStreamer获取数据包缓冲区,然后将数据包从dataQueue队列挪到ackQueue队列,最后通过blockStream,写数据。如果数据包是最后一个,那么,DataStreamer将会写一个长度域为0的包,指示DataNode数据传输结束。
DataStreamer的循环在最后一个数据包写出去以后,会等待直到ackQueue队列为空(表明所有的应答已经被接收),然后做清理动作(包括关闭socket连接,ResponseProcessor线程等),退出线程。
ResponseProcessor相对来说比较简单,就是等待来自DataNode的应答。如果是成功的应答,则删除在ackQueue的包,如果有错误,那么,记录出错的DataNode,并设置标志位。
Hadoop源代码分析(四零)
有了上面的基础,我们可以来解剖DFSOutputStream了。先看构造函数:
private DFSOutputStream(String src, long
int bytesPerChecksum) throws IOException
boolean
short replication, long
int buffersize, int bytesPerChecksum) throws
int
LocatedBlock lastBlock, FileStatus stat,
int bytesPerChecksum) throws
这些构造函数的参数主要有:文件名src;进度回调函数progress(预留接口,目前未使用);数据块大小blockSize;Block副本数replication;每个校验chunk的大小bytesPerChecksum;文件权限masked;是否覆盖原文件标记overwrite;文件状态信息stat;文件的最后一个Block信息lastBlock;buffersize(?未见引用)。
后面两个构造函数会调用第一个构造函数,这个函数会调用父类的构造函数,并设置对象的src,blockSize,progress和checksum属性。
第二个构造函数会调用namenode.create方法,在文件空间中建立文件,并启动DataStreamer,它被DFSClient的create方法调用。第三个构造函数被DFSClient的append方法调用,显然,这种情况比价复杂,文件拥有一些数据块,添加数据往往添加在最后的数据块上。同时,append方法调用时,Client已经知道了最后一个Block的信息和文件的一些信息,如FileStatus中包含的Block大小,文件权限位等等。结合这些信息,构造函数需要计算并设置一些对象成员变量的值,并试图从可能的错误中恢复(调用processDatanodeError),最后启动DataStreamer。
我们先看正常流程,前面已经分析过,通过FSOutputSummer,HDFS客户端能将流转换成package,这个包是通过writeChunk,发送出去的,下面是它们的调用关系。
在检查完一系列的状态以后,writeChunk先等待,直到dataQueue中未发送的包小于门限值。如果现在没有可用的Packet对象,则创建一个Packet对象,往Packet中写数据,包括校验值和数据。如果数据包被写满,那么,将它放入发送队列dataQueue中。writeChunk的过程比较简单,这里的写入,也只是把数据写到本地队列,等待DataStreamer发送,没有实际写到DataNode上。
createBlockOutputStream用于建立到第一个DataNode的连接,它的声明如下:
private boolean
boolean
nodes是所有接收数据的DataNode列表,client就是客户端名称,recoveryFlag指示是否是为错误恢复建立的连接。createBlockOutputStream很简单,打开到第一个DataNode的连接,然后发送下面格式的数据包,并等待来自DataNode的Ack。如果出错,记录出错的DataNode在nodes中的位置,设置errorIndex并返回false。
当recoveryFlag指示为真时,意味着这次写是一次恢复操作,对于DataNode来说,这意味着为写准备的临时文件(在tmp目录中)可能已经存在,需要进行一些特殊处理,具体请看FSDataset的实现。
当Client写数据需要一个新的Block的时候,可以调用nextBlockOutputStream方法。
private DatanodeInfo[] nextBlockOutputStream(String client) throws
这个方法的实现很简单,首先调用locateFollowingBlock(包含了重试和出错处理),通过namenode.addBlock获取一个新的数据块,返回的是DatanodeInfo列表,有了这个列表,就可以建立写数据的pipe了。下一个大动作就是调用上面的createBlockOutputStream,建立到DataNode的连接了。
有了上面的准备,我们来分析processDatanodeError,它的主要流程是:
l 参数检查;
l 关闭可能还打开着的blockStream和blockReplyStream;
l 将未收到应答的数据块(在ackQueue中)挪到dataQueue中;
l 循环执行:
1. 计算目前还活着的DataNode列表;
2. 选择一个主DataNode,通过DataNode RPC的recoverBlock方法启动它上面的恢复过程;
3. 处理可能的出错;
4. 处理恢复后Block可能的变化(如Stamp变化);
5. 调用createBlockOutputStream到DataNode的连接。
l 启动ResponseProcessor。
这个过程涉及了DataNode上的recoverBlock方法和createBlockOutputStream中可能的Block恢复,是一个相当耗资源的方法,当系统出错的概率比较小,而且数据块上能恢复的数据很多(平均32M),还是值得这样做的。
写的流程就分析到着,接下来我们来看流的关闭,这个过程也涉及了一系列的方法,它们的调用关系如下:
flushInternal会一直等待到发送队列(包括可能的currentPacket)和应答队列都为空,这意味着数据都被DataNode顺利接收。
sync作用和UNIX的sync类似,将写入数据持久化。它首先调用父类的flushBuffer方法,将可能还没拷贝到DFSOutputStream的数据拷贝回来,然后调用flushInternal,等待所有的数据都写完。然后调用namenode.fsync,持久化命名空间上的数据。
closeInternal比较复杂一点,它首先调用父类的flushBuffer方法,将可能还没拷贝到DFSOutputStream的数据拷贝回来,然后调用flushInternal,等待所有的数据都写完。接着结束两个工作线程,关闭socket,最后调用amenode.complete,通知NameNode结束一次写操作。close方法先调用closeInternal,然后再本地的leasechecker中移除对应的信息。
Hadoop源代码分析(四一)
前面分析的DFSClient内部类,占据了这个类的实现部分的2/3,我们来看剩下部分。
DFSClient的成员变量不多,而且大部分是系统的缺省配置参数,其中比较重要的是到NameNode的RPC客户端:
public final ClientProtocol namenode;
final private ClientProtocol rpcNamenode;
它们的差别是namenode在rpcNamenode的基础上,增加了失败重试功能。DFSClient中提供可各种构造它们的static函数,createClientDatanodeProtocolProxy用于生成到DataNode的RPC客户端。
DFSClient的构造函数也比价简单,就是初始化成员变量,close用于关闭DFSClient。
下面的功能,DFSClient只是简单地调用NameNode的对应方法(加一些简单的检查),就不罗嗦了:
setReplication/rename/delete/exists(通过getFileInfo的返回值是否为空判断)/listPaths/getFileInfo/setPermission/setOwner/getDiskStatus/totalRawCapacity/totalRawUsed/datanodeReport/setSafeMode/refreshNodes/metaSave/finalizeUpgrade/mkdirs/getContentSummary/setQuota/setTimes
DFSClient提供了各种create方法,它们最后都是构造一个OutputStream,并将文件名和生成的OutputStream加到leasechecker,完成创建动作。
append操作是通过namenode.append,获取最后的Block信息,然后构造一个OutputStream,并将文件名和生成的OutputStream加到leasechecker,完成append动作。
getFileChecksum用于获取文件的校验信息,它在得到数据块的位置信息后利用DataNode提供的OP_BLOCK_CHECKSUM操作,获取需要的数据,并综合起来。过程简单,方法主要是在处理OP_BLOCK_CHECKSUM需要交换的数据包。
DFSClient内部还有一些其它的辅助方法,都比较简单,就不再分析了。
Hadoop源代码分析(MapReduce概论)
大家都熟悉文件系统,在对HDFS进行分析前,我们并没有花很多的时间去介绍HDFS的背景,毕竟大家对文件系统的还是有一定的理解的,而且也有很好的文档。在分析Hadoop的MapReduce部分前,我们还是先了解系统是如何工作的,然后再进入我们的分析部分。下面的图来自http://horicky.blogspot.com/2008/11/hadoop-mapreduce-implementation.html,是我看到的讲MapReduce最好的图。
以Hadoop带的wordcount为例子(下面是启动行):
hadoop jar hadoop-0.19.0-examples.jar wordcount /usr/input /usr/output
用户提交一个任务以后,该任务由JobTracker协调,先执行Map阶段(图中M1,M2和M3),然后执行Reduce阶段(图中R1和R2)。Map阶段和Reduce阶段动作都受TaskTracker监控,并运行在独立于TaskTracker的Java虚拟机中。
我们的输入和输出都是HDFS上的目录(如上图所示)。输入由InputFormat接口描述,它的实现如ASCII文件,JDBC数据库等,分别处理对于的数据源,并提供了数据的一些特征。通过InputFormat实现,可以获取InputSplit接口的实现,这个实现用于对数据进行划分(图中的splite1到splite5,就是划分以后的结果),同时从InputFormat也可以获取RecordReader接口的实现,并从输入中生成<k,v>对。有了<k,v>,就可以开始做map操作了。
map操作通过context.collect(最终通过OutputCollector. collect)将结果写到context中。当Mapper的输出被收集后,它们会被Partitioner类以指定的方式区分地写出到输出文件里。我们可以为Mapper提供Combiner,在Mapper输出它的<k,v>时,键值对不会被马上写到输出里,他们会被收集在list里(一个key值一个list),当写入一定数量的键值对时,这部分缓冲会被Combiner中进行合并,然后再输出到Partitioner中(图中M1的黄颜色部分对应着Combiner和Partitioner)。
Map的动作做完以后,进入Reduce阶段。这个阶段分3个步骤:混洗(Shuffle),排序(sort)和reduce。
混洗阶段,Hadoop的MapReduce框架会根据Map结果中的key,将相关的结果传输到某一个Reducer上(多个Mapper产生的同一个key的中间结果分布在不同的机器上,这一步结束后,他们传输都到了处理这个key的Reducer的机器上)。这个步骤中的文件传输使用了HTTP协议。
排序和混洗是一块进行的,这个阶段将来自不同Mapper具有相同key值的<key,value>对合并到一起。
Reduce阶段,上面通过Shuffle和sort后得到的<key, (list of values)>会送到Reducer. reduce方法中处理,输出的结果通过OutputFormat,输出到DFS中。
Hadoop源代码分析(MapTask)
接下来我们来分析Task的两个子类,MapTask和ReduceTask。MapTask的相关类图如下:
MapTask其实不是很复杂,复杂的是支持MapTask工作的一些辅助类。MapTask的成员变量少,只有split和splitClass。我们知道,Map的输入是split,是原始数据的一个切分,这个切分由org.apache.hadoop.mapred.InputSplit的子类具体描述(前面我们是通过org.apache.hadoop.mapreduce.InputSplit介绍了InputSplit,它们对外的API是一样的)。splitClass是InputSplit子类的类名,通过它,我们可以利用Java的反射机制,创建出InputSplit子类。而split是一个BytesWritable,它是InputSplit子类串行化以后的结果,再通过InputSplit子类的readFields方法,我们可以回复出对应的InputSplit对象。
MapTask最重要的方法是run。run方法相当简单,配置完系统的TaskReporter后,就根据情况执行runJobCleanupTask,runJobSetupTask,runTaskCleanupTask或执行Mapper。由于MapReduce现在有两套API,MapTask需要支持这两套API,使得MapTask执行Mapper分为runNewMapper和runOldMapper,run*Mapper后,MapTask会调用父类的done方法。
接下来我们来分析runOldMapper,最开始部分是构造Mapper处理的InputSplit,更新Task的配置,然后就开始创建Mapper的RecordReader,rawIn是原始输入,然后分正常(使用TrackedRecordReader,后面讨论)和跳过部分记录(使用SkippingRecordReader,后面讨论)两种情况,构造对应的真正输入in。
跳过部分记录是Map的一种出错恢复策略,我们知道,MapReduce处理的数据集合非常大,而有些任务对一部分出错的数据不进行处理,对结果的影响很小(如大数据集合的一些统计量),那么,一小部分的数据出错导致已处理的大量结果无效,是得不偿失的,跳过这部分记录,成了Mapper的一种选择。
Mapper的输出,是通过MapOutputCollector进行的,也分两种情况,如果没有Reducer,那么,用DirectMapOutputCollector(后面讨论),否则,用MapOutputBuffer(后面讨论)。
构造完Mapper的输入输出,通过构造配置文件中配置的MapRunnable,就可以执行Mapper了。目前系统有两个MapRunnable:MapRunner和MultithreadedMapRunner,如下图。
原有API在这块的处理上和新API有很大的不一样。接口MapRunnable是原有API中Mapper的执行器,run方法就是用于执行用户的Mapper。MapRunner是单线程执行器,相当简单,首先,当MapTask调用:
MapRunnable<INKEY,INVALUE,OUTKEY,OUTVALUE> runner =
ReflectionUtils.newInstance(job.getMapRunnerClass(), job);
MapRunner的configure会在newInstance的最后被调用,configure执行的过程中,对应的Mapper会通过反射机制构造出来。
MapRunner的run方法,会先创建对应的key,value对象,然后,对InputSplit的每一对<key,value>,调用Mapper的map方法,循环结束后,Mapper对应的清理方法会被调用。我们需要注意,key,value对象在run方法中是被重复使用的,就是说,每次传入Mapper的map方法的key,value都是同一个对象,只不过是里面的内容变了,对象并没有变。如果你需要保留key,value的内容,需要实现clone机制,克隆出对象的一个新备份。
相对于新API的多线程执行器,老API的MultithreadedMapRunner就比较复杂了,总体来说,就是通过阻塞队列配合Java的多线程执行器,将<key,value>分发到多个线程中去处理。需要注意的是,在这个过程中,这些线程共享一个Mapper实例,如果Mapper有共享的资源,需要有一定的保护机制。
runNewMapper用于执行新版本的Mapper,比runOldMapper稍微复杂,我们就不再讨论了。
Hadoop源代码分析(MapTask辅助类 I)
MapTask的辅助类主要针对Mapper的输入和输出。首先我们来看MapTask中用的的Mapper输入,在类图中,这部分位于右上角。
MapTask.TrackedRecordReader是一个Wrapper,在原有输入RecordReader的基础上,添加了收集上报统计数据的功能。
MapTask.SkippingRecordReader也是一个Wrapper,它在MapTask.TrackedRecordReader的基础上,添加了忽略部分输入的功能。在分析MapTask.SkippingRecordReader之前,我们先看一下类SortedRanges和它相关的类。
类SortedRanges.Ranges表示了一个范围,以开始位置和范围长度(这样的话就可以表示长度为0的范围)来表示一个范围,并提供了一系列的范围操作方法。注意,方法getEndIndex得到的右端点并不包含在范围内(应理解为开区间)。SortedRanges包含了一系列不重叠的范围,为了保证包含的范围不重叠,在add方法和remove方法上需要做一些处理,保证不重叠的约束。SkipRangeIterator是访问SortedRanges包含的Ranges的迭代器。
MapTask.SkippingRecordReader的实现很简单,因为要忽略的输入都保持在SortedRanges.Ranges,只需要在next方法中,判断目前范围时候落在SortedRanges.Ranges中,如果是,忽略,并将忽略的记录写文件(可配置)
NewTrackingRecordReader和NewOutputCollector被新API使用,我们不分析。
MapTask的输出辅助类都继承自MapOutputCollector,它只是在OutputCollector的基础上添加了close和flush方法。
DirectMapOutputCollector用在Reducer的数目为0,就是不需要Reduce阶段的时候。它是直接通过
out
得到对应的RecordWriter,collect直接到RecordWriter上。
如果Mapper后续有reduce任务,系统会使用MapOutputBuffer做为输出,这是个比较复杂的类,有1k行左右的代码。
我们知道,Mapper是通过OutputCollector将Map的结果输出,输出的量很大,Hadoop的机制是通过一个circle buffer 收集Mapper的输出, 到了io.sort.mb * percent量的时候,就spill到disk,如下图。图中出现了两个数组和一个缓冲区,kvindices保持了记录所属的(Reduce)分区,key在缓冲区开始的位置和value在缓冲区开始的位置,通过kvindices,我们可以在缓冲区中找到对应的记录。kvoffets用于在缓冲区满的时候对kvindices的partition进行排序,排完序的结果将输出到输出到本地磁盘上,其中索引(kvindices)保持在spill{spill号}.out.index中,数据保存在spill{spill号}.out中。
当Mapper任务结束后,有可能会出现多个spill文件,这些文件会做一个归并排序,形成Mapper的一个输出(spill.out和spill.out.index),如下图:
这个输出是按partition排序的,这样的话,Mapper的输出被分段,Reducer要获取的就是spill.out中的一段。(注意,内存和硬盘上的索引结构不一样)
(感谢彭帅的Hadoop Map Stage流程分析 )
Hadoop源代码分析(MapTask辅助类,II)
有了上面Mapper输出的内存存储结构和硬盘存储结构讨论,我们来仔细分析MapOutputBuffer的流程。
首先是成员变量。最先初始化的是作业配置job和统计功能reporter。通过配置,MapOutputBuffer可以获取本地文件系统(localFs和rfs),Reducer的数目和Partitioner。
SpillRecord是文件spill.out{spill号}.index在内存中的对应抽象(内存数据和文件数据就差最后的校验和),该文件保持了一系列的IndexRecord,如下图:
IndexRecord有3个字段,分别是startOffset:记录偏移量,rawLength:初始长度,partLength:实际长度(可能有压缩)。SpillRecord保持了一系列的IndexRecord,并提供方法用于添加记录(没有删除记录的操作,因为不需要),获取记录,写文件,读文件(通过构造函数)。
接下来是一些和输出缓存区kvbuffer,缓存区记录索引kvindices和缓存区记录索引排序工作数组kvoffsets相关的处理,下面的图有助于说明这段代码。
这部分依赖于3个配置参数,io.sort.spill.percent是kvbuffer,kvindices和kvoffsets的总大小(以M为单位,缺省是100,就是100M,这一部分是MapOutputBuffer中占用存储最多的)。io.sort.record.percent是kvindices和kvoffsets占用的空间比例(缺省是0.05)。前面的分析我们已经知道kvindices和kvoffsets,如果记录数是N的话,它占用的空间是4N*4bytes,根据这个关系和io.sort.record.percent的值,我们可以计算出kvindices和kvoffsets最多能有多少个记录,并分配相应的空间。参数io.sort.spill.percent指示当输出缓冲区或kvindices和kvoffsets记录数量到达对应的占用率的时候,会启动spill,将内存缓冲区的记录存放到硬盘上,softBufferLimit和softRecordLimit为对应的字节数。
值对<key, value>输出到缓冲区是通过Serializer串行化的,这部分的初始化跟在上面输出缓存后面。接下来是一些计数器和可能的数据压缩处理器的初始化,可能的Combiner和combiner工作的一些配置。
最后是启动spillThread,该Thread会检查内存中的输出缓存区,在满足一定条件的时候将缓冲区中的内容spill到硬盘上。这是一个标准的生产者-消费者模型,MapTask的collect方法是生产者,spillThread是消费者,它们之间同步是通过spillLock(ReentrantLock)和spillLock上的两个条件变量(spillDone和spillReady)完成的。
先看生产者,MapOutputBuffer.collect的主要流程是:
l 报告进度和参数检测(<K, V>符合Mapper的输出约定);
l spillLock.lock(),进入临界区;
l 如果达到spill条件,设置变量并通过spillReady.signal(),通知spillThread;并等待spill结束(通过spillDone.await()等待);
l spillLock.unlock();
l 输出key,value并更新kvindices和kvoffsets(注意,方法collect是synchronized,key和value各自输出,它们也会占用连续的输出缓冲区);
kvstart,kvend和kvindex三个变量在判断是否需要spill和spill是否结束的过程中很重要,kvstart是有效记录开始的下标,kvindex是下一个可做记录的位置,kvend的作用比较特殊,它在一般情况下kvstart==kvend,但开始spill的时候它会被赋值为kvindex的值,spill结束时,它的值会被赋给kvstart,这时候kvstart==kvend。这就是说,如果kvstart不等于kvend,系统正在spill,否则,kvstart==kvend,系统处于普通工作状态。其实在代码中,我们可以看到很多kvstart==kvend的判断。
下面我们分情况,讨论kvstart,kvend和kvindex的配合。初始化的时候,它们都被赋值0。
下图给出了一个没有spill的记录添加过程:
注意kvindex和kvnext的关系,取模实现了循环缓冲区
如果在添加记录的过程中,出现spill(多种条件),那么,主要的过程如下:
首先还是计算kvnext,主要,这个时候kvend==kvstart(图中没有画出来)。如果spill条件满足,那么,kvindex的值会赋给kvend(这是kvend不等于kvstart),从kvstart和kvend的大小关系,我们可以知道记录位于数组的那一部分(左边是kvstart<kvend的情况,右边是另外的情况)。Spill结束的时候,kvend值会被赋给kvstart, kvend==kvstart又重新满足,同时,我们可以发现kvindex在这个过程中没有变化,新的记录还是写在kvindex指向的位置,然后,kvindex=kvnect,kvindex移到下一个可用位置。
大家体会一下上面的过程,特别是kvstart,kvend和kvindex的配合,其实,<key,value>对输出使用的缓冲区,也有类似的过程。
Collect在处理<key,value>输出时,会处理一个MapBufferTooSmallException,这是value的串行化结果太大,不能一次放入缓冲区的指示,这种情况下我们需要调用spillSingleRecord,特殊处理。
Hadoop源代码分析(MapTask辅助类,III)
接下来讨论的是key,value的输出,这部分比较复杂,不过有了前面kvstart,kvend和kvindex配合的分析,有利于我们理解这部分的代码。
输出缓冲区中,和kvstart,kvend和kvindex对应的是bufstart,bufend和bufmark。这部分还涉及到变量bufvoid,用于表明实际使用的缓冲区结尾(见后面BlockingBuffer.reset分析),和变量bufmark,用于标记记录的结尾。这部分代码需要bufmark,是因为key或value的输出是变长的,(前面元信息记录大小是常量,就不需要这样的变量)。
最好的情况是缓冲区没有翻转和value串行化结果很小,如下图:
先对key串行化,然后对value做串行化,临时变量keystart,valstart和valend分别记录了key结果的开始位置,value结果的开始位置和value结果的结束位置。
串行化过程中,往缓冲区写是最终调用了Buffer.write方法,我们后面再分析。
如果key串行化后出现bufindex < keystart,那么会调用BlockingBuffer的reset方法。原因是在spill的过程中需要对<key,value>排序,这种情况下,传递给RawComparator的必须是连续的二进制缓冲区,通过BlockingBuffer.reset方法,解决这个问题。下图解释了如何解决这个问题:
当发现key的串行化结果出现不连续的情况时,我们会把bufvoid设置为bufmark,见缓冲区开始部分往后挪,然后将原来位于bufmark到bufvoid出的结果,拷到缓冲区开始处,这样的话,key串行化的结果就连续存放在缓冲区的最开始处。
上面的调整有一个条件,就是bufstart前面的缓冲区能够放下整个key串行化的结果,如果不能,处理的方式是将bufindex置0,然后调用BlockingBuffer内部的out的write方法直接输出,这实际调用了Buffer.write方法,会启动spill过程,最终我们会成功写入key串行化的结果。
下面我们看write方法。key,value串行化过程中,往缓冲区写数据是最终调用了Buffer.write方法,又是一个复杂的方法。
l do-while循环,直到我们有足够的空间可以写数据(包括缓冲区和kvindices和kvoffsets)
u 首先我们计算缓冲区连续写是否写满标志buffull和缓冲区非连续情况下有足够写空间标志wrap(这个实在拗口),见下面的讨论;条件(buffull && !wrap)用于判断目前有没有足够的写空间;
u 在spill没启动的情况下(kvstart == kvend),分两种情况,如果数组中有记录(kvend != kvindex),那么,根据需要(目前输出空间不足或记录数达到spill条件)启动spill过程;否则,如果空间还是不够(buffull && !wrap),表明这个记录非常大,以至于我们的内存缓冲区不能容下这么大的数据量,抛MapBufferTooSmallException异常;
u 如果空间不足同时spill在运行,等待spillDone;
l 写数据,注意,如果buffull,则写数据会不连续,则写满剩余缓冲区,然后设置bufindex=0,并从bufindex处接着写。否则,就是从bufindex处开始写。
下图给出了缓冲区连续写是否写满标志buffull和缓冲区非连续情况下有足够写空间标志wrap计算的几种可能:
情况1和情况2中,buffull判断为从bufindex到bufvoid是否有足够的空间容纳写的内容,wrap是图中白颜色部分的空间是否比输入大,如果是,wrap为true;情况3和情况4中,buffull判断bufindex到bufstart的空间是否满足条件,而wrap肯定是false。明显,条件(buffull && !wrap)满足时,目前的空间不够一次写。
接下来我们来看spillSingleRecord,只是用于写放不进内存缓冲区的<key,value>对。过程很流水,首先是创建SpillRecord记录,输出文件和IndexRecord记录,然后循环,构造SpillRecord并在恰当的时候输出记录(如下图),最后输出spill{n}.index文件。
前面我们提过spillThread,在这个系统中它是消费者,这个消费者相当简单,需要spill时调用函数sortAndSpill,进行spill。sortAndSpill和spillSingleRecord类似,函数的开始也是创建SpillRecord记录,输出文件和IndexRecord记录,然后,需要在kvoffsets上做排序,排完序后顺序访问kvoffsets,也就是按partition顺序访问记录。
按partition循环处理排完序的数组,如果没有combiner,则直接输出记录,否则,调用combineAndSpill,先做combin然后输出。循环的最后记录IndexRecord到SpillRecord。
sortAndSpill最后是输出spill{n}.index文件。
combineAndSpill比价简单,我们就不分析了。
BlockingBuffer中最后要分析的方法是flush方法。调用flush方法,意味着Mapper的结果都已经collect了,需要对缓冲区做一些最后的清理,并合并spill{n}文件产生最后的输出。
缓冲区处理部分很简单,先等待可能的spill过程完成,然后判断缓冲区是否为空,如果不是,则调用sortAndSpill,做最后的spill,然后结束spill线程。
flush合并spill{n}文件是通过mergeParts方法。如果Mapper最后只有一个spill{n}文件,简单修改该文件的文件名就可以。如果Mapper没有任何输出,那么我们需要创建哑输出(dummy files)。如果spill{n}文件多于1个,那么按partition循环处理所有文件,将处于处理partition的记录输出。处理partition的过程中可能还会再次调用combineAndSpill,最记录再做一次combination,其中还涉及到工具类Merger,我们就不再深入研究了。
Hadoop源代码分析(Task的内部类和辅助类)
从前面的图中,我们可以发现Task有很多内部类,并拥有大量类成员变量,这些类配合Task完成相关的工作,如下图。
MapOutputFile管理着Mapper的输出文件,它提供了一系列get方法,用于获取Mapper需要的各种文件,这些文件都存放在一个目录下面。
我们假设传入MapOutputFile的JobID为job_200707121733_0003,TaskID为task_200707121733_0003_m_000005。MapOutputFile的根为
{mapred.local.dir}/taskTracker/jobcache/{jobid}/{taskid}/output
在下面的讨论中,我们把上面的路径记为{MapOutputFileRoot}
以上面JogID和TaskID为例,我们有:
{mapred.local.dir}/taskTracker/jobcache/job_200707121733_0003/task_200707121733_0003_m_000005/output
需要注意的是,{mapred.local.dir}可以包含一系列的路径,那么,Hadoop会在这些根路径下找一个满足要求的目录,建立所需的文件。MapOutputFile的方法有两种,结尾带ForWrite和不带ForWrite,带ForWrite用于创建文件,它需要一个文件大小作为参数,用于检查磁盘空间。不带ForWrite用于获取以建立的文件。
getOutputFile:文件名为{MapOutputFileRoot}/file.out;
getOutputIndexFile:文件名为{MapOutputFileRoot}/file.out.index
getSpillFile:文件名为{MapOutputFileRoot}/spill{spillNumber}.out
getSpillIndexFile:文件名为{MapOutputFileRoot}/spill{spillNumber}.out.index
以上四个方法用于Task子类MapTask中;
getInputFile:文件名为{MapOutputFileRoot}/map_{mapId}.out
用于ReduceTask中。我们到使用到他们的地方再介绍相应的应用场景。
介绍完临时文件管理以后,我们来看Task.CombineOutputCollector,它继承自org.apache.hadoop.mapred.OutputCollector,很简单,只是一个OutputCollector到IFile.Writer的Adapter,活都让IFile.Writer干了。
ValuesIterator用于从RawKeyValueIterator(Key,Value都是DataInputBuffer,ValuesIterator要求该输入已经排序)中获取符合RawComparator<KEY> comparator的值的迭代器。它在Task中有一个简单子类,CombineValuesIterator。
Task.TaskReporter用于向JobTracker提交计数器报告和状态报告,它实现了计数器报告Reporter和状态报告StatusReporter。为了不影响主线程的工作,TaskReporter有一个独立的线程,该线程通过TaskUmbilicalProtocol接口,利用Hadoop的RPC机制,向JobTracker报告Task执行情况。
FileSystemStatisticUpdater用于记录对文件系统的对/写操作字节数,是个简单的工具类。
Hadoop源代码分析(mapreduce.lib.partition/reduce/output)
Map的结果,会通过partition分发到Reducer上,Reducer做完Reduce操作后,通过OutputFormat,进行输出,下面我们就来分析参与这个过程的类。
Mapper的结果,可能送到可能的Combiner做合并,Combiner在系统中并没有自己的基类,而是用Reducer作为Combiner的基类,他们对外的功能是一样的,只是使用的位置和使用时的上下文不太一样而已。
Mapper最终处理的结果对<key, value>,是需要送到Reducer去合并的,合并的时候,有相同key的键/值对会送到同一个Reducer那,哪个key到哪个Reducer的分配过程,是由Partitioner规定的,它只有一个方法,输入是Map的结果对<key, value>和Reducer的数目,输出则是分配的Reducer(整数编号)。系统缺省的Partitioner是HashPartitioner,它以key的Hash值对Reducer的数目取模,得到对应的Reducer。
Reducer是所有用户定制Reducer类的基类,和Mapper类似,它也有setup,reduce,cleanup和run方法,其中setup和cleanup含义和Mapper相同,reduce是真正合并Mapper结果的地方,它的输入是key和这个key对应的所有value的一个迭代器,同时还包括Reducer的上下文。系统中定义了两个非常简单的Reducer,IntSumReducer和LongSumReducer,分别用于对整形/长整型的value求和。
Reduce的结果,通过Reducer.Context的方法collect输出到文件中,和输入类似,Hadoop引入了OutputFormat。OutputFormat依赖两个辅助接口:RecordWriter和OutputCommitter,来处理输出。RecordWriter提供了write方法,用于输出<key, value>和close方法,用于关闭对应的输出。OutputCommitter提供了一系列方法,用户通过实现这些方法,可以定制OutputFormat生存期某些阶段需要的特殊操作。我们在TaskInputOutputContext中讨论过这些方法(明显,TaskInputOutputContext是OutputFormat和Reducer间的桥梁)。
OutputFormat和RecordWriter分别对应着InputFormat和RecordReader,系统提供了空输出NullOutputFormat(什么结果都不输出,NullOutputFormat.RecordWriter只是示例,系统中没有定义),LazyOutputFormat(没在类图中出现,不分析),FilterOutputFormat(不分析)和基于文件FileOutputFormat的SequenceFileOutputFormat和TextOutputFormat输出。
基于文件的输出FileOutputFormat利用了一些配置项配合工作,包括mapred.output.compress:是否压缩;mapred.output.compression.codec:压缩方法;mapred.output.dir:输出路径;mapred.work.output.dir:输出工作路径。FileOutputFormat还依赖于FileOutputCommitter,通过FileOutputCommitter提供一些和Job,Task相关的临时文件管理功能。如FileOutputCommitter的setupJob,会在输出路径下创建一个名为_temporary的临时目录,cleanupJob则会删除这个目录。
SequenceFileOutputFormat输出和TextOutputFormat输出分别对应输入的SequenceFileInputFormat和TextInputFormat,我们就不再详细分析啦。
Hadoop源代码分析(IFile)
Mapper的输出,在发送到Reducer前是存放在本地文件系统的,IFile提供了对Mapper输出的管理。我们已经知道,Mapper的输出是<Key,Value>对,IFile以记录<key-len, value-len, key, value>的形式存放了这些数据。为了保存键值对的边界,很自然IFile需要保存key-len和value-len。
和IFile相关的类图如下:
其中,文件流形式的输入和输出是由IFIleInputStream和IFIleOutputStream抽象。以记录形式的读/写操作由IFile.Reader/IFile.Writer提供,IFile.InMemoryReader用于读取存在于内存中的IFile文件格式数据。
我们以输出为例,来分析这部分的实现。首先是下图的和序列化反序列化相关的Serialization/Deserializer,这部分的code是在包org.apache.hadoop.io.serializer。序列化由Serializer抽象,通过Serializer的实现,用户可以利用serialize方法把对象序列化到通过open方法打开的输出流里。Deserializer提供的是相反的过程,对应的方法是deserialize。hadoop.io.serializer中还实现了配合工作的Serialization和对应的工厂SerializationFactory。两个具体的实现是WritableSerialization和JavaSerialization,分别对应了Writeble的序列化反序列化和Java本身带的序列化反序列化。
有了Serializer/Deserializer,我们来分析IFile.Writer。Writer的构造函数是:
public
Class<K> keyClass, Class<V> valueClass,
CompressionCodec codec, Counters.Counter writesCounter)
conf,配置参数,out是Writer的输出,keyClass 和valueClass 是输出的Kay,Value的class属性,codec是对输出进行压缩的方法,参数writesCounter用于对输出字节数进行统计的Counters.Counter。通过这些参数,我们可以构造我们使用的支持压缩功能的输出流(类成员out,类成员rawOut保存了构造函数传入的out),相关的计数器,还有就是Kay,Value的Serializer方法。
Writer最主要的方法是append方法(居然不是write方法,呵呵),有两种形式:
public void append(K key, V value) throws
public void
append(K key, V value)的主要过程是检查参数,然后将key和value序列化到DataOutputBuffer中,并获取序列化后的长度,最后把长度(2个)和DataOutputBuffer中的结果写到输出,并复位DataOutputBuffer和计数。append(DataInputBuffer key, DataInputBuffer value)处理过程也比较类似,就不再分析了。
close方法中需要注意的是,我们需要标记文件尾,或者是流结束。目前是通过写2个值为EOF_MARKER的长度来做标记。
IFileOutputStream是用于配合Writer的输出流,它会在IFiles的最后添加校验数据。当Writer调用IFileOutputStream的write操作时,IFileOutputStream计算并保持校验和,流被close的时候,校验结果会写到对应文件的文件尾。实际上存放在磁盘上的文件是一系列的<key-len, value-len, key, value>记录和校验结果。
Reader的相关过程,我们就不再分析了。
Hadoop源代码分析(*IDs类和*Context类)
我们开始来分析Hadoop MapReduce的内部的运行机制。用户向Hadoop提交Job(作业),作业在JobTracker对象的控制下执行。Job被分解成为Task(任务),分发到集群中,在TaskTracker的控制下运行。Task包括MapTask和ReduceTask,是MapReduce的Map操作和Reduce操作执行的地方。这中任务分布的方法比较类似于HDFS中NameNode和DataNode的分工,NameNode对应的是JobTracker,DataNode对应的是TaskTracker。JobTracker,TaskTracker和MapReduce的客户端通过RPC通信,具体可以参考HDFS部分的分析。
我们先来分析一些辅助类,首先是和ID有关的类,ID的继承树如下:
这张图可以看出现在Hadoop的org.apache.hadoop.mapred向org.apache.hadoop.mapreduce迁移带来的一些问题,其中灰色是标注为@Deprecated的。ID携带一个整型,实现了WritableComparable接口,这表明它可以比较,而且可以被Hadoop的io机制串行化/解串行化(必须实现compareTo/readFields/write方法)。JobID是系统分配给作业的唯一标识符,它的toString结果是job_<jobtrackerID>_<jobNumber>。例子:job_200707121733_0003表明这是jobtracker 200707121733(利用jobtracker的开始时间作为ID)的第3号作业。
作业分成任务执行,任务号TaskID包含了它所属的作业ID,同时也有任务ID,同时还保持了这是否是一个Map任务(成员变量isMap)。任务号的字符串表示为task_<jobtrackerID>_<jobNumber>_[m|r]_<taskNumber>,如task_200707121733_0003_m_000005表示作业200707121733_0003的000005号任务,改任务是一个Map任务。
一个任务有可能有多个执行(错误恢复/消除Stragglers等),所以必须区分任务的多个执行,这是通过类TaskAttemptID来完成,它在任务号的基础上添加了尝试号。一个任务尝试号的例子是attempt_200707121733_0003_m_000005_0,它是任务task_200707121733_0003_m_000005的第0号尝试。
JVMId用于管理任务执行过程中的Java虚拟机,我们后面再讨论。
为了使Job和Task工作,Hadoop提供了一系列的上下文,这些上下文保存了Job和Task工作的信息。
处于继承树的最上方是org.apache.hadoop.mapreduce.JobContext,前面我们已经介绍过了,它提供了Job的一些只读属性,两个成员变量,一个保存了JobID,另一个类型为JobConf,JobContext中除了JobID外,其它的信息都保持在JobConf中。它定义了如下配置项:
l mapreduce.inputformat.class:InputFormat的实现
l mapreduce.map.class:Mapper的实现
l mapreduce.combine.class: Reducer的实现
l mapreduce.reduce.class:Reducer的实现
l mapreduce.outputformat.class: OutputFormat的实现
l mapreduce.partitioner.class: Partitioner的实现
同时,它提供方法,使得通过类名,利用Java反射提供的Class.forName方法,获得类对应的Class。org.apache.hadoop.mapred的JobContext对象比org.apache.hadoop.mapreduce.JobContext多了成员变量progress,用于获取进度信息,它类型为JobConf成员job指向mapreduce.JobContext对应的成员,没有添加任何新功能。
JobConf继承自Configuration,保持了MapReduce执行需要的一些配置信息,它管理着46个配置参数,包括上面mapreduce配置项对应的老版本形式,如mapreduce.map.class 对应mapred.mapper.class。这些配置项我们在使用到它们的时候再介绍。
org.apache.hadoop.mapreduce.JobContext的子类Job前面也已经介绍了,后面在讨论系统的动态行为时,再回来看它。
TaskAttemptContext用于任务的执行,它引入了标识任务执行的TaskAttemptID和任务状态status,并提供新的访问接口。org.apache.hadoop.mapred的TaskAttemptContext继承自mapreduce的对应版本,只是增加了记录进度的progress。
TaskInputOutputContext和它的子类都在包org.apache.hadoop.mapreduce中,前面已经分析过了,我们就不再罗嗦。
Hadoop源代码分析(包hadoop.mapred中的MapReduce接口)
前面已经完成了对org.apache.hadoop.mapreduce的分析,这个包提供了Hadoop MapReduce部分的应用API,用于用户实现自己的MapReduce应用。但这些接口是给未来的MapReduce应用的,目前MapReduce框架还是使用老系统(参考补丁HADOOP-1230)。下面我们来分析org.apache.hadoop.mapred,首先还是从mapred的MapReduce框架开始分析,下面的类图(灰色部分为标记为@Deprecated的类/接口):
我们把包mapreduce的类图附在下面,对比一下,我们就会发现,org.apache.hadoop.mapred中的MapReduce API相对来说很简单,主要是少了和Context相关的类,那么,好多在mapreduce中通过context来完成的工作,就需要通过参数来传递,如Map中的输出,老版本是:
output.collect(key, result); // output’s type is: OutputCollector
新版本是:
context.write(key, result); // output’s type is: Context
它们分别使用OutputCollector和Mapper.Context来输出map的结果,显然,原有OutputCollector的新API中就不再需要。总体来说,老版本的API比较简单,MapReduce过程中关键的对象都有,但可扩展性不是很强。同时,老版中提供的辅助类也很多,我们前面分析的FileOutputFormat,也有对应的实现,我们就不再讨论了。
Hadoop源代码分析(包mapreduce.lib.input)
接下来我们按照MapReduce过程中数据流动的顺序,来分解org.apache.hadoop.mapreduce.lib.*的相关内容,并介绍对应的基类的功能。首先是input部分,它实现了MapReduce的数据输入部分。类图如下:
类图的右上角是InputFormat,它描述了一个MapReduce Job的输入,通过InputFormat,Hadoop可以:
l 检查MapReduce输入数据的正确性;
l 将输入数据切分为逻辑块InputSplit,这些块会分配给Mapper;
l 提供一个RecordReader实现,Mapper用该实现从InputSplit中读取输入的<K,V>对。
在org.apache.hadoop.mapreduce.lib.input中,Hadoop为所有基于文件的InputFormat提供了一个虚基类FileInputFormat。下面几个参数可以用于配置FileInputFormat:
l mapred.input.pathFilter.class:输入文件过滤器,通过过滤器的文件才会加入InputFormat;
l mapred.min.split.size:最小的划分大小;
l mapred.max.split.size:最大的划分大小;
l mapred.input.dir:输入路径,用逗号做分割。
类中比较重要的方法有:
protected
递归获取输入数据目录中的所有文件(包括文件信息),输入的job是系统运行的配置Configuration,包含了上面我们提到的参数。
public
将输入划分为InputSplit,包含两个循环,第一个循环处理所有的文件,对于每一个文件,根据输入的划分最大/最小值,循环得到文件上的划分。注意,划分不会跨越文件。
FileInputFormat没有实现InputFormat的createRecordReader方法。
FileInputFormat有两个子类,SequenceFileInputFormat是Hadoop定义的一种二进制形式存放的键/值文件(参考http://hadoop.apache.org/core/docs/current/api/org/apache/hadoop/io/SequenceFile.html),它有自己定义的文件布局。由于它有特殊的扩展名,所以SequenceFileInputFormat重载了listStatus,同时,它实现了createRecordReader,返回一个SequenceFileRecordReader对象。TextInputFormat处理的是文本文件,createRecordReader返回的是LineRecordReader的实例。这两个类都没有重载FileInputFormat的getSplits方法,那么,在他们对于的RecordReader中,必须考虑FileInputFormat对输入的划分方式。
FileInputFormat的getSplits,返回的是FileSplit。这是一个很简单的类,包含的属性(文件名,起始偏移量,划分的长度和可能的目标机器)已经足以说明这个类的功能。
RecordReader用于在划分中读取<Key,Value>对。RecordReader有五个虚方法,分别是:
l initialize:初始化,输入参数包括该Reader工作的数据划分InputSplit和Job的上下文context;
l nextKey:得到输入的下一个Key,如果数据划分已经没有新的记录,返回空;
l nextValue:得到Key对应的Value,必须在调用nextKey后调用;
l getProgress:得到现在的进度;
l close,来自java.io的Closeable接口,用于清理RecordReader。
我们以LineRecordReader为例,来分析RecordReader的构成。前面我们已经分析过FileInputFormat对文件的划分了,划分完的Split包括了文件名,起始偏移量,划分的长度。由于文件是文本文件,LineRecordReader的初始化方法initialize会创建一个基于行的读取对象LineReader(定义在org.apache.hadoop.util中,我们就不分析啦),然后跳过输入的最开始的部分(只在Split的起始偏移量不为0的情况下进行,这时最开始的部分可能是上一个Split的最后一行的一部分)。nextKey的处理很简单,它使用当前的偏移量作为Key,nextValue当然就是偏移量开始的那一行了(如果行很长,可能出现截断)。进度getProgress和close都很简单。
Hadoop源代码分析(包mapreduce.lib.map)
Hadoop的MapReduce框架中,Map动作通过Mapper类来抽象。一般来说,我们会实现自己特殊的Mapper,并注册到系统中,执行时,我们的Mapper会被MapReduce框架调用。Mapper类很简单,包括一个内部类和四个方法,静态结构图如下:
内部类Context继承自MapContext,并没有引入任何新的方法。
Mapper的四个方法是setup,map,cleanup和run。其中,setup和cleanup用于管理Mapper生命周期中的资源,setup在完成Mapper构造,即将开始执行map动作前调用,cleanup则在所有的map动作完成后被调用。方法map用于对一次输入的key/value对进行map动作。run方法执行了上面描述的过程,它调用setup,让后迭代所有的key/value对,进行map,最后调用cleanup。
org.apache.hadoop.mapreduce.lib.map中实现了Mapper的三个子类,分别是InverseMapper(将输入<key, value> map为输出<value, key>),MultithreadedMapper(多线程执行map方法)和TokenCounterMapper(对输入的value分解为token并计数)。其中最复杂的是MultithreadedMapper,我们就以它为例,来分析Mapper的实现。
MultithreadedMapper会启动多个线程执行另一个Mapper的map方法,它会启动mapred.map.multithreadedrunner.threads(配置项)个线程执行Mapper:mapred.map.multithreadedrunner.class(配置项)。MultithreadedMapper重写了基类Mapper的run方法,启动N个线程(对应的类为MapRunner)执行mapred.map.multithreadedrunner.class(我们称为目标Mapper)的run方法(就是说,目标Mapper的setup和cleanup会被执行多次)。目标Mapper共享同一份InputSplit,这就意味着,对InputSplit的数据读必须线程安全。为此,MultithreadedMapper引入了内部类SubMapRecordReader,SubMapRecordWriter,SubMapStatusReporter,分别继承自RecordReader,RecordWriter和StatusReporter,它们通过互斥访问MultithreadedMapper的Mapper.Context,实现了对同一份InputSplit的线程安全访问,为Mapper提供所需的Context。这些类的实现方法都很简单。
Hadoop源代码分析(包org.apache.hadoop.mapreduce)
有了前一节的分析,我们来看一下具体的接口,它们都处于包org.apache.hadoop.mapreduce中。
上面的图中,类可以分为4种。右上角的是从Writeable继承的,和Counter(还有CounterGroup和Counters,也在这个包中,并没有出现在上面的图里)和ID相关的类,它们保持MapReduce过程中需要的一些计数器和标识;中间大部分是和Context相关的*Context类,它为Mapper和Reducer提供了相关的上下文;关于Map和Reduce,对应的类是Mapper,Reducer和描述他们的Job(在Hadoop 中一次计算任务称之为一个job,下面的分析中,中文为“作业”,相应的task我们称为“任务”);图中其他类是配合Mapper和Reduce工作的一些辅助类。
如果你熟悉HTTPServlet, 那就能很轻松地理解Hadoop采用的结构,把整个Hadoop看作是容器,那么Mapper和Reduce就是容器里的组件,*Context保存了组件的一些配置信息,同时也是和容器通信的机制。
和ID相关的类我们就不再讨论了。我们先看JobContext,它位于*Context继承树的最上方,为Job提供一些只读的信息,如Job的ID,名称等。下面的信息是MapReduce过程中一些较关键的定制信息:
参数 | 作用 | 缺省值 | 其它实现 |
InputFormat | 将输入的数据集切割成小数据集 InputSplits, 每一个 InputSplit 将由一个 Mapper 负责处理。此外 InputFormat 中还提供一个 RecordReader 的实现, 将一个 InputSplit 解析成 <key,value> 对提供给 map 函数。 | TextInputFormat | SequenceFileInputFormat |
OutputFormat | 提供一个 RecordWriter 的实现,负责输出最终结果 | TextOutputFormat | SequenceFileOutputFormat |
OutputKeyClass | 输出的最终结果中 key 的类型 | LongWritable |
|
OutputValueClass | 输出的最终结果中 value 的类型 | Text |
|
MapperClass | Mapper 类,实现 map 函数,完成输入的 <key,value> 到中间结果的映射 | IdentityMapper | LongSumReducer, |
CombinerClass | 实现 combine 函数,将中间结果中的重复 key 做合并 | null |
|
ReducerClass | Reducer 类,实现 reduce 函数,对中间结果做合并,形成最终结果 | IdentityReducer | AccumulatingReducer, LongSumReducer |
InputPath | 设定 job 的输入目录, job 运行时会处理输入目录下的所有文件 | null |
|
OutputPath | 设定 job 的输出目录,job 的最终结果会写入输出目录下 | null |
|
MapOutputKeyClass | 设定 map 函数输出的中间结果中 key 的类型 | 如果用户没有设定的话,使用 OutputKeyClass |
|
MapOutputValueClass | 设定 map 函数输出的中间结果中 value 的类型 | 如果用户没有设定的话,使用 OutputValuesClass |
|
OutputKeyComparator | 对结果中的 key 进行排序时的使用的比较器 | WritableComparable |
|
PartitionerClass | 对中间结果的 key 排序后,用此 Partition 函数将其划分为R份,每份由一个 Reducer 负责处理。 | HashPartitioner | KeyFieldBasedPartitioner PipesPartitioner |
Job继承自JobContext,提供了一系列的set方法,用于设置Job的一些属性(Job更新属性,JobContext读属性),同时,Job还提供了一些对Job进行控制的方法,如下:
l mapProgress:map的进度(0—1.0);
l reduceProgress:reduce的进度(0—1.0);
l isComplete:作业是否已经完成;
l isSuccessful:作业是否成功;
l killJob:结束一个在运行中的作业;
l getTaskCompletionEvents:得到任务完成的应答(成功/失败);
l killTask:结束某一个任务;