Lecture 11 - Cache Consistency: Frangipani

参考链接:

https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-11-cache-consistency-frangipani/11.1-frangipani-chu-tan

11.1Frangipani 初探

有关缓存一致性,分布式事务和分布式故障恢复,并且它还介绍了这几种功能之间的关联。从整体架构上来说,Frangipani就是一个网络文件系统(NFS,Network File System)。

缓存一致性是指,如果我缓存了一些数据,之后你修改了实际数据但是并没有考虑我缓存中的数据,必须有一些额外的工作的存在,这样我的缓存才能与实际数据保持一致。

Frangipani一般运行在我们桌子上的电脑,如台式机而非笔记本,在系统内核中,有一个Frangipani模块,它实现了文件系统。文件系统的数据结构,例如文件内容、inode、目录、目录的文件列表、inode和块的空闲状态,所有这些数据都存在一个叫做Petal的共享虚拟磁盘服务中。Petal运行在一些不同的服务器上,有可能是机房里面的一些服务器,但是不会是人们桌子上的工作站。

在大部分时候,Petal表现的就像是一个磁盘,你可以把它看做是共享的磁盘,所有的Frangipani都会与之交互。

Frangipani的设计并没有讨论安全,它被假设运行在一个彼此信任的小群体中。

Frangipani被设计为在读取一个文件后会在本地缓存,而不用去Petal再获取一次。除了最基本的缓存之外,Frangipani还支持Write-Back缓存:

这意味着,如果我想要修改某个数据,比如说我修改了一个文件,或者创建了一个文件,或者删除了一个文件,只要没有其他的工作站需要看到我的改动,Frangipani通过Write-Back缓存方式管理这些数据。

最开始的时候,我的修改只会在本地的缓存中。如果我创建了一个文件,至少在最开始,有关新创建文件的信息,比如说新创建的inode和初始化的内容,home目录文件列表的更新,文件名等等,所有的这些修改最初只会在本地缓存中存在。

因此类似于创建文件的操作可以非常快的完成,因为只需要修改本地的内存中对于磁盘的缓存,而这些修改要过一会才会写回到Petal。

在Frangipani的设计中,Petal作为共享存储系统存在,它不知道文件系统,文件,目录,它只是一个很直观简单的存储系统,所有的复杂的逻辑都在工作站中的Frangipani模块中。所以这是一个非常去中心化的设计

所以,我们现在有了一个系统,它在工作站里面做了大量的缓存,并且文件的修改可以在本地缓存完成。这几乎立刻引出了有关设计的几个非常严重的挑战。Frangipani的设计基本上就是用来解决相应的挑战的,我们接下来看一下会有哪些挑战。

11.2 Frangipani的挑战(Challenges)

Frangipani的挑战主要来自于两方面,一个是缓存,另一个是这种去中心化的架构带来的大量的逻辑存在于客户端之中进而引起的问题。

一:

第一个挑战是,假设工作站W1创建了一个文件 /A。最初,这个文件只会在本地缓存中创建。首先,Frangipani需要从Petal获得 / 目录下的内容,之后当创建文件时,工作站只是修改缓存的拷贝,并不会将修改立即返回给Petal。这里有个直接的问题,假设工作站W2上的用户想要获取 / 目录下的文件列表,我们希望这个用户 可以看到新创建的文件。

这意味着,文件系统必须要做一些事情来确保客户端可以读到最新的写入文件。我们之前讨论过这个话题,我们称之为强一致或者线性一致,在这里我们也想要这种特性。

但是在一个缓存的环境中,现在说的一致性的问题不是指存储服务器的一致性,而是指工作站上的一些修改需要被其他工作站看到。因为历史的原因,这通常被称为缓存一致性(Cache Coherence)。

缓存一致性表明,如果我缓存了一个数据,并且其他人在他的缓存中修改了这个数据,那么我的缓存需要自动的应用那个修改。所以我们想要有这种缓存一致性的属性。

二:

另一个问题是,因为所有的文件和目录都是共享的,非常容易会有两个工作站在同一个时间修改同一个目录。假设用户U1在他的工作站W1上想要创建文件/A,这是一个在 / 目录下的新文件,同时,用户U2在他的工作站W2上想要创建文件 /B

我们期望的是在这种并发情况下,最后两个文件都成功创建,而不是只成功了一个或者都失败了。

