# 12.4 故障恢复（Crash Recovery）

现在，我们需要在脑中设想各种可能发生的错误，并确认这里的两阶段提交协议是否仍然可以提供All-or-Noting的原子特性。如果不能的话，我们该如何调整或者扩展协议？

第一个我想考虑的错误是故障重启。我的意思是类似于断电，服务器会突然中断执行，当电力恢复之后，作为事务处理系统的一部分，服务器会运行一些恢复软件。这里实际上有两个场景需要考虑。

第一个场景是，参与者B可能在回复事务协调者的Prepare消息之前的崩溃了，

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDTwvIKCnK38dgCR55%2F-MGDZOpLn-THlArWKEqj%2Fimage.png?alt=media\&token=e438338f-3c5c-4a0e-b7bc-7b272309628d)

所以，B在回复Yes之前就崩溃了。从TC的角度来看，B没有回复Yes，TC也就不能Commit，因为它需要等待所有的参与者回复Yes。

如果B发现自己不可能发送Yes，比如说在发送Yes之前自己就故障了，那么B被授权可以单方面的Abort事务。因为B知道自己没有发送Yes，那么它也知道事务协调者不可能Commit事务。这里有很多种方法可以实现，其中一种方法是，因为B故障重启了，内存中的数据都会清除，所以B中所有有关事务的信息都不能活过故障，所以，故障之后B不知道任何有关事务的信息，也不知道给谁回复过Yes。之后，如果事务协调者发送了一个Prepare消息过来，因为B不知道事务，B会回复No，并要求Abort事务。

当然，B也可能在回复了Yes给事务协调者的Prepare消息之后崩溃的。B可能开心的回复给事务协调者说好的，我将会commit。但是在B收到来自事务协调者的commit消息之前崩溃了。

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDfuHDpbh4-WmdYuXw%2F-MGDikeItsf-Mq1_FStw%2Fimage.png?alt=media\&token=9cc16ee2-a942-4027-8419-afa2affaf940)

现在我们有了一个完全不同的场景。现在B承诺可以commit，因为它回复了Yes。接下来极有可能发生的事情是，事务协调者从所有的参与者获得了Yes的回复，并将Commit消息发送给了A，所以A实际上会执行事务分包给它的那一部分，持久化存储结果，并释放锁。这样的话，为了确保All-or-Nothing原子性，我们需要确保B在故障恢复之后，仍然能完成事务分包给它的那一部分。在B故障的时候，不知道事务是否能Commit，因为它还没有收到Commit消息。但是B还是需要做好Commit的准备。这意味着，在故障重启的时候，B不能丢失对于事务的状态记录。

在B回复Prepare之前，它必须确保记住当前事务的中间状态，记住所有要做的修改，记住事务持有的所有的锁，这些信息必须在磁盘上持久化存储。通常来说，这些信息以Log的形式在磁盘上存储。所以在B回复Yes给Prepare消息之前，它首先要将相应的Log写入磁盘，并在Log中记录所有有关提交事务必须的信息。这包括了所有由Put创建的新的数值，和锁的完整列表。之后，B才会回复Yes。

之后，如果B在发送完Yes之后崩溃了，当它重启恢复时，通过查看自己的Log，它可以发现自己正在一个事务的中间，并且对一个事务的Prepare消息回复了Yes。Log里有Commit需要做的所有的修改，和事务持有的所有的锁。之后，当B最终收到了Commit而不是Abort，通过读取Log，B就知道如何完成它在事务中的那部分工作。

所以，这里是我之前在介绍协议的时候遗漏的一点。B在这个时间点（回复Yes给TC的Prepare消息之前），必须将Log写入到自己的磁盘中。这里会使得两阶段提交稍微有点慢，因为这里要持久化存储数据。

最后一个可能崩溃的地方是，B可能在收到Commit之后崩溃了。

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDfuHDpbh4-WmdYuXw%2F-MGDmqJyDvgQFh1xGD2T%2Fimage.png?alt=media\&token=e0dd7ec1-55e7-4b20-941c-b2f615201091)

B有可能在处理完Commit之后就崩溃了。但是这样的话，B就完成了修改，并将数据持久化存储在磁盘上了。这样的话，故障重启就不需要做任何事情，因为事务已经完成了。

因为没有收到ACK，事务协调者会再次发送Commit消息。当B重启之后，收到了Commit消息时，它可能已经将Log中的修改写入到自己的持久化存储中、释放了锁、并删除了有关事务的Log。所以我们需要关心，如果B收到了同一个Commit消息两次，该怎么办？这里B可以记住事务的信息，但是这会消耗内存，所以实际上B会完全忘记已经在磁盘上持久化存储的事务的信息。对于一个它不知道事务的Commit消息，B会简单的ACK这条消息。这一点在后面的一些介绍中非常重要。

上面是事务的参与者在各种奇怪的时间点崩溃的场景。那对于事务协调者呢？它只是一个计算机，如果它出现故障，也会是个问题。

