7.4 持久化(Persistence)

下一个我想介绍的是持久化存储(persistence)。你可以从Raft论文的图2的左上角看到,有些数据被标记为持久化的(Persistent),有些信息被标记为非持久化的(Volatile)。持久化和非持久化的区别只在服务器重启时重要。当你更改了被标记为持久化的某个数据,服务器应该将更新写入到磁盘,或者其它的持久化存储中,例如一个电池供电的RAM。持久化的存储可以确保当服务器重启时,服务器可以找到相应的数据,并将其加载到内存中。这样可以使得服务器在故障并重启后,继续重启之前的状态。

你或许会认为,如果一个服务器故障了,那简单直接的方法就是将它从集群中摘除。我们需要具备从集群中摘除服务器,替换一个全新的空的服务器,并让该新服务器在集群内工作的能力。实际上,这是至关重要的,因为如果一些服务器遭受了不可恢复的故障,例如磁盘故障,你绝对需要替换这台服务器。同时,如果磁盘故障了,你也不能指望能从该服务器的磁盘中获得任何有用的信息。所以我们的确需要能够用全新的空的服务器替代现有服务器的能力。你或许认为,这就足以应对任何出问题的场景了,但实际上不是的。

实际上,一个常见的故障是断电。断电的时候,整个集群都同时停止运行,这种场景下,我们不能通过从Dell买一些新的服务器来替换现有服务器进而解决问题。这种场景下,如果我们希望我们的服务是容错的, 我们需要能够得到之前状态的拷贝,这样我们才能保持程序继续运行。因此,至少为了处理同时断电的场景,我们不得不让服务器能够将它们的状态存储在某处,这样当供电恢复了之后,还能再次获取这个状态。这里的状态是指,为了让服务器在断电或者整个集群断电后,能够继续运行所必不可少的内容。这是理解持久化存储的一种方式。

在Raft论文的图2中,有且仅有三个数据是需要持久化存储的。它们分别是Log、currentTerm、votedFor。Log是所有的Log条目。当某个服务器刚刚重启,在它加入到Raft集群之前,它必须要检查并确保这些数据有效的存储在它的磁盘上。服务器必须要有某种方式来发现,自己的确有一些持久化存储的状态,而不是一些无意义的数据。

Log需要被持久化存储的原因是,这是唯一记录了应用程序状态的地方。Raft论文图2并没有要求我们持久化存储应用程序状态。假如我们运行了一个数据库或者为VMware FT运行了一个Test-and-Set服务,根据Raft论文图2,实际的数据库或者实际的test-set值,并不会被持久化存储,只有Raft的Log被存储了。所以当服务器重启时,唯一能用来重建应用程序状态的信息就是存储在Log中的一系列操作,所以Log必须要被持久化存储。

那currentTerm呢?为什么currentTerm需要被持久化存储?是的,currentTerm和votedFor都是用来确保每个任期只有最多一个Leader。在一个故障的场景中,如果一个服务器收到了一个RequestVote请求,并且为服务器1投票了,之后它故障。如果它没有存储它为哪个服务器投过票,当它故障重启之后,收到了来自服务器2的同一个任期的另一个RequestVote请求,那么它还是会投票给服务器2,因为它发现自己的votedFor是空的,因此它认为自己还没投过票。现在这个服务器,在同一个任期内同时为服务器1和服务器2投了票。因为服务器1和服务器2都会为自己投票,它们都会认为自己有过半选票(3票中的2票),那它们都会成为Leader。现在同一个任期里面有了两个Leader。这就是为什么votedFor必须被持久化存储。

currentTerm的情况要更微妙一些,但是实际上还是为了实现一个任期内最多只有一个Leader,我们之前实际上介绍过这里的内容。如果(重启之后)我们不知道任期号是什么,很难确保一个任期内只有一个Leader。

在这里例子中,S1关机了,S2和S3会尝试选举一个新的Leader。它们需要证据证明,正确的任期号是8,而不是6。如果仅仅是S2和S3为彼此投票,它们不知道当前的任期号,它们只能查看自己的Log,它们或许会认为下一个任期是6(因为Log里的上一个任期是5)。如果它们这么做了,那么它们会从任期6开始添加Log。但是接下来,就会有问题了,因为我们有了两个不同的任期6(另一个在S1中)。这就是为什么currentTerm需要被持久化存储的原因,因为它需要用来保存已经被使用过的任期号。

这些数据需要在每次你修改它们的时候存储起来。所以可以确定的是,安全的做法是每次你添加一个Log条目,更新currentTerm或者更新votedFor,你或许都需要持久化存储这些数据。在一个真实的Raft服务器上,这意味着将数据写入磁盘,所以你需要一些文件来记录这些数据。如果你发现,直到服务器与外界通信时,才有可能持久化存储数据,那么你可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。