这里期望的行为有很多种叫法,但是这里我们称之为原子性(Atomicity)。我们希望类似于创建文件,删除文件这样的操作表现的就像即时生效的一样,同时不会与相同时间其他工作站的操作相互干扰。每一个操作就像在一个时间点发生,而不是一个时间段发生。

三:

最后一个问题是,假设我的工作站修改了大量的内容,由于Write-Back缓存,可能会在本地的缓存中堆积了大量的修改。

如果我的工作站崩溃了,但是这时这些修改只有部分同步到了Petal,还有部分仍然只存在于本地。

同时,其他的工作站还在使用文件系统。那么,我的工作站在执行操作的过程中的崩溃,最好不要损坏其他人同样会使用的文件系统。这意味着,我们需要的是单个服务器的故障恢复,我希望我的工作站的崩溃不会影响其他使用同一个共享系统的工作站。哪怕说这些工作站正在查看我的目录,我的文件,它们应该看到一些合理的现象。它们可以漏掉我最后几个操作,但是它们应该看到一个一致的文件系统,而不是一个损坏了的文件系统数据。所以这里我们希望有故障恢复。一如既往的,在分布式系统中,这增加了更多的复杂度,我们可以很容易陷入到这样一个场景,一个工作站崩溃了,但是其他的工作站还在运行。

11.3 Frangipani的锁服务(Lock Server)

Frangipani的第一个挑战是缓存一致性。在这里我们想要的是线性一致性和缓存带来的好处。对于线性一致性来说,当我查看文件系统中任何内容时,我总是能看到最新的数据。对于缓存来说,我们想要缓存带来的性能提升。某种程度上,我们想要同时拥有这两种特性的优点。

人们通常使用缓存一致性协议(Cache Coherence Protocol)来实现缓存一致性。这些协议在很多不同的场景都有使用,不只在分布式文件系统,在多核处理器每个核的缓存的同步中也有使用。只是不同场景中,使用的协议是不一样的

Frangipani的缓存一致性核心是由锁保证的。用锁来保证缓存一致,来帮助工作站确定当它们缓存了数据时,它们缓存的是最新的数据。

除了Frangipani服务器(也就是工作站),Petal存储服务器,在Frangipani系统中还有第三类服务器,锁服务器。逻辑上,锁服务器是独立的服务器,但是实际上我认为它与Petal服务器运行在一起。

在锁服务器里面,有一个表单,就叫做locks。对于每一个文件,我们都有一个锁,而这个锁,可能会被某个工作站所持有。

在这个例子中,我们假设锁是排他锁(Exclusive Lock),尽管实际上Frangipani中的锁更加复杂可以支持两种模式:要么允许一个写入者持有锁,要么允许多个读取者持有锁。

假设文件X最近被工作站WS1使用了,所以WS1对于文件X持有锁。同时文件Y最近被工作站WS2使用,所以WS2对于文件Y持有锁。锁服务器会记住每个文件的锁被谁所持有。当然一个文件的锁也有可能不被任何人持有。

在每个工作站,会记录跟踪它所持有的锁,和锁对应的文件内容。所以在每个工作站中,Frangipani模块也会有一个lock表单,表单会记录文件名、对应的锁的状态和文件的缓存内容。这里的文件内容可能是大量的数据块,也可能是目录的列表。

当一个Frangipani服务器决定要读取文件,比如读取目录 /、读取文件A、查看一个inode,首先,它会向一个锁服务器请求文件对应的锁,之后才会向Petal服务器请求文件或者目录的数据。收到数据之后,工作站会记住,本地有一个文件X的拷贝,对应的锁的状态,和相应的文件内容。

每一个工作站的锁至少有两种模式。工作站可以读或者写相应的文件或者目录的最新数据,可以在创建,删除,重命名文件的过程中,如果这样的话,我们认为锁在Busy状态。

在工作站完成了一些操作之后,比如创建文件,或者读取文件,它会随着相应的系统调用(例如rename,write,create,read)释放锁。只要系统调用结束了,工作站会在内部释放锁,现在工作站不再使用那个文件。但是从锁服务器的角度来看,工作站仍然持有锁。工作站内部会标明,这是锁时Idle状态,它不再使用这个锁。所以这个锁仍然被这个工作站持有,但是工作站并不再使用它。(因为如果你创建了它,很有可能接下来会继续对他操作,所以不会立刻释放)

