两个将军问题与分布式Saga


想象一下,在一个山谷里有一座城市。在山谷的两边,有一支由将军指挥的军队。左边的山上站着爱丽丝将军和她的军队。右边的山头上,站着鲍勃将军和他的军队。
爱丽丝和鲍勃想占领这座城市,但双方都没有足够大的军队来单独完成这一任务。
爱丽丝和鲍勃必须同时进攻城市,才有机会占领它。

这里就是我们的问题所在:爱丽丝和鲍勃只能通过山谷中的信使进行交流。这些信使有可能被城市的军队抓住。
鉴于这种情况,以及爱丽丝和鲍勃只有在确信对方会同时进攻时才会进攻的条件,爱丽丝和鲍勃如何协调他们的进攻?

比方说,爱丽丝决定采取主动。她给鲍勃发了一条信息,内容是
"让我们在5月17日进行攻击。发送一个信使回来,并附上你的回复。-爱丽丝"

如果信使没有送到和得到回应,爱丽丝就不会进攻,显然,鲍勃也不会。
如果消息传到鲍勃那里,他就会发出回应:
"好,我们开始吧。就在5月17日! -鲍勃"

现在,有趣的部分来了:
鲍勃的回复是否能送回来并不重要。无论怎样,鲍勃都不会进攻。
记住,鲍勃只有在他相信爱丽丝也会攻击时才会攻击。鲍勃知道爱丽丝只有在收到他的回复时才会攻击,但是鲍勃没有办法知道他的回复是否回到了爱丽丝那里。如果没有,她就不会进攻,而如果他在没有她的情况下进攻,他的军队就会被消灭掉。
由于鲍勃不知道他的信息是否传回来了,所以他在等待,什么也不做。

我们有什么可以做的吗?
让我们改变一下,让鲍勃要求爱丽丝发送确认函,确认她收到了他的回复。他发送的不是他原来的回复,而是现在这个:
"好吧,我们开始吧。5月17日就这样了! 发送另一个信使回来确认你收到了这个消息。-鲍勃"

进入第三阶段:现在爱丽丝发送了信息,鲍勃发送了他的回复,爱丽丝准备发送她收到他回复的确认信息。
但她意识到,她没有办法知道他是否会收到确认信息。如果他没有收到,他就不会进攻,而她的军队就会被打败。所以她决定发送这个信息。
"我收到了你的回信。请再派一个信使来确认这个确认。-爱丽丝"

总结一下:
现在我们有一条信息,一个回复,一个对回复的确认,以及对确认的确认。

假设鲍勃收到了回执,准备发送回执的回执。但是,等等! 他意识到他没有办法知道爱丽丝是否会收到确认书的确认。如果她没有收到,她就不会攻击,所以鲍勃决定发送。

"我确认了你的确认书。请再派一个信使来确认这个确认的确认。-鲍勃"

看到这里发生了什么?不管我们增加多少层确认,最后一个发送信使的人永远无法知道那个信使是否通过了。
因此,最后发送信使的人永远无法确信对方会攻击,所以也永远不会攻击自己。

两位将军问题的轻视解决
上面的问题被称为 "两个将军问题",它被证明是无法解决的。
有没有一个约束条件较少的宽松版本的问题是可以解决的?如果答案是否定的,我可能就不会问这个问题了--事实证明,是的,有。
如果我们去掉两个将军都必须百分之百相信另一个会进攻的条件,并改变一些其他的东西,我们就能在大多数时候解决这个问题。
我们仍然不能完全保证两个将军都会进攻,但我们可以合理地确定他们会进攻。

在这个问题的新版本中,爱丽丝是领导者。她决定什么时候进攻,一旦她决定什么时候进攻,无论如何她都会进攻。如果鲍勃收到消息说他应该在某个特定的时间进行攻击,他必须这样做。

