Hadoop源码篇 --- 面试常问的Namenode元数据管理及双缓冲机制

3,087 阅读14分钟

前言

双缓冲机制讲解:juejin.cn/post/705372…

这两个关于NameNode的问题其实非常地经典,不仅有很多细节可询,而且也是面试的一个高频问题,所以特意独立出来一篇。元数据管理会结合源码来讲,而双缓冲虽然暂时没去翻源码,但是我们可以借由一个简单的实现去向大家好好地说明。后面也会对这段源码进行一些修改操作来让它更为高效。那话不多说咱们就开始吧

因为直接看源码大家可能接受不了,所以我们先来聊聊双缓冲机制。

一、Namenode的双缓冲机制

1.1 问题场景

Namenode里面的元数据是以两种状态进行存储的:

第一状态即是存储在内存里面,也就是刚刚所提到的目录树,它就是一个list,在内存里面更新元数据速度是很快的。但是如果仅仅只在内存里存放元数据,数据是不太安全的。

所以我们在磁盘上也会存储一份元数据,可是此时问题就出现了,我们需要把数据写进磁盘,这个性能肯定是不太好的呀。可NameNode作为整个集群的老大,在hadoop上进行hive,HBASE,spark,flink等计算,这些数据都会不停给NameNode施压写元数据,一天下来一亿条元数据都是可能的,所以NameNode的设计肯定是要支持超高并发的,可是写磁盘这操作是非常非常慢的,一秒几十或者最多几百都已经封顶了,那现在咋办?

1.2 元数据的双缓冲机制

首先我们的客户端(这里指的是hive,hbase,spark···都没关系)所产生的数据将会走两个流程,第一个流程会向内存处写入数据,这个过程非常快,也不难理解

这时候肯定就不能直接写内存了,毕竟我们是明知道这东西非常慢的,真的要等它一条条数据写到磁盘,那特么我们都可以双手离开鼠标键盘下班走人了。那NameNode一个东西不行就整个集群都不行了,那现在我们该如何解决?

双缓冲机制就是指我们将会开辟两份一模一样的内存空间,一个为bufCurrent,产生的数据会直接写入到这个bufCurrent,而另一个叫bufReady,在bufCurrent数据写入(其实这里可能不止一条数据,等下会说明)后,两片内存就会exchange(交换)。然后之前的bufCurrent就负责往磁盘上写入数据,之前的bufReady就继续接收客户端写入的数据。其实就是将向磁盘写数据的任务交给了后台去做。这个做法,在JUC里面也有用到

而且在此基础上,hadoop会给每一个元数据信息的修改赋予一个事务ID号,保证操作都是有序的。这也是出于数据的安全考虑。这样整个系统要求的内存会非常大,所以这关乎一个hadoop的优化问题,在之后将会提及。

1.3 双缓冲机制的代码实现

这个对理解起来其实十分有帮助,希望大家能跟着思路走

1.3.1 模拟一个元数据信息类

我们先设计一条元数据信息出来

public class EditLog{
	//事务的ID
	public long taxid;
	public String log;
	
	public EditLog(long taxid, String log) {
		this.taxid = taxid;
		this.log = log;
	}

	@Override
	public String toString() {
		return "EditLog [taxid=" + taxid + ", log=" + log + "]";
	}
	
}

1.3.2 双缓冲区

代码其实不难,分为5块

① 定义了两个缓冲区currentBuffer(有序队列)和syncBuffer

② 一个write方法负责写入元数据

③ 一个flush方法把元数据写入到磁盘上,这里我只用了一个打印语句模拟了一下写入磁盘,写入完成后清空syncBuffer的数据

④ 一个exchange方法来交换currentBuffer和syncBuffer

⑤ 还有一个getMaxTaxid方法获取到正在同步数据的内存里面事务ID的最大ID,这个方法的作用稍后说明

这5块除了最后的获取ID,应该大家都知道是干嘛用的了吧,那行,之后就会揭晓