对于我们假设的排它锁,有两个规则:

  • 工作站不允许持有缓存的数据,除非同时也持有了与数据相关的锁。所以基本上来说,不允许在没有锁保护的前提下缓存数据。从操作意义上来说,这意味着对于一个工作站来说,在它使用一个数据之前,它首先要从锁服务器获取数据的锁。只有当工作站持有锁了,工作站才会从Petal读取数据,并将数据放在缓存中。所以这里的顺序是,获得锁,之后再从Petal读取数据。所以,直到获取了锁,工作站是不能缓存数据的,要想缓存数据,工作站必须先持有锁,之后,才能从Petal读取数据。

  • 如果你在释放锁之前,修改了锁保护的数据,那你必须将修改了的数据写回到Petal,只有在Petal确认收到了数据,你才可以释放锁,也就是将锁归还给锁服务器。所以这里的顺序是,先向Petal存储系统写数据,之后再释放锁

最后再从工作站的lock表单中删除关文件的锁的记录和缓存的数据。

11.4 缓存一致性(Cache Coherence)

这节主要就是描述了ws(工作服务器)如何从Ls(锁服务器)获取锁。

一个例子如下图:

假设有两个工作站,ws1和ws2,还有一个锁服务器ls。

现在ws1想要去修改z文件的内容:

第一步:先到ls服务器获取z文件的锁,它向锁服务器发送Request消息,ACQ z

第二步:如果当前没有人持有对文件Z的锁,或者锁服务器没听过对于文件Z的锁(初始化状态),锁服务器会在lock表单中增加一条记录,并返回Grant消息给工作站说,你现在持有了对于Z文件的锁。

第三步:WS1会从Petal读取并缓存Z的内容。之后,WS1也可以在本地缓存中修改Z的内容。

然后假设ws2也要去读z文件的内容了:

第一步:一开始WS2并没有对于文件Z的锁,所以它要做的第一件事情就是向锁服务器发送Request消息,请求对于文件Z的锁。

第二步:但是,锁服务器知道不能给WS2回复Grant消息,因为WS1现在还持有锁。接下来锁服务器会向WS1发送Revoke消息。

第三步:而工作站WS1在向Petal写入修改数据之前,不允许释放锁。所以它现在会将任何修改的内容写回给Petal。

第四步:写入结束之后,WS1才可以向锁服务器发送Release消息。

第五步:锁服务器必然会有一个表单记录谁在等待文件Z的锁,一旦锁的当前持有者释放了锁,锁服务器需要通知等待者。所以当锁服务器收到了这条Release消息时,锁服务器会更新自己的表单,并最终将Grant消息发送给工作站WS2。这个时候,WS2终于可以从Petal读取文件Z。

这就是缓存一致性协议的工作流程,它确保了,直到所有有可能私底下在缓存中修改了数据的工作站先将数据写回到Petal,其他工作站才能读取相应的文件。所以,这里的锁机制确保了读文件总是能看到最新写入文件的数据。

每个工作站用完了锁之后,不是立即向锁服务器释放锁,而是将锁的状态标记为Idle就是一种优化。

另一个主要的优化是,Frangipani有共享的读锁(Shared Read Lock)和排他的写锁(Exclusive Write Lock)。如果有大量的工作站需要读取文件,但是没有人会修改这个文件,它们都可以同时持有对这个文件的读锁。如果某个工作站需要修改这个已经被大量工作站缓存的文件时,那么它首先需要Revoke所有工作站的读锁,这样所有的工作站都会放弃自己对于该文件的缓存,只有在那时,这个工作站才可以修改文件。因为没有人持有了这个文件的缓存,所以就算文件被修改了,也没有人会读到旧的数据。

学生提问:如果没有其他工作站读取文件,那缓存中的数据就永远不写入后端存储了吗?

Robert教授:这是一个好问题。实际上,在我刚刚描述的机制中是有风险的,如果我在我的工作站修改了一个文件,但是没有人读取它,这时,这个文件修改后的版本的唯一拷贝只存在于我的工作站的缓存或者RAM上。这些文件里面可能有一些非常珍贵的信息,如果我的工作站崩溃了,并且我们不做任何特殊的操作,数据的唯一拷贝会丢失。所以为了阻止这种情况,不管怎么样,工作站每隔30秒会将所有修改了的缓存写回到Petal中。所以,如果我的工作站突然崩溃了,我或许会丢失过去30秒的数据,但是不会丢更多,这实际上是模仿Linux或者Unix文件系统的普通工作模式。在一个分布式文件系统中,很多操作都是在模仿Unix风格的文件系统,这样使用者才不会觉得Frangipani的行为异常,因为它基本上与用户在使用的文件系统一样