让我们看一个例子:
爱丽丝决定她要攻击。她知道她不可能100%确定鲍勃也会攻击,但她决定她要99%确定鲍勃会攻击。爱丽丝知道信使穿越山谷所需的时间长度,她也知道信使被抓的可能性。

知道这两件事后,她计算出需要多少名信使才能有99%的把握通过。然后,她计算出发送这么多信使需要多长时间,并将攻击时间设定在足够长的未来,以考虑到这一点。

接下来爱丽丝发送了一个带有以下信息的消息:
"下周四上午9点攻击。发送一个信使回来确认你收到了这个消息"。

然后爱丽丝等待足够的时间让信使到达鲍勃那里,并让鲍勃的确认信使到达她那里。如果信使到达了,爱丽丝就停止发送信使,爱丽丝和鲍勃都在等待,直到下周四上午9点他们的协调攻击。

如果确认信息从未到达,爱丽丝就会发送另一个相同信息的消息。
"下周四上午9点的攻击。发送一个信使回来确认你收到了这个消息"。

她不断重复这个过程,直到确认信息到达或者是下周四上午9点,这时她就会攻击,不管信息是否通过。因为Alice选择了一个足够远的时间(基于信息的失败率),所以有99%的机会在攻击时间之前至少有一个信使能传给Bob。

我们知道,下周四上午9点,两个将军都有99%的机会进攻。我们可以增加这个确定性数字吗?是的,我们可以。爱丽丝可以通过将攻击时间设置得越来越远,使这个数字尽可能接近100%。然而,如果不把攻击时间无限地设置到未来,她永远无法达到100%(这意味着它永远不会发生,所以这将是无用的)。

这里还有一件事要指出。如果我们不关心这些攻击是否一起发生,爱丽丝可以发送一条信息说。
"现在攻击。发送一个信使来确认你收到了这个消息。"

鲍勃在爱丽丝之前进行攻击:
在这种情况下,爱丽丝可以不断地重复发送消息,直到她收到确认,并在攻击前等待确认。

这种方法确实能保证双方最终都会攻击,但关键是,他们不会同时攻击。爱丽丝的攻击会比鲍勃晚,至少要比确认信使从鲍勃到爱丽丝所花的时间晚,而且可能比这个时间晚得多,这取决于有多少信使被捕获。

一个更实际的例子
让我们来看看一个更实际的例子。假设您正在设计一个系统,需要通过第三方服务预订一场战斗,并向您的用户发送一封电子邮件,告知该航班已被预订。

起初,这听起来是一个简单的问题。对航班预订系统进行一次网络调用。如果它返回一个OK状态,那么就发送电子邮件。如果它返回错误状态,就不要发送邮件(或发送一封写有稍后再试的邮件)。

但请记住,网络呼叫是不可靠的,所以我们实际上有的只是上述放松的两将军问题的一个版本。如果网络调用返回OK或错误状态,那么我们就没事了,但网络调用也可能因为没有收到响应而超时(就像信使被俘一样)。在这种情况下,我们该怎么做呢?

就像在二将问题轻视解决一样,我们可以不断重试消息,直到得到回应。只有在得到回应后,我们才能决定下一步该怎么做。如果响应是确定的,我们就发送电子邮件(我们有意忽略了发送电子邮件也是一个不可靠的操作)。如果响应是错误的,我们就不发送。

你认为这种方法有什么问题吗?如果网络出了问题,我们从来没有得到过回应怎么办?我们是否要一直重试下去,可能会阻塞资源,直到有人注意到问题,并手动杀死这个任务?

我们可以用我们原来对轻松的两个将军问题的部分解决方案来处理这个问题:
我们将添加一个定时器。具体来说,我们可以给我们的重试过程添加一个超时器。在我们第一次发送预订航班的信息时,我们将启动计时器。然后,我们将不断地重试消息(只要我们没有得到回应),直到定时器倒数为零。一旦定时器完成,我们将停止发送消息并采取行动。

