注:基于这位大佬的中文翻译笔记学习,有大段对翻译笔记的直接引用,以及一部分询问AI和结合自己理解的一些内容。写这份笔记只是为了方便自己复习和理解。
https://github.com/huihongxiao/MIT6.S081
lec16中我们将主要学习Linux Ext3文件系统的日志机制。几乎所有需要故障恢复的存储系统(数据库,文件系统,定制系统)都使用它,在分布式系统中也是从崩溃中恢复状态的核心机制,它是对"崩溃前发生的所有事件"的数据结构化记录,理解它就能掌握系统恢复的关键。
我们将会将ext3的日志机制和xv6进行对比。解释ext3是如何解决xv6日志系统中存在的性能瓶颈的。探讨在故障恢复时,ext3提供了怎样的语义保证。
温习xv6的日志机制
磁盘布局:xv6将磁盘分为两大部分,文件系统区(包含树状目录结构,文件数据块,元数据)和日志区(位于磁盘最开始的位置,记录事务性的更新)。
日志组成:Header Block(记录事务中涉及的block总数以及每个log block对应的实际文件系统block编号)和Log Blocks(存储实际被修改的数据块的副本)。
xv6日志工作流程:
1.begin_op:标记事务开始,在此之后,end_op之前的所有写操作都只更新内存中的缓存区,不直接写磁盘。
2.系统调用执行:修改内存中缓存区。
3.end_op(提交阶段):将修改过的缓存块写入磁盘中的日志区。
然后将修改的block数量写入磁盘日志区的Header Block中。这是原子性的关键点。如果崩溃在这之前发生,重启后Log Header中计数为0,此次事务中所有修改都被丢弃。如果崩溃在这之后发生,重启后恢复程序会将日志区中的内容写入对应磁盘块中。
然后将日志区中的数据块复制到对应磁盘块中。对于一个磁盘块,这一过程可能重复发生。假如磁盘块1在崩溃前已经拷贝了对应的日志块的数据,崩溃后,所有日志块都会重新拷贝到对应的磁盘块中。虽然效率略低,但保证了安全。
最后将Log Header的计数清零,标记事务结束,释放log空间以供复用。
对于所有日志系统,必须包含以下规则以保证Crash Safety:
Write Ahead Rule(预写日志规则):在将任何修改应用到文件系统的实际位置之前,必须先将所有修改记录在log中并持久化。
Freeing Rule(释放规则):只有当Log中的所有更新都已经安全写入到文件系统实际位置后,才能释放或重用这段log空间。
xv6日志系统的缺陷:
1.同步写入:系统调用必须等待整个 end_op 流程(写 Log -> 写 Header -> 写文件系统 -> 清 Header)全部完成后才能返回。
2.串行化:同时只能有一个事务在提交,其他系统调用被阻塞。
3.写放大:每个被修改的 block 都要写两次磁盘(一次进 Log,一次进文件系统)。
4.机械硬盘延迟:每次写操作约10ms,一个系统调用包含多次写操作,导致每秒只能处理极少数事务。
ext3文件系统中的日志机制
内存同样有缓存块,缓存块有三种状态:
1.Clean,和磁盘一致。
2.Dirty,已经被修改,需要写回。
3.Pinned,被固定在缓存中,在日志提交前不允许直接写回文件系统位置。
内存中同时维护正在进行的事务信息,包括事务序列号、涉及的 Block 编号列表、以及相关的系统调用句柄(Handle)。
磁盘日志格式:与 xv6 类似,ext3 在磁盘上有标准的文件系统树(inode, bitmap 等)和一段固定的日志区域。但 ext3 的日志结构更精细,支持追踪多个事务。
Log Super Block(日志超级块):位于日志区的最开始(不同于文件系统的超级块),记录第一个有效事务的起始位置(Block编号)和序列号。
注:日志区被视为一个固定大小的循环缓冲区。
Transaction在日志中的结构,日志中可以连续存储多个已提交的事务,每个事务包含三个部分:
-
Descriptor Block (描述符块):
-
类似 xv6 的 header block。
-
记录了后续数据块对应的实际文件系统 Block 编号。
-
Magic Number:以 32-bit 魔法数字开头,用于在恢复时区分描述符块和普通数据块。
-
-
Data Blocks (数据块):实际被修改的数据副本。
-
Commit Block (提交块):
-
标志该事务的结束和成功提交。
-
同样包含 Magic Number 以便识别。
-
多事务管理:ext3 与 xv6 最大的区别在于它可以同时处理多个处于不同阶段的事务,而不是像 xv6 那样串行处理(一次只能做一个系统调用)。
内存中,同一时刻只有一个正在进行的事务,接受新的系统调用写操作。
磁盘日志中,可以存在多个已提交的事务,等待被刷入文件系统中。
这里其实就是常见的用缓冲区实现松耦合的思想。正在发起文件系统操作的进程只管把数据刷入日志区中。由另一个专门负责的内核进程持续进行将日志区数据刷入文件系统的工作。现在,我们不必等待end_op将数据刷入文件对应磁盘块了,只要将数据刷入日志区中,就可以执行下一个事务了。
ext3文件系统性能提升策略:
1.异步系统调用:ext3允许系统调用在将数据写入缓存后(而非写入日志区)立即返回,而无需等待数据真正写入磁盘。
优点:用户程序不必被慢速磁盘I/Ozuse,CPU运算和磁盘后台写入可以并行进行。为后续的Transaction Batching提供了基础。
缺点:数据持久性不确定。系统调用返回成功不代表数据已落盘。若此时断电,数据可能丢失。对于数据库、文本编辑器等对数据安全敏感的应用,可使用 fsync(fd) 系统调用。fsync会强制将指定文件的所有修改刷入磁盘日志区中,并等待完成后才返回,确保数据安全。
优化了硬件速度不匹配的问题。
2.批量执行(Batching):ext3默认每隔一段时间创建一个新的Transaction,将这段时间内所有的系统调用打包在一起提交。
优点:
1.将多次系统调用的负担(如写日志头/尾,磁盘寻道)分摊到一个大的事务中,平均成本大幅降低。
2.写吸收:如果多个系统调用修改了同一个block,批量修改可以在内存中多次修改,最终只写一次磁盘。极大减少磁盘写入量。
3.磁盘调度优化:一次性向驱动发送大量写请求,让驱动程序有机会利用电梯算法对请求进行排序,从而实现顺序写入,显著提升吞吐量。
3.并发:ext3实现了比 xv6 更细粒度的并发控制,主要体现在两个维度:
A.多系统调用并发执行:多个CPU核上的不同进程可以同时发起系统调用,向同一个Open Transaction中添加修改。只要不涉及同一个Block的锁竞争,它们不需要互相等待。而xv6在一个Transaction提交期间,会阻塞所有新的文件系统操作。
B.多阶段事务并存:系统可以同时维护处于不同生命周期的多个事务,
Open Transaction:正在接受新系统调用的修改。
Committing Transaction:正在写入日志区。
Checkpointing Transaction:正在从日志/缓存写入文件系统实际位置。
新事务的开启不需要等待旧事务的完成,解决了新事务和旧事务之间的等待问题。
写时复制:当一个Block正在被旧事务写入磁盘,同时又被新事务修改时,如何保证旧事务数据的完整性?ext3会在内存中对该缓存块进行Copy-On-Write。旧事务使用旧副本进行落盘,新事务在新副本上修改,互不干扰。
总结:ext3 通过异步化 解耦了 CPU 与磁盘的速度差异,通过批量化 优化了磁盘 I/O 的物理特性,通过多版本并发充分利用了多核 CPU 和磁盘带宽。这些设计使得 ext3 在保持 Crash Safety 的同时,性能远超同步阻塞的 xv6。
Linux ext3文件系统调用在代码层面的通用结构:为了支持事务处理,每个涉及文件系统修改的系统调用都必须严格遵循一套三步走协议。
句柄(Handle):句柄是连接当前系统调用与底层事务的纽带。ext3需要知道当前正在进行的系统调用有哪些,以及它们修改了哪些块。句柄唯一标识了当前正在执行的这个系统调用实例。
第一步:声明开始。
调用start函数,系统返回一个handle,这标志着一个原子操作序列的开始。获取句柄的操作在系统调用中进行,如果内核中已经有一个开放事务的话,会很快地获取一个handle。事务切换时,会有短暂阻塞。如果日志空间不足的话,会发生严重阻塞。
第二步:修改数据
当系统调用需要修改某个Block时,它通过get函数获取该Block的缓存。调用get时必须传入handle。这等于告诉日志系统:这个Block的修改属于这个Handle对应的Transaction。
第三步:声明结束
调用stop函数,并传入handle。告诉日志系统,这个特定的系统调用已经完成了所有修改。
注意,调用stop不等于数据立即写入磁盘或事务立即提交。它只是将当前Transaction中的"正在运行的系统调用计数"减一。
一个事务中可能包含多个并发的系统调用。只有当该事务中所有已经start的系统调用都执行了stop之后,该Transaction才能被认为是封闭的,才允许被提交到磁盘日志中。
这种设计确保了原子性:无论一个事务包含多少个系统调用,它们要么一起成功,要么在提交前发生崩溃导致全部无效。
ext3文件系统事务提交流程和日志空间管理
ext3的日志提交是由后台内核线程(如kjournald)周期性(默认5秒)触发的。事务的提交可以分为三个阶段:
第一阶段:事务切换,这一阶段目标是封存当前的Open Transaction,并迅速开启一个新的事务供后续系统调用使用,以减少阻塞时间。
第一步:暂时禁止新的系统调用假如当前的Open Transaction。确保当前的事务的边界是确定的,不再有新的修改混入。这会带来短暂的性能停歇。
第二步:等待当前系统调用完成,等待那些已经拿到Handle的系统调用执行stop()。确保该事务包含的所有原子操作都已完整更新到了内存cache中。
第三步:创建一个新的事务容器,并解禁第一步中被阻塞的系统调用,让它们加入这个新的事务。从这一刻起,前台进程可以继续进行,后台线程开始处理旧事务的繁重磁盘I/O。
第二阶段:写入日志,这一阶段是将内存中的脏数据持久化到磁盘的日志区。
第四步:更新描述符块,构建并写入Descriptor Block,其中记录了该事务修改了哪些编号。(磁盘中事务包含三个部分,Descriptor Block,Date Block,Commit Block)
第五步:写入数据块,将事务修改的实际数据块写入磁盘日志区。前面提到的Copy-on-Write就有可能在这一阶段发生。如果新的事务也要修改同一Block,日志系统会使用该Block在事务结束时的内存拷贝进行写入,避免新旧数据混淆。
注:当T1中所有系统调用都stop时,ext3会在内存中对T1涉及的脏块进行Copy-on-Write快照。后台线程把T1的快照副本慢慢写入磁盘日志,前台进程T2可以立即修改Block的最新版本。
第六步:等待日志写入完成,确保Descriptor和Data Blocks都安全落盘。
第七步:写入提交块。
第八步:等待提交块写入完成,一旦此步完成,事务被视为Committed。即使此刻系统崩溃,重启后数据也能从日志中恢复。若在此步之前崩溃,数据丢失。这一步的地位等同于xv6中"将修改的block数量写入磁盘日志区的Header Block中"。
第三阶段:归位与清理,这一阶段是将数据从日志区搬到文件系统中。
第九步:将日志中的数据块写入文件系统实际的物理位置中。
第十步:释放日志空间。所有数据落盘后,更新Log Super Block,标记该事务占用的日志空间可以被重用。
日志空间的循环利用:日志空间是有限的,ext3将其作为一个循环缓冲区来管理。日志有一个Head(最早的有效事务)和一个Tail(最新的事务)。随着新事务写入,Tail向后移动;随着旧事务完成,Head向后移动。如果写得太快,系统必须停止所有新的写操作,强制等待Head处的旧事务完成并释放空间。
加入想要复用一个事务的话:该事务必须已经完全写入文件系统实际位置中。并且该事务之前的所有事务必须已经释放。
死锁预防:系统调用在start()时必须声明所需的Block数量。如果日志剩余空间不足以容纳这次声明的数量,系统调用会被挂起,知道有足够空间释放出来。这防止了"事务开始了一半却发现日志空间不足"的死锁情况。
ext3文件系统崩溃恢复
恢复的前提:假设发生电力故障或者内核崩溃,内核(RAM)中的数据全部丢失,但磁盘数据完好。此时会在操作系统运行任何用户程序之前,通过重放日志,将文件系统恢复到崩溃前最后一次成功提交的状态。
恢复过程主要分两个阶段:扫描和重放
第一步:寻找起点。恢复程序读取日志区的超级块。超级块中存储了一个指针,指向最早的一个有效事务的起始位置。这就是恢复扫描的起点。
第二步:寻找终点。恢复程序读取第一个有效事务的Descriptor Block,然后得知该事务有多少数据块,跳过这些数据块,检查下一个块是否为提交块。如果是说明事务有效,检查下一个事务,重复上述过程,知道扫描链条断裂。
第三条:当扫描的某个事务时,恢复程序需要能够判断后面是否还有新的事务。ext3规定,所有的描述符块和提交块必须以一个特定的32位魔法数字开头。如果用户正好在一个文本文件中写了这个魔法数字,导致某个数据块也已这个数字开头,怎么办?
由于文件系统向日志写入一个数据块时,如果发现它以魔法数字开头,系统会将这32位替换为0。同时,在描述符块中设置一个标记位,记录第几个数据块被转义了。恢复程序看到这个标记,在将数据写入文件系统之前,会将开头的0替换为魔法数字。
因此,在日志区中,只有元数据块会以魔法数字开头,这消除了所有歧义。
第四步:恢复程序会在以下两种情况停止扫描,并认为日志结束:
1.提交块后面的块不以魔法数字开头。
2.提交块之后虽然是描述符块,但顺着它找不到对应的提交块,这说明事务没写完就崩溃了。对于这种事务,恢复程序直接忽略。
第五步:重放日志。一旦确定日志的有效范围,回复程序将这些日志中的数据块依次写入文件系统的实际位置。完成后,文件系统恢复一致性,OS启动完毕,开始运行用户进程。
再次提及ext3相比xv6的核心优势:并发能力。
| 特性 | xv6 Logging | ext3 Logging |
|---|---|---|
| 事务容量 | 单事务:Log 中同一时刻只能存 1 个事务。 | 多事务:Log 中可以存多个处于不同状态的事务 (T6, T7, T8...)。 |
| 执行模式 | Stop-the-world :在提交(Commit)当前事务期间,必须完全停止所有新的系统调用。 | 流水线 (Pipeline) :可以同时进行 T6 的磁盘提交和 T7 的内存执行。 |
| 吞吐量 | 低。系统经常处于"等待磁盘写入"的停顿状态。 | 高。磁盘 I/O 和 CPU 计算并行处理。 |
ext3 事务切换机制中关键安全性设计
当ext3决定关闭当前的Open Transaction T1时,它必须阻止所有新的系统调用进入后续的事务。只有当 当前Open Transaction中所有已经开始的系统调用都执行了stop()之后,系统才允许开启下一个事务T2并接受新的请求。这会导致短暂的性能暂停,这段空窗期内,CPU无法处理新的文件系统请求。
为什么等待:如果不等待,允许T1额T2的系统调用重叠执行,会导致严重的文件系统损坏。举一个例子:
T1为旧事务,包含create(x),T2为新事务,包含unlink(y)。文件y占用inode17。
T2中的unlink(y)执行极快,将inode17标记为空闲。T1中的create(x)发现inode17为空,于是将x绑定到inode17。这里T1读取了T2的执行结果。
T1顺利完成,写入磁盘日志并提交。此时磁盘上记录,文件x使用inode17。然后在T2提交之前,系统崩溃。重启后,因为T1已经提交,恢复程序重放日志,结果:文件x存在,指向inode17。T2被丢弃,因为T2未提交,被直接忽略。结果是,unlink(y)从未发生,磁盘中,文件y依然存在,且元数据认为它仍指向inode17。此时,磁盘的文件系统中,目录A中可能有文件x,目录B中可能有文件y,前者记录inode17指向文件x,后者记录inode17指向文件y。而inode17在T1写入之后实际上指向文件x。当我们在目录B中修改文件y时,实际上修改文件x。
这个问题的本质上是违反了事务的隔离性,导致了原子性的丢失。T1的执行依赖于T2的中间状态,T2本来应该全做或者全不做,但在上述场景中,由于T1的固化,T2的一部分效果被永久保留了,而另一部分却因崩溃消失了。
为了杜绝这种问题,ext3采取了最简单粗暴也最安全的策略,即严格的序列化。直到T1中所有系统调用stop后T2才能开始,这样T1永远不能看到T2做的任何修改。
总结:日志的本质是保证原子性,面对崩溃,一组写操作要么全发生,要么全不发生。基石是在修改实际数据之前,必须先将修改记录持久化到日志中。这是崩溃恢复的唯一依赖,它使得恢复过程变得简单且快速。
ext3通过批量处理和并发获得了高性能,但代价是极高的复杂性。
一些学生问答中的补充:
1.日志循环复用,新事务T8 覆盖了旧事务 T5 的前半部分,但 T8 未写完就 Crash 了。此时 T8 的 Descriptor Block 后面紧跟着 T5 遗留的 Commit Block。恢复程序会误以为 T8 提交了吗?
解答:不会,每个 Descriptor Block 和 Commit Block 内部都记录了事务序列号,如果事务序列号不匹配,不会被视为已提交。
2.ext4的优化:校验和
在ext3中,必须先写完数据块,等它们全部落盘,然后给CPU发送信号,再写提交块。这导致磁头多转一圈,有性能损耗。
ext4允许数据块和提交块同时发给磁盘驱动。磁盘可能会先写提交块,后写数据块,如果中间崩溃,虽然有提交块,但是数据是坏的。为此,ext4在提交块中加入了校验和。恢复时计算日志数据的校验和,如果与提交块里的不一致,说明数据没写完,视为无效事务。这既提升了性能,又保障了安全。
3.ext3的数据模式:ext3提供三种模式来平衡性能与一致性,这里重点介绍两种。
A.Journaled Data(日志数据模式):Metadata(inode,目录)和文件内容全部写两遍,先写日志,再写文件系统。最安全,但极慢,适用于对安全要求极高的场景。
B.Ordered Data(有序数据模式):默认且最流行,Metadata写到日志中,文件内容直接写到文件系统实际位置中,不进入日志区。
必须先把文件内容写入磁盘实际位置,然后才能提交包含该文件inode更新的事务。
有序数据模式防止隐私泄露/旧数据复现
如果先写inode(指向新block)再写内容,中间崩溃的话,inode指向了新的Block,但是里面是别人的旧数据或者垃圾数据。
有序数据模式保证:只要inode指向了某个块,那个块里一定已经是新数据了。即使崩溃导致内容写了但inode没更新,也只是浪费了一个块的空间(Bitmap没变),不会导致数据错乱或隐私泄漏。
有序数据模式特点:性能好,安全性足够。
其本质是:磁盘依赖元数据管理数据。数据更新,元数据未更新,可以视为"全不发生"。但元数据更新,数据未更新或未更新完全是不可接受的。所以元数据必须再数据更新后才能进入日志区。元数据如果提交失败的话,那么即为全无,提交成功,则为全有。