文件系统
文件系统听起来很高大上,通俗话就用来存数据的一个容器而已,本质和你的行李箱、仓库没有啥区别。只不过文件系统存储的是数字产品而已。我有一个视频文件,我把这个视频放到这个文件系统里,下次来拿,要能拿到我完整的视频文件数据,这就是文件系统,对外提供的就是存取服务。
现实的存取场景
就跟你在火车站使用的寄存服务一样,包裹我能存进去,稍后我能取出来,就可以了。问题来了,存进去?怎么取?仔细回忆下存储行李的场景。
存行李的时候,是不是要登记一些个人信息?对吧,至少自己名字要写上。可能还会给你一个牌子,让你挂手上,这个东西就是为了标示每一个唯一的行李。

取行李的时候,要报自己名字,有牌子的给他牌子,然后工作人员才能去特定的位置找到你的行李(不然机场那么多人,行李都长差不多,他肯定不知道你的行李是哪个)。

**划重点:存的时候必须记录一些关键信息(记录ID、给身份牌),取的时候才能正确定位到。
**
文件系统
回到我们的文件系统,对比上面的行李存取行为,可以做个简单的类比;
- 登记名字就是在文件系统记录文件名;
- 生成的牌子就是元数据索引;
- 你的行李就是文件;
- 寄存室就是磁盘(容纳东西的物理空间);
- 管理员整套运行机制就是文件系统;
上面的对应并不是非常严谨,仅仅是帮助大家理解文件系统而已,让大家知道其实文件系统是非常朴实的一个东西,思想都来源于生活。
划重点:文件系统的存储介质是磁盘,文件系统是软件层面的,是管理员,管理怎么使用磁盘空间的软件系统而已。
空间管理
现在思考文件系统是怎么管理空间的?
如果,一个连续的大磁盘空间给你使用,你会怎么使用这段空间呢?
直观的一个想法,我把进来的数据就完整的放进去。

这种方式非常容易实现,属于眼前最简单,以后最麻烦的方式。因为会造成很多空洞,明明还有很多空间位置,但是由于整个太大,形状不合适(数据大小),哪里都放不下。因为你要放一个完整的空间。
这种不能利用的空间我们称之为碎片,准确的说是外部碎片,这种碎片在内存池分配内存的时候最常见,产生的原理是一样的。
怎么改进?有人会想,既然整个放不进去,那就剁碎了呗。这里塞一点,那里塞一点,就塞进去了。
对,思路完全正确。改进的方式就是切分,把空间按照一定粒度切分。每个小粒度的物理块命名为 Block,每个 Block 一般是 4K 大小,用户数据存到文件系统里来自然也是要切分,存储到每一个 Block 。Block 粒度越小则外部碎片则会越少(注意:元数据量会越大),可以尽可能的利用到空间,并且完整的用户数据文件存储到磁盘上则不再连续,而是切成一个个 Block 大小的数据块存到磁盘的各个角落上。

图示标号表示这个完整对象的 Block 的序号,用来复原对象用的。
随之而来又有一个问题:你光会切成块还不行,取文件数据的时候,要给完整的用户数据出去,用户不管你内部怎么实现,他只想要的是最初的样子。所以,要有一个表记录该文件对应所有 Block 的位置,要把每一个 Block 的位置记录好,取文件的时候,对照这表恢复出一个完整的块给到用户。
所以,写流程再完善一下就是这样子:
- 先写数据:数据先按照 Block 粒度存储到磁盘的各个位置;
- 再写元数据:然后把 Block 所在的各个位置保存起来,这也就是元数据,文件系统里叫做 inode(我用一本书来表示);

文件读流程则是:
- 先读元数据,找到各个 Block 的位置;
- 然后读数据,构造一个完整的文件,给到用户;

inode/block 概念
好,现在我们引出了两个概念:
- 磁盘空间是按照 Block 粒度来划分空间的,存储数据的区域全都是 Block,我们叫做数据区域;
- 文件存储不再连续存储在磁盘上,所以需要记录元数据,这个我们叫做 inode;
文件系统中,一个 inode 唯一对应一个文件,inode 的个数则是在文件系统格式化的时候就确定好了的,换言之,一个 local 文件系统支持的文件数是天然就有上限的。
block 固定大小,每个 4k(大部分文件系统都是,这里不做纠结),block 意图存储打散的用户数据。
无论是 inode 区,还是 block 区,本质上都是在线性的磁盘空间上。文件系统的空间层次如下:

