HDFS是Hadoop为了储存海量数据而使用的一种分布式文件系统。这种文件系统是运作于多个机器之上的。
HDFS为了保证数据储存的可靠和读取性能,会把保存的数据进行切块后进行复制并且储存在集群的多个节点中。
HDFS存在名字节点NameNode和数据节点DataNode:
- NameNode:储存元数据信息,也就是具体文件,block,datanode之间的映射关系。数据保存在内存和磁盘中。这是HDFS最重要的节点,通过NameNode才能读取文件。
- DataNode:储存block内容,维护block id到文件的映射关系,这样可以加快读取文件时的效率。数据储存在磁盘中。
HDFS优点:
- 支持储存超级大文件,可以支持储存PB级别大小的文件(如此大文件很难储存在一块硬盘,所以普通的文件系统根本储存不了),对于企业级别的应用,数据节点可能有上千个。
- 可以检测和快速应对硬件故障。这个优点特别重要,多节点集群有很高的故障率。所以HDFS对于故障检测和恢复的支持是比较好的。
- 流式数据访问,由于HDFS处理的数据比较大,所以HDFS以流来访问数据集,数据的吞吐量较大。
- 简化的一致性模型,HDFS操作文件时,一般是一次写入,多次读取。文件一旦创建,几乎很少修改。修改的效率非常低。
- 高容错,数据会被保存多个副本,副本丢失允许恢复。
- 可以通过扩展廉价机数量来提高效率。
缺点
- Hadoop以数据访问的延时为代价换取来换取吞吐量。所以不适合低延迟应用。
- HDFS不适合储存大量的小文件。大量小文件会使得NameNode非常大,小内存NameNode甚至不可以保存过多的文件。
- 不支持修改和追加写入(2.x支持追加)
- 对事务的支持不像关系型数据库强大。
HDFS 基本概念
Block
Block,数据块。HDFS一般储存超大规模的文件,HDFS一般按照一定标准把文件拆分成多块(block),对于1.x版本,一个Block大小为63M,2.x为128M。
Block可以保存在不同的磁盘上。HDFS在管理文件的时候实际上就是在管理block的分布。
在HDFS中,一个block通常会被复制3份。
如果一个文件的长度小于block的大小,那么这个文件并不会占用整个Block的空间大小。
NameNode
NameNode的作用是维护HDFS的元数据,包括文件是什么,文件被分成了几个Block,Block被储存在了那里。
NameNode将元数据储存在内存和磁盘中。内存储存的是实时数据,磁盘为数据的映像作为持久化储存(方便NameNode宕机时的重启)。
磁盘中存储3种文件:
- fsimage: 元数据的镜像文件。储存某NameNode元数据的信息。但不是实时同步内存的数据。
- edits: 操作日志文件,这个日志是实时的。
- fstime: 保存最近一次checkpoint时间。
当有写请求的时候,NameNode会首先把写操作的日志写到edits中,成功后修改内存,并向客户端返回结果(并不会马上改变fsImage)
根据edits的日志真正操作fsImage更新磁盘数据会在一定特定条件下发生。这种操作被叫做“合并”,也叫checkpoint,在合并发生后,合并时间会被记录在fstime中。
合并的操作并不是由NameNode完成的(NameNode压力不能太大),是由SecondaryNameNode完成的。
SecondaryNameNode并不是NameNode的热备份,而是专门协助NameNode进行元数据合并的(fsimage和edits文件),同时,它能提供冷备份,因为它也保存了部分NameNode数据。但是在极端的情况下,NameNode如果挂掉,SecondaryNameNode也许不能提供完整的数据恢复。
我们可以控制SecondaryNameNode的合并间隔,可以根据fs.checkpoint.period来设置,默认是3600秒。
配置项fs.checkpoint.size规定了edits文件的最大大小,当达到或者超过这个大小,也会进行一次合并(即使period没有到)。
在创建文件的时候,整个流程如下(NN&SNN架构):
- NameNode受到请求,会先检查edits的大小和有没有到checkpoint。如果edits太大或者已经到了checkpoint,会创建一个新的edits.new文件并将请求写入。
- NameNode会把原来的edits文件和fsimage文件通过HTTP GET发送给SecondaryNameNode。
- SecondaryNameNode根据edits和fsimage进行合并,将合并后的fsimage发送给NameNode。
- NameNode使用接收到的新的fsimage替换原来的fsimage,并将新的edits.new文件替换掉原来的。
这样做的好处是,在SecondaryNameNode进行合并操作的期间,新的请求会被写到edits.new文件中。合并完成之后edits.new替换原来的edits。这样一来合并对于用户来说就是透明的了,合并操作不会带来阻塞。
合并的另外一个好处是,SecondaryNameNode从一定程度上能够得到NameNode的fsimage和edits文件,这样在NameNode挂掉之后,SecondaryNameNode能做一些恢复(但是在合并期间的请求会丢失)。这就是所谓的“冷备份”。冷备份的恢复时间会比热备份要高得多。
考虑到效率和安全,NameNode和SecondaryNameNode不能在同一台机器。
DataNode
在Hadoop集群中,DataNode是负责储存数据的。数据以Block的形式储存在DataNode中。
DataNode会不断地向NameNode发送心跳报告,告知NameNode自己是存活的。
初始化时,每个DataNode会将当前储存的Block告知NameNode。随后DataNode就会使用心跳报告机制和NameNode保持联系(一般是3秒一次)。
DateNode也保存了一份元数据信息,在之后的工作中,它会不断接收来自NameNode的指令,从而更新元数据信息并且由指令对Block进行读取,创建,移动或删除。
如果NameNode10分钟没有受到来自DataNode的心跳,就认为这个DataNode挂掉了,并且将数据的Block复制到其它DataNode上。
所有的Block都会默认保存3份副本(真实集群),副本会被保存到不同的DataNode上。这样即使某个DataNode挂了,数据也不会丢失。
放置副本的策略(在机架策略打开的情况下):
- 第一个副本:如果上传文件的服务器是DataNode,就直接放置在上传文件的服务器上。如果是外部的客户端上传的文件,会随机选择一个DataNode(磁盘不忙,CPU占用率不高的机器)
- 第二个副本:放置在第一个副本不同的机架上。
- 第三个副本:放置在第二个副本相同的机架上(机架内通信速度比跨机架快)。
- 更多副本:随机选择节点。
那么如何知道两个机器是不是在一个机架上呢?这就需要用到机架感知策略了。这里不再详细介绍,有兴趣可以查阅资料。
HDFS 指令
在Linux下可以使用Hadoop指令操作HDFS,这些命令和Linux的bash命令很相似:
- hadoop fs -l DIR: 查看DIR路径。
- hadoop fs -mkdir DIR: 创建路径。
- hadoop fs -lsr DIR 或 hadoop fs -ls -R DIR(规范): 递归查询目录
- hadoop fs -put current PATH: 将本地的文件(current)复制到HDFS中。
- hadoop fs -get PARH current: 将HDFS中的文件下载到本地(current)。
- hadoop fs -rm PATH: 删除HDFS中的文件
- hadoop fs -rmdir PATH: 删除空目录
- hadoop fs -rmr PATH 或 hadoop fs -rm -r PATH(规范): 递归删除目录
- hadoop fs -cat PATH: 查看文件
- hadoop fs -mv PATH1 PATH2: 移动
- hadoop fs -touchz PATH: 创建一个空的文件或修改文件修改日期
- hadoop fsck PATH: 汇报目录的健康情况
HDFS 写流程
在HDFS中写入数据的过程如下:
- 使用HDFS提供的客户端开发库(Client),向远程NameNode发送RPC请求,NameNode会首先检查要创建的文件是否存在,以及创建者是否有权限进行操作,成功则会为文件创建一个记录,否则让客户端抛出异常。
- NameNode创建记录之后,开发库会把文件切分成多个packets(比block更加细),并在内部以数据队列的方式管理,并且向NameNode申请blocks。NameNode会返回用来储存数据的合适的DataNodes列表,列表的大小(决定了一个block会有几个replicas)由NameNode中的replication指定。
- Client会根据DataNodes列表建立一个pipeline(管道),这个管道连接了所有要储存数据的DataNode。packet会通过pipeline经过第一个DataNode,该DataNode储存数据之后,再将其传递给在此pipeline中的下一个DataNode,整个写数据的方式呈现流水形式。
- 最后一个DataNode写完数据之后,返回一个ack packet给Client,当Client收到DataNode的ack packet之后,会从packets队列中移除刚刚增加的packet。
- 当所有数据写入完毕之后,Client会通知NameNode关闭文件。
- 如果在Client向DataNode传输数据的途中,DataNode发生了故障,那么当前pipeline会被关闭,出现故障的DataNode会被从pipeline移除,剩余的block继续传输,同时NameNode会分配一个新的DataNode。
HDFS 读流程
HDFS读取数据的流程如下:
- Client远程通过RPC向NameNode发送读请求。
- NameNode会视情况返回文件的部分或者全部block列表。对于每个block,NameNode同时会返回它replicas的位置。
- Client会选取离Client最近的DataNode开始读取,如果客户端本身就是DataNode并且block就在自己身上(这种情况经常出现),那么直接在本地读取。
- 读取完一个block之后,关闭和这个block的连接,并继续寻找下一个最佳的block读取。
- 当读取完NameNode发过来列表中的所有blocks之后,Client发现文件还是没有读取完毕,就会再向NameNode申请文件剩下来的blocks列表,继续读取。
- 如果读取DataNode时出现错误,客户端会通知NameNode,再从下一个拥有这个block拷贝(replicas)的DataNode继续读取。
- 读取完毕之后,DataNode会负责告知NameNode读取完毕,关闭文件。
这里有一个很有意思的机制,那就是在读取的时候,如果Client发现某个DataNode有故障,它不会只是简单地跳过这个DataNode去读取replicas,而是会把这一情况告知NameNode。
NameNode受到这样的报告之后,就会确保以后再有数据读取的时候,不会再返回这个有故障的DataNode的位置了(会用它的replicas取代)。可以防止Client重复读取一个故障节点。
Client同时会检查接收到的数据的校验和,如果发现数据损坏,也会通知NameNode。
这个设计要求Client在读取数据的过程中始终保持和NameNode的联系。并且NameNode会指引Client读取最好的DataNode,这有利于集群的整体性能。
HDFS 删除流程
在删除某个文件的时候,会先在NameNode上执行Name的删除。
注意这时只是标记要删除的数据块,而没有真正通知DataNode去删除这个文件的blocks。
在保存着这些blocks的DataNode向NameNode发送心跳的时候,NameNode才会给DataNode发送指令,进而完全把block删除。
所以在执行完delete后,要过一段时间,数据才会被完全删除。
安全模式
在重新启动HDFS之后,会立即进入安全模式,此时不能操作HDFS的文件,只能查看文件名。
随后NameNode启动之后,需要载入fsimage并写入到内存,同时会执行一遍edits的各种操作,以保证数据是尽量新的。
一旦在内存中建立好fs映射,会创建一个新的fsimage文件,和一个空的edits文件。
这是一个归并过程,这个“重启归并”是NameNode亲自做的,SecondaryNameNode没有参与。
在NameNode进行归并的时候,FS对于Client是只读的。
在此阶段NameNode还会收集来自各个DataNode的报告,当所有blocks达到最小的replicas数以上时,认为这个block是安全的。
在一定比例的block被认定为安全之后,再等待若干时间,安全模式结束。
NameNode检测到replicas数量不足的block时,会再进行一次replication。
以下是HDFS提供的和安全模式相关的命令:
- hadoop dfsadmin -safemode get: 查看安全模式的开关状态
- hadoop dfsadmin -safemode leave: 离开安全模式(不建议轻易尝试)
- hadoop dfsadmin -safemode enter: 进入安全模式
- hadoop dfsadmin -report: 查看存活的DataNode信息
JAVA API
HDFS是由Java实现的,并且整个Hadoop都是以Java为主的,所以HDFS提供了Java API以方便用户开发客户端程序远程操作HDFS。
FileSystem 读流程
使用Hadoop提供的FileSystem类对HDFS进行读的过程和通过终端命令略有不同:
- 客户端通过调用FileSystem类的对象(子类为DistributedFileSystem)的open()来获取希望打开的文件。对于HDFS来说,这个对象是分布式文件系统的一个实例。
- DistributedFileSyste通过RPC来调用NameNode,以确定文件的开头部分的block的位置。对于每一block,NameNode返回具有该block的DataNode的地址,这些DataNode会根据网络集群的拓扑位置进行排序。如果该Client本身就是一个DataNode,那么会优先从本机读取。
- DistributeFileSystem返回一个FSDDataInputStream对象被Client读取数据,FSDDataInputStream转而包装了一个DFSInputStream与最近的DataNode连接。
- Client对这个输入流调用read()方法,数据就会从DataNode返回给Client。
- 当读到block的末端时,DFSInputStream会关闭和DataNo的联系,然后为下一个block找DataNode,继续读取。
- Client只需要读一个连续的流,对于block的分块读取是透明的。
FileSystem 写流程
- Client调用DistributFileSystem的create()方法来创建文件。
- DistributeFileSystem使用RPC调用NameNode。
- NameNode会检查,确保这个文件不会已经存在了,并且确保这个Client有创建这个文件的许可。如果一切正常,NameNode会创建新的文件记录,否则,会向Client抛异常。
- 创建文件记录后,NameNode会返回一个文件系统的输出流,让Client开始写入数据。我们通过DFSOutputStream向DataNo的写数据。
- 在Client写入数据的时候,DFSOutputStream将它们分成一个一个packet,并创建一个packet的发送队列和一个确认队列。
- 在发送数据之后,发送的packet会被从发送队列移除并放到确认队列中。
- 在DataNode确认保存数据后,会返回一个信息,这时就把保存成功的packet从确认队列删除。
- 如果DataNode坏掉了,就会把pipeline删掉,把确认队列的packet放回发送队列的头部,通知NameNode,接着重新建立pipeline发送。这是为了确保在产生故障时不会产生丢包的情况。
API
FileSystem类是整个操作的核心类,这是一个抽象类。通过静态方法get()可以取得对象:
public static FileSystem get(URI uri, Configuration conf, String user);
public static FileSystem get(URI uri, Configuration conf);
Uri表示访问的服务器的位置,格式是"hdfs://ip:port"。
注意这个方法返回的是分布式文件系统,Hadoop还提供了其它文件系统,这里不再叙述。
需要用到配置类Configuration,这个类可以通过默认构造创造。它可以对HDFS进行一些配置,利用set()方法完成:
public void set(String name, String value);
这个方法可以设置一些Hadoop的参数,如果不设置,那么就使用默认的参数。
在使用FileSystem之后,记得调用close()方法关闭和FS的连接。
我们可以通过fs对象来进行一些简单的文件操作。例如,创建目录:
public boolean mkdirs(Path path);
Path对象是Hadoop提供的,通过构造方法可以把路径传进去。
下面的代码是创建目录的示例:
public class TestMkDir {
public static void main(String[] args) throws URISyntaxException, IOException {
Configuration conf = new Configuration();
FileSystem fileSystem = FileSystem.get(new URI("hdfs://192.168.117.51:9000"), conf);
System.out.println(fileSystem);
fileSystem.mkdirs(new Path("/test01"));
fileSystem.close();
}
}
随后在Linux输入:hadoop fs -ls /。就可以看到创建的test01目录了。
我们通过FileSystem的create()方法,可以创建一个新的文件并取得写入数据的FSDataOutputStream流:
public FSDataOutputStream create(Path f);
这会在NameNode申请一个新的文件并且把写如这个文件的流传给客户端。
那么,接下来的操作就和操作普通流一模一样了。下面是把一个本地文件写入HDFS的例子(IOUtils可以直接把流的内容复制):
public class TestUpload {
public static void main(String[] args) throws URISyntaxException, IOException {
FSDataOutputStream out = null;
InputStream in = null;
FileSystem fs = null;
try {
Configuration conf = new Configuration();
fs = FileSystem.get(new URI("hdfs://192.168.117.51:9000"), conf);
out = fs.create(new Path("/test01/test.txt"));
in = new FileInputStream("test.txt");
IOUtils.copyBytes(in, out, 2048);
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
if (fs != null) {
fs.close();
}
}
}
}
默认情况下,会对这个文件的block保存3个副本,我们可以通过"dfs.replication"这个配置项修改这一行为。在程序中需要调用Configuration的set()方法:
conf.set("dfs.replication", "1");
这样就不会产生副本了(适合于伪分布式情况)
至于读取操作,则和写很像,FileSystem可以通过open()方法来取得要读取文件的FSDataInputStream对象:
public FSDataInputStream open(Path f);
下面是读取文件的示例代码:
public class TestDownload {
public static void main(String[] args) throws URISyntaxException, IOException {
FileSystem fs = null;
FSDataInputStream in = null;
try {
Configuration conf = new Configuration();
fs = FileSystem.get(new URI("hdfs://192.168.117.51:9000"), conf);
// InputStream 的后代类
in = fs.open(new Path("/test01/test.txt"));
// 将信息输出在终端
OutputStream out = System.out;
IOUtils.copyBytes(in, out, 2048);
} finally {
if (in != null) {
in.close();
}
if (fs != null) {
fs.close();
}
}
}
}
可见我们之前介绍的所有关于block,packet的操作在这里都是完全透明的。我们只需要像处理一般的流一样去操作就可以了。
我们甚至可以通过FileSystem来查看某个文件的block的位置:
public BlockLocation[] getFileBlockLocations(Path p, long start, long end);
参数说明:
- p: 表示要查看哪个文件的block位置
- start: 从哪个位置开始查看,0开始
- end: 查看到哪里,要查看全部的话可以使用Long.MAX_VALUE。
下面的代码演示了如何查看HDFS中某个文件(这里是"/test01/testData.tar.gz",这个文件大约有200M):
public class TestBlock {
public static void main(String[] args) throws URISyntaxException, IOException {
Configuration conf = new Configuration();
FileSystem fs = null;
try {
fs = FileSystem.get(new URI("hdfs://192.168.117.51:9000"), conf);
BlockLocation[] locations = fs.getFileBlockLocations(
new Path("/test01/testData.tar.gz"), 0, Long.MAX_VALUE);
for (BlockLocation location : locations) {
System.out.println(location);
}
} finally {
if (fs != null) {
fs.close();
}
}
}
}
在我的机器上,以上代码输出如下:
0,134217728,hadoop1
134217728,76389079,hadoop1
可见这个文件有两个块,因为我用的是伪分布式,所以这两个块都储存在一个节点上。
我们还可以查看文件的状态信息,这需要调用FileSystem的以下方法:
public FileStatus[] listStatus(Path f);
用起来和查看Block信息很像,下面是演示(只有关键代码):
fs = FileSystem.get(new URI("hdfs://192.168.117.51:9000"), conf);
FileStatus statuses[] = fs.listStatus(new Path("/test01/test.txt"));
for (FileStatus status : statuses) {
System.out.println(status);
}
控制台输出:
FileStatus{path=hdfs://192.168.117.51:9000/test01/test.txt; isDirectory=false; length=22; replication=3; blocksize=134217728; modification_time=1521715007458; access_time=1521715006307; owner=LazyCat; group=supergroup; permission=rw-r--r--; isSymlink=false}
我们可以看到FileStatus类的一些属性,通过相应的getter方法可以取得文件的具体状态。
要想删除文件或目录,则使用FileSystem的delete方法:
public boolean delete(Path f, boolean recursive);
注意recursive用于指明是否递归删除文件,如果设置为false并且试图去删除一个非空目录,那么会抛出异常。