同样的，这里的关键点在于，如果事务的任何一个参与者可能已经提交了，或者事务协调者可能已经回复给客户端了，那么我们不能忽略事务。比如，如果事务协调者已经向A发送了Commit消息，但是还没来得及向B发送Commit消息就崩溃了，那么事务协调者必须在重启的时候准备好向B重发Commit消息，以确保两个参与者都知道事务已经提交了。所以，事务协调者在哪个时间点崩溃了非常重要。

如果事务协调者在发送Commit消息之前就崩溃了，那就无所谓了，因为没有一个参与者会Commit事务。也就是说，如果事务协调者在崩溃前没有发送Commit消息，它可以直接Abort事务。因为参与者可以在自己的Log中看到事务，但是又从来没有收到Commit消息，事务的参与者会向事务协调者查询事务，事务协调者会发现自己不认识这个事务，它必然是之前崩溃的时候Abort的事务。所以这就是事务协调者在Commit之前就崩溃了的场景。

如果事务协调者在发送完一个或者多个Commit消息之后崩溃，

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGGbAh-6goLe-G_sFJL%2Fimage.png?alt=media\&token=b446b3b0-58f8-4374-aec3-e70b1713edca)

那么就不允许它忘记相关的事务。这意味着，在崩溃的时间点，也就是事务协调者决定要Commit而不是Abort事务，并且在发送任何Commit消息之前，它必须先将事务的信息写入到自己的Log，并存放在例如磁盘的持久化存储中，这样计算故障重启了，信息还会存在。

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGGbbuVVcBScu6poYto%2Fimage.png?alt=media\&token=919c04c2-4cec-4126-885a-f03af8b9e3a4)

所以，事务协调者在收到所有对于Prepare消息的Yes/No投票后，会将结果和事务ID写入存在磁盘中的Log，之后才会开始发送Commit消息。之后，可能在发送完第一个Commit消息就崩溃了，也可能发送了所有的Commit消息才崩溃，不管在哪，当事务协调者故障重启时，恢复软件查看Log可以发现哪些事务执行了一半，哪些事务已经Commit了，哪些事务已经Abort了。作为恢复流程的一部分，对于执行了一半的事务，事务协调者会向所有的参与者重发Commit消息或者Abort消息，以防在崩溃前没有向参与者发送这些消息。这就是为什么参与者需要准备好接收重复的Commit消息的一个原因。

这些就是主要的服务器崩溃场景。我们还需要担心如果消息在网络传输的时候丢失了怎么办？或许你发送了一个消息，但是消息永远也没有送达。或许你发送了一个消息，并且在等待回复，或许回复发出来了，但是之后被丢包了。这里的任何一个消息都有可能丢包，我们必须想清楚在这样的场景下该怎么办？

举个例子，事务协调者发送了Prepare消息，但是并没有收到所有的Yes/No消息，事务协调者这时该怎么做呢？

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGGg1n-iIx-ByMT6HgS%2Fimage.png?alt=media\&token=655f42ef-2cf7-46bb-afa4-e22f77200171)

其中一个选择是，事务协调者重新发送一轮Prepare消息，表明自己没有收到全部的Yes/No回复。事务协调者可以持续不断的重发Prepare消息。但是如果其中一个参与者要关机很长时间，我们将会在持有锁的状态下一直等待。假设A不响应了，但是B还在运行，因为我们还没有Commit或者Abort，B仍然为事务持有了锁，这会导致其他的事务等待。所以，如果可以避免的话，我们不想永远等待。

在事务协调者没有收到Yes/No回复一段时间之后，它可以单方面的Abort事务。因为它知道它没有得到完整的Yes/No消息，当然它也不可能发送Commit消息，所以没有一个参与者会Commit事务，所以总是可以Abort事务。事务的协调者在等待完整的Yes/No消息时，如果因为消息丢包或者某个参与者崩溃了，而超时了，它可以直接决定Abort这个事务，并发送一轮Abort消息。

之后，如果一个崩溃了的参与者重启了，向事务协调者发消息说，我并没有收到来自你的有关事务95的消息，事务协调者会发现自己并不知道到事务95的存在，因为它在之前就Abort了这个事务并删除了有关这个事务的记录。这时，事务协调者会告诉参与者说，你也应该Abort这个事务。

类似的，如果参与者等待Prepare消息超时了，那意味着它必然还没有回复Yes消息，进而意味着事务协调者必然还没有发送Commit消息。所以如果一个参与者在这个位置因为等待Prepare消息而超时，

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGGvaHxlf5i_q-IGRdO%2Fimage.png?alt=media\&token=34763f21-53ff-4cba-8cc3-5ed6f9caeebd)

