5.3 文件操作原理
我们平时对文件的操作(创建、读取、写入、删除)看起来很简单,但背后涉及到文件系统的很多复杂处理。理解这些操作的底层流程,有助于我们写出更高效的文件操作代码,排查文件相关的问题。
磁盘空间管理
在讲文件操作之前,我们需要先了解文件系统是如何管理磁盘空间的。
块(Block)
文件系统把磁盘划分为固定大小的块(Block),作为磁盘空间分配的最小单位,通常块的大小是4KB(可以格式化时设置)。
- 即使一个文件只有1字节,也会占用一个完整的块
- 大文件会占用多个块
- 块大小的选择是空间利用率和性能之间的权衡:块太小会增加元数据开销,降低性能;块太大会浪费空间(内部碎片)
空闲空间管理
文件系统需要记录哪些块是空闲的,可以分配给新的文件,常见的空闲空间管理方式:
- 空闲表:记录所有空闲块的起始地址和数量,适合磁盘空间不大的情况
- 空闲链表:把所有空闲块链成一个链表,分配时从链表头取,释放时插回链表,缺点是分配多个连续块时效率低
- 位图(Bitmap):用一个二进制位表示一个块是否空闲,0表示空闲,1表示已占用,位的位置对应块的编号。这种方式实现简单,查找连续块效率高,是现在主流文件系统常用的方式。
- 组描述符:EXT系列文件系统把磁盘分成多个块组,每个组有自己的位图和inode表,提升分配效率。
块分配策略
文件系统分配块时尽量让同一个文件的块连续存储,提升读取性能:
- 连续分配:给文件分配连续的块,性能最好,但容易产生外部碎片,文件大小不能动态增长,现在基本不用了
- 链式分配:每个块里存指向下一个块的指针,不需要连续空间,但随机访问性能差,可靠性低,一个块损坏后面的都访问不到
- 索引分配:inode里存储指向数据块的指针,是现在主流的分配方式。比如EXT4的inode里有12个直接指针(指向前12个数据块),1个间接指针(指向一个索引块,里面存数据块指针),1个二级间接指针,1个三级间接指针,可以支持非常大的文件。
- 盘区(Extent)分配:EXT4、XFS等现代文件系统采用Extent分配,一个Extent是一段连续的块,用起始块号和长度表示,大大减少了元数据的大小,提升了大文件的性能。
常见文件操作的底层流程
我们以EXT4文件系统为例,看一下常见文件操作的底层流程。
1. 文件读取流程
当我们调用read()读取一个文件时,大致流程如下:
- 进程调用read()系统调用,传入文件描述符、缓冲区地址、读取长度
- 操作系统根据文件描述符找到对应的文件对象,获取文件的inode
- 根据要读取的偏移量,计算对应的块号
- 检查Page Cache中是否有对应的缓存页:
- 如果有,直接把缓存中的数据拷贝到用户缓冲区,返回
- 如果没有,发起磁盘IO请求,从磁盘读取对应的块到Page Cache
- 把Page Cache中的数据拷贝到用户缓冲区
- read()系统调用返回,读取完成
2. 文件写入流程
当我们调用write()写入文件时,大致流程如下:
- 进程调用write()系统调用,传入文件描述符、缓冲区地址、写入长度
- 操作系统找到对应的文件和inode
- 根据写入的偏移量,分配需要的数据块(如果还没有分配的话)
- 把用户缓冲区中的数据拷贝到Page Cache对应的页中
- 标记这个页为脏页(Dirty Page),write()调用直接返回
- 操作系统会在合适的时机(比如脏页比例达到阈值、sync调用、定时刷脏)把脏页回写到磁盘上
注意:默认情况下write()返回后,数据不一定已经写到磁盘上,只是写到了Page Cache中,如果此时断电,可能会丢失数据。如果需要确保数据持久化到磁盘,需要调用fsync()或者fdatasync()系统调用。
3. 文件创建流程
当我们创建一个新文件时:
- 进程调用open()系统调用,传入文件路径和O_CREAT标志
- 操作系统解析文件路径,找到父目录的inode
- 检查权限,确认是否有写入权限
- 分配一个新的inode,初始化inode的元信息(权限、所有者、时间戳等)
- 在父目录中添加一个新的目录项,记录文件名和新的inode号
- 返回文件描述符,创建完成
4. 文件删除流程
当我们删除一个文件时(rm命令或者unlink()系统调用):
- 操作系统找到文件的inode,将链接计数减1
- 删除父目录中的对应目录项
- 如果链接计数变为0,说明没有任何文件名指向这个inode了,释放这个inode和对应的数据块,标记为空闲
- 如果还有其他硬链接指向这个inode,文件内容不会被删除,直到所有链接都被删除
注意:删除文件只是删除了目录项和减少了链接计数,实际的数据块还在磁盘上,只是被标记为空闲,直到被新的数据覆盖。这也是为什么删除的文件可以被数据恢复软件恢复的原因。
5. 目录的实现
目录是一种特殊的文件,它的内容是目录项的列表,每个目录项包含:
- inode号
- 文件名
- 其他元信息(文件类型等)
当我们遍历目录时,实际上就是读取目录文件的内容,解析出每个目录项。
硬链接和软链接的区别
- 硬链接:和原文件指向同一个inode,链接计数加1,删除原文件不会影响硬链接,不能跨文件系统,不能链接目录
- 软链接(符号链接):是一个独立的文件,内容是指向的目标文件的路径,有自己的inode,删除原文件软链接就失效了,可以跨文件系统,可以链接目录,支持相对路径和绝对路径
日志文件系统的工作原理
传统的非日志文件系统(比如EXT2)在修改文件时,需要修改inode、数据块、位图等多个元数据,如果修改中途断电,可能会导致文件系统不一致,比如inode已经分配了,但位图没有标记为已占用,或者反过来,导致数据丢失或者空间泄漏。
日志文件系统(Journaling File System)解决了这个问题:
- 在修改元数据和数据之前,先把要做的修改作为一个事务写到日志区域
- 日志写入完成后,再执行实际的修改操作
- 修改完成后,在日志中标记这个事务已完成
- 定期清理已经完成的日志
如果中途断电,重启后文件系统会检查日志:
- 如果事务已经完整写入日志,即使实际修改没完成,也可以根据日志重做,保证修改完成
- 如果事务没有完整写入日志,就忽略这个事务,不会修改实际的文件系统
这样就保证了文件系统的一致性,不会出现文件系统损坏的情况,fsck也会快很多。
日志的三种模式
- 日志模式(Journal):所有元数据和数据都先写入日志,再写入实际位置,最安全,但性能最差,因为所有数据都要写两次
- 顺序模式(Ordered):只记录元数据的日志,保证数据在元数据之前写入,是EXT3/EXT4的默认模式,兼顾安全性和性能
- 写回模式(Writeback):只记录元数据的日志,不保证数据的写入顺序,性能最好,但安全性最差,可能会出现旧数据出现在文件中的情况
文件系统性能优化要点
- 选择合适的块大小:大文件多的场景用大一点的块,小文件多的场景用小一点的块,兼顾性能和空间利用率
- 充分利用Page Cache:操作系统会自动缓存热数据,尽量避免直接IO绕过缓存,会严重降低性能
- 批量写入:合并小的写入操作,减少系统调用次数和IO次数
- 顺序读写优先:尽量避免随机读写,顺序读写的性能比随机读写高很多,特别是对于HDD
- 使用正确的刷盘策略:不需要立即持久化的不要频繁调用fsync,会严重影响性能;需要持久化的要记得调用fsync,避免数据丢失
- 预留空闲空间:文件系统快满的时候性能会急剧下降,至少保留10-20%的空闲空间
- 定期整理碎片:HDD可以定期碎片整理提升性能,SSD不需要碎片整理
思考问题
- 为什么删除文件的速度很快,几乎瞬间就能完成?删除的文件内容还在磁盘上吗?
- write()系统调用返回后,数据已经写到磁盘上了吗?如果此时断电会怎么样?
- 日志文件系统是怎么保证断电后文件系统不会损坏的?
- 硬链接和软链接有什么区别?分别适合什么场景?