06

文件系统

持久化数据

阅读量:3 · 预计 13 分钟读完

inode目录日志一致性
关联层级:L5 虚拟机代码
阅读进度4%

第六章 文件系统

导读

文件系统(File System)是操作系统管理持久化数据的核心组件。在计算机中,所有程序和数据最终都以文件的形式存储在磁盘(或其他持久化存储介质)上。文件系统负责将这些数据组织成用户友好的结构(文件和目录),提供高效的存储和检索机制,并确保数据的一致性和可靠性。

本章将系统介绍文件系统的设计原理和实现技术。我们将从文件和目录的基本概念开始,逐步深入到文件系统的接口、实现方式、日志机制、性能优化等核心内容。理解文件系统的工作原理,不仅有助于编写高效的I/O程序,也能深入理解数据持久化的本质。


6.1 文件与目录

6.1.1 文件的概念

文件(File)是一组相关信息的集合,具有名称和属性。从操作系统的角度看,文件就是命名的字节序列——文件系统不关心文件的内容结构,内容由应用程序解释。

文件的属性(元数据)包括:

  • 名称:用户可读的标识符。
  • 大小:文件包含的字节数。
  • 类型:普通文件、目录、符号链接、设备文件等。
  • 位置:文件数据在磁盘上的存储位置。
  • 保护信息:读、写、执行权限。
  • 时间信息:创建时间、最后修改时间、最后访问时间。
  • 所有者信息:文件的所有者和所属组。

6.1.2 文件的类型

UNIX/Linux系统支持多种文件类型:

  • 普通文件(Regular File):包含用户数据,可以是文本文件或二进制文件。
  • 目录(Directory):包含文件名到inode的映射,用于组织文件结构。
  • 符号链接(Symbolic Link):指向另一个文件的路径引用。
  • 设备文件(Device File)

- 字符设备(Character Device):如终端、串口,按字符流访问。

- 块设备(Block Device):如磁盘,按块访问。

  • 管道(Pipe):用于进程间通信的虚拟文件。
  • 套接字(Socket):用于网络通信的虚拟文件。

UNIX的"一切皆文件"哲学意味着所有这些不同类型的文件都使用统一的接口(open()read()write()close())进行操作。

6.1.3 目录结构

目录(Directory)是一种特殊的文件,其内容是文件名到inode编号的映射表。目录结构通常采用树形层次结构:

/
├── bin/        # 基本命令
├── etc/        # 系统配置
├── home/       # 用户主目录
│   └── user/
│       ├── documents/
│       └── downloads/
├── usr/        # 用户程序
├── var/        # 可变数据
└── tmp/        # 临时文件

路径名(Pathname)

  • 绝对路径:从根目录开始,如 /home/user/file.txt
  • 相对路径:从当前目录开始,如 ./file.txt../other/file.txt

6.1.4 硬链接与符号链接

硬链接(Hard Link)

  • 多个文件名指向同一个inode。
  • 删除一个文件名不影响文件数据,直到所有硬链接都被删除。
  • 不能跨文件系统,不能链接目录。

符号链接(Symbolic Link / Soft Link)

  • 一个特殊的文件,其内容是另一个文件的路径。
  • 删除目标文件后,符号链接变成"断链"。
  • 可以跨文件系统,可以链接目录。

6.2 文件系统接口

6.2.1 基本文件操作

创建文件

c
int fd = open("newfile.txt", O_CREAT | O_WRONLY, 0644);

打开文件

c
int fd = open("file.txt", O_RDONLY);  // 只读
int fd = open("file.txt", O_WRONLY);  // 只写
int fd = open("file.txt", O_RDWR);    // 读写

读取文件

c
char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));

写入文件

c
const char *data = "Hello, World!";
ssize_t bytes_written = write(fd, data, strlen(data));

关闭文件

c
close(fd);

获取文件信息

c
struct stat sb;
stat("file.txt", &sb);
printf("Size: %ld bytes\n", sb.st_size);

6.2.2 目录操作

c
// 创建目录
mkdir("newdir", 0755);