计时器的长度相当于在给鲍勃的消息中声明攻击时间。如果我们在周三上午9点开始发送消息,而Alice决定在周四上午9点进行攻击,我们就有一个24小时的超时计时器。就像设置攻击时间一样,我们可以通过增加超时的长度来增加信息通过的机会。然而,我们必须平衡这一点,不允许我们的系统占用资源太长时间。

我们知道超时后我们会采取行动,但我们还没有说这个行动是什么。由于我们一直没有得到回应,我们不知道航班是否被预订了。我们可以发送一封邮件说它已经被预订了,也可以发送一封邮件说它可能已经被预订了,或者什么都不发送(并记录一个超时错误)。最好的决定是因地制宜的,但在我们的案例中,我们会选择发送一封电子邮件说该航班可能已经被预订了。然后用户可以跟进。

为了使我们的解决方案奏效,我们必须处理另一个复杂的问题,这是 "两位将军问题 "所提出的问题的直接后果--在不可靠的网络上,确切地说是一次信息传递不可能。我们可以发送一次信息,并希望它能到达(但它可能不会),或者我们可以多次发送信息。在这种情况下,它可能会到达不止一次(也许更多次)。

想象一下,一个信使在前往鲍勃的路上迷路了。然后,另一个信使被派出去,直接到达目的地。在第二个信使到达后,第一个信使设法找到了他们去鲍勃的路。同样的事情也可以发生在计算机网络中。信息不是丢失,而是沿途卡在缓冲区。

就我们的航班预订系统而言,这可能是一个大问题。如果有多个消息说要预订一个航班,系统可能会预订多个航班。为了解决这个问题,预订航班请求必须是empotent的。同位素的意思是,如果一个请求被提出一次以上,请求的效果必须只发生一次。

有几种解决方法:一种方法是在请求中包含一个唯一的ID。只要我们的系统在每次重试时发送相同的ID,航班系统就可以存储这个ID,并使用它来忽略所有的信息,除了它收到的第一个信息。

增加第二个分布式操作
到目前为止,(引入重试与超时)似乎已经解决了所有的问题。然而,如果我们增加第二个分布式操作,我们的系统就会崩溃。
当我们希望我们的系统也能处理预订酒店时,会发生什么?
新的要求是,我们应该把航班和酒店一起预订。也就是说,只有当航班被成功预订后,我们才应该预订酒店,只有当酒店被成功预订后,我们才应该预订航班。在我们预订了它们之后,我们会发送一封带有状态更新的电子邮件。

即使不考虑超时,要求两者都被预订或都不被预订也会增加复杂性。我们可以用几种不同的方法来处理这种复杂性,但最直接的方法是确保我们的系统能够处理撤销这两种操作。这种方法被称为 "分布式Saga"。

下面是我们的航班和酒店预订系统的分布式Saga的样子(忽略了超时和丢失的消息):
首先,我们发送一条消息来预订航班。如果我们得到一个错误的响应,我们就中止整个过程。如果航班预订系统返回成功,我们就发送一个消息来预订酒店。如果成功了,我们就完成了,我们可以发送成功的电子邮件。

但是,如果酒店系统返回一个错误,我们就必须通过调用航班系统,告诉它撤销我们刚刚预订的航班,从而撤销航班预订。这就叫回滚Saga。在我们回滚Saga之后,我们可以发送一个适当的消息或记录一个错误。

在我们的实际实现中,我们实际上不能忽略丢失的信息:
我们如何在我们的传奇中处理这种可能性?
每个分布式动作(预订航班和预订酒店)必须有一个相应的补偿撤销动作(取消航班预订和取消酒店预订)。

我们可以使用补偿动作来处理调用原始动作时丢失的信息。比如说:
我们启动Saga,并发送一条消息来预订航班。如果预订航班的网络调用超时,我们可以不重试,而是回滚整个Saga。我们调用取消航班预订的端点,并将ID与原始预订航班请求一起发送。