一个文件的对应一个 inode,这个文件需要按照 Block 切分存储在磁盘上,存储的位置则由 inode 记录起来,通过 inode 则能找到 block,也就获取到用户数据。
现在有一个新的小问题,inode 区和 block 区都是在初始化就构造好的。存储一个文件的时候,需要取一个空闲的 inode,然后把数据切分成 4k 大小存储到空闲的 block 上,对吧?
划重点:空闲的inode,空闲的 block。 这个很关键,已经存储了数据的地方不能再让写,不然会把别人的数据覆盖掉。
那么,怎么区分空闲和已经在用的 inode ,block 呢?
答案是 :inode 区和 block 区分别需要另一张表,用来表示 inode 是否在用,block 是否在用,这个表的名字我们叫做 bitmap 表。bitmap 是一个 bit 数组,用 0 表示空闲,1 表示在用,如下:

bitmap 什么时候用呢?自然是写的时候,也就是分配 inode 或者 block 的时候,因为只有分配的时候,你才需要找空闲的空间。
上图我为了突出本质思想,类似于超级块,块描述符都省略了,这个感兴趣可以自己扩展,这里只突出主干哈。
小结一下:
- bitmap 本质是个 bit 数组,占用空间极其少,用 0 来表示空闲,1 表示在用。使用时机是在创建文件,或者写数据的时候;
- inode 则对应一个文件,里面存储的是元数据,主要是数据 block 的位置信息;
- block 里面存储的是用户数据,用户数据按照 block 大小(4k)切分,离散的分布在磁盘上。读的时候只有依赖于 inode 里面记录的位置才能恢复出完整的文件;
- inode 和 block 的总个数在文件系统格式化的时候就确定了,所以文件数和文件大小都是有上限的;
一个文件真实的模样
上面是抽象的样子,现在我们看一个真实的 inode -> block 的样子。一个文件除了数据需要存储之外,一些元信心也需要存储,例如文件类型,权限,文件大小,创建/修改/访问时间等,这些信息存在 inode 中,每个文件唯一对应一个inode 。
看一下 inode 的数据结构(就以 linxu ext2 为例,该结构定义在 linux/fs/ext2/ext2.h 头文件中 ):
struct ext2_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size; /* Size in bytes */
__le32 i_atime; /* Access time */
__le32 i_ctime; /* Creation time */
__le32 i_mtime; /* Modification time */
__le32 i_dtime; /* Deletion Time */
__le16 i_gid; /* Low 16 bits of Group Id */
__le16 i_links_count; /* Links count */
__le32 i_blocks; /* Blocks count */
__le32 i_flags; /* File flags */
__le32 i_block[EXT2_N_BLOCKS];/* Pointers to blocks */
__le32 i_file_acl; /* File ACL */
__le32 i_dir_acl; /* Directory ACL */
__le32 i_faddr; /* Fragment address */
};
重点:
- 上面的结构 mode,uid,size,time 等信息就是我们常说的文件类型,大小,创建修改等时间元数据;
- 注意到
i_block[EXT2_N_BLOCKS]这个字段,这个字段将会带你找到数据, 因为里面存储的就是 block 所在的位置,也就是 block 的编号;
再来,理解下什么叫做 block 的位置(编号)。

位置就是编号,记录位置就是记录编号,编号就是索引。
我们看到有一个数组:i_block[EXT2_N_BLOCKS],这个数组是存储 block 位置的数组。其中 EXT2_N_BLOCKS 是一个宏定义,值为 15 。也就是说,i_block 是一个 15 个元素的数组,每个元素是 4 字节(32 bit)大小。
举个例子,假设我们现在有一个 6k 的文件,那么只需要 2 个 block 就可以存下了,假设现在数据就存储在编号为 3 和 101 这两个 block 上,那么如下图:

i_block[15] 第一个元素存的是 3,第二个存储的是 101,其他槽位没用用到,由于 inode 的内存是置零分配的,所以里面的值为 0,表示没有在使用 . 我们通过 [3, 101] 这两个 block 就能拼装出完整的用户数据了。用户的 6k 文件组成如下:
- 第一个 4k 数据在 [34K, 44K] 范围;
- 第二个 2k 数据在 [ 1014K, 1014K+2K] 范围;
好,现在我们知道了每个定长 block 都有唯一编号,我们的 i_block[15] 数组 通过有序存储这个编号找到文件数据所在的位置,并且拼装出完整文件。
思考问题:区分文件的切分成 4k 块的编号和 磁盘上物理 4k 块的编号的区别。
举个栗子,一个文件 12K 的大小,那么按照 4K 切分会存储到 2 个 物理 block 上。
文件第 0 个 4k 存储到了 101 这个物理 block 上;
文件第 1 个 4k 存储到了 30 这个物理 block 上;
文件第 2 个 4k 存储到了 11 这个物理 block 上;
文件逻辑空间上的编号是从 0 开始,到 2 结束,对应存储的物理块编号分别是 101,30,11 。
思考问题:这么一个 inode 结构能够表示多大的文件?
我们看到 inode->i_block[15] 是一个一维数组,里面能存 15 个元素。也就是能存 15 个 block 的编号,那么如果直接存储文件的 block 编号最大能表示 60K (15*4K) 的文件。换句话说,如果我拿着 15 个槽位全部用来存储文件的编号,这个文件系统支撑的最大文件却就是 60K。惊呆了?(注意:ext2 文件系统是可以创建 4T 以内的文件的!!)
那我们自然会思考,怎么解决呢?怎么才能支撑更大的文件?
最直接思考就是用更大的数组,把 inode->i_block 数组变得更大。比如,如果你想要支持 100G 的文件:
那么,需要 i_block 数组大小为 26214400 (计算公式:100\*1024\*1024/4),也就是要分配一个 i_block[26214400] 的数组。
每个编号占用 4 字节,这个数组就占用 100M 的空间(计算公式:(26214400\*4)/1024/1024)。
100M !这里就有点夸张了,注意到 i_block 只是一个 inode 内部的字段,是一个静态分配的数组,也就是说,这个文件系统为了支持最大 100G 的文件存入,每一个 inode 都要占用 100M 的内存,就算你是一个 1K 的文件,inode 也会占用这么大的内存空间。并且,这种方案扩展性差,支持的文件 size 越大,i_block[N] 消耗内存情况越严重。
这是无法接受的。那么怎么才能解决这个问题呢?怎么才能让你既能表示更大的文件,又能不浪费占用空间?
我们仔细分析这个问题,你会发现,这里有 2 个核心问题:
- 第一点,核心在于浪费内存空间(关键点是要保证 inode 内存结构的稳定,无论文件怎么变,inode 结构本身不能变);
- 第二点,仔细思考你会发现,无论是什么神仙方案,如果你要存储一个按照 4k 切分的 100G 文件,都是需要 100M 的空间来存储索引( block 编号),但是 99.99% 的文件可能都没有这么大;
我们前面用一个大数组来一把存储 block 编号的方案固然简单,但是问题在于太过死板。核心问题在于存储 block 编号的数组是预分配的,为了还没有发生并且 99% 场景都不会发生的事情(文件大小达到 100G),却不管三七二十一,提前准备好了完整的 block 索引数组,预分配就是浪费的根源。
那么知道了这两个问题,下一步分析下一个个解决:
索引存磁盘
问题一的解决:索引存磁盘:
既然问题在于浪费内存,inode 内存分配不灵活,那就可以看把 inode->i_block 下放到磁盘。
为什么?
因为磁盘的空间比内存大了不止一个量级。100M 对内存来说很大,对磁盘来说很小。换句话说,用把用户数据所在的 block 编号存到磁盘上去,这个也需要物理空间,使用的也是 block 来存储,只不过这种 block 存储的是 block 编号信息,而不是用户数据。
那么我们怎么通过 inode 找到用户数据呢?
因为这个 block 本身也有编号,我们则需要把这个存储用户 block 编号的 block 所在块的编号存储在 inode->i_block[15] 里,当读数据的时候,我们需要先找到这个存储编号的 block,然后再通过里面存储的用户数据所在的 block 编号找到用户所在的 block ,去读数据。
这个存储用户 block 编号的 block 所在块的编号我们叫做间接索引,然后我们根据跳转的次数可以分类成一级索引,二级索引,三级索引。顾名思义,一级索引就是跳转 1 次就能定位到用户数据,二级索引就是跳转 2 次,三级索引就是跳转 3 次才能定位到用户数据。那么 inode->i_block[15] 里面存储的可以直接定位到用户数据的 block 就是直接索引。
终于可以说回 ext2 的使用了,ext2 的 inode->i_block[15] 数组。知识点来了,按照约定,这 15 个槽位分作 4 个不同类别来用:
- 前 12 个槽位(也就是 0 - 11 )我们成为直接索引;
- 第 13 个位置,我们称为 1 级索引;
- 第 14 个位置,我们称为 2 级索引;
- 第 15 个位置,我们称为 3 级索引;