11.5 原子性(Atomicity)

下一个挑战是确保原子性。当我做了一个复杂的操作,比如说创建一个文件,这里涉及到标识一个新的inode、初始化一个inode(inode是用来描述文件的一小份数据)、为文件分配空间、在目录中为新文件增加一个新的名字,这里有很多步骤,很多数据都需要更新。我们不想任何人看到任何中间的状态,我们希望其他的工作站要么发现文件不存在,要么文件完全存在,但是我们绝不希望它看到中间状态。所以我们希望多个步骤的操作具备原子性

为了实现原子性,为了让多步骤的操作,例如创建文件,重命名文件,删除文件具备原子性,Frangipani在内部实现了一个数据库风格的事务系统,并且是以锁为核心。并且,这是一个分布式事务系统,我们之后会在这门课看到更多有关分布式事务系统的内容,它在分布式系统中是一种非常常见的需求。

Frangipani是这样实现分布式事务的:在我完全完成操作之前,Frangipani确保其他的工作站看不到我的修改。首先我的工作站需要获取所有我需要读写数据的锁,在完成操作之前,我的工作站不会释放任何一个锁。并且为了遵循一致性规则(11.3),将所有修改了的数据写回到Petal之后,我的工作站才会释放所有的锁

11.6 Frangipani Log

下一个有意思的事情是故障恢复。

我们需要能正确应对这种场景:一个工作站持有锁,并且在一个复杂操作的过程中崩溃了。比如说一个工作站在创建文件,或者删除文件时,它首先获取了大量的锁,然后会更新大量的数据,在其向Petal回写数据的过程中,一部分数据写入到了Petal,还有一部分还没写入,这时工作站崩溃了,并且锁也没有释放(因为数据回写还没有完成)。这是故障恢复需要考虑的有趣的场景。

其中一种处理方法是,如果发现工作站崩溃了,就释放它所有的锁。假设工作站在创建新文件,它已经在Petal里将文件名更新到相应的目录下,但是它还没有将描述了文件的inode写入到Petal,Petal中的inode可能还是一些垃圾数据,这个时候是不能释放崩溃工作站持有的锁(因为其他工作站读取这个文件可能读出错误的数据)。

另一种处理方法是,不释放崩溃了的工作站所持有的锁。这至少是正确的。如果工作站在向Petal写入数据的过程中崩溃了,因为它还没有写完所有的数据,也就意味着它不能释放所有的锁。所以,简单的不释放锁是正确的行为,因为这可以将这里的未完成的更新向文件的读取者隐藏起来,这样没人会因为看到只更新了一半的数据而感到困惑了。但是另一方面,如果任何人想要使用这些文件,那么他需要永远等待锁,因为我们没有释放这些锁

所以,我们绝对需要释放锁,这样其他的工作站才能使用这个系统,使用相同的文件和目录。但同时,我们也需要处理这种场景:崩溃了的工作站只写入了与操作相关的部分数据,而不是全部的数据。

Frangipani与其他的系统一样,需要通过预写式日志(Write-Ahead Log,WAL,见10.2)实现故障可恢复的事务(Crash Recoverable Transaction)。我们在上节课介绍Aurora时,也使用过WAL。

当一个工作站需要完成涉及到多个数据的复杂操作时,在工作站向Petal写入任何数据之前,工作站会在Petal中自己的Log列表中追加一个Log条目,这个Log条目会描述整个的需要完成的操作。只有当这个描述了完整操作的Log条目安全的存在于Petal之后,工作站才会开始向Petal发送数据。所以如果工作站可以向Petal写入哪怕是一个数据,那么描述了整个操作、整个更新的Log条目必然已经存在于Petal中。

这是一种非常标准的行为,它就是WAL的行为。但是Frangipani在实现WAL时,有一些不同的地方:

第一个是,在大部分的事务系统中,只有一个Log,系统中的所有事务都存在于这个Log中。当有故障时,如果有多个操作会影响同一份数据,我们在这一个Log里,就会保存这份数据的所有相关的操作。所以我们知道,对于一份数据,哪一个操作是最新的。但是Frangipani不是这么保存Log的,它对于每个工作站都保存了一份独立的Log。

另一个有关Frangipani的Log系统有意思的事情是,工作站的Log存储在Petal,而不是本地磁盘中。几乎在所有使用了Log的系统中,Log与运行了事务的计算机紧紧关联在一起,并且几乎总是保存在本地磁盘中。但是出于优化系统设计的目的,Frangipani的工作站将自己的Log保存在作为共享存储的Petal中。每个工作站都拥有自己的半私有的Log,但是却存在Petal存储服务器中。这样的话,如果工作站崩溃了,它的Log可以被其他工作站从Petal中获取到。所以Log存在于Petal中

