在对NameNode节点进行格式化时,调用了FSImage的saveFSImage()方法和FSEditLog.createEditLogFile()存储当前的元数据,启动NameNode节点时,又要从镜像和编辑日志中读取元数据。所以先分析FSImage是如何存储元数据到镜像文件和如何加载元数据到内存的。
在NameNode运行时会将内存中的元数据信息存储到所指定的文件,即${dfs.name.dir}/current目录下的fsimage文件,此外还会将另外一部分对NameNode更改的日志信息存储到${dfs.name.dir}/current目录下的edits文件中。fsimage文件和edits文件可以确定NameNode节点当前的状态,这样在NameNode节点由于突发原因崩溃时,可以根据这两个文件中的内容恢复到节点崩溃前的状态,所以对NameNode节点中内存元数据的每次修改都必须保存下来。但是如果每次都保存到fsimage文件中,这样效率就特别低效,所以引入编辑日志文件edits,保存对对元数据的修改信息,也就是fsimage文件保存NameNode节点中某一时刻内存中的元数据(即目录树),edits保存这一时刻之后的对元数据的更改信息。
NameNode节点通过方法FSImage.saveFSImage()方法保存内存元数据到fsimage文件中,方法的代码如下:
/** * 将当前时刻的命名空间镜像保存到newFile命名的文件中 */ void saveFSImage(File newFile) throws IOException { FSNamesystem fsNamesys = FSNamesystem.getFSNamesystem(); FSDirectory fsDir = fsNamesys.dir; long startTime = FSNamesystem.now(); // Write out data,创建输出流 DataOutputStream out = new DataOutputStream( new BufferedOutputStream( new FileOutputStream(newFile))); try { //保存命名空间镜像的文件头 out.writeInt(FSConstants.LAYOUT_VERSION); out.writeInt(namespaceID); out.writeLong(fsDir.rootDir.numItemsInTree());//目录树包含的节点数 out.writeLong(fsNamesys.getGenerationStamp());//当前数据块版本号 byte[] byteStore = new byte[4*FSConstants.MAX_PATH_LENGTH]; ByteBuffer strbuf = ByteBuffer.wrap(byteStore); // save the root saveINode2Image(strbuf, fsDir.rootDir, out);//单独保存根节点,由于根节点是NameNode管理的特殊INode,它的成员属性INode.name长度为0,所以必须做特殊处理 // save the rest of the nodes saveImage(strbuf, 0, fsDir.rootDir, out);//保存目录树中的其他节点 fsNamesys.saveFilesUnderConstruction(out);//保存构建中的节点 fsNamesys.saveSecretManagerState(out);//安全信息 strbuf = null; } finally { out.close(); } }
saveFSImage方法只有一个参数,这个参数代表内存元数据将要保存的位置。在saveFSImage方法中,先得到已经创建的FSNamesystem对象和FSDirectory(在调用FSImage.saveFSImage()方法之前,会创建FSNamesystem对象和FSDirectory对象),然后创建到文件参数newFile的一个输出流。再向文件中写入镜像文件头信息,如表示HDFS存储系统信息结构的版本号的FSConstants.LAYOUT_VERSION,存储系统标识namespaceID,目录树的节点数,文件系统的时间戳信息等。
在saveFSImage方法中,使用了FSConstants.MAX_PATH_LENGTH常量,它表示HDFS文件/目录的绝对路径所占用的最大空间,为8000字节,但是定义的缓存大小则是4*8000即31.25KB,为什么要使用8000*4呢?个人觉得这是为了兼容三种Unicode编码UTF-8/UTF-16/UTF-32,最大编码占用4个字节(参考http://hi.baidu.com/tinggu_android/item/77c2930ecb7811ca90571855),与次相关的另一个常量是FSConstants.MAX_PATH_DEPTH,它表示目录深度最多是1000层,这两个变量为何定义成这两个值,在https://issues.apache.org/jira/browse/HADOOP-438这里有讨论。定义完缓冲区的大小之后,再使用ButeBuffer类将其包装成一个ByteBuffer对象,这样对这个ByteBuffer对象的操作,可以在字节数组中表现出来,对字节数组的操作也可以在ByteBuffer对象中表现出来。
下面,调用saveINode2Image()方法来保存元数据所表示的目录树的根节点,因为根节点是NameNode管理的特殊INode,它的成员属性INode.name长度为0,所以必须做特殊处理,该刚发也会在saveImage()方法中调用,所以先来分析saveImage方法,代码如下:
private static void saveImage(ByteBuffer parentPrefix, int prefixLength, INodeDirectory current, DataOutputStream out) throws IOException { int newPrefixLength = prefixLength; if (current.getChildrenRaw() == null) return;//空目录 for(INode child : current.getChildren()) {//输出当前节点的所有子节点 // print all children first parentPrefix.position(prefixLength); parentPrefix.put(PATH_SEPARATOR).put(child.getLocalNameBytes()); saveINode2Image(parentPrefix, child, out); } for(INode child : current.getChildren()) {//子节点是目录,输出该目录下面的节点 if(!child.isDirectory()) continue;//文件,忽略 parentPrefix.position(prefixLength); //准别参数 parentPrefix.put(PATH_SEPARATOR).put(child.getLocalNameBytes()); newPrefixLength = parentPrefix.position(); //递归调用 saveImage(parentPrefix, newPrefixLength, (INodeDirectory)child, out); } parentPrefix.position(prefixLength); }
saveImage方法有四个参数,第一个参数parentPrefix是一个字节缓冲区,存储着当前目录要保存的目录的父目录路径,第二个参数是父路径的长度,即路径在字节缓冲区占内存空间的大小,第三个参数是当前的目录,第四个参数是镜像数据的输出流。如果当前的目录是/current目录,那么parentPrefix中的内容就是目录/current的字节表示。在saveImage方法中首先记录下当前的parentPrefix路径长度,因为saveImage方法是递归调用的,整个过程中都需要使用同一个缓冲区,所以每次保存一个目录时都需要保存当前的prefixLength变量。再判断当前目录下是否存在文件或目录,不存在就直接返回,如果存在,遍历这个目录下面的所有子节点,分别调用saveINode2Image方法进行输出。如何输出子节点呢?首先记录下子节点的路径,再调用saveINode2Image。ByteBuffer.position()方法用于设置ByteBuffer类的position变量,表示从下次对缓冲区的读写从position开始进行,然后向缓冲中加入路径分隔符和当前要输出的节点名(参考http://ifeve.com/buffers/)。再调用saveINode2Image方法输出节点。saveImage方法输出完所有的节点之后,会再次遍历当前目录下的节点,对目录子节点进行递归的输出,同样是要先设置缓冲区的position变量,再加入子目录的目录名,然后递归调用。方法的最后是恢复表示路径的字节缓冲区,方便与current目录在同一个目录下的目录进行输出。
FSImage.saveINode2Image()方法的功能就是保存一个节点数据到镜像文件中,方法的代码如下:
private static void saveINode2Image(ByteBuffer name, INode node, DataOutputStream out) throws IOException { int nameLen = name.position(); out.writeShort(nameLen); out.write(name.array(), name.arrayOffset(), nameLen); if (!node.isDirectory()) { // write file inode,输出文件的INode INodeFile fileINode = (INodeFile)node; out.writeShort(fileINode.getReplication()); out.writeLong(fileINode.getModificationTime()); out.writeLong(fileINode.getAccessTime()); out.writeLong(fileINode.getPreferredBlockSize()); Block[] blocks = fileINode.getBlocks(); out.writeInt(blocks.length); for (Block blk : blocks) blk.write(out); FILE_PERM.fromShort(fileINode.getFsPermissionShort()); PermissionStatus.write(out, fileINode.getUserName(), fileINode.getGroupName(), FILE_PERM); } else { // write directory inode,输出目录的INode out.writeShort(0); // replication out.writeLong(node.getModificationTime()); out.writeLong(0); // access time out.writeLong(0); // preferred block size out.writeInt(-1); // # of blocks,数据块数,-1代表这是目录 out.writeLong(node.getNsQuota()); out.writeLong(node.getDsQuota()); FILE_PERM.fromShort(node.getFsPermissionShort()); PermissionStatus.write(out, node.getUserName(), node.getGroupName(), FILE_PERM); } }
输出完内存中的元数据之后,再返回到saveFSImage()方法中,再调用FSNamesystem.saveFilesUnderConstruction()方法保存当前系统中以写方式打开的文件,即当前系统中存在的INodeFileUnderConstruction类型的对象。最后会保存一些安全信息。这部分的代码暂时还为研究,以后再来分析。
编辑日志用于保存对某一时刻的镜像文件之后的元数据的修改信息,镜像文件并不能完全反映NameNode节点中真实的数据,因为在每个时刻将NameNode节点的内存修改信息保追加到同一个镜像文件中是非常低效的,所以HDFS的开发者引入了编辑日志,来保存对NameNode节点内存元数据的修改。与编辑日志相关的代码在FSEditLog类,EditLogOutputStream类中。
编辑日志文件在NameNode节点格式化的时候会创建,在文章Hadoop源码分析之NameNode的格式化 中分析NameNode格式化的过程中,分析到saveCurrent方法,在saveCurrent方法中,会针对保存编辑日志类型的目录调用FSEditLog.createEditLogFile()方法创建一个编辑日志文件,该方法的代码如下:
public synchronized void createEditLogFile(File name) throws IOException { EditLogOutputStream eStream = new EditLogFileOutputStream(name); eStream.create(); eStream.close(); }
先来看看EditLogFileOutputStream类的定义,代码如下:
static class EditLogFileOutputStream extends EditLogOutputStream { /** Preallocation buffer, padded with OP_INVALID */ private static final ByteBuffer PREALLOCATION_BUFFER = ByteBuffer.allocateDirect(MIN_PREALLOCATION_LENGTH); static { PREALLOCATION_BUFFER.position(0).limit(MIN_PREALLOCATION_LENGTH); for(int i = 0; i < PREALLOCATION_BUFFER.capacity(); i++) { PREALLOCATION_BUFFER.put(OP_INVALID); } } /**输出文件对应的文件通道**/ private File file; /**日志写入缓冲区**/ private FileOutputStream fp; // file stream for storing edit logs /**日志输出通道**/ private FileChannel fc; // channel of the file stream for sync /**日志写入缓冲区**/ private DataOutputBuffer bufCurrent; // current buffer for writing /**写文件缓冲区**/ private DataOutputBuffer bufReady; // buffer ready for flushing EditLogFileOutputStream(File name) throws IOException { super(); file = name; bufCurrent = new DataOutputBuffer(sizeFlushBuffer); bufReady = new DataOutputBuffer(sizeFlushBuffer); RandomAccessFile rp = new RandomAccessFile(name, "rw"); fp = new FileOutputStream(rp.getFD()); // open for append fc = rp.getChannel(); fc.position(fc.size()); }
再回到FSEditLog.createEditLogFile()方法,创建EditLogFileOutputStream对象后,再调用其create()方法,只有调用了create方法之后才能向这个日志输出文件写数据。create方法的代码如下:
/** * 通过日志输出文件对应的文件通道fc,将文件清空,并向缓冲区bufCurrent写入版本号 */ @Override void create() throws IOException { fc.truncate(0); fc.position(0); bufCurrent.writeInt(FSConstants.LAYOUT_VERSION);//保存日志格式的版本号 setReadyToFlush();//交换bufCurrent和bufReady flush();//将bufReady缓冲区中的数据写出到文件 }
create方法先将日志输出文件清空,再将当前的position设置为文件的开始处,然后向文件中写入HDFS系统的版本号,再调用setReadyToflush()方法交换bufCurrent和bufReady缓冲区,最后输出。
setReadyToFlush()方法主要是交换bufCurrent缓冲区和bufReady缓冲区,为写入数据到日志文件中做准备,方法的代码如下:
void setReadyToFlush() throws IOException { assert bufReady.size() == 0 : "previous data is not flushed yet"; DataOutputBuffer tmp = bufReady; bufReady = bufCurrent; bufCurrent = tmp; }
再来分析flush方法,这个方法的作用就是将数据刷出到文件中,方法的代码如下:
public void flush() throws IOException { numSync++; long start = FSNamesystem.now();//记录统计信息 flushAndSync(); long end = FSNamesystem.now(); totalTimeSync += (end - start); }
/ ** 输出内存缓冲区中的日志数据并持久化到磁盘 */ @Override protected void flushAndSync() throws IOException { preallocate(); // preallocate file if necessary bufReady.writeTo(fp); // write data to file bufReady.reset(); // erase all data in the buffer fc.force(false); // metadata updates not needed because of preallocation }
preallocate()方法很有意思,它会为文件预先分配一定的数据块,方法的代码如下:
private void preallocate() throws IOException { long size = fc.size(); int bufSize = bufReady.getLength(); long need = bufSize - (size - fc.position()); if (need <= 0) { return; } long oldSize = size; long total = 0; long fillCapacity = PREALLOCATION_BUFFER.capacity(); while (need > 0) { PREALLOCATION_BUFFER.position(0); do { size += fc.write(PREALLOCATION_BUFFER, size); } while (PREALLOCATION_BUFFER.remaining() > 0); need -= fillCapacity; total += fillCapacity; } if(FSNamesystem.LOG.isDebugEnabled()) { FSNamesystem.LOG.debug("Preallocated " + total + " bytes at the end of " + "the edit log (offset " + oldSize + ")"); } }
分析完了如何将编辑日志写入文件,那么在修改了NameNode节点的元数据之后,是如何创建EditLogFileOutputStream对象,进而调用响应的方法将日志写入文件呢?HDFS使用了FSEditLog类。在这个类中,定义了大量以log开头的方法,如logOpenFile方法表示打开一个文件的日志记录,logMkDir表示创建一个目点的日志等,每种log方法都需要记录相应的信息,如路径信息,最后访问时间,最后修改时间等。在这些以log开头的方法中有两个比较特殊,分别是logEdit和logSync方法,对NameNode节点元数据进行修改的log方法都需要调用logEdit方法将日志信息写入到bufCurrent缓冲区,日志文件的写入是通过调用logSync方法实现的。先来看看方法logEdit,方法代码如下:
synchronized void logEdit(byte op, Writable ... writables) { if (getNumEditStreams() < 1) { throw new AssertionError("No edit streams to log to"); } long start = FSNamesystem.now(); for (int idx = 0; idx < editStreams.size(); idx++) { EditLogOutputStream eStream = editStreams.get(idx); try { eStream.write(op, writables); } catch (IOException ioe) { removeEditsAndStorageDir(idx); idx--; } } exitIfNoStreams(); // get a new transactionId txid++;//获取一个新的irzhi交易标识并记录 // // record the transactionId when new data was written to the edits log // TransactionId id = myTransactionId.get(); id.txid = txid; // update statistics,记录一些统计信息 long end = FSNamesystem.now(); numTransactions++; totalTimeTransactions += (end-start); if (metrics != null) // Metrics is non-null only when used inside name node metrics.addTransaction(end-start); }
logEdit方法先遍历输出流集合,将要日志数据保存在bufCurrent中,再给当前线程分配一个事务ID(在《Hadoop技术内幕:深入理解Hadoop Common和HDFS架构设计与实现原理》称TransactionId为交易标识,但是个人觉得这里仿照关系数据块中译为事务更合适些)。并记录统计信息。
每个线程都有个事务ID,这个事务ID标识用于同步线程间对FSEditLog对象的调用,具体这个事务ID如何起作用,在logSync方法中分析。当调用logEdit方法后,数据写入到了EditLogFileOutputStream对象的bufCurrent缓冲区中,那么在何时将数据刷出到文件中呢?下面来分析logSync方法,代码如下:
/** * Sync all modifications done by this thread.<br/> * 同步日志修改 */ public void logSync() throws IOException { ArrayList<EditLogOutputStream> errorStreams = null; long syncStart = 0; // Fetch the transactionId of this thread. long mytxid = myTransactionId.get().txid; ArrayList<EditLogOutputStream> streams = new ArrayList<EditLogOutputStream>(); boolean sync = false; try { synchronized (this) { printStatistics(false); // if somebody is already syncing, then wait,有其他线程在执行日志同步操作 while (mytxid > synctxid && isSyncRunning) { try { wait(1000); } catch (InterruptedException ie) { } } // // If this transaction was already flushed, then nothing to do,日志已经被其他线程同步 // if (mytxid <= synctxid) { numTransactionsBatchedInSync++; if (metrics != null) // Metrics is non-null only when used inside name node metrics.incrTransactionsBatchedInSync(); return; } // now, this thread will do the sync,同步由当前线程执行,记录相关信息 syncStart = txid; isSyncRunning = true; sync = true; // swap buffers,交换缓冲区 exitIfNoStreams(); for(EditLogOutputStream eStream : editStreams) { try { eStream.setReadyToFlush(); streams.add(eStream); } catch (IOException ie) { LOG.error("Unable to get ready to flush.", ie); // // remember the streams that encountered an error. // if (errorStreams == null) { errorStreams = new ArrayList<EditLogOutputStream>(1); } errorStreams.add(eStream); } } } // do the sync,执行日志同步 long start = FSNamesystem.now(); for (EditLogOutputStream eStream : streams) { try { eStream.flush(); } catch (IOException ie) { LOG.error("Unable to sync edit log.", ie); // // remember the streams that encountered an error. // if (errorStreams == null) { errorStreams = new ArrayList<EditLogOutputStream>(1); } errorStreams.add(eStream); } } long elapsed = FSNamesystem.now() - start; removeEditsStreamsAndStorageDirs(errorStreams); exitIfNoStreams(); if (metrics != null) // Metrics is non-null only when used inside name node metrics.addSync(elapsed); } finally { synchronized (this) { if(sync) { synctxid = syncStart; isSyncRunning = false; } this.notifyAll(); } } }
logSync方法可以分为四个部分,第一个部分是等待其他同步线程执行完毕,直到当前线程可以执行;第二个部分是,判断当前的线程是否还需要执行,如果当前线程的事务id小于FSEditLog对象记录的事务id,那么当前线程就没有执行的必要了,第三个部分是遍历日志输出流集合,将正常的日志输出流加入到集合streams中,第四个部分是将streams集合中所有的日志输出流中的日志数据刷出到对应文件中。下面分别对这四个部分进行分析。
第一个部分是等待其他同步线程执行完毕,为什么要等待呢?在NameNode节点运行过程中,可能有多个线程在执行,这多个线程中可能都持有FSEditLog对象的引用,那么就需要对这些线程进行同步,在一个线程调用FSEditLog的同步方法的过程中,其他线程不能调用这些同步方法。logSync方法使用synctxid变量记录当前正在使用logSync方法同步块的线程事务ID,使用isSyncRunning布尔变量记录logSync方法的同步块正在被使用,这样如果本线程的事务ID(mytxid)大于synctxid,且isSyncRunning为true,那么就这个线程就进入等待,将计算资源让给其他线程执行。
第二个部分是判断当前线程是否还需要执行,如果当前线程的事务id小于FSEditLog对象记录的事务id,那么当前线程就没有执行的必要了。为什么这么说?每个线程将日志数据刷出到文件都要执行logEdit方法和logSync方法,假设有一个线程执行了logEdit方法后由于线程切换等原因,计算资源被其他线程占用,而此时线程2连续执行了logEdit方法和logSync方法,线程1执行了完了logEdit方法,说明线程1的日志数据已经存放在了EditLogFileOutputStream对象的bufCurrent缓冲区中,那么此时线程2继续执行logSync方法,假设线程1的事务ID是N,线程2的事务ID是2,将isSyncRunning变量设置为true,其他变量都不能执行logSync方法的第一个synchronized块,线程2将FSEditLog对象的集合editStreams中的数据全部刷出到文件,并且在方法的最后给synctxid变量赋值为线程2的事务ID,那么此时FSEditLog对象的变量synctxid就记录了线程2的事务ID,线程1的事务ID为N就比synctxid变量的值小,说明此时线程2已经将所有执行了logEdit方法的线程的日志输出流刷出到了文件中,这样线程1就不需要再执行logSync方法进行刷出数据了,线程1就返回。
第三部分是遍历日志输出流集合,将正常的日志输出流加入到集合streams中。为什么多要定义streams,直接使用FSEditLog对象的editStreams集合不行吗?因为第一部分,第二部分,第三部分都是在一个synchronized代码块中执行的,并且同步的是FSEditLog这个对像,且logEdit整个方法是一个同步方法,如果使用editStreams执行数据刷出,则同一时间只有一个线程能使用editStreams集合,那么效率肯定相当低效,多定义一个集合streams临时变量,用于刷出数据,这样效率会高很多,比如一个线程在执行logEdit方法时,另一个线程可执行logSync的非同步代码块将streams中的数据刷出。
第四部分就是将streams的数据刷出到文件中,即调用了EditLogOutputSteam的flush方法,这个方法上面已经分析了。
在方法的最后有一个同步代码块,给synctxid赋值当前线程的事务ID,并且将变量isSyncRunning设置为false。
以上的过程就是NameNode节点的元数据的存储过程。
http://ifeve.com/buffers/
http://hi.baidu.com/tinggu_android/item/77c2930ecb7811ca90571855
https://issues.apache.org/jira/browse/HADOOP-438
《Hadoop技术内幕:深入理解Hadoop Common和HDFS架构设计与实现原理》
/**无效/结束**/ static final byte OP_INVALID = -1; /**创建文件**/ private static final byte OP_ADD = 0; /**改名**/ private static final byte OP_RENAME = 1; // rename /**删除**/ private static final byte OP_DELETE = 2; // delete /**创建目录**/ private static final byte OP_MKDIR = 3; // create directory /**设置文件副本数**/ private static final byte OP_SET_REPLICATION = 4; // set replication //the following two are used only for backward compatibility : /**添加节点,已经废弃**/ @Deprecated private static final byte OP_DATANODE_ADD = 5; /**移除数据节点**/ @Deprecated private static final byte OP_DATANODE_REMOVE = 6; /**设置权限**/ private static final byte OP_SET_PERMISSIONS = 7; /**设置文件主**/ private static final byte OP_SET_OWNER = 8; /**关闭文件**/ private static final byte OP_CLOSE = 9; // close after write /**设置数据块版本号**/ private static final byte OP_SET_GENSTAMP = 10; // store genstamp /* The following two are not used any more. Should be removed once * LAST_UPGRADABLE_LAYOUT_VERSION is -17 or newer. */ /**设置节点配额**/ private static final byte OP_SET_NS_QUOTA = 11; // set namespace quota /**清除节点配额**/ private static final byte OP_CLEAR_NS_QUOTA = 12; // clear namespace quota /**设置访问或修改时间**/ private static final byte OP_TIMES = 13; // sets mod & access time on a file /**设置配额**/ private static final byte OP_SET_QUOTA = 14; // sets name and disk quotas. /**连接文件**/ private static final byte OP_CONCAT_DELETE = 16; // concat files. /**新授权令牌**/ private static final byte OP_GET_DELEGATION_TOKEN = 18; //new delegation token /**更新授权令牌**/ private static final byte OP_RENEW_DELEGATION_TOKEN = 19; //renew delegation token /**取消授权令牌**/ private static final byte OP_CANCEL_DELEGATION_TOKEN = 20; //cancel delegation token /**更新主密钥**/ private static final byte OP_UPDATE_MASTER_KEY = 21; //update master key