public class DoubleBuffer{
	//写数据,有序队列
	LinkedList<EditLog> currentBuffer = new LinkedList<EditLog>();
	//用来把数据持久化到磁盘上面的内存
	LinkedList<EditLog> syncBuffer = new LinkedList<EditLog>();
	/**
	 * 写元数据信息
	 * @param editLog
	 */
	public void write(EditLog editLog){
		currentBuffer.add(editLog);
		
	}
	/**
	 * 把数据写到磁盘
	 */
	public void flush() {
		for(EditLog editLog:syncBuffer) {
			//模拟将数据写到磁盘
			System.out.println(editLog);	
		}
		syncBuffer.clear();
		
	}
	/**
	 * 交换currentBuffer和syncBuffer
	 */
	public void exchange() {
		LinkedList<EditLog> tmp=currentBuffer;
		currentBuffer=syncBuffer;
		syncBuffer=tmp;
	}
	/**
	 * 获取到正在同步数据的内存里面事务ID的最大ID
	 */
	public long getMaxTaxid() {
		return syncBuffer.getLast().taxid;
	}
	
}

1.3.3 写元数据日志的核心方法

那我现在要保证这个写操作(这里的写操作是客户端向bufCurrent写)的顺序,所以我们在这里会使用synchronized来加锁,然后通过taxid++顺序处理。然后new出一个元数据对象,把对象写进磁盘

long taxid=0L;//
DoubleBuffer doubleBuffer=new DoubleBuffer();
//每个线程自己拥有的副本
ThreadLocal<Long> threadLocal=new ThreadLocal<Long>();
private void logEdit(String log) {
	synchronized (this) {
		taxid++;
		
		// 让每个线程里面都拥有自己的事务ID号,作用后面会解释
		threadLocal.set(taxid);
		EditLog editLog=new EditLog(taxid,log);
		//往内存里面写东西
		doubleBuffer.write(editLog);
		
	} 
	// 此时锁释放
	
	//将数据持久化到硬盘的方法
	logFlush();
	
}

那有小伙伴就会有疑问了,都加了锁了这运行的性能能好?可是你要知道这把锁里面doubleBuffer.write(editLog)这是往内存里面写东西的呀。所以这是没有问题的,也能完美支持高并发

事先提及一下,这里将会用到分段加锁,比如此时我们有3个线程,线程1进来logEdit,执行完write之后,立刻锁就会被释放,然后线程2立刻又能紧随其后write,写完又到线程3。

因为写内存的速度是极快的,所以此时在还没轮到**logFlush()方法(将数据持久化到硬盘的方法)**执行,我们可能都已经都已经完成了3个数据往bufCurrent写入的操作。

温馨提示:此时这边的线程1将要进入到logFlush了,可是此时bufCurrent可能已经夹带了线程1,2,3的数据了,现在我先做个假设,线程1,2,3写入的元数据分别就是1,2,3,这句话非常重要!!!这句话非常重要!!!这句话非常重要!!!非常重要的事情说三遍,然后请看logFlush的解释

1.3.4 logFlush --- 将数据持久化到硬盘的方法

//判断此时后台正在把数据同步到磁盘上
public boolean isSyncRunning =false;
//正在同步磁盘的内存块里面最大的一个ID号。
long maxtaxid=0L;
boolean isWait=false;