// 删除目录
rmdir("olddir");

// 读取目录
DIR *dir = opendir(".");
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
    printf("%s\n", entry->d_name);
}
closedir(dir);

// 改变当前目录
chdir("/home/user");

6.2.3 文件描述符

文件描述符(File Descriptor, FD)是一个非负整数,用于标识进程打开的文件。每个进程有自己的文件描述符表:

  • 0:标准输入(stdin)
  • 1:标准输出(stdout)
  • 2:标准错误(stderr)
  • 3+:其他打开的文件

文件描述符指向内核中的打开文件表项,表项包含:

  • 文件偏移量(当前读写位置)
  • 文件状态标志(O_RDONLY、O_WRONLY等)
  • 文件访问模式
  • 指向inode的指针

6.3 文件系统的实现

6.3.1 文件系统的层次结构

文件系统的实现可以分为多个层次:

I/O层:直接操作块设备,发送读/写命令。

基本文件系统:管理物理块,处理块级别的读写。

文件组织模块(FOM):将逻辑文件转换为物理块地址。

逻辑文件系统:管理目录结构、文件元数据,提供高层文件操作接口。

6.3.2 inode 与数据块

inode(索引节点) 是UNIX文件系统的核心数据结构。每个文件(包括目录)对应一个inode,inode包含:

  • 文件类型和权限
  • 所有者(UID)和组(GID)
  • 文件大小
  • 时间戳(atime、mtime、ctime)
  • 链接数
  • 数据块指针(直接指针、间接指针)

注意:inode不包含文件名。文件名存储在目录文件中,目录的内容是(文件名,inode编号)的映射表。

6.3.3 数据块分配

文件系统需要管理磁盘上的空闲块和已分配块。常见的分配策略:

连续分配(Contiguous Allocation)

  • 文件的所有数据块在磁盘上连续存放。
  • 优点:顺序访问性能极好。
  • 缺点:文件扩展困难、容易产生外部碎片。

链接分配(Linked Allocation)

  • 每个数据块包含指向下一个数据块的指针。
  • 优点:无外部碎片、文件扩展容易。
  • 缺点:顺序访问慢(需要跟随指针)、不支持高效的随机访问。

索引分配(Indexed Allocation)

  • 使用专门的索引块存储文件的所有数据块地址。
  • 优点:支持高效的随机访问、无外部碎片。
  • 缺点:索引块需要额外空间。

UNIX/Linux的ext文件系统采用多级索引结构:

  • 直接块指针:直接存储数据块地址(通常12个)。
  • 一级间接块:一个块存储数据块地址(可存储数百个地址)。
  • 二级间接块:一个块存储一级间接块的地址。
  • 三级间接块:一个块存储二级间接块的地址。

这种结构使得小文件使用直接指针快速访问,大文件通过间接指针扩展容量。

6.3.4 空闲空间管理

文件系统需要跟踪哪些块是空闲的、哪些块已被分配。常见方法:

位图(Bitmap)

  • 每个块对应一个位(0表示空闲,1表示已分配)。
  • 优点:实现简单、容易找到连续空闲块。
  • 缺点:位图本身需要占用空间、需要加载到内存。

链表(Linked List)

  • 将所有空闲块链接成链表。
  • 优点:不占用额外空间(空闲块本身存储指针)。
  • 缺点:遍历慢、难以找到连续空闲块。

成组链接法

  • UNIX System V使用的方法。
  • 将空闲块分组,每组包含下一组的地址和本组的空闲块号。
  • 结合了位图和链表的优点。

6.4 日志文件系统

6.4.1 一致性问题

文件系统操作通常涉及多个磁盘写入。如果在写入过程中系统崩溃,文件系统可能处于不一致状态。例如,创建文件需要:

分配inode

更新目录项

分配数据块

如果在步骤1和2之间崩溃,inode已分配但目录中没有对应条目,导致inode泄漏。

6.4.2 日志(Journaling)的原理

日志文件系统通过在正式修改文件系统之前,先将操作记录到日志区域(Journal),来解决一致性问题。

