HDFS的架构演变
HDFS(Hadoop Distributed File System)的架构演变其实就是Hadoop的更新迭代的过程,目前Hadoop有Hadoop1、Hadoop2、Hadoop3三个版本,对应的就有HDFS1,HDFS2,HDFS3。
HDFS1
HDFS1是一个主从架构,主节点只有一个叫NameNode,从节点可以由多个叫DataNode。
NameNode的职责:
- 管理元数据信息(文件目录树):文件与Block块,Block块与DataNode主机的关系
- NameNode为了快速响应用户的操作请求,所以把元数据加载到了内存里面
DataNode的职责:
- 存储数据,把上传的数据划分成为固定大小的文件块(HDFS1默认为64M)
- 为了保证数据安全,每个文件块默认都有三个副本
HDFS1的架构缺陷
- 单点故障问题(最主要的缺陷)
因为只有单NameNode,如果NameNode挂了,系统瘫痪。 - 内存受限问题
NameNode为了快速响应用户的请求,会把元数据存放在内存中,压迫其他进程。
HDFS2
为了解决第一个问题,HDFS2提出了HA高可用搭建方式:
如果使用多NameNode的话,有三个问题需要解决:
- 元数据如何同步
- 如何判断NameNode的主备
- 如何切换主备
为了解决第一个问题,需要搭建一个JournalNode集群,他会从主NameNode中获取元数据,再同步给备NameNode。
为了解决第二个问题,需要搭建一个ZooKeePer集群,他会促使NameNode向ZK的锁中写信息,先写进去的为主NameNode。
为了解决第三个问题,ZooKeePer集群的ZKFC进程会实时监听NameNode的存活情况,如果主NameNode死亡,那么备NameNode会成为主NameNode。
虽然有了一个JournalNode集群,但是元数据受内存的限制并没有完美解决,所有又有了另一种搭建模式:联邦搭建模式
联邦模式简单来说就是把NameNode的内存拿出来组合成一个跨越物理限制的内存集合。
简单来说就是NameNode拿到元数据存放在所有NameNode的内存中,而所有的NameNode共享所有的元数据和所有的DataNode(本质上已经没有主备之分,所有的NameNode都是主节点也都是备节点)。
联邦模式算是解决了内存受限问题,同时也间接解决了NameNode宕机问题,而且NameNode也可以构建HA高可用,将两个模式集成起来(联邦模式适合一千个节点以上的集群)。
HDFS3
HDFS3比较HDFS2做出的升级是:
- HA高可用多个NameNode
- 纠删码技术
HDFS支撑亿级流量的核心源码设计
分段加锁和双缓冲方案
HDFS写元数据的时候,最终元数据会写到磁盘上的EditLog中,但是直接和磁盘进行交互的话会导致性能降低,无法应对高并发场景。所以提出了双缓冲方案:
用户向CurrentBuffer写入数据到一定地步,触发阈值后SyncBuffer内存和CurrentBuffer进行交换,CurrentBuffer内存清空,SyncBuffer开始向磁盘写入日志。
package com.zhenghe.zhenghetohive;
import java.util.LinkedList;
public class FSEditLog {
private long txid=0L;
private final DoubleBuffer editLogBuffer= new DoubleBuffer();
//是否正在刷写磁盘
private volatile Boolean isSyncRunning = false;
private volatile Boolean isWaitSync = false;
private volatile Long syncMaxTxid = 0L;
//每个线程都对应自己的一个副本
private final ThreadLocal<Long> localTxid= new ThreadLocal<>();
// main方法,模拟一个高并发请求,调用logEdit方法
public static void main(String[] args) {
final FSEditLog fsEditLog = new FSEditLog();
for (int i = 0; i < 50; i++) {
new Thread(()-> {
for (int j = 0; j < 1000; j++) {
fsEditLog.logEdit("日志信息");
}
}
).start();
}
}
// 写日志,main方法进来一个高并发请求
public void logEdit(String content){
synchronized (this){//加锁的目的就是为了事务ID的唯一,而且是递增
// 创建一个日志编号
txid ++;
// 线程1的第一条日志的编号就是1
localTxid.set(txid);
// 创建一个日志对象
EditLog log = new EditLog(txid, content);
// 将数据写入当前内存 editLogBuffer是双缓冲对象
editLogBuffer.write(log);
}
// 释放锁, 其他线程上锁,开始循环
logSync();
}
private void logSync(){
// 加锁
synchronized(this){
// 判断 SyncRunning 是否在刷写磁盘
if(isSyncRunning){
// 如果现在正在刷写磁盘,先查看当前线程的日志的编号
long txid = localTxid.get();
// 如果当前日志编号小于正在刷写磁盘的最大日志编号的话,退出
if(txid <= syncMaxTxid){
return;
}
// 判断是否在等待刷写磁盘 加入已经有线程在等待刷写,其他线程就不必在等待
if(isWaitSync){
return;
}
// 修改isWaitSync 状态
isWaitSync = true;
// 判断是否有人在刷写磁盘
while(isSyncRunning){
try{
// 释放锁
// 1)被唤醒
// 2)到时间
wait(2000);
}catch (Exception e){
e.printStackTrace();
}
}
isWaitSync = false;
}
// 没有刷写磁盘的话,直接交换内存 (内存里面数据达到阈值,交换内存)
editLogBuffer.setReadyToSync();
// 内存交换
if(editLogBuffer.syncBuffer.size() > 0) {
syncMaxTxid = editLogBuffer.getSyncMaxTxid();
System.out.println(syncMaxTxid);
}
// 修改状态 释放锁
isSyncRunning = true;
}
// 内存中的数据写入磁盘 写磁盘操作没有加锁 分段加锁
editLogBuffer.flush();
synchronized (this) {
// 修改状态
isSyncRunning = false;
// 唤醒wait
notify();
}
}
/**
* 使用了面向对象的思想,把一条日志看成一个对象
*/
static class EditLog{
//顺序递增
long txid;
//操作内容 mkdir /a
String content;
//构造函数
public EditLog(long txid,String content){
this.txid = txid;
this.content = content;
}
//为了测试方便
@Override
public String toString() {
return "EditLog{" +
"txid=" + txid +
", content='" + content + '\'' +
'}';
}
}
/**
* 双缓存方案
*/
static class DoubleBuffer{
//内存1
LinkedList<EditLog> currentBuffer = new LinkedList<>();
//内存2
LinkedList<EditLog> syncBuffer= new LinkedList<>();
// 向内存1中写入日志
public void write(EditLog log){
currentBuffer.add(log);
}
// 内存1和内存2交换
public void setReadyToSync(){
LinkedList<EditLog> tmp= currentBuffer;
currentBuffer = syncBuffer;
syncBuffer = tmp;
}
// 获取当前刷磁盘的内存里的ID最大值
public Long getSyncMaxTxid(){
return syncBuffer.getLast().txid;
}
// 刷写磁盘
public void flush(){
for(EditLog log:syncBuffer){
//把数据写到磁盘上
System.out.println("存入磁盘日志信息:"+log);
}
//把内存2里面的数据要清空
syncBuffer.clear();
}
}
}