文件系统的实现
1.块管理
以磁盘为例,磁盘在逻辑上会划分为磁道、柱面和扇区,扇区是磁盘的读写单位,也是磁盘读写时候的最小寻址单位,一个扇区一般是512字节(自2009年底开始,磁盘制造商开始引入使用4096字节扇区的磁盘)。
块管理用于记录存储块和文件的关联关系,对于随机存储设备(如磁盘)而言,一般有如下三种方法来实现块管理。
(1)连续分配
最简单的物理结构是连续分配,连续分配将文件中的N个逻辑块映射到N个地址连续的物理块上。以磁盘为例,如果扇区的大小是512字节,50KB的文件需要分配连续的100个扇区。这种方案简单、性能好,允许驱动器花较少的时间对整个文件进行读取和写入。
(2)链接表
存储文件的第二种方式是为每一个文件构造存储空间的链接表,在每个存储单元的特定部位,保存下一个存储单元的位置,每个逻辑块都包含一个带连接的头,用来指向下一个逻辑块对应的物理块的地址。
(3)索引链式表
为了克服链接表分配的不足,取出每个磁盘块的指针字段,把它放到一张索引表中,就形成了使用索引链式表分配。
2.目录管理
将目录作为特殊文件(往往是结构化文件,支持基于记录的操作)保存,或保存在某一个特定的存储区域中。以MS-DOS目录项为例,它的目录项有32字节。
Linux文件系统
Linux的本地文件系统包括:
Ext2(Second Extended Filesystem,第二扩展文件系统)
Ext3(ThirdExtended Filesystem,第三扩展文件系统)
i-node或索引节点是Linux/UNIX文件系统中最有名也是最重要的概念,它存储了文件和目录的元数据(HDFS中对应的结构分别是INodeFile和INodeDirectory,它们是INode的子类,借鉴了i-node的命名)。所有索引节点大小相同都是128字节,这样,如果磁盘的块(块由多个连续的扇区组成)大小为1024字节,它可以包含8个索引节点。
基本的索引节点开始部分保存了文件的一些元信息,如文件类型与权限(最前面的2字节)、所有者标识(接下来的2字节)、以字节为单位的文件长度(4字节,严格地说,是文件长度的低32位)等信息。索引节点后半部分是文件所在数据块的索引,开始的12个块地址(每个地址需要占用4字节)存放在i-node内,这样,对于小文件,所需信息都在i-node中。如果(假设)文件管理器的块大小是4KB,那么这12个索引能表示的最大文件是48KB。对于稍大的文件,在i-node中有一个称为一次间接块的索引,索引指向的块以连续的方式存放了数据块的索引,也就是文件内容对应的块地址。对于大于48KB的文件(假设块是4KB),部分数据的访问,需要访问一次索引节点和一次间接块,才能定位到数据所在的块。类似的,更大的文件可以通过二次间接块、三次间接块保存数据块的索引。
下面是Linux Ext2文件系统i-node定义的源代码
ext2-inode {
__u16 i_mode; /* 文件类型和访问权限 */
__u16 i_uid; /* 拥有者标识符 */
__u32 i_size; /* 以字节为单位的文件长度 */
__u32 i_atime; /* 最后一次访问文件时间 */
__u32 i_ctime; /*索引节点最后改变时间*/
__u32 i_mtime; /*文件内容最后改变时间*/
__u32 i_dtime; /* 文件删除时间 */
__u16 i_gid; /* 用户组标识符 */
__u16 i_links_count; /* 硬链接计数器 */
__u32 i_blocks; /* 文件的数据块数 */
__u32 i_flags; /* 文件标志 */
union {
……
} osd1; /* 特定操作系统信息 */
__u32 i_block[EXT2-N_BLOCKS]; /* 指向数据块的指针 */
__u32 i_generation; /* 文件版本(for NFS) */
__u32 i_file_acl; /* 文件访问控制列表 */
__u32 i_dir_acl; /* 目录访问控制列表 */
__u32 i_faddr; /* 片的地址 */
union {
……
} osd2; /*特定操作系统信息*/
};
要查找路径名“/home/alice/data.txt”的文件,首先文件管理器要找到根目录,Linux的根目录总是位于系统的2号i-node,通过2号i-node可以找到根目录目录项所在的数据块,在该块的内容中找路径的第一部分:“home”,从而获得目录“/home”的i-节点号,由i-node号可以很直接找到对应的i-node,然后通过这个i-node找到对应的数据块,就可以开始查找目录“/home/alice”了。利用相同的流程,最终获得“/home/alice/data.txt”的i-node号。
超级块保存了这个逻辑磁盘结构的一些信息,包括块大小、总块数、总i-node数等。数据块位图和i-node位图分别记录了i-node表和数据块的使用情况。
虚拟文件系统
Linux内核使用了虚拟文件系统(Virtual File System,VFS),它是内核中的一个软件层,为上层应用提供文件系统接口,该接口隐藏了底层各种文件系统的具体细节。
当应用程序对文件系统进行操作时,内核文件系统首先调用VFS的相应函数,处理与文件系统无关的操作,然后再调用真正文件系统中对应的函数,处理与设备相关的操作。
对于文件的读、写和执行权限,具体内容为:
r(read):可以读取文件的内容。
w(write):可以编辑、修改文件的内容。
x(execute):该文件可以被执行。
对于目录,如果将目录下的所有文件/子目录看成是目录的内容,其读、写和执行权限具体内容为:
r(read):可以读取文件夹内容列表,但如果用户没有“x”权限,就只能看到文件名而无法查看其他内容(大小、权限等)。
w(write):用户具有“w”权限,就可以修改目录的内容,即文件夹记录列表,前提是用户拥有“x”权限,可以进入这个目录。修改目录的内容,也就是“w”权限,包括:建立新的文件或文件夹、删除已存在的文件或文件夹、对已存在的文件或文件夹改名和更改目录内文件或文件夹的位置。
x(execute):可以进入该文件夹,没有“x”权限便无法执行该目录下的任何命令。
Linux文件系统API
1.文件I/O函数
如果有两个不同的进程打开了同一个文件,如“/home/alice/data.txt”,那么每个进程都有它们自己的文件读写位置。打开文件的同时,文件的i-node也会被加载到主存,文件结构表中有指针指向内存中的i-node拷贝。特定文件的i-node拷贝只有一份,也就是说,两个不同的进程打开同一个文件,它们各自的打开文件表,指向了同一个i-node拷贝。
文件管理器接到read()调用时,根据fid找到对应的文件结构项和内存i-node。接着,读请求被分成几个段,每段对应于设备上的一块。例如,当前文件位置为1600字节,要读取的数据长度为1K字节,设备块大小为1K字节,那么读请求将分为两个部分,分别为1600~2023字节和2024~2623字节。通过i-node的直接块指针,可以找到相应的块号,在不考虑文件管理器高速缓存的情况下,内核向设备管理系统发出两个读操作请求。设备管理系统读操作返回后,再将数据拷贝到用户提供的缓存区,然后文件管理器送出响应消息,告知拷贝字节数,调用结束。
在文件关闭或应用程序发出sync()命令后,内存中的i-node会被更新到设备上。
2.文件/目录函数
(1)文件属性的操作
文件/目录API中,处理文件属性可以通过stat()函数获得,该函数的原型如下:
#include <sys/types.h>
#include <sys/stat.h>
int stat(const char* pathname,struct stat* buf)
给定一个文件pathname,stat()函数通过填充buf指向的一个结构,返回与此文件相关的信息。其Linux源代码如下:
struct stat {
dev_t st_dev; /* 文件所在的设备ID */
ino_t st_ino; /* i-node号 */
mode_t st_mode; /* 访问权限 */
nlink_t st_nlink; /* 硬链接数 */
uid_t st_uid; /* 文件所有者的用户ID */
gid_t st_gid; /*文件所有者的组ID */
dev_t st_rdev; /* 设备(应用的特殊文件) */
off_t st_size; /* 文件的大小,以byte为单位 */
blksize_t st_blksize; /* 用于文件IO的块大小*/
blkcnt_t st_blocks; /* 已经分配的文件块数*/
time_t st_atime; /* 最后访问时间*/
time_t st_mtime; /* 最后修改时间*/
time_t st_ctime; /* 元数据最后修改时间*/
};
分布式文件系统
基本NFS体系结构
NFS背后的基本思想是:每个文件服务器都提供其本地文件系统的一个标准化视图。换句话说,无论本地文件系统是如何实现的,每个NFS服务器通过一个通信协议,支持相同的模型,该协议允许客户以完全相同的方法访问存储在一个服务器上的文件,从而允许客户进程共享公用的文件系统。用于UNIX系统的基本NFS体系结构如下图。其中,VFS是虚拟文件系统,NFS客户端作为VFS的一个具体的文件系统。当应用程序对文件进行操作时,内核处理完与文件系统无关的操作后,调用NFS客户,该组件负责处理对存储在远程服务器上的文件的访问。
在NFS中,所有的客户–服务器通信都是通过远程过程调用(Remote Process Call,RPC)完成的。在服务器端可以看到相似的组织方式,NFS服务器负责处理传入的客户请求,并把这些请求转换为常规的VFS文件操作,随后将这些操作传递给VFS层,VFS负责对实际的文件系统进行操作。这种模式的优点使NFS很大程度上独立于本地文件系统,从原理上讲,它不关心客户端或服务器端的操作系统实现。
基于UNIX系统的基本NFS体系结构如图5-12所示。
NFS版本3和版本4最大的差别是,NFS 3服务器是无状态的,而NFS 4通过引入租约(Lease),实现带状态的服务器,引入open和close也是为了适应这一变化。通过租约,NFS 4提供了简单的文件锁定机制(HDFS目前还不提供这样的能力)和有限的权限委托(GFS实现了部分这样的能力,HDFS不提供)。在NFS 4中,打开一个不存在的文件会创建相应的文件,所以不需要create操作。
NFS位置透明性是通过将远程文件系统加载(mount)到客户端的本地命名空间实现,由于远程文件系统加载需要在客户端上进行操作,因此NFS不能完全达到移动透明性。
Java文件系统
ile对象还提供了大量的方法,用于访问文件/目录的元数据。例如,canRead()方法可用于判断文件/目录能否由当前应用程序读取;getCannonicalFile()返回一个包含文件规范路径名的File对象。文件/目录的规范路径名是绝对路径名,并且是唯一的,其中,路径名中多余的名称(如“.”目录)会被删除,并使用正确的目录分隔符。
URI(Uniform Resource Identifier,统一资源标识符)以特定的语法标识一个资源的字符串。
模式:模式特有部分#片段([:][#])
模式特有部分的语法依赖于所使用的模式,比较常用的模式有:
file:本地磁盘上的文件,上面toURI()例子的一个可能输出为“file:/E:/”。
http:使用超文本传输协议的万维网服务器,例如“http://www.hzbook.com”。
ftp:FTP服务器。mailto:电子邮件地址。
telnet:与基于Telnet协议的服务的连接。
有两种类型的URI:
URL(Uniform Resource Locator,统一资源定位符)
URN(Uniform Resource Name,统一资源名)
与Linux文件API lseek()对应的Java操作是RandomAccessFile.seek(),而不是流的某一个具体方法。由于流是对数据源的抽象,大部分数据源是不支持随机存取功能的,如来自网络的数据流,为此,Java引入了RandomAccessFile类,用于在(磁盘)文件的任何位置查找或者写入数据。
RandomAccessFile的实例支持对随机访问文件的读取和写入,通过如下的构造函数,可以创建RandomAccessFile对象,其中,对应的文件可以通过指定名称或File对象传入,参数mode用于指定打开方式,如只读打开(“r”)或者读写打开(“rw”)。方法声明如下:
public RandomAccessFile(String name,String mode)
public RandomAccessFile(File file,String mode)
通过getFilePointer()方法可以返回文件指针的当前位置;而seek方法将文件指针设置在文件内部的任何字节位置。需要注意的是,这两个方法都被声明为native方法。代码如下:
public native long getFilePointer() throws IOException;
public native void seek(long pos) throws IOException;
RandomAccessFile实现了DataInputStream实现的DataInput接口,也实现了DataOutputStream实现的DataOutput。
hadoop抽象文件系统
下面整理了org.apache.hadoop.fs.FileSystem中的抽象方法:
//获得文件系统URI
public abstract URI getUri();
//为读打开一个文件,并返回一个输入流,下一节讨论
public abstract FSDataInputStream open(Path f,int bufferSize)
throws IOException;
//创建一个文件,并返回一个输出流,下一节讨论
public abstract FSDataOutputStream create(Path f,
FsPermission permission,
boolean overwrite,
int bufferSize,
short replication,
long blockSize,
Progressable progress) throws IOException;
//在一个已经存在的文件中追加数据
public abstract FSDataOutputStream append(Path f,int bufferSize,
Progressable progress) throws IOException;
//修改文件名或目录名
public abstract boolean rename(Path src,Path dst) throws IOException;
//删除文件
public abstract boolean delete(Path f) throws IOException;
public abstract boolean delete(Path f,boolean recursive)
throws IOException;
//如果Path是一个目录,读取一个目录下的所有项目和项目属性;
//如果Path是一个文件,获取文件属性
public abstract FileStatus[] listStatus(Path f) throws IOException;
//设置当前的工作目录
public abstract void setWorkingDirectory(Path new_dir);
//获取当前的工作目录
public abstract Path getWorkingDirectory();
//如果Path是一个文件,获取文件属性
public abstract boolean mkdirs(Path f,FsPermission permission)
throws IOException;
//获取文件或目录的属性
public abstract FileStatus getFileStatus(Path f) throws IOException;
Hadoop输入/输出流
Seekable接口提供在(文件)流中进行随机存取的方法,其功能类似于RandomAccessFile中的getFilePointer()方法(Seekable.getPos()方法)和seek()方法,它提供了某种随机定位文件读取位置的能力。
Seekable接口中的seekToNewSource()是一个比较特殊的方法,当文件数据有多个副本的时候,如在HDFS中,seekToNewSource()可用于重新选择一个副本。代码如下:
/** 接口,用于支持在流中定位 */
public interface Seekable {
//将当前偏移量设置到参数位置,下次读数据将从该位置开始
void seek(long pos) throws IOException;
//得到当前偏移量
long getPos() throws IOException;
//重新选择一个副本
boolean seekToNewSource(long targetPos) throws IOException;
}
FSDataInputStream实现的另一个接口是PositionedReadable,它提供了从流中某一个位置开始读数据的一系列方法,这些方法的第一个参数position,用于指定流中的一个位置。其中,read()和readFully()的差别在于:一个试图读取指定长度的数据,另一个读取指定长度的数据,直到读满缓冲区或流结束。接口如下:
//接口,用于在流中进行定位读
public interface PositionedReadable {
//从指定位置开始,读最多指定长度的数据到buffer中offset开始的缓冲区中
//注意,该函数不改变读流的当前位置,同时,它是线程安全的
public int read(long position,byte[] buffer,int offset,int length)
throws IOException;
//从指定位置开始,读指定长度的数据到buffer中offset开始的缓冲区中
public void readFully(long position,byte[] buffer,int offset,
int length) throws IOException;
public void readFully(long position,byte[] buffer) throws IOException;
}
相对于FSDataInputStream,FSDataOutputStream比较简单,它没有实现Seekable接口,也就是说,Hadoop文件系统不支持随机写,用户不能在文件中重新定位写位置,并通过写数据来覆盖文件原有的内容。但用户可以通过getPos()方法获得当前流的写位置,为了实现getPos()方法,FSDataOutputStream定义了内部类PositionCache,该类继承自FilterOutputStream,并通过重载write()方法跟踪目前流的写位置。
FileSystem.get()处理过程中另一个可配置并需要判断的条件是:是否使用FileSystem.CACHE中保存的、已经缓存的文件系统。打开一个文件系统是一个比较耗费资源的操作,如打开HDFS,需要和名字节点建立IPC通信,所以,共享文件系统是一个不错的优化。但是,由于文件系统间是相互共享的,应用不小心关闭共享的文件系统,将会影响其他使用者。
${fs.hdfs.impl.disable.cache},该配置项决定,如果FileSystem.get()获取的是(可共享的)HDFS文件系统,方法返回一个新的HDFS文件系统实例,或者是共享HDFS文件系统实例。
Hadoop文件系统中的协议处理器
前面在介绍Java的URL类时提到,通过URL.openStream()可以获得到某一个资源的输入流,在下面的例子中,可以打开HDFS上的某个文件的输入流,然后读取数据:
InputStream in=null;
try {
FsUrlStreamHandlerFactory factory=
new org.apache.hadoop.fs.FsUrlStreamHandlerFactory();
//安装协议处理系统
java.net.URL.setURLStreamHandlerFactory(factory);
in=new URL("hdfs://example:port/path/file").openStream();
……//后续处理
} finally {
IOUtils.closeStream(in);
}
Java将协议处理任务分成几个部分,java.net包中的4个类URL、URLStreamHandler、URLConnection和URLStreamHandlerFactory一起实现了协议处理器机制。具体类参见图5-24。
在Hadoop文件系统中,它们分别是FsUrlStreamHandler、FsUrlConnection和FsUrlStreamHandlerFactory。
Hadoop具体文件系统
FilterFileSystem有两个子类:
□ChecksumFileSystem:用于在原始文件系统上添加校验和功能。数据在不可靠的设备存放或传输时可能会损坏,如果读数据时产生的校验和与原有校验和不一致,数据会被认为是损坏的。为了发现损坏数据,ChecksumFileSystem会为文件保存对应的校验信息文件(在写文件的时候生成),并在读文件的时候进行校验。
□HarFileSystem:保存大量的(小)文件会消耗HDFS名字节点的大量内存,并对HDFS的性能造成影响。Hadoop Archives或HAR文件系统,通过将(小)文件归档(类似于UNIX系统中的归档命令tar),形成一些大文件,更高效地将大量(小)文件存放在原始文件系统中(注意,HAR文件也可以保存在其他基础文件系统中)。
RawLocalFileSystem的实现
public class RawLocalFileSystem extends FileSystem {
……
public File pathToFile(Path path) {
checkPath(path);
if (!path.isAbsolute()) {
path=new Path(getWorkingDirectory(),path);
}
return new File(path.toUri().getPath());
}
……
public boolean mkdirs(Path f) throws IOException {
Path parent=f.getParent();
File p2f=pathToFile(f);
//如果父目录非空,试图先创建父目录
//通过File创建目录,并判断成功创建目录
return (parent==null||mkdirs(parent)) &&
(p2f.mkdir()||p2f.isDirectory());
}
……
}
class LocalFSFileInputStream extends FSInputStream {
FileInputStream fis;
private long position;
……
public void seek(long pos) throws IOException { //移动文件当前位置
fis.getChannel().position(pos);
this.position=pos;
}
public long getPos() throws IOException {
return this.position;
}
public boolean seekToNewSource(long targetPos) throws IOException {
//不支持,简单返回false
return false;
}
public int available() throws IOException { return fis.available(); }
public void close() throws IOException { fis.close(); }
public boolean markSupport() { return false; }
public int read() throws IOException {
try {
int value=fis.read();
if (value >=0) {
this.position++;//更新文件当前位置
}
return value;
}
……
}
……
}
ChecksumFileSystem的实现
public abstract class ChecksumFileSystem extends FilterFileSystem {
……
public Path getChecksumFile(Path file) {
return new Path(file.getParent(),"."+file.getName()+".crc");
}
public static boolean isChecksumFile(Path file) {
String name=file.getName();
return name.startsWith(".") && name.endsWith(".crc");
}
}
public boolean rename(Path src,Path dst) throws IOException {
if (fs.isDirectory(src)) {
return fs.rename(src,dst);
} else {
boolean value=fs.rename(src,dst);//修改数据文件文件名
if(!value)
return false;
Path checkFile=getChecksumFile(src);
if(fs.exists(checkFile)) {//修改校验信息文件文件名
if(fs.isDirectory(dst)) {
value=fs.rename(checkFile,dst);
} else {
value=fs.rename(checkFile,getChecksumFile(dst));
}
}
}
}