文件系统 入门
一台服务器往 access.log 追加一行。那次 write() 不到 1 微秒就返回,而这一行还待在断电够得着的地方。 25 张你能上手拖的图,一个论点:write() 承诺的只有 RAM。
文件就是一个名字、一个 inode、一堆 block
文件不是基本概念。它是目录里的一个名字、名字指向的 inode,以及 inode 指向的那堆 block。每个操作都在重排这 3 样东西。
自下而上:block 是设备上的定长块,ext4 上是 4 KB;inode 是单文件元数据加上从偏移到 block 的映射;目录就是一个 inode,数据恰好是一张名字表。先看内核最初见到的那条路径:只是一串名字,一个都还没读,滑块一次走一环:
这一步什么都不花,而这正是它要说的:一条路径是一串查找,不是一次。每一次查找都得读目录自己的数据 block 才能在里面找到那个名字。同一条路,只加上这一个方块:
5 个名字,5 个目录 block。但目录里的名字只是一个号码 —— 它说的是哪一个 inode,而不是里面有什么 —— 所以每次查找还得再读一次它点名的 inode。把那个方块也加上,再走一遍这些分量,然后把缓存从冷切到热:
注意成本落在哪儿:不在文件上。冷的时候,5 个分量等于 10 次 block 读,而日志的第一个字节还没碰到。热的时候是 0 ——dentry 缓存从 RAM 里回答了每一次查找。所以深路径上的 open() 只贵第一次,之后就白送。
inode 里的映射是 15 个槽:12 个直接,然后是一级、二级、三级间接。一个字节坐在哪儿,决定读它要花多少。拖偏移,或者把手柄沿轴拉:
看这条链怎么变长。4 KB block 加 4 字节块号,一个间接块装 1,024 个指针,所以 4 级的可达范围是 48 KB、4 MB、4 GB、4 TB。 处的字节要 3 次 block 读,那个要 4 次,其中 3 次纯粹是映射。文件越深,尾巴越贵。
extent 把指针列表换成「一段」。一个 ext4_extent 是 12 字节,能描述最多 128 MB 连续 block,而且 4 个就塞得进 inode 本身。把碎片度拖上去,看 extent 映射怎么追上指针列表:
1 GB 是 262,144 个 block。用指针就是 1 MB 的映射表,外加装它的那些间接块;用 8 个 extent 就是 96 字节 —— 可就连这也塞不进去。ee_len 只有 15 个可用位,一个 extent 最多覆盖 32,768 个 block,所以哪怕完全连续的 1 GB 也要 8 个;而 i_block 只有 60 字节, 12 字节头加 条 12 字节记录,第 5 条就把映射表挤进 extent 树的叶子块 —— 多一次读,而不是多 32 次。
名字和文件是两个东西,而 inode 会数自己有几个名字。把计数拖到 0,再切成 symlink 重来一遍 —— 名字、inode、剩下什么:
因为 inode 数的是引用,rm 其实是 unlink:去掉一个名字,计数减 1。归零才释放 block —— 而且如果还有进程开着这个文件,归零也不释放,这就是被删掉的日志还在撑爆磁盘、rm 却救不回来的原因。 symlink 什么都不数:把目标删了,链接还在,指着 ENOENT。
目录是成本藏身的另一处。ext4 用 htree 给目录做索引 —— 一棵一到两层的哈希 B 树;没有它,一次查找就是把每个块线性扫一遍。把条目数沿对数轴拖上去,看两条线怎么分开:
它们立刻就分开了。时,htree 是 3 次 block 读,线性扫描平均 2,942 次。有索引在,所以没人注意 —— 直到某个工具 readdir 完又对每一条 stat,那是任何索引都救不了的。
这一切都在 mkfs 时铺好、也在那时定死。先拖 inode 密度,再把你打算存的文件大小拖到它下面:
每 16 KB 一个 inode 的默认值,要花掉设备的 1.6% —— 4 TB 卷上是 64 GB,空着,在你写任何东西之前。(本页 KB 按 1,024 字节算:4 TB ÷ 16 KB = 268,435,456 个 inode × 256 字节 = 64 GB。)数量在格式化时定死: 的文件系统上存 ,用到容量的 6.3% 时 inode 就用光了,你拿到 ENOSPC,而 df 还显示盘几乎是空的。只有 df -i 会说出原因。
page cache,以及 write() 到底承诺了什么
你的代码和设备之间坐着一层 RAM 里的文件页缓存。读几乎都命中它,写都先落进它 —— 后面这件事就是数据丢失的来源。
page cache 是内核手里最近碰过的文件数据副本,按(inode、偏移)索引 ——free -h 里的 buff/cache,服务器上经常是一半的 RAM,别人一要立刻还回去。读的时候内核先看这儿:驻留的页是一次 memcpy,不在的页要跑一趟设备。把滑块横着走过这些页,再换一下底下的设备:
注意这道差距。命中是 0.7 µs —— 一次系统调用、一次查找、4 KB memcpy。同样一次读走 NVMe 是 80 µs,114 倍;走 7200 转磁盘是 10.2 ms —— 平均寻道 6 ms 加 7200 转下 4.17 ms 的平均旋转 ——。你的代码一个字都没变。变的只是那一页碰巧在不在。
写不一样:内核往缓存里拷一份,把页标记成脏,然后返回。把它弄到介质上是别人的活,晚点再说。沿时间轴拖断电,找到这页不再由你来丢的那一刻:
因为 dirty_expire_centisecs 是 3000、flusher 每 5 秒醒一次,现在写下的页要 30 秒后才有资格被回写,最多能在 RAM 里待 35 秒。write() 成功只意味着字节在内核内存里,不意味着它到了断电够不着的地方。
脏页是有上限的,而这个上限压在正在写的人头上。dirty_background_ratio 是 RAM 的 10%,dirty_ratio 是 20%。两条线以下,一次写就是一次 memcpy。把脏页占比推过这两条线,看 write() 开始要多少钱:
看那一级台阶。10% 以下,写就是一次 memcpy;过了 20%,调用者被抓去自己做回写,成本变成设备的成本 —— 时是 321 µs —— 这个数是模型而不是实测:越过阈值后 balance_dirty_pages 跑的是一个反馈环,按超出的比例让写者睡下去,图里把一次睡眠折成两次设备写。曲线多高取决于内核版本,20% 处那一级台阶不取决于。而不是 0.7 µs。大拷贝时「进程卡了两秒」就是这么来的。没有谁卡住:是一次 write() 替所有人还了债。
读能拿到写拿不到的帮助。当访问看起来是顺序的,内核会开一个预读窗口,并且一路翻倍到 read_ahead_kb 里的 128 KB。下面这条带子正好是这样一个窗口 —— 文件里的 32 个 block —— 而步长决定你到底要其中哪些:
时窗口开着:一个请求盖住全部 32 个 block,每用掉 1 KB 花 1.1 µs。 时它关上:你要的每个 block 都变成自己的一个请求 —— 真正读到的 64 KB 用掉 16 个 —— 一个有用的 KB 涨到 20 µs,差 18 倍,而且都停在这里。你付的不是字节,是请求;而每个请求还是拖进整整一个 4 KB block,你只用其中一个字段。
O_DIRECT 把缓存整个拿掉:从你的缓冲区 DMA 到设备,连同对齐限制一起。人们常常把它当优化抓过来。拖重复读的次数,看缓存正在拉开的那道差距:
它正好撑住一次读。从起,走 page cache 是 0.7 µs,而 O_DIRECT 又跑一趟设备 —— 读同一个 block 两次是 81 µs 对 159 µs。O_DIRECT 只在你自己有缓存、并且比内核更懂的时候才赢,所以数据库用它,而应用代码通常不该用。
持久性 —— journal、fsync,以及没人给的承诺
断电就是考试。重启之后文件系统还得说得通,而你的文件要么是旧的、要么是新的。这是两个不同的保证,而且只有一个是白送的。
一次元数据修改会同时碰好几个块 —— 位图、inode、目录块 —— 中途被打断,留下的就是自相矛盾的文件系统。不过在那之前,字节总得先到某个地方。4 个地方,只有最后一个活得下来:你的缓冲区、page cache、设备自己的写缓存,然后是介质。按播放,或者自己拖这个序列:
注意 fsync 是两步,不是一步。把脏页刷到块层还不够:盘会把写确认在易失的 DRAM 里,所以 fsync 还要发一条 cache flush 命令,并且等设备承认自己写完了。停在第 5 步的写,能撑到断电为止 —— 一秒不多。
journal 让元数据修改变成原子的。内核先把新块写进日志,再写 commit 块,之后才碰文件系统。把断电挪过整个事务,读每个位置上的判决:
不变量就一句话:恢复时,带 commit 块的事务被,不带的被,所以文件系统永远恰好是旧的那个,或者恰好是新的那个。而且恢复的上界是日志,不是磁盘 —— mke2fs 把 journal 封顶在 128 MB,顺序读 NVMe 是 38 ms;而光是把 4 TB 卷上的 inode 表流一遍就要 20 秒。这 20 秒是下界不是估计:真正的 e2fsck 还要走位图和整棵目录树,走的是寻道不是顺序读。
ext4 给了 3 种模式,默认那种是折中。data=ordered 只把元数据写进 journal,但保证数据块先落盘;data=journal 把数据也过一遍日志;data=writeback 同样只记元数据,但数据什么时候落盘都行。把分段控件从左拖到右,再用滑块把事务撑大:
看写放大。时,data=journal 写 2.01 倍 —— 每个字节写两遍 —— 换来现有最强的崩溃语义。再看 data=writeback 没买到什么:它那根条和 data=ordered 一样长,因为写的字节完全一样。它省掉的是顺序,而这在出事之前一直是白省的 —— 元数据可能先于数据提交,所以崩溃可能让一个刚扩展的文件露出这些块之前装的东西,那可能是别人删掉的数据。一个不花钱却可能把别人的字节交给你的模式,正是该躲开的那个。
fsync 是 POSIX 里唯一承诺了点什么的调用,而且它按提交次数收费,不按字节。挑一个设备,再往一次调用后面塞更多写者:
因为收费按 flush 算,批量几乎是白送的吞吐。7200 转磁盘上一个写者每秒拿到 100 次持久提交 —— 盘片得转过来,其中 4.17 ms 是 7200 转的平均旋转延迟。共用一次 fsync 拿到 1,581 次 —— 一转 16 次提交,再减去这张图按每多一个写者 8 µs 计的排队开销。只有转速是实测的,排队那一项是图自己的模型,也是这个数没落在整数 1,600 上的唯一原因。数据库的 group commit 就是这个,一字不差。
下一个问题是躲不掉的:fsync 要 700 µs,那 fdatasync 省掉了什么,为什么不总是用它?拖动调用,再换一下它运行的条件:
看第二列。 按下数据,跳过 inode,省掉了那次元数据写和它的日志提交 —— 540 µs 对 700 µs。但切到,这个折扣就没了:新的大小是把数据读回来所必需的,POSIX 因此把它算进数据里,于是追加写的那一类 —— 日志、WAL、段文件 —— 从 fdatasync 身上一分钱都省不到。先用 fallocate 预分配,折扣才会回来。
另外三行是同一份账单缺了几项,而这几项的大小差得很远。把那次 flush 从三次设备写里拿出来,看剩下多少:
sync_file_range 是最容易被读错的那一行。,别的什么都不强制 —— 没有元数据,也没有 cache flush —— 所以它是提前发起回写的手段,永远不是持久化的手段。它自己的 man page 就是这么写的。而 就是把同一个删除动作用到 fsync 上:去掉 flush,一次提交从 700 µs 变成 241 µs。它只在带掉电保护的设备上安全,也就是梯子上那一级 PLP;换到别的设备上,它是一个 2.9 倍的加速,代价是断电时悄悄丢掉最后一秒的写。
而且 fsync 会失败。设备报错时,内核手里攥着一个写不下去的脏页,又不能永远留着,于是把它丢了。走一遍,看下一次调用报什么:
这就是 fsyncgate,2018 年在 PostgreSQL 里被发现。第二次 fsync 返回 0,因为错误已经被消费掉,而那一页早就没了 —— 为一次根本没发生的写报了成功。Linux 4.13 之前,甚至只有一个文件描述符能看到这个错误。行业的答案是统一的:把 fsync 失败当成不可恢复,panic,然后从日志重建。
所以替换一个文件是一套 4 步的配方,而不是一次 write。挑一种配方,再把断电挪过去,读一读重启后剩下什么:
只有完整那套在每个断电点上都安全。原地覆盖会把文件留成一半旧一半新,哪一份都没留底。 rename 之前不 fsync 临时文件,名字会翻到根本没分配的块上 —— 一个零字节的配置文件,2009 年这坑了成千上万 ext4 用户。错的版本和对的版本差两行,而且错的那版能通过你为它写的每个测试:
write(f, buf, n) fsync(f) # 错的那版少了这行 close(f) rename(tmp, dst) fsync(dirfd) # 也少了这行
4 套配方底下都压着同一条硬地板:设备的 4 KB 扇区是最大的原子单位,再往上什么都不是。把断电挪过一次页写,再把页写宽:
8 KB 的页跨两个扇区,所以 3 个断电点里有 1 个会把它留成撕开的 —— 一半新页、一半旧页,校验和不过。 POSIX 从来没承诺过别的。Postgres 用 WAL 里的整页写来补这个缺口; ZFS 和 btrfs 干脆从不覆盖活着的块 —— 那就是 §04 的开头。
ext4、XFS、ZFS、btrfs 真正的分歧在哪儿
到这儿为止的一切,4 个都一样。分开它们的是 3 个决定:覆盖还是复制、给数据算校验和还是信设备、以及分配器动手时知道多少。
ext4 和 XFS 原地覆盖,并把元数据写进 journal。ZFS 和 btrfs 从不覆盖一个还活着的块:写落到新空间,把到根的整条路径重写一遍,变更就发布了 —— 提交就是一个指针。把一次 4 KB 覆盖写从叶子一路走到 superblock,看新块怎么出现在旧块旁边,而不是压在它上面:
注意旧树在最后一步之前的每一步都是完整、可挂载的。这正是 journal 用日志换来的性质;COW 直接从写的形状里拿到。账单是放大 —— 一次 4 KB 覆盖写碰了 6 个块 —— 还有碎片,所以 COW 文件系统在随机写下会变慢,需要定期 balance。
第二个差别是有没有人在检查。消费级磁盘的规格是:每读 1014 bit,不可读扇区少于 1 个。把读过的量拖到一次阵列重建的规模:
时,撞上一个的概率是 62%。ext4 和 XFS 给自己的元数据算校验和,不给你的数据算,所以文件里翻掉的一个 bit 会被当成数据交还给你;ZFS 和 btrfs 给每个块算校验和,能告诉你这个文件是错的。是检测,不是纠正 —— 纠正需要第二份副本,那是镜像的活。
第三个是分配器动手时知道多少。ext4 把分配推迟到回写,所以它一次看到整段,而不是一次一个 write()。改一改分配器做选择之前先攒多少:
4 KB 粒度下,1 GB 变成 262,144 个 extent、3 MB 的映射表。按 128 MB 攒着写就是 8 个 extent、96 字节 —— 仍比 i_block 装得下的 4 条多两条,但换来的是一个叶子块,而不是一棵三层的树。延迟分配也是为什么 write() 之后立刻崩溃可能什么都没分配到:让文件连续的那个把戏,和让文件变空的那个把戏,是同一个。
还有一个,因为今天大多数人是透过容器认识文件系统的。 overlayfs 把一层只读的下层垫在可写的上层底下,而对任何下层文件的第一次写,都要把整个文件拷上来。拖一下文件大小:
对一个 改一个字节,阻塞大约 175 ms —— 复制在同一块盘上边读边写,约 1.2 GB/s —— 然后吃掉容器可写层的 200 MB。所以镜像里的数据库想要 volume,所以对某一层 chmod -R 并不是看上去那么便宜的操作。
速查
4 个该张口就答的问题,以及 5 个危险信号。
一次文件操作到底花多少?
全看答案在哪儿找到,而跨度是 4 个数量级。下面每一级都是一次操作,滑块走的是正在付账的那一级:
该背下来的一步是第 3 级到第 4 级:是 80 µs,同一块消费级盘上的是 700 µs —— 点任意一个,梯子就会把它和它据以计价的那次 page cache 命中括起来。缺口以上,你用缓存买吞吐;缺口以下,你只能用 group commit 买,再没有别的东西可买。
write() 承诺了什么,fsync() 又加了什么?
write() 承诺字节在 page cache 里,并且之后任何进程的 read() 都能看到它们。它对介质什么都没承诺:那一页可以脏着待 35 秒。fsync() 加上的是刷到块层以及设备的 cache flush,并且只在设备确认之后才返回。它要是失败了,就把数据当成没了。
为什么一个目录塞 100 万个文件是个坏主意?
查找本身没问题 —— htree 让它只要 3 次 block 读。出问题的是所有要走一遍目录的东西。把条目数沿对数轴推上去:
注意第二段带子。ls -l 是一次 readdir 加上每条一次 stat,时就是 68,383 次 block 读 —— NVMe 上 5.5 秒,机械盘上 697.5 秒。像 git 那样散列进子目录。
什么时候选 XFS 而不是 ext4,什么时候两个都不选而上 ZFS?
超大文件加大量并发写者选 XFS:allocation group 让多个核各自分配,不抢同一张位图。再往下就不是口味问题了 —— 有两个能数出来的机制在替你决定。把数据集拖大,再把问题从一次快照要花多少切到翻掉一个 bit 会怎样:
注意哪一行两种模式下都是青色。ext4 没有 reflink,快照就是 cp;XFS 克隆的是 extent 映射表 —— 一条 xfs_bmbt_rec 16 字节,br_blockcount 有 21 位,所以一条记录能盖将近 8 GiB,连续的 1 GB 克隆下来就是一条记录,装在一个 512 字节的 inode 里。 ZFS 付的是一条分支 —— 6 个 block,24 KB,把数据集拖到 1 TB 那根条也不动,因为它复制的那条分支本来就已经写下去了。但只有 ZFS 给数据算校验和 —— 另外两个会把翻掉的字节当成一个值交给你。这两行都不是你的问题,就用 ext4。
- 循环里
write(),然后声称已经持久。离「从来没存在过」只有一次断电。 - 重试失败的 fsync。那一页已经没了,重试只会返回 0。
- 用
O_TRUNC覆盖配置文件。临时文件、fsync、rename、再fsync目录。 - 把目录当键值存储。查找一直便宜;
readdir加stat不是。 - 把
O_DIRECT当优化。从第二次读起,你拿掉的那个缓存本来是白送的。