第二章 理解文件系统
2.1 理解格式化原理
在前文《磁盘开篇:扒开机械硬盘坚硬的外壳!》和《拆解固态硬盘结构》中,我们了解到了硬盘基本单位是扇区。在《磁盘分区也是隐含了技术技巧的》中我们也了解了磁盘分区是怎么回事,但刚分完区的硬盘也是不能直接被操作系统使用的,必须还得要经过格式化。那么今天我们就简单聊一聊,Linux下的格式化到底都干了些什么。
Linux下的格式化命令是 mkfs,mkfs 在格式化的时候需要制定分区以及文件系统类型。该命令其实就是把我们的连续的磁盘空间进行划分和管理。我在我的机器上执行了一下,输出如下:
# mkfs -t ext4 /dev/vdb
mke2fs 1.42.9 (28-Dec-2013)
文件系统标签=
OS type: Linux
块大小=4096 (log=2)
分块大小=4096 (log=2)
Stride=0 blocks, Stripe width=0 blocks
6553600 inodes, 26214400 blocks
1310720 blocks (5.00%) reserved for the super user
第一个数据块=0
Maximum filesystem blocks=2174746624
800 block groups
32768 blocks per group, 32768 fragments per group接下来让我们深入理解一下上面输出里携带的信息。
inode 与 block
在上面结果中我们看到了几个重要信息:
- 块大小:4096 字节
- inode 数量:6553600
- block 数量:26214400
块大小设置的是 4096 字节,我们来分析两种场景:
- 假如你的文件系统全部都用来存储 1KB 以下的小文件,这个时候你的磁盘 1/3 的空间将会被浪费无法使用。
- 假如你的文件全都是 GB 以上的大文件,这个时候你的 inode 索引节点里就需要直接或间接维护许许多多的 block 索引号。
很明显,以上这两种情况下 4096 字节的块大小是不合适的。你需要自己根据情况选择自己的块大小进行重新格式化。
我们再看另外两个数据,inode 数量和 block 数量。我们用 block 数量除以下 inode:26214400 / 6553600 = 4,也就是说平均 4 个 block 会有一个 inode。再举两个极端的例子:
- 第一种情况:假如说我们的文件都是 4KB 以下的,那么我们的文件系统用到最后出现的情况就是 inode 全部用光了,还有 1/3 的 block 空闲,而且再也没有办法创建新文件了。
- 第二种情况:假如我们的文件都特别大,每一个文件需要 1000 个 block,最后的情况就是 block 全部都用光了,但是 inode 又都空闲下来了,这个时候也是没办法再建文件的。
这些情况下,block 和 inode 的配比也都是不符合你使用的,你需要根据自己的业务重新配置。mkfs 傻瓜格式出来的结果无法满足你的业务需求,你就需要使用另外一些格式化命令了,比如 mke2fs,这个命令允许你输入更详细的格式化选项,demo 如下:
mke2fs -j -L "卷标" -b 2048 -i 8192 /dev/sdb1块组
我们再回头看格式化后的结果,结果中显示了和一些 groups 相关的东西,如下:
800 block groups
32768 blocks per group, 32768 fragments per group
8192 inodes per group
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
4096000, 7962624, 11239424, 20480000, 23887872
那么这个 groups 到底说的是什么呢?其实呀,格式化后的所有 inode 并不是挨着一起放的,同样 block 也不是。而是分成了一个个的 group,每一个 group 里都有一些 inode 和 block。逻辑图如下:
graph TD subgraph 块组 block_bitmap[块位图] inode_bitmap[inode位图] inode_table[inode表] data_blocks[数据块] end
这个块组一般是多大呢?注意每个块中的数据块位图只有一个,假如你的块大小为 4KB,这样一个 bit 代表一个数据块,4KB 可以有 32K 个 bit,可以管理 32K * 4K = 128M 的数据块。让我们来实际动手验证一下,如下:
# dumpe2fs /dev/vdb
......
Block size: 4096
Inode size: 256
Inode count: 6553600
Block count: 26214400
......
Group 16: (Blocks 524288-557055) [INODE_UNINIT, ITABLE_ZEROED]
Checksum 0xe838, unused inodes 8192
Block bitmap at 524288 (+0), Inode bitmap at 524304 (+16)
Inode表位于 524320-524831 (+32)
24544 free blocks, 8192 free inodes, 0 directories, 8192个未使用的inodes
可用块数: 532512-557055
可用inode数: 131073-139264
......
Group 799: (Blocks 26181632-26214399) [INODE_UNINIT, ITABLE_ZEROED]
......上述结果中包含信息如下:
- 该分区总共格式化好了 800 个块组
- 块组 16 共有 32K 个 block(第 524288-557055),
- block 位图在 524288 这个块上
- inode 位图在 524304 这个块上
- inode table 占用了 612 个 block(524320-524831)
- 剩下的其它的 block(32K-1-1-612)就都真的是给用户准备的了,目前空闲未分配的在 Free blocks 可以查看到。
再次理解目录
好了,了解了以上原理以后,让我们回头再看看目录使用的数据是怎么在磁盘上组织的。创建目录的时候,操作系统会在 inode 位图上寻找尚未使用的 inode 编号,找到后把 inode 分配给你。目录会默认分配一个 block,所以还需要查询 block 位图,找到后分配一个 block。在 block 里面,存储的就是文件系统自己定义的 dentry 结构了,每一个结构里会保存其下的文件名、文件的 inode 编号等信息。某个实际文件夹在磁盘上最终使用的空间如下图所示:
graph TD subgraph 文件系统布局 inode_table[inode表] block_area[数据块区域] dentry_block[目录的block<br>存储dentry结构] end inode_table --> |目录inode| dentry_block dentry_block --> |包含文件名和inode号| file_inode[文件inode] file_inode --> |指向数据块| file_data[文件数据块]
目录的 block 中保存的是其下面的文件和子目录的 dentry 结构体,保存着它们的文件名和 inode 号。理解了目录,对于文件也是一样的。也需要消耗 inode,当有数据写入的时候,再去申请 block。
结论
硬盘就是一个扇区组成的大数组,是无法被我们使用的,需要经过分区、格式化和挂载三个步骤。分区是把所有的扇区按照柱面分割成不同的大块,格式化就把原始的扇区数组变成了可被 Linux 文件系统使用的 inode、block 等基本元素了。感觉格式化程序有点像是厨师团队里的那个切墩的,把原材料变成了可被厨师直接使用的葱花、肉段。格式化完后再经过最后一步挂载,对应的命令是 mount,然后你就可以在它下面创建和保存文件了。
再扩展一下其实刚分完区的设备也是可以使用的,这个时候的分区叫裸分区,也叫裸设备。比如 Oracle 就是绕开操作系统直接使用裸设备的。但是这个时候你就无法利用 Linux 文件系统里为你封装好的 inode、block 组成的文件与目录了,开发工作量会增加。
2.2 新建一个空文件占用多少磁盘空间
今天我们来思考一个简单的问题。在 Linux 下你用 touch 命令新建一个空文件:
# touch empty_file.txt操作完成后,是否要消耗掉我们的一些磁盘空间?需要的话,大概能消耗多少?嗯,是的,这个问题简单的超乎你的想象,但是不知道你能否给你自己一个满意的答案。
我前面的几篇文章都是介绍的磁盘物理层面的构成,但这对于理解文件相关的问题帮助可能还不够。从今天开始让我们从物理层往上走,到 Linux 文件系统原理里去寻找答案。
实践出真知
我觉得可能先丢开内核原理,直接动手操作来实验更有意思一些。你一定知道 ls 这个命令你可以查看文件大小,那么就让我们用它来看一下。
# ls -l
total 0
-rw-r--r-- 1 root root 0 Aug 17 17:49 empty.file额,ls 命令告诉我这个空文件占用的是 0。文件的大小确实是 0,因为我们还没有为该文件写入任何内容。但是我们现在要思考的是,一个空文件是否占用磁盘空间。所以直觉告诉我们这绝对不可能,磁盘上多出来一个文件,怎么可能一点空间开销都没有!
为了解开这个谜底,还需要借助 df 命令。输入 df -i:
# df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
......
/dev/sdb1 2147361984 12785019 2134576965 1% /search这个输出帮我们展示了我们文件系统中 inode 的使用情况。注意 IUsed 是 12785019。我们继续新建一个空文件:
# touch abcdefghigklmn.txt
# df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
......
/dev/sdb1 2147361984 12785020 2134576964 1% /search注意 IUsed 变成了 12785020。
哈哈,我们的一个结论就出来了。新建一个空文件会占用一个 Inode。
细说 inode
那么 inode 里都存了哪些和文件相关的信息呢?我们再稍微看一下内核的源代码。大家可以下载一份 Linux 的源代码。以 ext2 文件系统为例,在我下载的 linux-2.6 里的文件 fs/ext2/ext2.h 中,可以找到内核对于 inode 结构体的定义。该结构体较为复杂,主要存储除了文件内容以外的一些其他数据,我们选一些比较关键的截取出来:
struct ext2_inode {
__le16 i_mode; # 文件权限
__le16 i_uid; # 文件所有者ID
__le32 i_size; # 文件字节数大小
__le32 i_atime; # 文件上次被访问的时间
__le32 i_ctime; # 文件创建时间
__le32 i_mtime; # 文件被修改的时间
__le32 i_dtime; # 文件被删除的时间
__le16 i_gid; # 文件所属组ID
__le16 i_links_count; # 此文件的inode被连接的次数
__le32 i_blocks; # 文件的block数量
......
__le32 i_block[EXT2_N_BLOCKS]; # 指向存储文件数据的块的数组可以看到和文件相关的所属用户、访问时间等都是存在 inode 中的。另外在 include/linux/fs.h 中,还有个 VFS 层面的 inode 的定义,这里咱就不发散了。使用 stat 命令就可以直接看到文件 inode 中的数据:
# stat test
File: `test'
Size: 0 Blocks: 0 IO Block: 1024 regular empty file
Device: 801h/2049d Inode: 26 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2020-03-01 12:14:31.000000000 +0800
Modify: 2020-03-01 12:14:31.000000000 +0800
Change: 2020-03-01 12:14:31.000000000 +0800每个 inode 到底是多大呢?dumpe2fs 可以告诉你(XFS 的话使用 xfs_info):
# dumpe2fs -h /dev/mapper/vgroot-lvroot
dumpe2fs 1.41.12 (17-May-2010)
......
Inode size: 256Inode size 表示每个 Inode 的大小。我的这台机器上,每个 inode 都是 256 字节。两个 inode 的大小正好对齐到磁盘扇区的 512 字节。
文件名存到哪里了
inode 结构体都看完了,搞了半天不知道有没有发现一个问题,inode 里并没有存储文件名!!那么,文件名到底跑哪儿去了?
在 fs/ext2/ext2.h 中,我找到了如下文件夹相关的结构体:
struct ext2_dir_entry {
__le32 inode; /* Inode number */
__le16 rec_len; /* Directory entry length */
__le16 name_len; /* Name length */
char name[]; /* File name, up to EXT2_NAME_LEN */
};这个结构体就是我们司空见惯的文件夹。没错,文件名是存在其所属的文件夹数据结构中的,就是其中的 char name[] 字段。和文件名一起,文件夹里还记录了该文件的 inode 等信息。
结论
- 新建一个空文件需要消耗掉一个 inode,用来保存用户、创建时间等元数据。
- 新建一个空文件还需要消耗掉其所有目录的 block 中一定的空间,这些空间用来保存文件名、权限、时间等信息。
所以,看起来新建一个空文件而已,只要你想挖,真的能挖出很多知识的。最后分享一个我们团队里同学遇到的一个故障。我们的一台离线任务机直接歇菜了,重启后排查原因是 inode 被消耗光了。再追查发现一个进程创建了太多的空日志文件。虽然文件都是空文件,但是 inode 却被浪费光了。后来让负责的同学修改了创建日志文件的逻辑,删掉了多出来的空文件,该机器恢复正常。
2.3 只写入一个字节的文件占用多少磁盘空间
在前面我们了解到了一个空文件的磁盘开销。今天我们再思考另外一个问题,假如我们给文件里只写入 1 个字节,那么这个文件实际的磁盘占用也是 1 个字节吗?
查看 1 个字节的文件
和前文一样,先不谈原理,直接动手操作。
在一个目录中创建了一个空的文件以后,通过 du 命令看到的该文件夹的占用空间并没有发生变化。这倒是符合我们之前的认识,因为空文件只占用 inode。好,那让我们修改文件,添加一个字母:
# mkdir tempDir
# cd tempDir
# du -h
0 .
# touch test
# du -h
0 .
echo "a" > test
# du -h
4.0K .保存后再次查看该目录的空间占用。我们发现由原来的 0 增加到了 4K。
所以说,文件里的内容不论多小,哪怕是一个字节,其实操作系统也会给你分配 4K 的。哦,当然了还得再算前文中说的 inode 和文件夹数据结构中存储的文件名等所用的空间。
所以,不要在你的系统里维护一大堆的碎文件。文件再小,占用磁盘其实一点都不少!
NOTE
注意我的实验环境是在 ext 文件系统下进行的。如果是 XFS 可能表现会些许出入。
继续讨论这个 4K
再把 Linux 源代码文件 fs/ext2/ext2.h 里关于 inode 的定义翻出来,我们找到结构体中定义的指向数据节点用的 block 数组:
struct ext2_inode {
......
__le32 i_block[EXT2_N_BLOCKS]; # 指向存储文件数据的块的数组
......当文件没有数据需要存储的时候,这个数组都是空值。而当我们写入了 1 个字节以后,文件系统就需要申请 block 去存储了,申请完后,指针放在这个数组里。哪怕文件内容只有一个字节,仍然会分配一个整的 Block,因为这是文件系统的最小工作单位。那么这个 block 大小是多大呢,ext 下可以通过 dumpe2fs 查看:
#dumpe2fs -h /dev/mapper/vgroot-lvroot
......
Block size: 4096在我的机器上,一个 Block 是 4KB。
文件内容再大了怎么办
不知道你留意没,inode 中定义的 block 数组大小呢,只有 `EXT2_N_BLOCKS#### 文件内容再大了怎么办
不知道你留意没,inode 中定义的 block 数组大小呢,只有 EXT2_N_BLOCKS 个。我们再查看一下这个常量的定义,发现它是 15,相关内核中定义如下:
#define EXT2_NDIR_BLOCKS 12
#define EXT2_IND_BLOCK EXT2_NDIR_BLOCKS
#define EXT2_DIND_BLOCK (EXT2_IND_BLOCK + 1)
#define EXT2_TIND_BLOCK (EXT2_DIND_BLOCK + 1)
#define EXT2_N_BLOCKS (EXT2_TIND_BLOCK + 1)就按 4K 的 block size 来看,15 个 block 只够存的下 15 * 4 = 60K 的文件。这个文件大小相信你一定不满意,你存一个 avi 大片都得上 G 了。那 Linux 是怎么实现大文件存储的呢?嗯,其实上面宏的定义过程已经告诉你了,就是只有 12 个数组元素直接存 block 指针,其余的用来做间接索引(EXT2_IND_BLOCK),二级间接索引(EXT2_DIND_BLOCK)和三级索引(EXT2_TIND_BLOCK)。
这样,一个文件可以使用的空间就指数倍的扩展了。文件小的时候,都用直接索引,磁盘 IO 少,性能好。文件大的时候,访问一个 block 可能得先进行三次的 IO,性能略慢,不过有 OS 层面的页缓存、目录项缓存的加持,也还好。
结论
文件系统是按照块来管理的,所以不管你的文件多小,哪怕只有一个字节,都会消耗掉整整一个块。这个块大小可以通过 dumpe2fs 等命令来查看。如果想改变这个块大小怎么办?对不起,只能重新格式化。
至此,第一段内容(格式化原理、空文件与单字节文件占用的磁盘空间)已完成输出。下一段将涉及“文件过多时 ls 命令卡住的原因”。
2.2 新建一个空文件占多少磁盘空间
思考
在Linux下用
touch命令新建一个空文件,是否会消耗磁盘空间?消耗多少?
实践验证
# touch empty_file.txt
# ls -l
total 0
-rw-r--r-- 1 root root 0 Aug 17 17:49 empty.file
# df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sdb1 2147361984 12785019 2134576965 1% /searchls 显示文件大小为0,但用 df -i 查看 inode 使用情况。创建第二个空文件后:
# touch empty_file2.txt
# df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sdb1 2147361984 12785020 2134576964 1% /search结论:新建一个空文件会占用一个 inode。
inode 内部结构
以 ext2 文件系统为例(源码 fs/ext2/ext2.h),struct ext2_inode 主要字段:
struct ext2_inode {
__le16 i_mode; // 文件权限
__le16 i_uid; // 所有者ID
__le32 i_size; // 文件大小
__le32 i_atime; // 上次访问时间
__le32 i_ctime; // 创建时间
__le32 i_mtime; // 修改时间
__le32 i_dtime; // 删除时间
__le16 i_gid; // 组ID
__le16 i_links_count; // 硬链接计数
__le32 i_blocks; // 块数量
__le32 i_block[EXT2_N_BLOCKS]; // 指向数据块的指针数组
};每个 inode 的大小可通过 dumpe2fs 查看:
# dumpe2fs -h /dev/mapper/vgroot-lvroot
Inode size: 256文件名存在哪里?
文件名并不存储在 inode 中,而是存储在目录的数据结构中。
struct ext2_dir_entry {
__le32 inode; // 文件inode号
__le16 rec_len; // 目录项长度
__le16 name_len; // 文件名长度
char name[]; // 文件名
};目录项保存在其父目录的 data block 中。
小结
- 新建一个空文件消耗一个 inode(256字节)。
- 同时在其父目录的 block 中新增一个
ext4_dir_entry_2项,消耗一定空间(文件名越长,消耗越多)。
生产事故
某团队离线任务机因创建太多空日志文件导致 inode 耗尽,机器无法创建新文件。删除多余空文件后恢复。
2.3 只写入一个字节的文件占用多少磁盘空间
实验
# mkdir tempDir
# cd tempDir
# du -h
0 .
# touch test
# du -h
0 .
# echo "a" > test
# du -h
4.0K .即使只写一个字节,文件也会占用一个完整的 Block(4KB)。
原因
inode 中的 i_block[] 数组指向数据块。当文件有内容时,文件系统分配一个完整的 Block,即使只有1字节。Block 大小可通过 dumpe2fs 查看:
# dumpe2fs -h /dev/mapper/vgroot-lvroot
Block size: 4096大文件如何存储?
i_block[] 数组只有15个元素(EXT2_N_BLOCKS),前12个直接索引,后3个为间接索引(一级、二级、三级),可支持更大文件。
- 小文件:使用直接索引,性能好。
- 大文件:需多次间接索引,但借助 Page Cache 和 目录项缓存,影响可控。
结论
- 文件系统按块分配空间,即使1字节也占用一个完整 Block(默认4KB)。
- 块大小只能在格式化时设定,后期无法修改。
2.4 文件过多时 ls 命令为什么会卡住
问题现象
当目录下文件数量极大(例如数万至数十万),执行 ls 会长时间卡住。
原因分析
1. 目录本身也消耗 inode
创建空目录:
# mkdir temp
# df -i空目录分配一个 inode(256字节)。
2. 目录的 block 消耗
目录的 block 中存储 ext4_dir_entry_2 数组,每个子文件/子目录对应一项。
# mkdir test
# cd test
# du -h
4.0K .
# touch aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab
# touch aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# du -h
4.0K .创建100个32字节文件后:
# for i in $(seq 1 100); do touch tempDir/$(printf "%032d" $i); done
# du -h
12K .创建10000个文件后:
# du -h
548K .每个目录项约54字节(32字节文件名 + 其他元数据)。当文件数量巨大时,目录的 block 越来越多。
3. ls 命令的工作方式
ls 需要遍历目录的所有 block,读取所有 ext4_dir_entry_2,然后对每个文件调用 stat 获取元数据(inode 信息)。如果目录块不在 Page Cache 中,就会触发磁盘 IO,大量随机读导致性能极差。
解决方案
- 多级目录散列:将文件分布到多个子目录中,控制单目录文件数量(如 <10万)。
- 常用做法:用一级或二级哈希路径,例如
hash($name)/$name。
一个 ext 的“Bug”:删除文件后目录空间不释放
# rm -f *
# du -h
72K .这是因为 ext4_dir_entry_2 中的 rec_len 用于链表遍历,删除文件时只是将 inode 设为0,并不回收该目录项(假删除)。新文件可复用这些空闲槽位,但如果文件全部删除,目录的 block 不会自动缩小。XFS 文件系统已无此问题。
检查当前目录的 block 大小
# stat -f .
Block size: 4096结论
- 目录下的文件/子目录越多,目录的 block 越多。
- 遍历目录时若命中 Page Cache 则快,否则触发磁盘 IO,造成
ls卡顿。 - 实际工程中应通过多级目录散列控制单目录文件数量。
实践建议
使用
dumpe2fs或stat查看文件系统的 Block 大小、inode 大小等信息,合理规划文件分布。