好,那我们在来看下直接索引,一级,二级,三级索引的表现力。
直接索引:能存 12 个 block 编号,每个 block 4K,就是 48K,也就是说,48K 以内的文件,只需要用到 inode->i_block[15] 前 12 个槽位存储编号就能完全 hold 住。
一级索引:
inode->i_block[12] 这个位置存储的是一个一级索引,也就是说这里存储的编号指向的 block 里面存储的也是 block 编号,里面的编号指向用户数据。一个 block 4K,每个元素 4 字节,也就是有 1024 个编号位置可以存储。
所以,一级索引能寻址 4M(1024 * 4K)空间 。
二级索引:
二级索引是在一级索引的基础上多了一级而已,换算下来,有了 4M 的空间用来存储用户数据的编号。所以二级索引能寻址 4G (4M/4 * 4K) 的空间。
三级索引:
三级索引是在二级索引的基础上又多了一级,也就是说,有了 4G 的空间来存储用户数据的 block 编号。所以二级索引能寻址 4T (4G/4 * 4K) 的空间。
最后,看一眼完整的表示图:

所以,在我们 ext2 的文件系统上,通过这种间接块索引的方式,最大能支撑的文件大小 = 48K + 4M + 4G + 4T ,约等于 4 T。文件系统最大支撑 16T 空间,因为 4 Byte 的整形最大数就是 2^32=4294967296 , 乘以 4K 就等于 16 T。
ext2 文件系统支持的最大单文件大小和文件系统最大容量就是这么算出来的(温馨提示:ext4 文件系统不仅兼容间接块的实现,还使用的是 extent 模式来管理的空间,最大支持单文件 16 TB ,文件系统最大 1 EB)。
思考:这种多级索引寻址性能表现怎么样?
在不超过 12 个数据块的小文件的寻址是最快的,访问文件中的任意数据理论只需要两次读盘,一次读 inode,一次读数据块。访问大文件中的数据则需要最多五次读盘操作:inode、一级间接寻址块、二级间接寻址块、三级间接寻址块、数据块。
多级索引和后分配
问题二解决:多级索引和后分配
一级索引不够,表现力太差,预留空间又太浪费,不预留空间又无法扩展,怎么解决?
既然问题在于预分配,我们使用后分配(瘦分配,或精简分配)解决。也就是说用户文件数据有多大,我才分配出多大的数组。举个例子,我们存储 100 G 的文件,那么就要用到三级索引块,最多分配 26214400 个槽位的数组(因为要 26214400 个 block)。如果是存储 6K 的文件,那么只需要 2 个槽位的数组。
索引数组的后分配
后分配这里说的是 block 索引编号数组的后分配,需要用到的时候才分配,而不是说,现在用户存储一个 1k 的文件,我上来就给他分配一个 100M 的索引数组,只是为了以后这个文件可能增长到 100 G。
数据的后分配
既然这里说到,关于后分配还有一个层面,就是数据所占的空间也是用到了才分配,这个也就是涉及到今天 cp的秘密的核心问题。
实际的栗子
先看下下正常的文件写入要做的事情(注意这里只描述主干,实际流程可能,有优化):
- 创建一个文件,这个时候分配一个 inode;
- 在 [ 0,4K ] 的位置写入 4K 数据,这个时候只需要 一个 block 假设编号 102,把这个编号写到
inode->i_block[0]这个位置保存起来; - 在 [ 1T,1T+4K ] 的位置写入 4K 数据,这个时候需要分配一个 block 假设编号 7,因为这个位置已经落到三级索引才能表现的空间了,所以需要还需要分配出 3 个索引块;
- 写入完成,close 文件;
这里解释下文件偏移位置 [1T, 1T+4K] 为什么落到三级索引。
- offset 为 1T,按照 4K 切分,也就是 block 268435456 块(注意这个是虚拟文件块,不是物理位置);
- 先算出范围:直接索引的范围是 [0, 11] 个,一级索引 [12, 1035],二级索引 [1036, 1049611], 三级索引 [1049612, 1074791435],(有人如果不知道怎么来的话,可以往前看看 inode 的结构,直接索引 12个,一级索引 1024 个,二级 1M 个,三级 1G 个,然后算出来的);
- 268435456 落在三级索引 [1049612, 1074791435] 这个范围;
实际存储如图:
计算索引:
12 + 1024 + 1024 * 1024 + 1024 * 1024 * 254 + 1024 * 1022 + 1012 = 268435456
实际的物理分配如图:

因为偏移已经用到了 3 级索引,所以除了用户数据的两个 block ,中间还需要 3 个间接索引 block 分配出来。
如果要读 [1T, 1T+4K] 这个位置的数据怎么办?
流程如下:
- 计算 offset 得出在第 268435456 的位置;
- 读出三级索引
inode->i_block[14]里存储的 block 编号,找到对应的物理 block,这个是第一级的 block; - 然后读该 block 的第 254+1 个槽位里的数据,里面存储的是第二级的 block 编号,把这个编号读出来,通过这个编号找到对应的物理 block;
- 读该 block 的第 1022 +1 个操作的数据,里面存储的是第三级的 block 编号,通过这个编号可以找到物理 block 的数据,里面存储的是用户数据所在 block 的编号;
- 读该 block 第 1012+1 个槽位里存储的编号,找到物理 block,这个 block 里存的就是用户数据了;
这个时候,我们的文件看起来是超大文件,size 等于 1T+4K ,但里面实际的数据只有 8 K,位置分别是 [ 0,4K ] ,[ 1T,1T+4K ]。
重点:文件 size 只是 inode 里面的一个属性,实际物理空间占用则是要看用户数据放了多少个 block 。
划重点:没写数据的地方不用分配物理 block 块。
没写数据不分配物理块?那是什么?那就是我们下面要说的稀疏文件。
文件的稀疏语义
什么是稀疏文件
终于到我们文件的稀疏语义了,稀疏语义什么意思?
稀疏文件英文名 sparse file 。稀疏文件本质上就是计算机文件,用户不感知,文件系统支持稀疏文件只是为了更有效率的使用磁盘空间而已。稀疏文件就是后分配空间的一种实现形式,做到真正用时才分配,最大效率的利用磁盘空间。
就以上面举的栗子,文件大小 1T,但是实际数据只有 8K,这种就是稀疏文件,逻辑大小和实际物理空间是可以不等的。文件大小只是一个属性,文件只是数据的容器,没有用户数据的位置可以不分配空间。
为什么要支持稀疏语义?
还是以上面 1T 的文件举例,如果这 1T 的文件只有首尾分别写了 4K 的数据,而文件系统却要分配 1T 的物理空间,这里将带来巨大的浪费。何不等存了用户数据的时候再分配了,实际数据有多少,才去分配多大的 block ,何必着急的预分配呢?
后分配本着用多少给多少的原则,尽量有效的利用空间。
后分配还有一个优点,这也减少了首次写入的时间,怎么理解?
因为,如果文件大小 1T,就要分配 1T 的空间,那么初始分配需要写入全零到空间,否则上面的数据可能是随机数。
对于稀疏文件空洞的地方,不占用物理空间,但要保证读的时候返回全 0 数据的语义,即可。
又一个知识点:有时候稀疏文件的空洞和用户真正的全 0 数据是无法区分的,因为对外表现是一样的。
稀疏文件也要文件系统支持,并不是所有的文件系统都支持稀疏语义,比如 ext2 就没有,ext4 才有稀疏语义,支持的标志是实现文件系统的 fallocate 接口。
怎么创建一个稀疏文件?
可以使用 truncate 命令在一个 ext4 的文件系统创建一个文件。
truncate -s 100G test.txt
- 你
ls -lh ./test.txt命令看会发现是一个 100 G 的文件; - 但是
du -sh ./test.txt会发现是一个 0 字节的文件; stat ./test.txt会发现是Size: 107374182400 Blocks: 0的文件;
这就是一个典型的稀疏文件。size 只是文件的逻辑大小,实际的物理空间占用还是得看 Blocks 这个数值。
下面这种 1T 的文件,因为只写了头尾 8K 数据,所以只需要分配 2 个 block 存储用户数据即可。