处理超时和丢失的信息
你可能已经注意到一个问题。我们不知道预订航班的请求是否通过了。如果它没有通过,我们仍然发送取消航班预订请求怎么办?与预订航班请求必须是等价的一样,预订航班和取消航班预订的组合必须是换位的。航班预订系统收到它们的顺序应该不重要。

如果取消航班预订消息先到,航班预订系统必须存储请求的ID,并阻止随后出现的任何带有该ID的预订航班请求。如果取消航班预订信息在预订航班信息之后到达,航班预订系统必须撤销原来的行动。

我们已经通过把问题踢给补偿行动来处理预订航班行动的丢失信息,但如果补偿行动请求丢失了会怎样?

补偿行动必须不能够失败:
它们永远不能返回错误代码。这意味着它们失败的唯一方式是超时。
正因为如此,我们可以不断重试补偿动作,直到它成功(在实践中,我们可能希望这里有一个超时来放弃,并最终提醒人类出了问题)。

如果预订航班成功,我们就转到预订酒店。如果失败了,我们就回滚到上一个步骤。
如果超时,我们通过调用取消酒店预订来回滚当前步骤,然后我们回滚上一步,取消传奇并退出。如果一切都成功了,我们就继续发送电子邮件。

生产系统的注意事项
对于生产系统,有几个最后的注意事项值得一提。我们已经处理了远程系统的故障,但没有处理我们本地系统的故障。我们还需要存储我们在当前Saga中的位置,以便在我们的系统在中途崩溃时,我们可以从该点恢复。

此外,你将读到的关于分布式Saga的大多数文献都会像我刚才那样提到commutative换元属性,但换元commutative 并没有真正强大到足以描述所需内容。

让我们举一个简单的例子:在一个Saga中,有一个叫做 "增量 "的动作和它的补偿动作叫做 "减量"。不出所料,"增加 "将一个数字增加1,"减少 "将其减少1。从计数器0开始,先增量后减量=先减量后增量=0。

0 + 1 - 1 = 0 - 1 + 1 = 0 
无论何种顺序,最终结果都是一样的。但Saga中的补偿动作也必须在只有补偿动作信息到达目的地时发挥作用。

也就是说,从0开始,先增后减=先减后增=只减不增=0。

我不再说动作和它的补偿动作必须是换元的,而是开始用补偿动作必须支配动作的说法来指代这一要求。增量和减量也必须都是等价的,所以不管有多少信息到达(具有相同的ID),只要有一个减量到达,最后的结果必须和Saga开始前一样。

另一个从0开始的例子:
增量->减量->增量=0=减量->增量->减量 
由于这些特性,Decrement不是补偿动作的好名字。最好叫它Compensate Increment补偿增量之类的名字。

实现这一点的一个方法是让Compensate Increment与Increment共享其ID。当服务器处理Compensate Increment时,它检查是否有任何带有该ID的Increment信息已经被处理过。如果是这样,服务器就会递减该值以进行补偿。如果没有,它将保持该值不变。然后,在前面两种情况下,它都会保存该ID,以便今后任何带有该ID的消息都会被忽略。

文献中也将分布式Saga中的每一步称为事务,但我在这里将它们称为行动actions,以避免与其他并发控制方法相混淆。

关于作者
塞斯-阿切尔-布朗目前正在写一本名为《计算机网络从零开始》的免费书籍。如果你喜欢这里的内容,你会在书中找到类似的内容。

从零开始的计算机网络》是对最大的计算机网络--互联网--的内部运作的一次参观。它完全从零开始(假设完全没有网络知识),以一个允许用户使用弹珠和塑料管而不是电信号和电线来回发送信息的系统为例。然后,它建立了一个类似于60年代初最先进的计算机网络的东西。

本书是一项正在进行中的工作,但在本书结束时,它将提供一个关于互联网今天工作的概述,以及即将到来的情况。