如果你在Linux服务器上遇到过“删了一个大文件,但df显示磁盘空间还是没释放”的诡异现象,或者“磁盘明明还有几十G,系统却提示No space left on device”,那说明你已经站在了文件系统的门边上,就差推开那扇门了。
这篇文章是“Linux文件系统”系列的第二篇,上一篇讲的是文件、目录、路径这些基本概念,这次往下钻一层:文件的数据到底是怎么存放在磁盘上的、读写一个文件时内核在背后做了什么、为什么日志文件系统掉电之后还能保持基本一致,以及不同文件系统(ext4、XFS、Btrfs、ZFS)在实际使用中的表现差异。
这篇文章适合两类人:一是被文件系统问题折磨过的运维和新晋后端开发,二是准备系统设计面试、想把底层机制讲清楚的进阶学习者。
1. 文件系统到底“系统”在哪里:从块设备到文件的翻译官
很多人在刚接触Linux时有个误解,觉得文件系统就是“格式化出来的那个东西”,格式化完之后就固定不变了。实际上,文件系统不是一个被动存在的静态结构,而是一整套嵌入在操作系统IO路径里的活跃机制。它的核心作用,是把一块冷冰冰的块设备(比如SSD、NVMe盘)翻译成我们熟悉的那种“目录套目录、文件套文件”的层级结构。
1.1 为什么裸设备没法直接用
想象一下你拿到一块完全没有格式化的硬盘,它在操作系统眼里就是一个巨长的数组,按扇区编号分成一块一块的,每块512字节或者4KB。想在上面存东西,你得记住“张三的照片在第1000个扇区到第2000个扇区”,时间一长记不住不说,删改文件之后还会留下大量零碎空隙,空间很快就不够用了。
文件系统干的事情,就是在这堆扇区之上建立一套索引规则:谁占用了哪些块、哪些块是空闲的、一个文件由哪些块组成、目录和文件之间的归属关系是什么。在内核里,这一切通过一个名为VFS(Virtual File System,虚拟文件系统)的抽象层统一起来。VFS是Linux的一个设计精髓:它定义了一套标准接口(读、写、打开、关闭、读取目录项等),每个具体文件系统去实现这套接口。
1.2 你用到的所有存储操作都经过VFS
当你执行cat /etc/passwd时,用户态程序调用的其实是read()系统调用,这个调用首先进入内核的VFS层,VFS根据路径解析找到对应的文件系统实例,再调用具体文件系统(比如ext4)的读取函数,ext4再向下经过通用块层和驱动,最终把数据从磁盘读上来。
这套设计的直接好处是:上层应用完全不需要关心底层用什么文件系统。对用户来说,ext4、XFS、Btrfs的编程接口完全一样;而文件系统开发者也只需要实现VFS规定的接口,就能被Linux完整支持。
在运维工作中,这个架构也给了我们排查问题的基本思路:如果一个IO操作卡顿,到底是卡在VFS层、具体文件系统层、通用块层,还是设备驱动和硬件本身?不仅能通过iostat、iotop这类工具看设备层指标,也能通过strace看系统调用的时间分布,用echo 3 > /proc/sys/vm/drop_caches清缓存来区分文件系统缓存命中和实际读盘的开销。
2. 磁盘上的真实组织:超级块、inode、目录项与数据块
文件系统把磁盘划分成了几个核心组成部分,这些结构的名字你可能都听过,但未必清楚它们之间如何配合。简单说,一个文件在磁盘上的存在形式,由四种元信息决定:超级块(Superblock)、索引节点(inode)、目录项(dentry)和数据块(Data Block)。
2.1 超级块:整个文件系统的“户口本”
超级块存的是文件系统级别的全局信息:文件系统类型、块大小、总块数、空闲块数、inode总数与空闲数、挂载状态、最近挂载时间等。可以说,内核挂载一个文件系统时,第一件事就是读取超级块,确认这个文件系统的“身份信息”和“健康状态”。
超级块损坏时,文件系统基本无法挂载,所以很多文件系统(如ext系列)会在磁盘不同位置保存多份超级块副本。当你在dumpe2fs的输出里看到“Superblock backups”那一串块号,指的就是这些备份位置,主超级块坏了可以用e2fsck -b 备份块号尝试恢复。
2.2 inode:文件的“身份证”,但名字不在这里
需要反复强调的一点:程序里我们叫“文件名”,但是文件系统底层几乎不用文件名做索引。磁盘上的一个文件,真正对应的是一个inode——里面记录了文件类型、权限、属主、大小、时间戳,以及指向数据块的指针列表(或者extent映射段)。
那文件名存在哪里?答案是目录项(dentry)。目录本质上也是一个文件,它的数据块里存放的是一张“名字→inode号”的映射表。所以你创建一个文件时,实际上做了两件事:分配一个inode存放文件元数据,然后在所在目录的数据块里追加一条“文件名 + inode号”的记录。
理解了这一点,硬链接就很容易说清:硬链接只是在另一个目录项里添加了一条指向同一inode的记录,inode里的链接计数加一。所以ls -l显示的第二列数字(硬链接数),本质上是“有多少个目录项指向这个inode”。
inode的数量在格式化时就固定了(某些文件系统如XFS可以动态分配,但ext4是固定的)。这意味着,即使磁盘剩余空间很大,一旦inode耗尽,系统同样会报“No space left on device”。用df -i看inode使用率,用dumpe2fs -h /dev/xxx查看具体inode总量,是排查这类问题的第一道命令。
2.3 数据块和它的两种组织方式
文件的内容存在数据块里。早期ext2/3采用直接/间接块指针的方式管理文件占用的块:inode里放十几个直接块指针,不够了指向一级间接块,再不够指向二级、三级间接块。这种方式实现简单,但大文件的访问要循着指针链逐级跳转,性能一般。
后来的文件系统普遍改用extent映射:inode里记录“起始块号+连续块数”这样的段(extent),文件在磁盘上表现为一段或几段连续区域。大文件只要记录起点和长度,不必为每个块单独存指针。这也是ext4相比ext3在读写大文件时性能提升的关键原因之一;而XFS从一开始就设计为基于B+树来管理extent,所以它在处理超大文件、高并发写场景时更从容。
了解这些结构之后,很多命令输出就不再是干巴巴的数字:stat file打印的就是inode里的元数据;ls -i显示的是文件对应的inode号;filefrag -v file能看到文件在磁盘上的连续段分布。
3. 读一个文件,内核实际做了什么:page cache与预读
打开一个文件往里看,内存和磁盘之间隔着一个几乎永远在工作的缓存机制——page cache(页缓存)。它是Linux内核把读写性能做上去的关键武器,也是你在free命令里看到buff/cache那一大坨数值的来源。
3.1 数据先到page cache,再给你
读过20世纪90年代Unix系统书籍的人可能记得一个说法:一切皆文件。而在现代Linux里,更准确的说法是:你的文件数据,基本都是先被映射到名为address_space的对象管理的page cache里,再通过read()拷贝给用户态进程。
当你第一次cat一个文件时,内核先去page cache里查该文件的页是否已缓存。没有,于是发起磁盘IO,数据从块设备加载进page cache,再拷贝到用户缓冲区;第二次再cat同一个文件,数据直接从内存中的page cache返回,不再碰磁盘。这就是为什么“第二次读快得离谱”,同样也是数据库类应用为什么需要关注缓存命中率的原因。
page cache最小管理单位是页(通常4KB)。一个页被加载进缓存后,它的真实归属是“文件+文件内偏移”,而不是一个孤零零的内存页。所以Linux才能做到:即使物理内存紧张,内核也能把最久没用过的页写回并回收,而不是一古脑儿把所有文件缓存都丢掉。
3.2 预读(readahead)是怎么让顺序读飞起来的
如果每次读4KB都发起一次磁盘IO,顺序读一个1GB的大文件可能要发起几十万次IO请求,速度可想而知。内核的做法是预读:检测到进程正在顺序访问文件时,一次性往page cache里多塞一些后续数据(典型是数百KB到1MB),这样顺序读操作可以做到“内存快得让磁盘追不上”。
预读效果最好的场景是拷贝大文件、日志顺序扫描这类全顺序访问;反之,随机小IO密集场景(比如数据库B树索引扫描)预读不仅无效,还可能浪费IO带宽,所以数据库服务器常常会建议在内核IO调度层面关闭预读或降低预读量。
3.3 mmap读为什么有时比read更快
除了read()/write()这组系统调用,还有另一个读取文件的方式:mmap()。它把文件的一段区域映射进进程地址空间,之后进程访问映射区域时,缺页异常触发内核从page cache取页。由于不需要每次读写都做内核态到用户态的显式拷贝,mmap在某些场景(比如读大文件中的若干碎片、进程间共享文件数据)性能更优。
不过mmap并不总是银弹:它有额外的缺页异常开销,映射管理也需要占地址空间和页表结构,小文件场景用read反而更简单直接。我见过有同事把公司配置中心的小文件也改成mmap读取,结果性能没显著提升,反而因映射管理导致内存占用上涨,最后又改回来了。
4. 写文件时的“忍住不写”:延迟写与日志事务
比起读路径,写路径要复杂得多,因为写操作不能简单地“缓存起来就行了”——万一掉电怎么办?万一内核刚在内存里改了元数据、还没来得及落盘就崩溃了呢?于是有了两套关键机制:延迟写(write-back)和日志(journaling)。
4.1 write-back:先把改动攒在内存,再批量回写
早期Linux用write-through策略,用户每次write()都直接刷到磁盘,请求多、冲突大、性能差。后来改为write-back:write()返回时,数据只是被写进了page cache(标记为脏页,dirty page),内核隔一阵子或达到阈值后,由后台线程(flush相关的pdflush/f2fs等各有差异)把脏页批量写回磁盘。
这种“忍住不写”的设计,让磁盘IO可以按顺序、按段合并地批量处理,性能比逐次写盘高出一个量级。代价是:掉电时,page cache中还没来得及落盘的数据会丢失。在需要强一致性的场景(如数据库的事务日志、消息队列的持久化确认),应用就必须主动调用fsync()、fdatasync(),或者打开文件时使用O_SYNC/O_DSYNC标志,强制让数据真正落到稳定存储后才返回。
这里有个极其常见的运维误区:只fsync数据文件,却忘了同步目录项。新增一个文件时,文件数据本身和“目录项里新加的那个名字”都可能在page cache里。如果应用创建完文件后只fsync了文件内容,没fsync目录,掉电后文件有可能消失或者目录项不完整。正确的做法是:对新建文件的目录也执行一次fsync。
4.2 ext3的教训:写日志不是“又写一份数据”
ext2时代没有日志,掉电后需要对整个文件系统做fsck扫描,大磁盘可能要扫几小时。于是ext3引入了日志文件系统,但它的设计常被误解为“写日志=把数据写两遍,性能必然下降”。如果真那么蠢,也不会所有主流文件系统都采用日志机制了。
日志机制的本质是:在真正修改磁盘上的元数据和数据之前,先在一个独立的、顺序写入的日志区域记录“我要做哪些修改”,这些修改被记录成一个个事务(transaction)。事务完整提交后,内核才把改动真正写到文件系统的主区域。掉电恢复时,只需检查日志里最后一个事务,如果它完整,就重放(redo)它;如果不完整,就丢弃(undo),文件系统因此能快速回到一个一致状态,不用全盘扫描。
4.3 ext4的三种日志模式
ext4沿用了ext3的日志模式设计,分为三种:
- journal(全日志):元数据和数据都先写日志,一致性最强,但数据写入要过两遍盘,性能最差。
- ordered(顺序模式,默认):只记录元数据事务,但保证数据块在元数据被提交到日志之前已经落盘。也就是“先写数据,再记元数据日志”,这样恢复时不会出现“元数据指向了还没写成功的数据块”的脏场景。这是性能和一致性的平衡点。
- writeback(回写模式):只记录元数据,不保证数据块落盘顺序。有概率在崩溃后出现:文件元数据被恢复到一个有效状态,但文件里的内容却是老数据或混合数据。
对于大多数常规服务器,ordered模式足够安全且性能可接受;如果业务需要极强的数据一致性,要么切换到journal模式,要么在上层应用自己做事务/校验,不要指望文件系统兜底。
4.4 fdatasync比fsync省在哪里
很多文章说fsync和fdatasync的区别,但这俩并不是谁比谁“更快”这么简单。fsync()会把文件的数据和所有元数据(大小、时间戳、属性等)都刷到磁盘;fdatasync()只保证“数据内容”以及“为了取回数据所需的元数据”落盘,不保证比如mtime/ctime这种不直接影响数据定位的属性。
对数据库这类频繁提交事务的应用,用一个fdatasync()替代fsync()能减少一次元数据刷写,尤其在高频提交时有可感知的性能收益。不过这只是一种微优化,真正的关键还是:确保你的崩溃一致性逻辑正确,先用fsync跑通,再谈优化。
5. 不只是ext4:主流文件系统的架构差异与选型思路
如果你只在Linux的默认安装上做过ext4的分区,可能觉得“文件系统不就长这样吗”。但真要按场景选型时,ext4、XFS、Btrfs、ZFS的差异远不止格式化时间不同这么简单。
5.1 ext4:成熟、稳定、哪里都是它
ext4作为ext3的进化版,兼容性、稳定性和通用性是最大优势。在线扩展文件系统、extent映射、多块分配、延迟分配这些特性让它成为一个“不功不过”的默认选择。对大多数Web服务器、文件服务器、虚拟机镜像存储来说,ext4只要别折腾特殊需求,通常不会出问题。
它的短板在于:不支持数据校验和,无法发现静默损坏;在线缩容文件系统极难(基本不支持);高并发下的扩展性不如XFS。所以如果跑数据库、大文件存储,我不太建议用ext4扛。
5.2 XFS:大文件、高并发、并行IO的扛把子
XFS诞生于SGI时代,早期就是为大规模并行IO设计的。它的inode支持动态分配,B+树管理的extent结构对超大文件和高并发场景友好。RHEL/CentOS 7以后把XFS作为默认文件系统,不是没有道理。
实际操作中,XFS有几个特性要特别注意:XFS不支持在线收缩(在线减小分区在XFS上是做不到的,只能备份重做);XFS的xfs_repair在极端损坏情况下修复成功率不错,但修复时间随磁盘大小增长明显;XFS默认分配策略偏“激进”,大量小文件的元数据管理不如ext4紧凑。所以小文件密集型场景(比如邮件目录、源码仓库),ext4有时候反而比XFS表现好。
5.3 Btrfs与ZFS:功能丰富,但需要懂行的人驾驭
Btrfs支持快照、子卷、压缩、校验和、RAID,是很多人心中的“全功能文件系统”;ZFS(通常用OpenZFS)则更进一步,把存储池、快照、克隆、压缩、校验和融为一体。它们都具备数据和元数据的校验和,能在数据被静默篡改时发现并尝试修复,这是ext4/XFS不具备的。
但功能丰富不等于零代价。Btrfs的RAID5/6实现历史上出过数据丢失问题;ZFS对内存的需求很大(默认建议每1TB存储配1GB内存做ARC缓存),而且跨文件系统迁移、扩展缩容也有自己的规则。我的态度是:如果你的团队没有深度接触过Btrfs/ZFS的运维经验,不要贸然在核心数据库存储上直接上这些文件系统;可以用在备份、归档、容器数据目录这些“值得快照但不至于命悬一线”的位置。
选型其实没有标准答案,核心是识别你的工作负载:大量小文件的随机读写、大文件的顺序读写、强一致性要求、CPU/内存是否充裕,都直接影响选择。下面这张表是我平时给团队成员的一个精简参照:
| 用途 | 推荐 | 理由 |
|---|---|---|
| Linux根分区、通用服务器 | ext4 | 兼容性好、问题少、恢复工具成熟 |
| 大数据顺序读写(视频、备份) | XFS | extent/B+树结构更适合大文件并行IO |
| 大量小文件(邮件、代码仓库) | ext4 | 元数据密度更高,小文件场景更省空间 |
| 需要快照的容器/虚拟化存储 | Btrfs/ZFS | 快照、克隆能力是刚需 |
| 数据库数据盘 | XFS或ext4+应用层一致性 | 文件系统层保持简单,靠上层机制保证一致 |
6. 实战排障:三个文件系统“灵异事件”背后的真相
写到这里,回看文章开头抛出的两个问题,正好对应文件系统结构里的两处关键设计:空间未释放和inode耗尽。把它们和另一个高频问题“IO延迟抖动”放在一起,正好覆盖了文件系统运维中最常见的三个误判。
6.1 删了大文件,df空间却没变少
几乎所有运维都遇到过这种情况:rm掉一个几十G的日志文件,du也看不到它了,但df的Used就是没降下来。
根本原因是:文件被某个进程仍在持有打开的文件句柄。rm删除的只是目录项,而文件对应的inode仍被进程引用,内核认为这个文件还有使用者,所以不能释放其数据块。只有进程关闭该文件句柄后,inode引用数归零,空间才会真正释放。
排查手段是lsof +L1或lsof | grep '(deleted)',找出还在占用已删除文件的进程,重启它或让它释放句柄即可。如果短期内不能重启进程,只能先确认磁盘剩余空间够不够撑过去。经验之谈:因为这个问题,我养成习惯,每次大日志轮转后都会顺手lsof +L1 | grep deleted看一眼,防止线上服务因为“看起来删了却还占着”被拖垮。
6.2 磁盘还有空间,却报No space left on device
这个现象有两个常见原因:inode耗尽和保留块耗尽。
inode耗尽上面讲过,df -i确认;如果确认是inode不够,ext4可以用tune2fs -m调大预留比例,但更彻底的方案是:在规划分区时估算文件数量——每创建一个文件,不管是1字节还是1GB,都要占用一个inode,如果你的目录结构里小文件有几千万个,格式化时务必考虑inode数量。
还有一个常被忽略的:ext4/XFS默认会保留一部分块给root用户,防止文件系统写满后root无法执行系统管理操作。普通用户写入时,即使可用空间显示还有几GB,到了预留阈值也会报ENOSPC。这个可以通过tune2fs -m 0(ext4)临时调整,但不建议彻底关掉,毕竟那点预留块某些时候能救命。
6.3 同一块盘,有时写入时好时坏
还有一类延迟问题,表面看是磁盘故障,实际是文件系统层的回收、日志提交、写回突发造成的间歇性卡顿。比如XFS的xfsaild在脏日志积压时会集中刷新,ext4的kjournald2在日志提交突发时也会让写操作排队变长。
这类问题的排查,不能只看iostat的平均值,要看峰值和分位数。用iostat -x 1观察%util和await的即时变化,配合blktrace抓一下IO的分布模式,通常能发现“周期性排队写盘”的规律。如果确认是写回压力集中导致的抖动,可以在/sys/block/xxx/queue/read_ahead_kb调低预读量,或者调整/proc/sys/vm/dirty_writeback_centisecs的间隔,不过这些参数需要结合具体业务反复调,不要照抄网络上的所谓“优化脚本”。
文件系统不是格式化完就能一劳永逸的东西,它在你每一次读写、每一条链路里真实参与运转,只是平时不声不响。如果把文件系统结构、读写缓存、日志机制这三块吃透,再配上df -i、lsof +L1、dumpe2fs这些基本工具,就算暂时不用看内核源码,也足够对付日常环境里绝大多数存储相关的问题了。
最后分享一个我个人的习惯:每次新接手一台服务器,我都会先花几分钟跑一遍mount、df -h、df -i、cat /proc/mounts,确认每个分区用的什么文件系统、挂载选项是什么、inode余量怎么样。这个习惯救过我很多次——很多故障在发生之前,其实已经在这些基础信息里漏出过苗头。