You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

148 lines
15 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 16日志型文件系统写入文件的时候断电了会发生什么
你好,我是微扰君。
今天我们就来聊一聊操作系统最常见的外存——磁盘的问题。我们知道计算机的内存一旦断电,数据就会全部丢失,所以如果需要持久化一些数据,磁盘就是必不可少的硬件,甚至在计算机上运行的整个操作系统的大部分代码逻辑,其实也是存储在磁盘中的。
计算机要和磁盘打交道,就需要用到文件系统。
文件系统其实就是操作系统中用于管理文件的子系统它通过建立一系列诸如文件、目录以及许多类似于inode这样的元数据的数据结构把基础的文件访问能力抽象出来给用户提供了一套好用的文件接口。
和一般的数据结构和算法主要考虑性能不同,文件系统还需要考虑一件非常重要的事情——数据的可持久化。因为文件系统一定要保证,计算机断电之后整个文件系统还可以正常运作,只要磁盘没有损坏,上面的数据在重新开机之后都可以正常访问。
这件事听起来感觉很简单,但是真正实践起来可要难得多,在过去几十年里为了解决各种各样不同的问题,文件系统层出不穷,今天我们就来讨论其中一个问题:**写文件写到一半断电了,或者因为各种各样的原因系统崩溃了,系统重启之后文件是否还能被正常地读写呢?如果不能的话,我们应该怎么办呢**
这个问题我们一般叫崩溃一致性问题crash-consistent problem。目前最流行的解决方案是Linux中的Ext3和Ext4文件系统所采用的日志方案也就是journaling而Ext3和Ext4自然也就是所谓的日志型文件系统。
## 崩溃一致性
在讲解问题的具体解决方案之前,我们还是得先来认真审视一下崩溃一致性问题的本质:**写文件的时候我们具体做了哪些事情呢?崩溃后为什么可能会产生一致性问题?**
这自然也就关系到文件在系统中到底是如何存储的了。不过不同的文件系统对文件的组织方式千差万别我们会以Ext4中的存储方式为例带你简单了解演化了许多年之后现在主流的文件系统是如何存储数据的。
目前最主流的持久化存储介质还是磁盘。限于磁盘的物理结构它读写的最小单位是扇区大小是512B但是每次都只读一个扇区不利于读写效率的提升所以文件系统普遍会把多个扇区组成一个块也就是block。在Ext4中逻辑块的大小是4KB包含8个扇区。
我们的数据自然也就是放在这一个个数据块上的。不过和内存一样,**磁盘空间大小虽然大的多,但仍然是有限的,我们需要为不同的文件划分出自己的区域**,也就是数据具体要存储在哪些块上的。
为了更灵活地存储文件、更高效地利用磁盘空间、更快速地访问到每个文件的数据存储在哪些块上Linux的做法是把文件分成几块区域至少包括超级块、索引节点区、数据块区。
![图片](https://static001.geekbang.org/resource/image/db/9a/dbe36ee8745fdb52b7953e13fb50809a.jpg?wh=1920x1145)
* 超级块,是文件系统中的第一个块,用来存放文件系统本身的信息,比如可以用于记录每块区域的大小;
* 索引节点区每个文件对应索引节点区中的一个块我们称为索引节点也就是Inode存放每个文件中所用到的数据块的地址Inode也是元数据主要存储的地方
* 数据块区也就是Data Blocks这里是真实数据存放的区域一个文件的inode可能存有多个指向数据块的指针。
另外为了标记哪些Inodes和Data Blocks可以被使用操作系统还建立了两块存放Bitmap的区域。
这样我们就可以非连续地表示各种大小的文件了,因为在索引节点上就会有很多指针链到数据区的不同区块;我们也就可以快速地获取文件所需要的数据内容了,只要在访问文件的时候根据索引节点中的指针和操作系统的磁盘调度算法,读取文件中数据块中的内容即可。
### 引入元数据的问题
但是在文件系统中引入了元数据带来了灵活性的同时,也带来了问题。
现在每个文件都对应一个Inode它记录了所有数据块的位置可以预想到之后在修改、创建文件的时候除了修改数据块区的内容也需要修改文件的元数据和Bitmaps。比如当我们往某个文件里追加数据很可能就需要创建新的数据块把数据写入其中并且在Inode上追加一个指向这个数据块的指针。
总之这么看,**每次写文件的操作其实都是一个操作序列,而不是一个单一的操作**。但是,磁盘在同一时间里肯定只能接受一次读写的请求,因此文件系统就引入了崩溃一致性问题。
这很好理解,我们看一个具体的例子。
![图片](https://static001.geekbang.org/resource/image/2e/20/2e5e7befb06b024741d5fdc0fbaf7420.jpg?wh=1920x1145)
已知我们写一个文件至少会碰到 Bitmaps、Inodes 和 Data Blocks 三块数据的修改,在不会遇到崩溃的时候,我们可能就像这张图一样顺利修改了每个部分,此时文件系统没有任何问题。
但假设修改完Inodes了我们把Inodes中某个指针指向了一段即将要存放数据内容的数据块此时如果遭遇了断电Data Blocks 上的内容是未知的,很可能是很早之前别的程序写过的数据,我们可以认为此时上面的数据是脏的垃圾数据。等系统恢复,我们重新去读取文件数据的时候,会发现也没有什么有效的依据供我们检验文件是否正常。
![图片](https://static001.geekbang.org/resource/image/cc/fb/ccf6227b5cacab6975c67395d75328fb.jpg?wh=1920x1145)
在这种情况下Inodes指向的文件内容和我们的预期就会不一致从而产生文件损坏的情况。其实和我们平时说的事务性、原子性是类似的场景。
这也只是不一致的一种情况。事实上,因为操作系统会对磁盘读写的顺序做调度,以提高读写的效率,我们其实不能知道这几个写磁盘的独立步骤确切的执行顺序。
不过可以想见在其他几种写Inodes、Data Blocks和Bitmaps的顺序下**如果没有执行完全部步骤就遭遇了断电等情况,文件系统在大部分时候仍然都会进入不正确的状态,这就是崩溃一致性问题**,如果你感兴趣可以模拟其他几种情况。
## 如何解决
现在我们搞清楚了崩溃一致性问题的本质自然就要尝试解决它。历史上比较流行的解决方案有两种一种是早期操作系统普遍采用的FSCK机制file system check另一种就是我们今天主要学习的日志机制journaling file system
我们先从早期的FSCK机制学起。
### 解决方案1FSCK
FSCK机制的策略很简单错误会发生没关系我们挂载磁盘的时候检查这些错误并修复就行。
比如检查发现Inodes和Bitmaps不一致的时候我们选择相信Inodes而更新Bitmaps的状态或者当两个Inodes都指向同一个block时我们会把其中明显是异常的一个Inodes移除。
但因为崩溃毕竟有一部分信息是丢失了的所以很多时候我们也没有办法智能地解决所有问题尤其是前面说的Inodes指向脏数据的情况事实上这种情况下FSCK没有办法做任何事情因为它本质上只是让文件系统元数据的状态内部保持一致而已。但是这还不是FSCK最大的问题。
**FSCK真正的问题是每次出现问题需要执行FSCK的时候的时间非常久**
因为需要扫描全部磁盘空间并对每种损坏的情况都做校验才能让磁盘恢复到一个合法的状态对普通的家用电脑来说很可能需要长达几十分钟甚至几小时的时间。所以现在FSCK基本上已经不再流行取而代之的就是日志型文件系统。
### 解决方案2Journaling
日志型文件系统这个方案其实是从DBMS也就是数据库系统中借鉴而来的。
journaling file system 的核心思想是鼎鼎大名的预写日志 WAL 也就是 write-ahead logging这个也正是数据库系统中用于实现原子事务的主要机制也很好理解毕竟事务和文件系统一样都需要保证一致性。
那具体是怎么做的呢?
思想也很简单,就是**每次在真正更新磁盘中的数据结构之前我们把要做的操作先记录下来然后再执行真正的操作这也就是先写日志WAL中write-ahead的意思**。这样做的好处在于,如果真的发生错误的时候,没有关系,我们回去查阅一下日志,按照日志记录的操作从头到尾重新做一遍就可以了。
这样我们通过每次写操作时增加一点额外的操作,就可以做到任何时候遭遇崩溃,都可以有一个修复系统状态的依据,从而永远能恢复到一个正确的状态。
对于文件系统的布局我们也只是需要增加一块区域用于存放日志就行改动不是很大这块区域我们叫做日志区Journal
![图片](https://static001.geekbang.org/resource/image/00/8d/0003032e2ea1bb710533c19405fe2f8d.jpg?wh=1920x1145)
那Journal区里到底要存放点什么样的内容呢
这里会涉及一次完整的写文件对磁盘的一系列操作,你不熟悉的话也没有关系,就当这是几个独立的操作就行。**我们核心就是要实现:希望可以通过某种记录日志的方式,让这些操作一旦决定被提交,即使后续对磁盘上元数据和数据块上数据结构的改动进行到一半,系统断电了,仍然可以根据这个日志恢复出来**。
那要怎么做呢?和数据库一样,我们为了让一系列操作看起来具有原子性,需要引入“事务”的概念。
我们每次进行一次对文件的写操作除了会先在预写日志中记录对Inodes的修改记录、对Bitmaps的修改记录以及对具体数据块的修改记录之外还会同时在这几条记录的前后分别引入一个事务开始记录和一个事务结束记录。
![图片](https://static001.geekbang.org/resource/image/47/92/477680186c827738e076726c9c24e992.jpg?wh=1920x1145)
第一个写入的记录是TxB也就是Transaction Begin记录最后一个写入的记录就是TxE也就是Transaction End记录**在TxE记录完成后就意味着整个写文件的操作全过程都被记录在案了我们把这个步骤叫做Journal Write**。
从而日志的组织形式就是由这样一个个事务拼接而成日志的首尾是TxB和TxE块中间是具体对元数据和数据块修改的记录块。
当我们完成Journal Write操作之后就可以放心大胆地把这些实际的元数据或文件数据覆写到磁盘上对应的数据结构中了这个步骤我们叫做 checkpointing。
当这个步骤也成功完成,我们就可以说整个写文件的操作被完成了,在全部成功的情况下当然没什么特别的,**但整个设计的关键之处就在于,面对任意时刻崩溃的情况,我们也能把文件系统恢复到某一合法状态的能力**。
![图片](https://static001.geekbang.org/resource/image/e7/8e/e7859d16930576ceb6f9158d21c2228e.jpg?wh=1920x1145)
我们具体看看崩溃出现的时候引入了journaling的文件系统会有什么不同吧。
* 如果崩溃出现在journal write步骤中
假设崩溃是出现在TxE块完成写操作之前那其实对系统也没有任何影响。因为相当于事务没有被成功提交而我们写的是日志对文件本身也没有任何实际影响。
当系统断电又恢复之后只要发现某个事务ID没有对应的TxE块说明这个事务没有提交成功不可能进入checkpointing阶段丢弃它们对文件系统没有任何不良影响只是相当于上次写文件的操作失败了而已。
* 如果崩溃刚好发生在journal write结束之后
不管是刚刚写完TxE还是已经进入了checkpointing的某一步我们的处理也都是一样的。既然事务已经被提交系统断电恢复之后我们也不用关心之前到底checkpointing执行到了哪一步比如是已经更新了inodes还是bitmaps都没有任何影响直接按照日志重做一遍就可以最坏的下场也不过就是重新执行了一遍执行过的操作。
顾名思义,这种重做一遍的日志我们也把它称为 redo logging它也是一种最基本、最常见的日志记录方式。不只在文件系统中在数据库等场景下也使用非常广泛。
## 总结
我们就通过引入预写日志的手段完美解决了操作系统文件状态在崩溃后可能不一致的问题相比于从头到尾扫描检查的FSCK机制。预写日志在每次写操作的时候引入一些额外的写成本让文件系统始终得以始终处于一种可以恢复到一致的状态如果崩溃只需要按照日志重放即可。
当然我们其实还有很多优化可以做。比如可以进行批量日志的更新把多个独立的文件写操作放到一个事务里提交提高吞吐量或者记录日志的时候只记录元数据而不记录文件写操作的大头数据块等等。如果你仔细看Linux文件系统的实现就会发现做的优化非常多集结了许多前人的智慧我们可以从中领略到很多思想也许有一天在你的工作中这些想法就会成为你解决一些问题的关键。
以我们今天学习的“日志”思想为例。我曾经在一家做安全硬件的公司实习,当时就有个需求要写一段代码用单片机往一个类似闪存的芯片里写一些状态,包含好几个独立的字段,每个字段都需要顺序地写,也就是说要分好几次独立的操作去写。
但是,单片机是可能随时可以掉电的,我们如何保证这个闪存中的状态是正确的,而不是状态的某几个字段是上次写的,某几个字段是这次写的呢?其实这个问题我们就可以用类似日志的思想去解决。
一种可行的方式就是在闪存中开辟一段额外的空间先预写日志用TxB和TxE来标记一次完整的状态每次启动时候先检查日志把最新的状态覆写到指定的地址区域即可。当然事实上解决的办法要更简单一些也更省空间欢迎在留言区和我一起讨论。
### 课后练习
最后布置一个思考题,文件系统为了提高读写吞吐,实际在写日志的时候也可以通过调度,调整写不同块的先后顺序。那我们在记录日志的时候是不是也能利用这个特性,进一步提高写文件的性能呢?如果可以的话,需要做什么限制吗?
欢迎你留言和我一起讨论。如果你觉得这篇文章对你有帮助的话,也欢迎你转发给你的好朋友一起学习。我们下节课见~