我们需要大概知道Log条目的内容是什么,但是Frangipani的论文对于Log条目的格式没有非常清晰的描述,论文说了每个工作站的Log存在于Petal已知的块中,并且,每个工作站以一种环形的方式使用它在Petal上的Log空间。Log从存储的起始位置开始写,当到达结尾时,工作站会回到最开始,并且重用最开始的Log空间(20%)。所以工作站需要能够清除它的Log,这样就可以确保,在空间被重复利用之前,空间上的Log条目不再被需要。

每个Log条目都包含了Log序列号,这个序列号是个自增的数字,每个工作站按照12345为自己的Log编号,这里直接且唯一的原因在论文里也有提到,如果工作站崩溃了,Frangipani会探测工作站Log的结尾,Frangipani会扫描位于Petal的Log直到Log序列号不再增加,这个时候Frangipani可以确定最后一个Log必然是拥有最高序列号的Log。所以Log条目带有序列号是因为Frangipani需要检测Log的结尾。

除此之外,每个Log条目还有一个用来描述一个特定操作中所涉及到的所有数据修改的数组。数组中的每一个元素会有一个Petal中的块号(Block Number)一个版本号和写入的数据。类似的数组元素会有多个,这样就可以用来描述涉及到修改多份文件系统数据的操作。

为了能够让操作尽快的完成,最初的时候,Frangipani工作站的Log只会存在工作站的内存中,并尽可能晚的写到Petal中。这是因为,向Petal写任何数据,包括Log,都需要花费较长的时间,所以我们要尽可能避免向Petal写入Log条目,就像我们要尽可能避免向Petal写入缓存数据一样。

所以,这里的完整的过程是。当工作站从锁服务器收到了一个Revoke消息,要自己释放某个锁,它需要执行好几个步骤:

1.首先,工作站需要将内存中还没有写入到Petal的Log条目写入到Petal中

2.之后,再将被Revoke的Lock所保护的数据写入到Petal

3.最后,向锁服务器发送Release消息。

这里采用这种流程的原因是,在第二步我们向Petal写入数据的时候,如果我们在中途故障退出了,我们需要确认其他组件有足够的信息能完成我们未完成修改。

学生提问:Revoke的时候会将所有的Log都写入到Petal吗?

Robert教授:对于Log,你绝对是正确的,Frangipani工作站会将完整的Log写入Petal。所以,如果我们收到了一个针对特定文件Z的Revoke消息,工作站会将整个Log都写入Petal。但是因为工作站现在需要放弃对于Z的锁,它还需要向Petal写入Z相关的数据块。所以我们需要写入完整的Log,和我们需要释放的锁对应的文件内容,之后我们就可以释放锁。

或许写入完整的Log显得没那么必要,在这里可以稍作优化。如果Revoke要撤回的锁对应的文件Z只涉及第一个Log,并且工作站中的其他Log并没有修改文件Z,那么可以只向Petal写入一个Log,剩下的Log之后再写入,这样可以节省一些时间。

11.7 故障恢复(Crash Recovery)

接下来,我们讨论一下,当工作站持有锁,并且故障了会发生什么:

两种情况:

要么工作站正在向Petal写入Log,所以这个时候工作站必然还没有向Petal写入任何文件或者目录。

要么工作站正在向Petal写入修改的文件,所以这个时候工作站必然已经写入了完整的Log。

例如:(下面代表时间顺序)

ws1 删除了文件 f crash了

ws2 创建了文件 f

ws3 执行ws1的恢复

如果不好好处理,那ws3的恢复程序会去重放ws1的log entry,导致ws2创建的f被删除。

所以我们给log entry中加上一个版本号。

之后,当WS3执行恢复流程时,WS3会重新执行WS1的Log,它会首先检查版本号,通过查看Log条目中的版本号,并查看Petal中存储的版本号,如果Petal中存储的版本号大于等于Log条目中的版本号,那么WS3会忽略Log条目中的修改,因为很明显Petal中的数据已经被故障了的工作站所更新,甚至可能被后续的其他工作站修改了。所以在恢复的过程中,WS3会选择性的根据版本号执行Log,只有Log中的版本号高于Petal中存储的数据的版本时,Log才会被执行

Last updated