第三章 文件系统原理

3.1 新建一个空文件占用多少磁盘空间

今天我们来思考一个简单的问题。在 Linux 下你用 touch 命令新建一个空文件,操作完成后,是否要消耗掉我们的一些磁盘空间?需要的话,大概能消耗多少?嗯,是的,这个问题简单得超乎你的想象,但是不知道你能否给你自己一个满意的答案。

我前面的几篇文章都是介绍的磁盘物理层面的构成,但这对于理解文件相关的问题帮助可能还不够。从今天开始让我们从物理层往上走,到 Linux 文件系统原理里去寻找答案。

实践出真知

我觉得可能先丢开内核原理,直接动手操作来实验更有意思一些。你一定知道 ls 这个命令可以查看文件大小,那么让我们就用它来看一下。

# touch empty_file.txt
# touch abcdefghigklmn.txt  
# ls -l  
total 0  
-rw-r--r-- 1 root root 0 Aug 17 17:49 empty.file  

额,ls 命令告诉我这个空文件占用的是 0。文件的大小确实是 0,因为我们还没有为该文件写入任何内容。但是我们现在要思考的是,一个空文件是否占用磁盘空间。所以直觉告诉我们这绝对不可能,磁盘上多出来一个文件,怎么可能一点空间开销都没有!

为了解开这个谜底,还需要借助 df 命令。输入 df -i

这个输出帮我们展示了我们文件系统中 inode 的使用情况。注意 IUsed12785019。我们继续新建一个空文件,这下注意 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 中的数据。

# df -i
Filesystem            Inodes   IUsed   IFree IUse% Mounted on
......
/dev/sdb1            2147361984 12785019 2134576965    1% /search
# touch empty_file2.txt
df -i
Filesystem            Inodes   IUsed   IFree IUse% Mounted on
......
/dev/sdb1            2147361984 12785020 2134576964    1% /search

每个 inode 到底是多大呢?dumpe2fs 可以告诉你(XFS 的话使用 xfs_info)。

# dumpe2fs -h /dev/mapper/vgroot-lvroot
dumpe2fs 1.41.12 (17-May-2010)
......
Inode size:               256

Inode 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 等信息。

结论

  1. 新建一个空文件需要消耗掉一个 inode,用来保存用户、创建时间等元数据。
  2. 新建一个空文件还需要消耗掉其所有目录的 block 中一定的空间,这些空间用来保存文件名、权限、时间等信息。

所以,看起来新建一个空文件而已,只要你想挖,真的能挖出很多知识的。最后分享一个我们团队里同学遇到的一个故障。我们的一台离线任务机直接歇菜了,重启后排查原因是 inode 被消耗光了。再追查发现一个进程创建了太多的空日志文件。虽然文件都是空文件,但是 inode 却被浪费光了。后来让负责的同学修改了创建日志文件的逻辑,删掉了多出来的空文件,该机器恢复正常。


3.2 只写入一个字节的文件占用多少磁盘空间

在前面我们了解到了一个空文件的磁盘开销。今天我们再思考另外一个问题,假如我们给文件里只写入 1 个字节,那么这个文件实际的磁盘占用也是 1 个字节吗?

查看 1 个字节的文件

和前一文一样,先不谈原理,直接动手操作。

# mkdir tempDir
# cd tempDir
# du -h
0    .
# touch test
# du -h
0    .
echo "a" > test
# du -h
4.0K    .

在一个目录中创建了一个空的文件以后,通过 du 命令看到的该文件夹的占用空间并没有发生变化。这倒是符合我们之前的认识,因为空文件只占用 inode。好,那让我们修改文件,添加一个字母。保存后再次查看该目录的空间占用。我们发现由原来的 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 个。我们再查看一下这个常量的定义,发现它是 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 等命令来查看。如果想改变这个块大小怎么办?对不起,只能重新格式化。


3.3 文件过多时 ls 命令为啥会卡住

不知道你有没有遇到过当一个文件夹下文件特别多,在下面执行 ls 命令的时候要等好长时间才能展现出来的问题?如果有,你有想过这是为什么吗,我们该如何解决?

要想深入理解这个的问题产生的原因,我们就需要从文件夹占用的磁盘空间开始讨论了。

inode 消耗验证

在《新建一个空文件占用多少磁盘空间?》中我提到了每一个文件会消耗其所在文件夹中的一点空间。文件夹呢,其实也一样会消耗 inode 的。我们先看一下当前 inode 的占用情况:

# df -i  
Filesystem            Inodes   IUsed   IFree IUse% Mounted on
......
/dev/sdb1            2147361984 12785020 2134576964    1% /search
# mkdir temp
# df -i
Filesystem            Inodes   IUsed   IFree IUse% Mounted on
......
/dev/sdb1            2147361984 12785021 2134576963    1% /search

再创建一个空文件夹,通过 IUsed 可以看到,和空文件一样,空的文件夹也会消耗掉一个 inode。不过这个很小,我的机器上才是 256 字节而已,应当不是造成 ls 命令卡主的元凶。

block 消耗验证

文件夹的名字存在哪儿了呢?嗯,和《新建一个空文件占用多少磁盘空间?》里的文件类似,会消耗一个 ext4_dir_entry_2(今天用 ext4 举例,它在 linux 源码的 fs/ext4/ex4.h 文件里定义),放到其父目录的 block 里了。根据这个,相信你也很快能想到,如果它自己节点下创建一堆文件的话,就会占用它自己的 block。我们来动手验证一下:

# mkdir test
# cd test  
# du -h
4.0K    .

这里的 4KB 就表示消耗掉了一个 block。空文件不消耗 block,空目录为啥一开始就消耗 block 了呢,那是因为其必须默认带两个目录项 "."".."。另外这个 4K 在你的机器上不一定是这么大,它其实是一个 block size,在你格式化的时候决定的。

我们再新建两个空的文件,再查看一下:

# touch aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab
# touch aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# du -h
4.0K    .

貌似,没有什么变化。这是因为:

  • 第一、新的空文件不占用 block,所以这里显示的仍然是目录占用的 block。
  • 第二、之前文件夹创建时候分配的 4KB 里面空闲空间还有,够放得下这两个文件项。

那么我再多创建一些试试,动用脚本创建 100 个文件名为长度 32 Byte 的空文件。

#!/bin/bash
for((i=1;i<=100;i++));
do
        file="tempDir/"$(echo $i|awk '{printf("%032d",$0)}')
        echo $file
        touch $file
done
# du -h
12K    .

哈哈,这时我们发现目录占用的磁盘空间变大了,成了 3 个 Block 了。当我们创建到 10000 个文件的时候:

# du -h
548K     .

在每一个 ext4_dir_entry_2 里都除了文件名以外,还记录着 inode 号等信息,详细定义如下:

struct ext4_dir_entry_2 {
        __le32  inode;                  /* Inode number */
        __le16  rec_len;                /* Directory entry length */
        __u8    name_len;               /* Name length */
        __u8    file_type;
        char    name[EXT4_NAME_LEN];    /* File name */
};

我们计算一下,平均每个文件占用的空间 = 548K / 10000 = 54 字节。也就是说,比我们的文件名 32 字节大一点点,基本对上了。这里我们也领会到一个事实,文件名越长,在其父目录中消耗的空间也会越大。

本章结论

一个文件夹当然也是要消耗磁盘空间的。

  • 首先要消耗掉一个 inode,我的机器上它是 256 字节
  • 需要消耗其父目录下的一个目录项 ext4_dir_entry_2,保存自己 inode 号,目录名。
  • 其下面如果创建文件夹或者文件的话,它就需要在自己的 block 里 ext4_dir_entry_2 数组
  • 目录下的文件/子目录越多,目录就需要申请越多的 block。另外 ext4_dir_entry_2 大小不是固定的,文件名/子目录名越长,单个目录项消耗的空间也就越大。

对于开篇的问题,我想你现在应该明白为什么了,问题出在文件夹的 block 身上。

这就是当你的文件夹下面文件特别多,尤其是文件名也比较长的时候,它会消耗掉非常多的 block。当你遍历文件夹的时候,如果 Page Cache 中没有命中你要访问的 block,就会穿透到磁盘上进行实际的 IO。在你的角度来看,就是你执行完 ls 后,卡住了。

那么你肯定会问,我确实要保存许许多多的文件,我该怎么办?其实也很简单,多创建一些文件夹就好了,一个目录下别存太多,就不会有这个问题了。工程实践中,一般的做法就是通过一级甚至是二级 hash 把文件散列到多个目录中,把单目录文件数量控制在十万或万以下。

TIP

可以利用 hash 分目录的方法避免单目录文件过多导致的性能问题。


Githubhttps://github.com/yanfeizhang/coder-kung-fu

欢迎关注个人公众号「开发内功修炼」,了解你的每一比特,用好你的每一纳秒。

有想继续加入知识星球的同学微信扫描下面的二维码即可加入。另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券。