日志的基本流程:

Begin Transaction:标记事务开始。

Write Metadata to Journal:将要修改的元数据写入日志区域。

Commit Transaction:标记事务提交。

Write Metadata to Final Location:将元数据写入文件系统的最终位置。

Checkpoint:标记事务完成,可以回收日志空间。

如果系统在步骤3之前崩溃,日志中的事务未提交,重启时忽略。

如果系统在步骤3之后、步骤5之前崩溃,重启时重放日志中的已提交事务。

6.4.3 日志的类型

元数据日志(Metadata Journaling)

  • 只记录元数据(inode、目录项等)的修改。
  • 数据本身不记录日志。
  • 可能出现的"孤儿块"问题:数据已写入但元数据未更新,重启后数据块成为垃圾。
  • ext3/ext4默认使用这种方式。

数据日志(Data Journaling)

  • 同时记录元数据和数据的修改。
  • 更强的一致性保证,但性能开销更大。
  • ext4支持但默认不启用。

写Anywhere日志(Write-Anywhere File Systems)

  • 如WAFL(NetApp)、ZFS。
  • 不使用传统日志,而是通过写时复制(Copy-on-Write)和校验和保证一致性。

6.4.4 ext4文件系统

ext4是Linux最常用的文件系统,主要特性:

  • 日志:默认元数据日志。
  • 扩展范围(Extents):用范围描述连续块,减少大文件的索引开销。
  • 多块分配器(mballoc):一次分配多个连续块,减少碎片。
  • 延迟分配:数据先缓存在内存中,flush时再决定块分配。
  • 快速fsck:将未使用的inode组标记为已检查,加速文件系统检查。

6.5 文件系统的性能优化

6.5.1 缓存与缓冲

缓冲区缓存(Buffer Cache)

  • 内核维护的磁盘块缓存。
  • 读取文件时,先检查缓存;缓存未命中时从磁盘读取并加入缓存。
  • 写入文件时,先写入缓存,稍后异步刷到磁盘(Write-back)。

页面缓存(Page Cache)

  • 以页(通常4KB)为单位的缓存。
  • 支持文件的内存映射(mmap)。
  • Linux统一使用页面缓存,缓冲区缓存建立在页面缓存之上。

6.5.2 预读(Read-ahead)

对于顺序访问,文件系统会预读后续块到缓存中。例如,当读取第N块时,文件系统可能同时读取第N+1、N+2块。预读可以显著提高顺序读取性能。

6.5.3 延迟写入(Write-back)

写入操作不立即刷到磁盘,而是先写入缓存,由后台线程定期刷出。这减少了磁盘I/O次数,但增加了数据丢失的风险(系统崩溃时缓存中的数据可能丢失)。

6.5.4 磁盘调度

磁盘调度算法决定I/O请求的执行顺序,目标是减少磁头移动距离、提高吞吐量:

  • FCFS:按请求到达顺序执行。简单但不优化。
  • SSTF(Shortest Seek Time First):选择距离当前磁头位置最近的请求。可能导致远端请求饥饿。
  • SCAN(电梯算法):磁头从一端扫到另一端,途中服务请求。到达端点后反向扫描。
  • C-SCAN:类似SCAN,但只在一个方向服务请求,到达另一端后立即返回起点。提供更均匀的等待时间。

6.6 特殊文件系统

6.6.1 /proc 文件系统

/proc 是一个虚拟文件系统,不存储在磁盘上,而是在内存中动态生成。它提供进程和系统信息的接口:

bash
# 查看CPU信息
cat /proc/cpuinfo

# 查看内存信息
cat /proc/meminfo

# 查看进程信息
cat /proc/[pid]/status
cat /proc/[pid]/maps

6.6.2 /sys 文件系统

/sys 是设备文件系统,提供设备和驱动的接口。与 /proc 类似,/sys 也是虚拟文件系统,但结构更规范,专门用于设备管理。

6.6.3 tmpfs