那么它也可以决定Abort事务。在之后的时间里，如果事务协调者上线了，再次发送Prepare消息，B会说我不知道有关事务的任何事情并回复No。这也没问题，因为这个事务在这个时间也不可能在任何地方Commit了。所以，如果网络某个地方出现了问题，或者事务协调器挂了一会，事务参与者仍然在等待Prepare消息，总是可以允许事务参与者Abort事务，并释放锁，这样其他事务才可以继续。这在一个负载高的系统中可能会非常重要。

但是，假设B收到了Prepare消息，并回复了Yes。大概在下图的位置中，

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGGy9xYCiyOQj-5Ap69%2Fimage.png?alt=media\&token=8d33289f-5a28-410f-b8f5-68f6eb503dd6)

这个时候参与者没有收到Commit消息，它接下来怎么也等不到Commit消息。或许网络出现问题了，或许事务协调器的网络连接中断了，或者事务协调器断电了，不管什么原因，B等了很长时间都没有收到Commit消息。这段时间里，B一直持有事务涉及到数据的锁，这意味着，其他事务可能也在等待这些锁的释放。所以，这里我们应该尽早的Abort事务，并释放锁。所以这里的问题是，如果B收到了Prepare消息，并回复了Yes，在等待了10秒钟或者10分钟之后还没有收到Commit消息，它能单方面的决定Abort事务吗？

很不幸的是，这里的答案不行。

在回复Yes给Prepare消息之后，并在收到Commit消息之前这个时间区间内，参与者会等待Commit消息。如果等待Commit消息超时了，参与者不允许Abort事务，它必须无限的等待Commit消息，这里通常称为Block。

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGH-JIMvSUrsVuspTfo%2Fimage.png?alt=media\&token=9ed892c4-44e8-4f9a-be68-2a0ca51e8c59)

这里的原因是，因为B对Prepare消息回复了Yes，这意味着事务协调者可能收到了来自于所有参与者的Yes，并且可能已经向部分参与者发送Commit消息。这意味着A可能已经看到了Commit消息，Commit事务，持久化存储事务的结果并释放锁。所以在上面的区间里，B不能单方面的决定Abort事务，它必须无限等待事务协调者的Commit消息。如果事务协调者故障了，最终会有人来修复它，它在恢复过程中会读取Log，并重发Commit消息。

就像不能单方面的决定Abort事务一样，这里B也不能单方面的决定Commit事务。因为A可能对Prepare消息回复了No，但是B没有收到相应的Abort消息。所以，在上面的区间中，B既不能Commit，也不能Abort事务。

这里的Block行为是两阶段提交里非常重要的一个特性，并且它不是一个好的属性。因为它意味着，在特定的故障中，你会很容易的陷入到一个需要等待很长时间的场景中，在等待过程中，你会一直持有锁，并阻塞其他的事务。所以，人们总是尝试在两阶段提交中，将这个区间尽可能快的完成，这样可能造成Block的时间窗口也会尽可能的小。所以人们尽量会确保协议中这部分尽可能轻量化，甚至对于一些变种的协议，对于一些特定的场景都不用等待。

这就是基本的协议。为什么这里的两阶段提交协议能构建一个A和B要么全Commit，要么全Abort的系统？其中一个原因是，决策是在一个单一的实例，也就是事务协调者完成的。A或者B不能决定Commit还是不Commit事务，A和B之间不会交互来达成一致并完成事务的Commit，相反的只有事务协调者可以做决定。事务协调者是一个单一的实例，它会通知其他的部分这是我的决定，请执行它。但是，使用一个单一实例的事务协调者的缺点是，在某个时间点你需要Block并等待事务协调者告诉你决策是什么。

一个进一步的问题是，我们知道事务协调者必然在它的Log中记住了事务的信息，那么它在什么时候可以删除Log中有关事务的信息？这里的答案是，如果事务协调者成功的得到了所有参与者的ACK，

![](https://2933519158-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MAkokVMtbC7djI1pgSw%2F-MGDo7lTac6bjSnx42qj%2F-MGH8NoVwnL-MCuN-QVj%2Fimage.png?alt=media\&token=518cb7fb-8a70-4291-921c-3166d49b33bc)

那么它就知道所有的参与者知道了事务已经Commit或者Abort，所有参与者必然也完成了它们在事务中相应的工作，并且永远也不会需要知道事务相关的信息。所以当事务协调者得到了所有的ACK，它可以擦除所有有关事务的记忆。

类似的，当一个参与者收到了Commit或者Abort消息，完成了它们在事务中的相应工作，持久化存储事务结果并释放锁，那么在它发送完ACK之后，参与者也可以完全忘记相关的事务。

当然事务协调者或许不能收到ACK，这时它会假设丢包了并重发Commit消息。这时，如果一个参与者收到了一个Commit消息，但是它并不知道对应的事务，因为它在之前回复ACK之后就忘记了这个事务，那么参与者会再次回复一个ACK。因为如果参与者收到了一个自己不知道的事务的Commit消息，那么必然是因为它之前已经完成对这个事务的Commit或者Abort，然后选择忘记这个事务了。