好,我们再深入思考下,文件系统为什么能做到这个?
这也是为什么理解稀疏语义要先了解文件系统的实现的原因。
- 首先,最关键的是把磁盘空间切成离散的、定长的 block 来管理;
- 然后,通过 inode 能查找到所有离散的数据(保存了所有的索引);
- 最后,实现索引块和数据块空间的后分配;
这三点是层层递进的。
稀疏语义接口
为了知识的完整性,简要介绍稀疏语义的几个接口:
- preallocate(预分配):提供接口可以让用户预占用文件内指定范围的物理空间;
- punch hole(打洞):提供接口可以让用户释放文件内指定范围的物理空间;
这两个操作刚好相反。
预分配的意思就是说,当你创建一个 1T的文件,如果你没写数据,这个时候其实没有分配物理空间的,支持稀疏语义的文件系统会提供一个 fallocate 接口给你,让你实现预分配,也就是说把这 1T 的物理空间现在就分配出来。
思考:这个有什么好处呢?
- 第一,如果你命中注定要 1T 的空间,预分配是有好处的,把空间分配的工作量集中在初始化的时候一把做了,避免了实时现场分配的开销;
- 第二,如果不提前占坑,很有可能等你想要的时候已经没有空间可占用了。所以你把物理空间先占好,就可以安心使用了;
linux 提供了一个 fallocate 命令,可以用来预分配空间。
fallocate -o 0 -l 4096 ./test.txt
这个命令的意思就是给 text.txt 这个文件 [0, 4K] 的位置分配好物理空间。
打洞(punch hole) 是干啥的呢?
这个调用允许你把已经占用的物理空间释放掉,从而达到快速释放的目的。这种操作在虚拟机镜像的场景用得多,通常用于快速释放空间,punch hole 能够让业务更有效的利用空间。
linux 提供了一个 fallocate 命令也可以用来 punch hole 空间。
fallocate -p -o 0 -l 4096 ./test.txt
这个命令的意思是把 test.txt [ 0, 4K ] 的物理空间释放掉。
稀疏文件的应用
Go 语言实现
稀疏文件本身和编程语言无具体关系,我下面以 Go 为例,看下稀疏文件的预分配和打洞(punch hole)是怎么实现的。
预分配实现:
func PreAllocate(f *os.File, sizeInBytes int) error {
// use mode = 1 to keep size
// see FALLOC_FL_KEEP_SIZE
return syscall.Fallocate(int(f.Fd()), 0x0, 0, int64(sizeInBytes))
}
punch hole 实现:
// mode 0 change to size 0x0
// FALLOC_FL_KEEP_SIZE = 0x1
// FALLOC_FL_PUNCH_HOLE = 0x2
func PunchHole(file *os.File, offset int64, size int64) error {
err := syscall.Fallocate(int(file.Fd()), 0x1|0x2, offset, size)
if err == syscall.ENOSYS || err == syscall.EOPNOTSUPP {
return syscall.EPERM
}
return err
}
可以看到,本质上都是系统调用 fallocate ,然后带不同的参数而已。指定文件偏移和长度,就能预分配物理空间或者释放物理空间了。
这里有一个知识点:punch hole 的调用要保证 4k 对齐才能释放空间。
举个例子,比如:
punch hole [0, 6k] 的数据,你会发现只有 [0, 4k] 的数据物理块被释放了,[4k, 6k] 所占的 4k 物理块还占着空间呢。
这个很容易理解,因为磁盘的物理空间是划分成 4k 的 block,这个是最小单位了,不能再分了,你无法切割一个最小的单位。
值得注意的是,就算你没有 4k 对齐的发送调用,fallocate 也不会报错,这个请注意了。