
1. 从一块裸盘说起文件管理到底在解决什么问题把一块新买的固态盘插到机器上它本质上就是一条长长的、线性的地址空间从 0 号扇区一直排到最后一个扇区。你往里写 1MB 数据再想找回来就得自己记住我当初是从第 8 号扇区开始写的、写了 2048 个扇区、中间有没有被别的东西覆盖。这种玩法在只有几十 KB 存储的年代还能凑合放到今天动辄几个 TB 的容量上纯属自找麻烦。操作系统里的文件管理干的活就是把这堆裸块包装成人能理解的形态。我习惯把它概括成一句话文件管理是一套把无名的物理块翻译成有名的逻辑对象的规则集合。翻译的结果就是我们每天双击的文件夹、拖进去的图片、正在编辑的文档。你在资源管理器里看到的一个文件 2.3GB在磁盘上可能散落在几千个不相邻的块里中间还夹着别人的数据碎片——这中间的映射关系全部由文件管理系统维护。很多同学对文件管理的印象停留在增删改查文件这个层面觉得它就是个高级版的管理工具没什么原理可言。真正拉开差距的地方在于两件事空间怎么分分配方式和地址怎么找索引结构。这两件事决定了你的文件系统是能扛住大文件、还是小文件满天飞就卡死是掉电之后数据全在、还是目录树直接烂掉。不管你是准备操作系统期末考试、还是回头啃《计算机操作系统》教材里的索引结点计算题又或者只是想搞明白 Linux 上df -i为什么会被一堆小文件耗光这一整套东西都得从最底下捋一遍。1.1 裸盘视角与用户视角的六处差异我整理过一张对照表把同一块硬盘的两种看法直接摆在一起。想清楚这六条差异文件管理存在的理由基本就自明了。维度裸盘视角用户视角寻址方式扇区号 / LBA 线性偏移路径名 文件名长度信息不存在块本身不知道边界文件有明确大小属性空闲空间不知道哪些块空着不关心系统自动分配并发访问谁都能覆盖谁有权限位、有文件锁断电后果数据损坏无从恢复有日志、有 fsck 兜底可移植性依赖具体硬件几何挂载到任意系统都能读这张表里最容易被忽略的是第三行。空闲空间管理听起来像个配角实际上它是文件系统里访问频率最高的结构之一——每创建一个新文件系统都得从空闲结构里抠出一块地方抠得快不快、抠完之后结构还完整不完整直接决定了大目录下批量创建文件时的性能。1.2 文件管理必须回答的四个问题把上面那些差异收拢文件管理其实就在反复回答四个问题怎么命名和组织文件叫什么、放在哪个目录下、目录之间怎么形成层级。这就是文件逻辑结构和目录结构要解决的事。数据放在哪里一个文件占哪些物理块、这些块之间怎么串起来。这就是物理结构分配方式要解决的事。怎么高效定位用户说读第 100 万字节系统怎么在 O(1) 或 O(常数) 次磁盘访问内把块号算出来。这就是索引结构要解决的事。谁能用、能不能同时用多用户、多进程环境下文件的共享、保护、并发控制。这就是存取控制和文件锁要解决的事。这四个问题不是并列关系而是层层递进。你选定了物理结构就直接决定了能支撑什么样的索引结构你选定了索引结构又反过来约束了文件的最大长度和随机访问性能。教材上把它们拆成独立章节讲考试时也分开考但在真实的文件系统代码里这四个决策是绑在一块儿拍的。1.3 文件系统的分层一个系统调用要穿过七层从应用程序敲下read()到数据真正从闪存颗粒里读出来中间穿过的层级比多数人想象的多。典型的操作系统教材会把它拆成这么几层我从下往上倒着说更符合排查问题时的思路辅助分配模块管理空闲块负责给我一块空闲空间和这块不用了还回去。设备管理把逻辑块号翻译成物理地址处理中断、DMA、缓存。物理文件系统负责物理块的分配和回收维护位示图或空闲链表。逻辑文件系统与文件信息缓冲区负责逻辑块号到物理块号的换算读写索引结点。存取控制模块校验权限判断这次操作合不合法。文件目录系统把路径名解析成对应的索引结点。用户接口对外暴露 open/read/write/close 这些系统调用。分层的好处是每层只关心自己的事坏处是出问题时排查路径变长。我遇到过一种典型的文件读不出来但磁盘明明是好的的情况最后定位到是目录系统这一层——某个目录的索引结点损坏路径解析直接失败跟底层存储介质一点关系都没有。所以看到报错别急着换硬盘先按层往上找。2. 逻辑结构与物理结构用户看到的和磁盘上真实存在的这一节是整个文件管理里最容易考、也最容易在实际工作中踩坑的部分。核心是要把两个概念彻底分开逻辑结构是站在用户角度看的组织方式物理结构是站在磁盘角度看的存放方式。这两者之间没有必然的对应关系同一个逻辑结构可以用不同的物理结构实现。2.1 逻辑结构从字节流到带记录的文件逻辑结构主要分这么几类无结构文件流式文件是最常见的一种。文件内容就是一串字节没有任何内部结构系统不关心你里面存的是文本、图片还是可执行代码读第 N 个字节就给你第 N 个字节。UNIX、Linux、Windows 上你日常接触的文件几乎都是这一种。它最大的好处是灵活——任何格式都能往里面塞格式的解析交给应用程序自己负责。有结构文件记录式文件把文件看成一条条记录的集合每条记录有固定或可变的字段。这种结构在早期的数据处理系统里很主流现在更多出现在数据库底层或者一些专用格式里。按记录的组织方式又可以继续细分顺序文件记录按某种顺序排列只能顺序或者按关键字顺序访问。定长记录的顺序文件支持折半查找变长记录就只能老实从头扫。索引文件给文件额外建一张索引表表里存关键字 → 记录地址。想查哪条记录直接查表代价是多占一块存储、多一次访问。索引顺序文件顺序文件和索引文件的折中方案把记录分组每组建一条索引项组内顺序查找。这是实际工程里用得最多的一种思路很多日志系统的分块索引、Kafka 的分段索引都是这个套路。直接文件散列文件用哈希函数把关键字直接映射到记录位置。查询最快但没法做范围查询哈希冲突还得单独处理。选哪种逻辑结构取决于你的访问模式。如果 90% 的操作是按时间顺序追加、按时间范围扫描那索引顺序文件就很合适如果全是按 ID 精确查一条直接文件更香。2.2 连续分配最简单也最暴力的方案连续分配要求一个文件占用的物理块在磁盘上彼此相邻。文件目录里只需要记两项起始块号 文件长度。定位任意位置的公式非常干脆物理块号 起始块号 逻辑块号这个方案的优点一目了然。支持顺序访问也支持随机访问一次寻道就能把整个文件连着读完磁头移动最少。放到今天依然有它的用武之地——Linux 的fallocate就是提前把连续空间划出来数据库的临时排序文件、视频剪辑的中间产物都喜欢这么干。缺点同样致命。外部碎片是最头疼的磁盘上明明加起来有足够的空闲空间但都是零散的塞不下一个需要大块连续空间的文件。另一条更现实的问题是文件增长困难。你写了 10 分钟的文件一开始只分了 8 个块写到第 9 个块的时候发现后面那块被别的文件占了要么整体搬家代价极高要么就宣告失败。早期系统靠预留一段空间来缓解但预留多少是个赌博预少了不够用预多了浪费。提示连续分配在考试里常被拿来考最坏情况下需要多少次磁盘访问。答案是 1 次寻道 1 次旋转 1 次传输因为整个文件是连续的不用换道。2.3 链接分配用指针把散块串起来链接分配把文件块分散到磁盘各处用指针把它们串成一条链。它有两种形态区别非常大。隐式链接把指针藏在每个数据块里——每个块的最后几个字节记录下一个块的块号。目录项里只存首块指针和末块指针。好处是没有外部碎片文件可以随时增长只要有空闲块就能往后接。坏处是不能随机访问想读第 500 个块得从第 1 个块开始一个个跳过去500 次磁盘访问能把人急死。而且指针占用了数据块的空间实际可用容量打了折扣一旦中间某个指针损坏整条链就断了。显式链接把指针从数据块里抽出来集中放到一张**文件分配表FAT**里。FAT 的每一行对应一个物理块行里存的是该块的下一块是第几块。这张表在系统启动时会被整个读进内存常驻于是随机访问变快了顺着 FAT 在内存里跳不用读磁盘。指针不再占用数据块空间。代价是那张表占内存。FAT32 有大约 2^28 个表项每项 4 字节光这张表就是 1GB 的量级——这也是为什么 32GB 以上的移动存储不适合用 FAT32以及为什么大容量盘的 FAT 表会吃掉可观的常驻内存。链接分配还有个隐性的性能毛病块与块之间不相邻读一个大文件时磁头要在盘上到处乱跑。所以它在顺序读写大文件时性能明显不如连续分配。实际部署里通常会配合碎片整理或者干脆交给 SSD 来抹平寻道成本。2.4 索引分配把所有块号收进一张索引表索引分配给每个文件单独配一个索引块索引块里存着这个文件所有数据块的块号。目录项里只需要记录索引块的块号。想读第 N 个块直接去索引块里查第 N 项一次间接访问搞定。这个方案同时解决了连续分配的外部碎片问题和链接分配不能随机访问的问题代价是多了一层索引块的开销读一次数据要额外读一次索引块小文件尤其吃亏——一个 100 字节的文件也得配一个完整的 4KB 索引块浪费严重。工程上的解法是分层。按索引块的组织方式可以分三级单级索引一个索引块直接指向所有数据块。文件上限 每块能存的地址数 × 块大小。多级索引索引块指向下一级索引块。多一层文件长度上限就翻几个数量级但访问一次数据要读的索引层数也增加了。混合索引给索引结点配若干直接地址项和若干间接地址项小的直接寻址、大的走间接。这是绝大多数现代文件系统采用的做法下一节会用具体数字把寻址过程算一遍。2.5 三种分配方式的取舍与真实场景把三种方式放在一起对照思路会清楚很多分配方式随机访问文件增长外部碎片额外空间开销典型代表连续分配强困难有几乎没有ISO 镜像、数据库临时文件链接分配隐式弱容易无每块几个字节早期操作系统链接分配显式中容易无一张常驻内存的大表FAT16/FAT32/exFAT索引分配强容易无每个文件一个索引块NTFS、ext4、XFS我的实操体会是这三种方式不是互相替代的关系而是各管一段。ext4 用混合索引管主数据流同时提供fallocate走连续分配给大文件提速一些日志结构文件系统干脆全程用索引加写时复制。真要在自己的项目里做选型先问清楚三件事——文件平均多大、访问是不是随机的、增长是否可预测。答案不同选型就完全不同。3. 目录与索引结点文件名到磁盘块的那一跳用户敲的是路径磁盘认的是块号。把这中间接上的就是目录和索引结点。这部分是我认为整个文件管理里最优美的设计也是考试里计算题最集中的地方。3.1 目录的本质就是一张映射表剥掉所有装饰目录就是一张表表项是文件名 → 它对应的物理地址信息。早期系统直接把文件的所有属性大小、创建时间、权限、块列表全塞在目录项里导致目录项很大一个目录下的文件多了光是扫描目录就得读好几块磁盘。后来演进成了索引结点inode方案把所有属性从目录里挪出去集中放进一个固定大小的数据结构目录项只留两样东西——文件名 inode 号。这一改动把目录项压缩到了很小目录检索的 I/O 次数大幅下降。目录结构本身也经历过几轮演进单级目录所有文件挤在一起重名冲突无解→ 两级目录分用户→ 树形目录我们现在用的就是→ 无环图目录允许共享用链接。树形目录的路径解析过程本质上是从根开始每读一级目录就拿路径的下一段去匹配匹配上就拿到 inode 号继续往下走。所以路径越深解析越慢每多一层就要多一次地址映射查询。3.2 一个 inode 里到底装了什么以经典的 UNIX 风格索引结点为例里面大概有这么几类字段字段类别具体内容作用文件元数据文件类型、权限位、链接计数、所有者 UID/GID存取控制和共享判定时间戳atime、mtime、ctime记录访问、修改、状态变更时间尺寸信息文件字节大小、占用块数快速读取不用遍历地址索引直接地址项 一级/二级/三级间接地址项逻辑块号到物理块号的换算在 Linux 上用stat就能看到这套字段的实际样子$ stat demo.bin File: demo.bin Size: 4194304 Blocks: 8200 IO Block: 4096 regular file Device: 803h/2051d Inode: 131074 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 1000/ user) Gid: ( 1000/ user) Access: 2024-05-11 09:12:03 Modify: 2024-05-11 09:10:47 Change: 2024-05-11 09:10:47注意Blocks: 8200这一行。这是一个 4MB 的文件实际占用 8200 个 512 字节的块也就是约 4.2MB。多出来的那部分就是索引块和元数据本身的开销。小文件多的时候这个开销会变得极其显眼——一个 1KB 的文件可能实际占用 8KB 甚至更多。3.3 混合索引的寻址计算把公式推一遍这是操作系统考试里出现频率最高的计算题也是理解大文件系统的关键。设定索引结点里有 13 个地址项其中 0~9 号是直接地址项10 号是一级间接11 号是二级间接12 号是三级间接每个磁盘块 4KB每个地址项占 4 字节。先算一个索引块能装多少个地址项4096 字节 / 4 字节 1024 个地址项于是各级能覆盖的范围是地址项类型覆盖逻辑块数覆盖字节数0~9直接1010 × 4KB 40KB10一级间接10241024 × 4KB 4MB11二级间接1024² 10485761048576 × 4KB 4GB12三级间接1024³1024³ × 4KB 4TB所以这个单文件最大能到约 40KB 4MB 4GB 4TB ≈ 4TB 出头。一个 128 字节的索引结点撑起了 TB 级的文件这就是多级索引的杠杆效应。具体定位某个字节比如偏移量 50000 处的数据在第几个逻辑块、需要几次磁盘访问逻辑块号 50000 / 4096 12向下取整 块内偏移 50000 - 12 × 4096 848逻辑块号 12 落在哪个地址项直接地址项覆盖 0~9一级间接覆盖 10 ~ (10 1023) 10 ~ 1033。所以 12 在一级间接范围内一级间接内的编号 12 - 10 2也就是说要去 10 号地址项指向的那个一级索引块里取第 2 项下标从 0 开始拿到数据块号再读那个数据块的第 848 字节。磁盘访问次数先读一级索引块1 次再读数据块1 次共 2 次。如果访问的是二级间接覆盖的范围次数就是 3 次。这个计算我可以给一条固定的解题路径考试的时候照着套就行用字节偏移除块大小求逻辑块号取整。判断逻辑块号落在哪一级先减 10若还在 0~1023 内则是一级间接再减 1024若在 0~1048575 内则是二级间接剩下的就是三级间接。对每一级做除法和取模得到该级索引块内的下标序列。磁盘访问次数 索引层数 1。注意目录项本身也算一次访问。如果题目问的是通过路径名访问一个文件共需要多少次磁盘访问别忘了把逐级读目录的次数加进去这是丢分的重灾区。3.4 硬链接与软链接动手验证才能记住这两个概念很多人背过但一到实际场景就用错。我用一个最小实验把区别摆出来$ echo hello file a.txt $ ln a.txt b_hard.txt # 硬链接 $ ln -s a.txt c_soft.txt # 软链接 $ ls -li 131074 -rw-r--r-- 2 user user 11 May 11 10:00 a.txt 131074 -rw-r--r-- 2 user user 11 May 11 10:00 b_hard.txt 131075 lrwxrwxrwx 1 user user 5 May 11 10:00 c_soft.txt - a.txt看 inode 号那一列。a.txt和b_hard.txt的 inode 号是同一个131074链接计数是 2。它们压根就是同一个文件的两个名字指向同一份磁盘数据。c_soft.txt的 inode 号是另一个131075它是一个独立的文件内容恰好是字符串a.txt。关键在于删除行为。删掉原文件之后$ rm a.txt $ cat b_hard.txt # 正常输出 hello file $ cat c_soft.txt # 报错No such file or directory硬链接不受影响因为数据还在只是少了一个名字链接计数从 2 减到 1。软链接直接变哑巴因为它存的只是一个路径字符串路径指向没了它就死了。这解释了软链接的经典坑——相对路径软链接一挪目录就断绝对路径软链接换机器就断。还有两条限制要记住硬链接不能跨文件系统inode 号只在单个文件系统内唯一一般也不允许指向目录会造成目录环find之类的工具会死循环。软链接没有这些限制可以跨分区、可以指向目录代价是多一次路径解析。4. 空闲空间管理四种做法和它们的适用场景每创建或删除一个文件文件系统都要去问空闲结构哪里还有空。这个结构被访问得如此频繁以至于它的性能直接决定了文件系统的整体手感。教材里讲了四种做法我把它们的效果和代价摊开看。4.1 空闲表与空闲链表空闲表法维护一张表每一项记录一段连续空闲区的起始块号 块数。分配时从头扫找到足够大的空闲区就切一刀。它的思路跟内存的动态分区分配几乎一样所以首次适应、最佳适应、最坏适应这几套算法可以原样搬过来用。优点是连续分配场景下效率高一次就能拿到一大块连续空间。缺点也跟内存管理一样表会变长而且随着反复分配释放空闲区会被切得越来越碎最后全是小块大文件分配不进去。空闲链表法把空闲块串成链表。可以按块为单位串每个空闲块里存下一个空闲块的指针也可以按连续区间为单位串每个结点记录一段空闲区。前者实现简单但链很长找一块空闲块要遍历后者链短但结点结构复杂些。这两种方式在今天的通用文件系统里都不常见了因为它们要么需要线性扫描、要么维护成本高。但在嵌入式或者一些专用存储里依然能看到身影原因是实现足够简单、代码量小。4.2 位示图用一个比特表示一个块位示图法给磁盘上的每个块分配一个二进制位0 表示空闲1 表示已分配或者反过来看约定。所有比特排成一个矩阵按机器字长分行。它的优势是结构极度紧凑——1TB 的磁盘、4KB 块大小总共 2.68 亿个块每位一个块也只需要约 32MB 的位示图。更关键的是位示图可以按字并行操作。找一个空闲块不需要逐个比特扫只要找出第一个非全 1 的字再在这个字里找第一个 0 就行。现代文件系统里还有更狠的做法直接调用 CPU 的指令在 64 位字上一次性判断有没有空位。位示图的编号换算公式必须背熟。设字长为 n 位位示图的行号字号从 1 开始位号也从 1 开始物理块号从 0 开始块号 b 对应字号 i b / n 1整除位号 j b % n 1 反过来块号 b (i - 1) × n (j - 1)举个例子字长 32 位要分配 1 号字号里的空闲块。假设位示图第一行的内容是1100 0000 0000 0000 0000 0000 0000 0000也就是说第 1、2 位已经被占用从第 3 位开始空闲。第 3 位对应的块号是b (1 - 1) × 32 (3 - 1) 2也就是 2 号物理块。分配出去之后把这一位置 1再改一下位示图的统计值。实操心得位示图法的性能瓶颈不在查找而在内存占用和一致性维护。位示图通常需要常驻内存的一部分如果系统内存紧张就得把部分位示图换出这会导致分配时产生额外的 I/O。所以在低内存设备上做文件系统选型位示图的常驻比例是个必须提前算清楚的指标。4.3 成组链接法UNIX 经典方案的巧妙之处成组链接法是为大容量磁盘设计的它把空闲表和空闲链表的优点捏在了一起。核心思想是把空闲块分组每组用一个块来记录其他空闲块的块号组与组之间用链表串起来。具体结构是这样系统在内存里维护一个栈栈里存着当前这一组所有空闲块的块号另外还存一个下一组的起始块号和这一组还剩多少块。分配时直接从栈顶弹一个块号出来当栈空了就把下一组起始块号里记录的那一整套块号读进来填满栈继续分配。回收时反过来栈没满就压进去满了就先把当前栈的内容连同下一组起始块号一起写到新回收的这个块里然后把这个块作为新的下一组起始块号。这个方案的精妙之处在于内存里只保留一组空闲块的完整信息磁盘上却保存着全部空闲块的链接关系。既不用把整张位示图常驻内存又不需要在分配时遍历长链表一次磁盘 I/O 就能补充几百个空闲块号。代价是实现复杂度上来了。写这个逻辑的时候边界条件特别多——栈满、栈空、栈里只剩一个块、这个块本身又被分配出去……我见过不少人照着教材伪代码写跑测试时一切正常一到边界就挂。写的时候建议把分组头块单独看待它既是链表结点、又是空闲块、又是栈的后备存储三种身份叠在一起这才是最容易出错的地方。4.4 四种空闲管理方式的横向对比方式内存开销分配效率查找连续区实现难度适用场景空闲表中中强中需要连续分配的文件系统空闲链表小低弱低嵌入式、简易文件系统位示图大需常驻高弱低通用文件系统成组链接很小高弱高大容量 UNIX 文件系统选型的核心权衡是内存 vs 分配速度。位示图把速度买来了代价是几十 MB 的常驻内存成组链接把内存省下来了代价是代码复杂度和偶尔的补充 I/O。这两种思路今天都还活着只是实现形式变了——现代日志结构文件系统用了类似位示图的位图但把它们放在可以按需换入换出的位置兼顾了两头。5. 一次 read 到底走了多远前面讲的都是静态结构这一节讲讲动态过程。理解一次文件读取的完整链路是排查 I/O 性能问题的基本功。我用read()这个最常见的系统调用做例子。5.1 open 阶段路径解析与 inode 加载open()做的事情比read()多得多但也只做一次。它大概经历这么几步路径解析把/home/user/data/a.txt从根目录开始切段逐级查询目录。每查到一段拿到对应的 inode 号。索引结点加载检查索引结点是否已在内存的 inode 缓存里不在就从磁盘读进来填充到内存结构里。权限校验拿当前进程的 UID/GID 和 inode 里的权限位对比判断是否有打开权限。分配文件描述符在进程的打开文件表里找一个空槽记录文件位置指针、访问模式、指向内存 inode 的指针。返回 fd把一个整数返回给用户态。这里面有缓存命中率的问题。目录项缓存dentry cache和inode 缓存都是内核里非常关键的加速结构。第一次访问某个目录下的文件会很慢要真的读磁盘同一目录下的第二个文件就快很多目录块已经在缓存里了。这也解释了一个现象遍历一个有几十万文件的目录时第一次慢得吓人第二次就快了不少。5.2 页缓存与预读为什么顺序读比随机读快一个数量级read()真正拿数据的时候走的是页缓存。内核以页为单位通常是 4KB缓存文件内容读的时候先看页缓存里有没有命中就直接从内存拷到用户缓冲区完全不碰磁盘没命中就触发一次磁盘读把整页读进缓存再从缓存拷给用户。在没命中的情况下内核还会做预读readahead既然你读了第 100 页那多半还会读第 101 页那就顺便把后面几页一起读进来。预读窗口会动态调整——顺序访问时窗口逐渐放大检测到随机访问时窗口缩小甚至关闭。这就是为什么顺序读一个大文件能跑满带宽而随机读小文件会慢一个数量级前者每次磁盘 I/O 都拉回来一堆有用的页后者每次只用到一页寻道和延迟全浪费了。在 Linux 上观察这一点很直观# 清掉页缓存需要 root $ echo 3 /proc/sys/vm/drop_caches # 第一次读冷缓存 $ time dd ifbigfile of/dev/null bs1M count1024 10240 records in 10240 records out 1073741824 bytes (1.1 GB) copied, 4.83 s, 222 MB/s # 第二次读热缓存 $ time dd ifbigfile of/dev/null bs1M count1024 1073741824 bytes (1.1 GB) copied, 0.31 s, 3.4 GB/s同样的数据、同样的命令差了一个数量级以上。中间那部分差距全部来自页缓存。这也提醒一件事做磁盘性能测试必须考虑缓存状态否则测出来的数字毫无意义这一点我在做存储选型对比时踩过好几次坑。5.3 直接 I/O、mmap 与 fsync 的代价页缓存虽然快但在某些场景反而是负担。比如数据库喜欢自己管缓存不希望数据被内核再缓存一遍内存被重复占用还多了一次拷贝。这时候就用直接 I/OO_DIRECT绕过页缓存直接对磁盘读写。代价是失去预读能力每次 I/O 的延迟都暴露出来所以数据库通常配合自己的异步 I/O 和批量提交策略一起用。mmap是另一种玩法把文件映射到进程地址空间读写内存就是读写文件。它省掉了用户态和内核态之间的数据拷贝对随机访问密集的场景很友好。但它也有坑——缺页中断的开销不可控而且msync的行为在不同系统上细节不一样写回时机不是你能完全掌控的。fsync是最容易被忽视的性能杀手。它强制把数据有时候还包括元数据刷到持久化介质上保证掉电不丢。很多人写代码时习惯每写一小段就调一次fsync结果吞吐量直接掉到几十 IOPS。正确的做法是批量累积后一次性提交或者用fdatasync只刷数据不刷元数据。我做过一个对比同样是写 10000 条小记录一条一刷和攒够 1000 条刷一次耗时差了几十倍。6. 掉电之后一致性、日志与崩溃恢复文件管理最考验设计水平的地方不是正常路径而是异常路径。断电、内核崩溃、进程被强杀这些情况下的数据一致性全靠文件系统的机制兜底。6.1 不一致是怎么产生的根本原因在于更新一个文件往往涉及多个元数据结构的修改而这些修改无法在一次原子写里完成。比如创建一个文件至少要动三处在目录里加一个目录项、在 inode 位示图里标记一个 inode 已用、在块位示图里标记数据块已用。这三步之间一旦断电就会出现目录里有个名字但没有对应的 inode或者inode 标记已用但目录里找不到它这类不一致状态。雪上加霜的是缓存回写顺序。数据块和元数据块都在页缓存里什么时候刷到磁盘由内核决定顺序并不保证跟你代码里的调用顺序一致。你可能先写完数据再改元数据但落盘顺序可能反过来于是磁盘上就出现了一个指向尚未写入内容的数据块的指针。6.2 日志式文件系统先记账再干活日志Journaling的思路很朴素在真正修改磁盘结构之前先把我要做什么写到一个连续的区域日志区里。日志写完并落盘之后再去改真正的数据结构。如果中途断电重启时只要读一遍日志就能判断哪些操作只做了一半。日志一般分三段描述块说明这次事务要修改哪些块。元数据块 / 数据块记录修改后的内容。提交块一个事务写完并落盘的标志。看到提交块说明这次事务是完整的。恢复时系统扫描日志区对每个有提交块的事务重做一遍对没有提交块的事务直接丢弃。这就是所谓的redo 日志另一类undo 日志记录的是修改前的内容用于回滚。日志模式也不止一种代价和安全性差别很大日志模式记录内容安全性性能journal全日志数据 元数据最高最低数据写两遍ordered顺序只记元数据数据先于元数据落盘高中ext4 默认writeback回写只记元数据数据顺序不保证中最高但可能读到旧数据选 mode 这件事我的建议很直接业务数据用 ordered 起步除非你对性能有极端要求并且能接受崩溃后文件内容可能是旧版本否则别碰 writeback。6.3 写时复制与日志的取舍另一种思路是写时复制COW永远不覆盖旧数据要改哪个块就把新内容写到一个新块所有更新完成后再原子地把根指针切到新树上。因为切换根指针这个动作本身是原子的所以任何时刻磁盘上都存在一棵完整可用的树。COW 的优点是天然一致、天然支持快照旧根指针留着就是一份快照。代价是写放大改一个字节可能牵动整条路径上的所有节点都要重写碎片也会更严重。所以 COW 类文件系统通常要配后台整理进程靠定期搬数据来缓解碎片。日志和 COW 不是对立的。有的系统在 COW 树上再加一层日志用日志来加速小块元数据的更新属于两种思路的混合。6.4 出问题之后怎么排查真遇到文件系统报错排查顺序我一般这么走看挂载状态mount | grep 挂载点如果看到ro说明内核检测到错误后主动切成了只读避免继续写入扩大损害。看内核日志dmesg -T | tail -50文件系统的报错信息基本都在这里关键词是 I/O error、EXT4-fs error、metadata。看介质健康smartctl -a /dev/sdX看重分配扇区计数、待映射扇区这类指标。很多文件系统坏了其实是盘先坏了。离线检查卸载之后跑文件系统自检工具让它修一致性。这一步必须在卸载状态下做挂载状态下强制修会直接搞出更大的破坏。备份优先如果数据重要先整盘镜像出来再动任何修复命令。修复工具本身也可能改坏东西。注意文件系统自检工具在发现严重不一致时有时会把无法归属的数据块挂到lostfound目录下。数据和文件名之间的对应关系已经丢了你只能靠文件内容和创建时间一个个认领。这就是为什么日志和定期备份都不是可选项。7. 多用户环境下的共享、保护与并发单个用户单进程的模型足够简单一旦叠加多用户和多进程文件管理就多出一堆新问题怎么让别人能读但不能改、怎么防止两个进程同时写同一个文件、删除文件的时候正在读它的进程怎么办。7.1 权限位、属主与访问控制列表最基础的模型是属主 / 属组 / 其他三段式权限每段三个位读、写、执行。这个模型的优点是极其紧凑——9 个比特就描述完一个文件的访问规则检查起来快得可以忽略成本。它的局限也很明显粒度太粗。如果我想让 A 用户能读、B 用户能写、C 用户只能执行9 个位根本表达不了。于是有了访问控制列表ACL允许给任意主体单独指定权限。代价是存储开销和检查开销都上去了——每次访问都要遍历一张可能很长的列表。工程上的常见做法是基础权限位 可选 ACL绝大多数文件只用 9 个位需要精细控制的少数文件才挂 ACL。Linux 的getfacl/setfacl就是干这个的$ setfacl -m u:alice:r-- report.pdf # 给 alice 单独加读权限 $ setfacl -m g:audit:r-x logs/ # 给 audit 组加读和执行权限 $ getfacl report.pdf # file: report.pdf # owner: user # group: user user::rw- user:alice:r-- group::r-- mask::r-- other::r--注意输出里那个mask::r--。ACL 里有个易错点mask 定义了除属主和其他之外所有条目的权限上限。你给 alice 设了rw-但 mask 是r--那 alice 实际只能读。这个坑我在配置共享目录时踩过一次查了半天才发现是 mask 在做限制setfacl默认会重算 mask但用-n参数时不会。7.2 文件锁劝告锁与强制锁两个进程同时写一个文件结果必然是互相覆盖。解决方案是加锁而锁有两种截然不同的语义劝告锁advisory lock只是君子协定。进程加锁之后别的进程如果也规规矩矩地先查锁状态再加锁就能协调好。但如果有进程压根不检查直接write内核不会拦。UNIX/Linux 的flock、fcntl默认都是这种。强制锁mandatory lock由内核强制执行。文件被加锁期间任何不符合锁语义的读写请求都会被阻塞或直接报错。听起来更强实际用得很少原因一是需要特殊挂载选项二是会在语义上带来很多诡异行为比如一个进程加锁后自己忘记释放整个功能就卡死。实际工程里用得更多的是锁文件 原子创建这种土办法#!/bin/bash LOCKFILE/var/run/myjob.lock # noclobber 保证 set -C 下不会覆盖已有文件实现原子争抢 if ( set -o noclobber; echo $$ $LOCKFILE ) 2/dev/null; then trap rm -f $LOCKFILE; exit $? INT TERM EXIT # 临界区 do_work rm -f $LOCKFILE trap - INT TERM EXIT else echo 另一个实例正在运行退出 exit 1 fiset -o noclobber加重定向利用的是 open 系统调的O_EXCL标志——文件存在则失败这一步是原子的。这个模式简单、跨语言、跨平台我在定时任务里用得最多。它的缺点是进程异常退出时锁文件会残留所以要在锁文件里写 PID启动时先检查 PID 是否还活着。7.3 删除一个正在被打开的文件会发生什么这是理解索引结点机制的一个绝佳切入点。用户执行rm实际做的事情是从目录里删掉这个文件名对应的目录项。把 inode 的链接计数减 1。如果链接计数变成 0且没有进程打开这个文件才真正释放数据块。也就是说只要还有进程持有这个文件的描述符数据就不会被释放。Linux 上可以用/proc/pid/fd/看到这些已删除但仍被打开的文件$ ls -l /proc/12345/fd/ lrwx------ 1 root root 64 May 11 10:20 3 - /var/log/app.log (deleted)df显示磁盘满了但du加起来对不上——这种经典疑难杂症十有八九就是某个进程还握着已删除的大文件描述符。解决办法是重启那个进程或者用truncate直接把 fd 指向的文件截断$ truncate -s 0 /proc/12345/fd/3这个技巧我救过好几次生产环境的急。理解了删除 断链 计数减一这个模型这类问题就不再是玄学。8. 高频计算题手推与概念辨析期末复习和面试准备最后总要落到做题上。这一节我把几类最常见题型的完整推导过程写出来都是可以直接套用的模板。8.1 索引结点寻址题的标准解法再走一道综合题。某文件系统索引结点有 12 个地址项0~9 为直接地址10 为一级间接11 为二级间接块大小 1KB地址项 4 字节。问读取文件偏移量 200000 处的数据需要几次磁盘访问先算索引块容量1024 / 4 256 个地址项各级覆盖范围直接10 × 1KB 10KB覆盖偏移 0 ~ 10239一级间接256 × 1KB 256KB覆盖 10240 ~ 272383二级间接256² × 1KB 64MB覆盖 272384 起200000 落在 10240 ~ 272383 之间属于一级间接。计算逻辑块号 200000 / 1024 195 一级间接内下标 195 - 10 185 块内偏移 200000 - 195 × 1024 320磁盘访问次数读一级索引块 1 次 读数据块 1 次 2 次。如果题目问的是首次通过路径名打开这个文件还要加上逐级读目录的开销一般按目录层级数算。8.2 位示图题的两步走设位示图字长 64 位物理块从 0 开始编号位示图从 1 号字号、1 号位开始编号。问1537 号物理块在位示图的哪个位置字号 1537 / 64 1 24 1 25 位号 1537 % 64 1 1 1 2答案第 25 个字、第 2 位。反向题位示图第 10 个字的第 20 位对应哪个块块号 (10 - 1) × 64 (20 - 1) 576 19 595这类题的唯一风险是编号起点。有的题目从 0 开始编号有的从 1 开始算出来的差值正好是 1但选择题的选项往往就差那 1所以读题时一定要把编号约定圈出来。8.3 磁盘访问时间的分解计算一次磁盘读写的时间由三部分构成总时间 寻道时间 旋转延迟 传输时间某磁盘转速 7200 RPM平均寻道时间 8ms传输速率 200MB/s读写 64KB 数据旋转延迟平均 60 / 7200 / 2 s 4.17 ms 传输时间 64KB / 200MB/s 65536 / (200 × 1024 × 1024) s ≈ 0.31 ms 总时间 ≈ 8 4.17 0.31 12.48 ms别看传输时间只有 0.31ms占总量不到 3%。这解释了两件重要的事第一机械盘时代每次多读一点几乎不要钱所以预读策略能带来巨大收益第二随机访问的位置数才是性能的决定因素而不是数据量本身。这也正是 SSD 出现后体验提升巨大的根本原因——它把寻道和旋转延迟这两项几乎削到零只剩下传输时间。8.4 几个容易翻车的概念辨析复习的时候有几组概念特别容易被混淆我列出来对照着记概念对区别要点记忆抓手逻辑结构 vs 物理结构一个是用户视角的组织一个是磁盘视角的存放逻辑管长什么样物理管放哪里硬链接 vs 软链接是否共享同一个索引结点硬链接是别名软链接是路径目录项 vs 索引结点目录项只存名字和 inode 号属性在 inode 里名字归目录属性归 inode位示图 vs 成组链接内存换速度还是省内存换复杂度一个是全图常驻一个是按组分批劝告锁 vs 强制锁内核是否强制拦截违规访问一个靠自觉一个靠执法日志 vs 写时复制先记账再改还是永不覆盖一个是记账本一个是换新本再补一条容易被忽略的文件的打开与文件的读取是两回事。open做的是路径解析、inode 加载、权限校验、分配 fd一次就够read做的是页缓存查找、必要时触发磁盘 I/O。很多性能问题出在频繁open/close上而不是数据本身——把循环里的open提到循环外往往能带来数量级的提升这一点我在处理批量小文件时验证过很多次。最后分享一个我自己复习这类内容的小方法不要按教材章节顺序背而是按一次文件操作要走哪些数据结构这条线串。创建一个文件动了哪些结构、读一个块动了哪些结构、删一个文件又要动哪些结构——把这三条路径画出来前面所有零散的知识点会自动挂到线上。我当年就是这么把整章内容压缩成三张图的比死记公式管用得多。