之所以这很重要是因为,向磁盘写数据是一个代价很高的操作。如果是一个机械硬盘,我们通过写文件的方式来持久化存储,向磁盘写入任何数据都需要花费大概10毫秒时间。因为你要么需要等磁盘将你想写入的位置转到磁针下面, 而磁盘大概每10毫秒转一次。要么,就是另一种情况更糟糕,磁盘需要将磁针移到正确的轨道上。所以这里的持久化操作的代价可能会非常非常高。对于一些简单的设计,这些操作可能成为限制性能的因素,因为它们意味着在这些Raft服务器上执行任何操作,都需要10毫秒。而10毫秒相比发送RPC或者其他操作来说都太长了。如果你持久化存储在一个机械硬盘上,那么每个操作至少要10毫秒,这意味着你永远也不可能构建一个每秒能处理超过100个请求的Raft服务。这就是所谓的synchronous disk updates的代价。它存在于很多系统中,例如运行在你的笔记本上的文件系统。

设计人员花费了大量的时间来避开synchronous disk updates带来的性能问题。为了让磁盘的数据保证安全,同时为了能安全更新你的笔记本上的磁盘,文件系统对于写入操作十分小心,有时需要等待磁盘(前一个)写入完成。所以这(优化磁盘写入性能)是一个出现在所有系统中的常见的问题,也必然出现在Raft中。

如果你想构建一个能每秒处理超过100个请求的系统,这里有多个选择。其中一个就是,你可以使用SSD硬盘,或者某种闪存。SSD可以在0.1毫秒完成对于闪存的一次写操作,所以这里性能就提高了100倍。更高级一点的方法是,你可以构建一个电池供电的DRAM,然后在这个电池供电的DRAM中做持久化存储。这样,如果Server重启了,并且重启时间短于电池的可供电时间,这样你存储在RAM中的数据还能保存。如果资金充足,且不怕复杂的话,这种方式的优点是,你可以每秒写DRAM数百万次,那么持久化存储就不再会是一个性能瓶颈。所以,synchronous disk updates是为什么数据要区分持久化和非持久化(而非所有的都做持久化)的原因(越少数据持久化,越高的性能)。Raft论文图2考虑了很多性能,故障恢复,正确性的问题。

有任何有关持久化存储的问题吗?

学生提问:当你写你的Raft代码时,你实际上需要确认,当你持久化存储一个Log或者currentTerm,这些数据是否实时的存储在磁盘中,你该怎么做来确保它们在那呢?

Robert教授:在一个UNIX或者一个Linux或者一个Mac上,为了调用系统写磁盘的操作,你只需要调用write函数,在write函数返回时,并不能确保数据存在磁盘上,并且在重启之后还存在。几乎可以确定(write返回之后)数据不会在磁盘上。所以,如果在UNIX上,你调用了write,将一些数据写入之后,你需要调用fsync。在大部分系统上,fsync可以确保在返回时,所有之前写入的数据已经安全的存储在磁盘的介质上了。之后,如果机器重启了,这些信息还能在磁盘上找到。fsync是一个代价很高的调用,这就是为什么它是一个独立的函数,也是为什么write不负责将数据写入磁盘,fsync负责将数据写入磁盘。因为写入磁盘的代价很高,你永远也不会想要执行这个操作,除非你想要持久化存储一些数据。

所以你可以使用一些更贵的磁盘。另一个常见方法是,批量执行操作。如果有大量的客户端请求,或许你应该同时接收它们,但是先不返回。等大量的请求累积之后,一次性持久化存储(比如)100个Log,之后再发送AppendEntries。如果Leader收到了一个客户端请求,在发送AppendEntries RPC给Followers之前,必须要先持久化存储在本地。因为Leader必须要commit那个请求,并且不能忘记这个请求。实际上,在回复AppendEntries 消息之前,Followers也需要持久化存储这些Log条目到本地,因为它们最终也要commit这个请求,它们不能因为重启而忘记这个请求。

最后,有关持久化存储,还有一些细节。有些数据在Raft论文的图2中标记为非持久化的。所以,这里值得思考一下,为什么服务器重启时,commitIndex、lastApplied、nextIndex、matchIndex,可以被丢弃?例如,lastApplied表示当前服务器执行到哪一步,如果我们丢弃了它的话,我们需要重复执行Log条目两次(重启前执行过一次,重启后又要再执行一次),这是正确的吗?为什么可以安全的丢弃lastApplied?

这里综合考虑了Raft的简单性和安全性。之所以这些数据是非持久化存储的,是因为Leader可以通过检查自己的Log和发送给Followers的AppendEntries的结果,来发现哪些内容已经commit了。如果因为断电,所有节点都重启了。Leader并不知道哪些内容被commit了,哪些内容被执行了。但是当它发出AppendEntries,并从Followers搜集回信息。它会发现,Followers中有哪些Log与Leader的Log匹配,因此也就可以发现,在重启前,有哪些被commit了。

另外,Raft论文的图2假设,应用程序状态会随着重启而消失。所以图2认为,既然Log已经持久化存储了,那么应用程序状态就不必再持久化存储。因为在图2中,Log从系统运行的初始就被持久化存储下来。所以,当Leader重启时,Leader会从第一条Log开始,执行每一条Log条目,并提交给应用程序。所以,重启之后,应用程序可以通过重复执行每一条Log来完全从头构建自己的状态。这是一种简单且优雅的方法,但是很明显会很慢。这将会引出我们的下一个话题:Log compaction和Snapshot。

最后更新于