private void logFlush() {
	synchronized(this) {
		if(isSyncRunning) {
			//获取当前线程的是事务ID
			long localTaxid=threadLocal.get();
			if(localTaxid <= maxtaxid) {
				return;
			}
			if(isWait) {
				return;
			}
			isWait=true;
			while(isSyncRunning) {
				try {
					//一直等待
					//wait这个操作是释放锁
					this.wait(1000);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				
			}
			isWait=false;
					
			//
		}
		//代码就走到这儿
		//syncBuffer(1,2,3)  
		doubleBuffer.exchange();
		//maxtaxid = 3
		if(doubleBuffer.syncBuffer.size() > 0) {
			maxtaxid=doubleBuffer.getMaxTaxid();
		}
		isSyncRunning=true;
		
	}
	//释放锁
	//把数据持久化到磁盘,比较耗费性能的。
	doubleBuffer.flush();
	//分段加锁
	synchronized (this) {
		//修改isSyncRunning的状态
		isSyncRunning=false;
		//唤醒wait;
		this.notifyAll();
		
	}
	

思路非常清晰,现在我们就来整理一下。

这个logFlush的方法的流程就是,我先使用一个boolean值 isSyncRunning 判断此时后台正在把数据同步到磁盘上,这个东西在客户端没有足够数据写进来之前一开始肯定是false的,但是如果写进bufCurrent的数据已经差不多了,那我就要把bufCurrent和syncBuffer交换,把 isSyncRunning 改成true。此时记录一下正在同步磁盘的内存块里面最大的一个ID号maxtaxid(后面需要使用)。然后让原本的bufCurrent往磁盘上写数据。 在写入完成后,isSyncRunning的值修改回false

如果现在第二个线程夹带着它的数据2进来了logFlush,此时写入磁盘的操作还没有执行完成,那它就会先获取当前线程的事务ID---localTaxid,如果当前的这个localTaxid小于我现在进行同步的事务ID的最大值(2<3),那就说明现在的这个线程2所夹带的数据我已经在上一个线程操作中了。那我就直接无视(如果不理解为啥无视,直接看下一段话)

这里就要使用到我刚刚说了三遍很重要的事情了,上一个线程1进来的时候,bufCurrent已经夹带了数据1,2,3,此时我的maxtaxid=3,线程2所夹带的2,已经是在处理中的数据了

但是如果localTaxid大于我现在进行同步的事务ID的最大值,但是此时又还有线程在同步元数据,那我就让它等wait,此时我这边一wait,那边客户端又可以继续往bufCurrent写入元数据了。这里代码的逻辑是等待1s后,又重新去查看是否有线程正在同步元数据。然后在我同步元数据的操作后面,添加上唤醒这个wait的操作,因为在这一瞬间我同步结束后,如果这个线程仍然在wait,那岂不是在白等了,所以我这边处理完了,立刻就唤醒它来继续同步,不浪费大家时间。

1.3.5 编写一个main方法跑跑看

public static void main(String[] args) {
	FSEdit fs=new FSEdit();
	for (int i = 0; i < 1000; i++) {
		new Thread(new Runnable() {
			@Override
			public void run() {
				for (int j = 0; j < 100; j++) {
					fs.logEdit("日志");
				}
				
			}
		}).start();
	}
}

我们随便上个10W条跑跑看,似乎是3到4秒就搞定了,而且生成的EditLog都是有序的

这个套路其实完全是模仿了hadoop的源码写了一个大概的,后面我们也会对这个地方的源码进行修改。但是也是非常地接近了。

在这里我也可以说明有哪些地方的不足,比如我们这样操作内存频繁地交换,那肯定是会对性能产生一定影响的,所以我们会在这块设置一个合理的大小再进行交换。

二、NameNode是如何管理元数据的

分析NameNode对元数据的管理这个问题我们的做法很简单,先通过命令创建一个目录,然后看HDFS的元数据是否随之发生了变化

hadoop fs -mkdir /user/soft

按照这个思路,那我们打开hadoop-src吧

2.1 通过Java代码先模拟创建目录的操作

现在通过一段Java代码来创建目录

public static void main(String[] args) throws IOException {
	Configuration configuration=new Configuration();
	FileSystem fileSystem=FileSystem.newInstance(configuration);
	//创建目录(分析的是元数据的管理流程)
	fileSystem.mkdirs(new Path(""));

	/**
	 * TODO 分析HDFS上传文件的流程
	 * TODO 做一些重要的初始化工作
	 */
	FSDataOutputStream fsous=fileSystem.create(new Path("/user.txt"));
	
	//TODO 完成上传文件的流程
	fsous.write("showMeYourDream".getBytes());
	
}

那我现在已经写好在这里了,接下来就开始分析

2.2 mkdirs的操作

点进去mkdirs方法,发现它跳转到了 FileSystem.java 的mkdirs

继续深入,仍旧是在 FileSystem.java ,你会发现咱们没得再点了,这时候我们只能让hadoop的开发者告诉我们了

仍旧是在 FileSystem.java ,此时我们把位置拉取到源码大约87行的位置,可以看到这么一段话,The local implementation is {@link LocalFileSystem} and distributed implementation is DistributedFileSystem.

粘贴到百度翻译,本地实现是{@link LocalFileSystem},并且是分布式的。实现是DistributedFileSystem 。

此时我们就懂了,这个mkdirs方法是被这个DistributedFileSystem(分布式文件系统)类给实现了,那我们就过去它那里去找找看吧

2.2.1 DistributedFileSystem的mkdirs

直接查找mkdirs方法,看到一个本地的mkdirs,继续点进去

看起来还是没有我们想要看的逻辑,我们还得继续点进去mkdirs

2.2.2 DFSClient的mkdirs

我们暂时不关心这些什么权限不权限的,我们只关心创建的逻辑,继续点primitiveMkdir

说过的有try就直接看try,看到这里我们就知道了,原来我们服务端的创建文件目录的操作其实就是调用了NameNode的mkdirs,而这里的NameNode肯定是一个我们服务端的代理对象,不信我们点一下namenode

DFSClient.java第261行左右,继续点击ClientProcotol

跳转到这里,然后我们直接复制它的注释到百度翻译

那它其实不就是我们的一个服务端代理对象吗

所以现在我们可以直接找出NameNode,好好看看它的mkdirs方法了

2.2.3 NameNode的mkdirs

前面的if直接无视,看到return namesystem.mkdirs那,点进去

这里的try里面会先进行一个安全模式的判断,如果集群处于安全模式的话是集群是只能支持读不能支持写的,然后没问题的话就会执行创建目录的步骤,这时候有一个问题,就是如果你创建了目录,是会产生相应的日志的,所以后面会有一步对日志的持久化操作。

继续点进去FSDirMkdirOp.mkdirs

代码很长,有一点小复杂,所以我们分步骤说明一下

2.3 FSDirectory和FSNameSystem的关系

首先是第一个关键词FSDirectory,这个是管理元数据的目录树(fsimage),元数据就是在NameNode的内存里面

而且这里还要注意,我们的元数据是有两份的,一份是在磁盘中的 fsimage + edit log,由FSNameSystem来进行管理,还有一份是内存里面的fsimage,由这个FSDirectory来管理

那这种东西我口说无凭啊,那我是怎么知道的,那当然还是hadoop的开发者告诉我的啦,点进去FSDirectory

如果我直接把原有的注释扔到百度翻译,结果是这样的

当然这里的记忆翻译是错的,翻译应该是内存。可是后面关于FSNamesystem的说明已经很直白了,就是它把我们的元数据记录信息是持久化到磁盘上面的。你看,这我不会骗你的

而这个FSDirectory的对象是如何管理一个目录的呢,我们要通过FSDirectory的代码说明

2.4 FSDirectory的结构及目录树的生成流程

这个rootDir就是根目录,我们点进去INodeDirectory

可以看到它最重要的属性children,这里有个细节需要注意,我们看到这个children是一个list,而这个list里面存放的是INode这个类型的数据

这个INode是什么东西?我们知道我们平时磁盘上的文件夹,文件夹里面既可以是文件夹,也可以是文件,那INode也是这么去设计的,如果它的子目录下面是文件夹,那就是用INodeDirectory来表示,而如果是文件,那就是INodeFile,明白是这么设计的就好了

回到FSDirMkdirOp中的mkdirs

这里的lastINode是个啥意思呢,比如我们现在已经存在的目录是/user/hive/warehouse,我们需要创建的目录是:/user/hive/warehouse/data/mytable,那我就是在data之前的那些目录都不需要自己再创建了呀。所以首先找到最后一个INode,其实就是warehouse(也就是lastINode)。 从这个INode开始创建INodeDirectory(data),再创建INodeDirectory(mytable)

之后就是这个逻辑,nonExisting就是指我们仍未创建的那部分INode,对应刚刚的例子就是/data/mytable。然后进行判断,如果length大于1(对于这个例子length就是大于1的,因为length指代的是要创建的Inode个数,例子中有data和mytable两个INode)

其实无论是创建1个还是多个,都是进去的同一个方法createChildrenDirectories,在这个方法里面使用了for循环,不管你是几个,我都用同一套逻辑,完成代码复用而已,那我们现在看到了,创建逻辑就是那么一句话createSingleDirectory而已,点进去

继续点unprotectedMkdir

之后就是创建出来一个名称叫做dir(/data/mytable)的目录,然后把这个dir添加到原本/user/hive/warehouse的末尾处

到现在为止,目录树fsimage就完成了更新。

流程总结

DistributedFileSystem实现了mkdirs方法,跳转到DFSClient,在DFSClient中看到了这个mkdirs其实是调用了Namenode的mkdirs,所以跳到Namenode的mkdirs,

fsimage其实就是一个目录树,我们的元数据是有两份的,一份是在磁盘中的 fsimage + edit log,由FSNameSystem来进行管理,还有一份是内存里面的fsimage,由这个FSDirectory来管理,还有就是目录树的每一个节点都为INode,这个INode有两种形态,INodeFIle代表文件,INodeDirectory代表文件夹,创建目录其实就是,先获取到集群原本已存在的目录的最后一个INode,我们称之为lastNode,然后通过一个for循环来将目录拼接到这个lastNode的末尾。

finally

字数算是较多,不过其实总得来说都不难理解。重要的位置基本都已经加粗了,也是希望对大家有所帮助。以后再被问到这俩问题,在明白这个套路的前提下,按照我们的总结过程简短地说出来即可。希望大家都能装的一手好B,hhh。