tmpfs 是基于内存的文件系统,数据存储在RAM中(可以交换到swap)。/tmp/dev/shm 通常使用tmpfs。优点是速度快,缺点是重启后数据丢失。


6.7 常见误区

误区一:close() 保证数据写入磁盘

close() 只是释放文件描述符和相关的内核资源,不保证数据已刷到磁盘。如果需要确保数据持久化,应使用 fsync()fdatasync()

c
write(fd, data, len);
fsync(fd);  // 强制将数据刷到磁盘
close(fd);

误区二:文件删除后数据立即消失

unlink() 只是删除目录项和减少inode的链接数。只有当链接数减为0且没有进程打开该文件时,inode和数据块才会被释放。在释放之前,数据仍然在磁盘上,可以通过数据恢复工具找回。

误区三:日志文件系统不会丢失数据

日志文件系统保证的是文件系统元数据的一致性,而不是用户数据的完整性。如果数据写入缓存后系统崩溃,缓存中的数据可能丢失。需要 fsync() 才能确保数据持久化。

误区四:SSD不需要磁盘调度

虽然SSD没有机械部件,不需要寻道时间,但I/O调度仍然重要。SSD的写入放大(Write Amplification)和垃圾回收(Garbage Collection)机制使得写入顺序和合并写入对性能影响很大。

误区五:文件系统碎片不影响性能

文件系统碎片会导致文件的块分散在磁盘各处,增加寻道时间(HDD)或降低SSD的写入效率。定期整理碎片(e4defrag)可以恢复性能。


6.8 实践应用

6.8.1 查看文件系统信息

bash
# 查看挂载的文件系统
df -hT

# 查看文件系统类型
mount | column -t

# 查看inode使用情况
df -i

# 查看文件系统的详细信息
tune2fs -l /dev/sda1

6.8.2 文件I/O性能测试

bash
# 使用dd测试写入性能
dd if=/dev/zero of=testfile bs=1M count=1024 oflag=direct

# 使用dd测试读取性能
dd if=testfile of=/dev/null bs=1M iflag=direct

# 使用hdparm测试读取速度
hdparm -Tt /dev/sda

6.8.3 高效的文件I/O编程

c
// 使用mmap进行高效文件访问
int fd = open("largefile.dat", O_RDONLY);
struct stat sb;
fstat(fd, &sb);

void *mapped = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

// 直接通过指针访问文件内容
char *data = (char *)mapped;
process_data(data, sb.st_size);

munmap(mapped, sb.st_size);
close(fd);

6.8.4 确保数据持久化

c
int safe_write(const char *path, const void *data, size_t len) {
    int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) return -1;
    
    // 写入数据
    ssize_t written = write(fd, data, len);
    if (written != (ssize_t)len) {
        close(fd);
        return -1;
    }
    
    // 确保数据刷到磁盘
    if (fsync(fd) < 0) {
        close(fd);
        return -1;
    }
    
    // 确保目录项也刷到磁盘
    int dirfd = open(".", O_RDONLY);
    fsync(dirfd);
    close(dirfd);
    
    close(fd);
    return 0;
}

6.9 本章小结

本章系统介绍了文件系统的设计原理和实现技术,主要内容包括:

文件与目录:文件是命名的字节序列,目录是文件名到inode的映射。UNIX支持多种文件类型,统一使用文件接口操作。

文件系统接口open()read()write()close() 等基本操作。文件描述符是进程访问文件的标识。

文件系统实现:inode存储文件元数据和数据块指针。数据块分配策略包括连续、链接和索引分配。空闲空间管理使用位图或链表。

日志文件系统:通过日志保证文件系统一致性。元数据日志记录元数据修改,数据日志同时记录数据修改。ext4是典型的日志文件系统。

性能优化:缓存(Buffer Cache、Page Cache)、预读、延迟写入、磁盘调度等技术提升文件系统性能。

特殊文件系统:/proc、/sys是虚拟文件系统,tmpfs是基于内存的文件系统。

文件系统是操作系统管理持久化数据的核心组件。理解文件系统的工作原理,对于编写高效的I/O程序、保证数据一致性至关重要。