分布式系统和一致性问题

拜占庭将军问题

我们前面讨论的一致性协议,有一个重要的前提条件,就是:各个节点都是可以信任的,它们都严格遵守同样的一套规则。这个条件,在一个公司的内部网络中可以认为是基本能满足的。但如果这个条件不满足会怎么样呢?假设网络中有些节点是恶意的,它们不但不遵守协议,还故意捣乱(比如胡乱发送消息),那么其它正常的节点还能够顺利工作吗?

在分布式系统理论中,这个问题被抽象成了一个著名的问题—拜占庭将军问题(Byzantine Generals Problem)。这个问题由大名鼎鼎的Leslie Lamport提出,也就是Paxos的作者。同时,Lamport还是2013年的图灵奖得主。

  • 这要从一个故事开始说起。拜占庭帝国的几支军队攻打到了敌人的城市外面,然后分开驻扎。
  • 每一支军队由一位拜占庭将军(Byzantine general)率领。为了制定出一个统一的作战计划,每一位将军需要通过信差(messenger)与其它将军互通消息。
  • 但是,在拜占庭将军之间可能出现了叛徒(traitor)。这些叛徒将军的目的是阻挠其他忠诚的将军(loyal generals)达成一致的作战计划。
  • 为了这一目的,他们可能做任何事,比如串通起来,故意传出虚假消息,或者不传出任何消息。

我们来看一个简单的例子。假设有5位将军,他们投票来决定是进攻还是撤退。其中两位认为应该进攻,还有两位认为应该撤退,这时候进攻和撤退的票数是2:2打平了。第五位将军恰好是个叛徒,他告诉前两位应该进攻,但告诉后两位应该撤退,结果前两位将军最终决定进攻,而后两位将军却决定撤退。没有达成一致的作战计划。

这个问题显然比我们在前一章讨论的可信任环境下的一致性问题要更难。要解决这个问题,我们是希望能找到一个算法,保证在存在叛徒阻挠的情况下,我们仍然能够达成如下目标:

  • A. 所有忠诚的将军都得到了相同(一致)的作战计划。比如都决定进攻,或都决定撤退,而不是有些将军认为应该进攻,其他将军却决定撤退。
  • B. 忠诚的将军不仅得到了相同的作战计划,还应该保证得到的作战计划是合理的(reasonable)。比如,本来进攻是更有利的作战计划,但由于叛徒的阻挠,最终却制定出了一起撤退的计划。这样我们的算法也算失败了。

可以看出,上面的目标A,是比较明确的,至少给定一个算法很容易判定它有没有达到这个目标。但目标B却让人无从下手。一个作战计划是不是「合理」的,本来就不好定义。即使没有叛徒的存在,忠诚的将军们也未必就一定能制定出合理的计划。这涉及到科学研究中一个非常重要的问题,如果一个事情不能用一种形式化的方式清晰的定义出来,对于它的研究也就无从谈起,这个事情本身也无法上升到科学的层面。Lamport在对拜占庭将军问题的研究中,一个突出的贡献就是,把这个看似不太好界定的问题,巧妙地归约到了一个能用数学语言精确描述的问题上去。下面我们就看一下这个过程是怎么做的。

首先我们考虑一下将军们制定作战计划的过程(先假设没有叛徒)。每一位将军根据自己对战局的观察,给出他建议的作战计划——是进攻还是撤退,这称为作战提议。然后,每位将军把自己的作战提议通过信差传达给其他每一位将军。现在每一位将军都知道了其他将军的作战提议,再加上他自己的作战提议,他需要根据所有这些信息得到最终的一个作战计划。为了表达上更清晰,我们给每位将军进行编号,分别是1, 2, …, n,每位将军提出的作战提议记为v(1), v(2), …, v(n),一共是n个值,这其中有些代表「进攻」,有些代表「撤退」。经过信差传递消息之后,每位将军都看到了相同的作战提议序列v(1), v(2), …, v(n),当然这其中的一个是当前这位将军自己提出来的。然后只要每位将军采用同样的方法,对所有的v(1), v(2), …, v(n)这些信息进行汇总,就能得到同样的最终作战计划。比如,容易想到的一个方法是投票法,即对v(1), v(2), …, v(n)中不同的作战提议进行投票,最后选择得票最多的作为最终作战计划

当然,这样得到的最终作战计划也不能保证就是最好的,但这应该是我们能做到的最好的了。我们现在仍然假设将军里没有叛徒。我们发现,前面提到的目标A和目标B的要求可以适当「降低」一些:我们不再关注将军们是否能达成最终一致的作战计划,并且这个计划是不是「合理」;我们只关注每个将军是否收到了完全相同的作战提议v(1), v(2), …, v(n)。只要每位将军收到的这些作战提议是完全相同的,他们再用同样的方法进行汇总,就很容易得到最终一致的作战计划。至于这个最终的作战计划是不是最好的,那就跟很多「人为」的因素有关了,我们不去管它。

现在我们考虑将军中出现了叛徒。遵循前面的思路,我们仍然希望每位将军能够收到完全相同的作战提议v(1), v(2), …, v(n)。现在我们仔细审视一下其中的一个值,v(i),在前面的描述中,它表示来自第i个将军的作战提议。如果第i个将军是忠诚的,那么这个定义没有什么问题。但是,如果第i个将军是叛徒,那么就有问题了。为什么呢?因为叛徒可以为所欲为,他为了扰乱整个作战计划的制定,完全可能向不同的将军给出不同的作战提议。这样的话,不同的忠诚将军收到的来自第i个将军的v(i)可能是不同的值。这样v(i)这个定义就不对了,它需要改一改。

不管怎么样,即使存在叛徒,我们还是希望每位将军最终是基于完全相同的作战提议来做汇总,这些作战提议仍然记为v(1), v(2), …, v(n)。不过,这里的v(i)不再表示来自第i个将军的作战提议,而是表示经过我们设计的某个一致性算法处理之后,每位将军最终看到的第i个提议。这里需要分两种情况讨论。首先第一种情况,如果第i个将军是忠诚的,那么我们自然希望这个v(i)就是第i个将军发送出来的作战提议。换句话说,我们希望经过一致性算法处理之后,第i个将军如果是忠诚的,那么它的提议能够被如实地传达给其他将军,而不会被叛徒的行为所干扰。这是可能制定出「合理」作战计划的前提。第二种情况,如果第i个将军是叛徒,那么他有可能向不同的将军发送不同的提议。这时候我们不能够只听他的一面之词,而是希望经过一致性算法处理之后,各个将军之间充分交换意见,然后根据其他各个将军转述的信息,综合判断得到一个v(i)。这个v(i)是进攻还是撤退,并不太重要,关键是要保证每位将军得到的v(i)是相同的。只有这样,各位将军经过汇总所有的v(1), v(2), …, v(n)之后才能得到最终的完全一致的作战计划。

根据上面的分析,我们发现,在这两种情况中,我们都只需要关注单个将军(也就是第i个将军)所发出的提议如何传达给其他将军。重点终于来了!至此,我们就能够把原来的问题归约到一个子问题上。这个子问题,才是Leslie Lamport在他的论文中被真正命名为「拜占庭将军问题(Byzantine Generals Problem)」的那个问题。在这个问题中,我们只关注发送命令的单个将军,称他为主将(commanding general),而其他接受命令的将军称为副官(lieutenant)。下面是「拜占庭将军问题」的精确描述。

一个主将发送命令给n-1个副官,如何才能确保下面两个条件:

  • (IC1) 所有忠诚的副官最终都接受相同的命令。
  • (IC2) 如果主将是忠诚的,那么所有忠诚的副官都接受主将发出的命令。

这其实正好对应了我们前面已经讨论过的两种情况。如果主将是忠诚的,那么条件IC2保证了命令如实地传递,这时候条件IC1自然也满足了;如果主将是叛徒,那么条件IC2没有意义了,而条件IC1保证了,即使叛徒主将对每个副官发出不同的命令,每个副官仍然能最终获得一致的命令。

这里有两个地方可能让人产生疑惑。

第一,有些人会问了,难道主将还能是叛徒?主将都是叛徒了,还有啥搞头啊?其实是这样的,这个「拜占庭将军问题」只是原问题的一个子问题。当n个将军通过传递消息来决策作战计划的时候,可以分解成n个「拜占庭将军问题」,即分别以每位将军作为主将,以其余n-1位将军作为副官。如果有一个算法能够解决「拜占庭将军问题」,那么同时运行n个算法实例,就能使得每位将军都获得完全相同的作战提议序列,即前面我们提到的v(1), v(2), …, v(n)。最后,每位将军将v(1), v(2), …, v(n)使用相同的方法进行汇总(比如按多数投票),就能得到最终的作战计划。

第二,当主将是叛徒的时候,他可以向不同的副官发送不同的命令,怎么可能每个副官仍然能最终获得一致的命令呢?这正是算法需要解决的。其实这也容易解释(我们前面也提到过这个思路),由于主将可能向不同的副官发送不同的命令,所以副官不能直接采用主将发来的命令,而是也要看看其他副官转述来的主将的命令是什么。然后,一个副官综合了由所有副官转述的命令(再加上主将直接发来的命令)之后,就可能得到比较全面的信息,从而做出一致的判断(在实际中是个不断迭代的过程)。

好了,我们用了这么多篇幅,终于把「拜占庭将军问题」本身描述清楚了。这实际上也是最难的部分。我们上一章提到过,理解问题本身比理解问题的答案更重要。只要问题本身分析清楚了,如何设计一个能解决它的算法就只是细节问题了。我们这里不深入算法的细节了,感兴趣的读者可以去查阅下列论文:

我们这里只提一下论文给出的算法的结论。

使用不同的消息模型,「拜占庭将军问题」有不同的解法。

  • 如果将军之间使用口头消息(oral messages),也就是说,消息被转述的时候是可能被篡改的,那么要对付m个叛徒,需要至少有3m+1个将军(其中至少2m+1个将军是忠诚的)。
  • 如果将军之间使用签名消息(signed messages),也就是说,消息被发出来之后是无法伪造的,只要被篡改就会被发现,那么对付m个叛徒,只需要至少m+2个将军,也就是说至少2个忠诚的将军(如果只有1个忠诚的将军,显然这个问题没有意义)。这种情况实际相当于对忠诚将军的数目没有限制。

容错性(fault tolerance)

我们前面提到过,以Paxos为代表的分布式一致性协议,是在可信任的环境下运行的。而在「拜占庭将军问题」中,网络中则存在恶意节点。因此我们很容易产生一个想法:Paxos是不是「拜占庭将军问题」在叛徒数为零时的一个特例解?

这样看其实有点问题。在「拜占庭将军问题」中,除了叛徒,剩下的是忠诚的将军。「忠诚」这个词,其实暗含了一个意思:他是能够正常工作的(即你可以随时通过消息跟他进行交互)。为什么这么说呢?我们知道,一个叛徒可以做任何事,包括发送错误消息,也包括不发送任何消息。「不发送任何消息」,相当于不能正常工作,或者说,发生了某种故障。所以,不仅仅是故意的恶意行为,即使是单纯的故障,也应该能归入叛徒的行为。这在其他将军看来没有区别。

按照这种理解,「忠诚」这个词并不是很恰当。叛徒数为零,相当于网络中每个节点都在正常工作。但是Paxos的设计也是能够容错的,就像我们在前面讨论的一样,网络中的少数节点发生故障(比如宕机),Paxos仍然能正常工作。可见,Paxos并不能看成是「拜占庭将军问题」在叛徒数为零时的一个特例解。

那「拜占庭将军问题」和Paxos这类分布式一致性算法的关系应该如何看待呢?我们可以从容错性的强弱程度上来分析。

一般来说,设计一个计算机系统,小到一块芯片,大到一个分布式网络,都需要考虑一定的容错性(fault tolerance)。但根据错误不同的性质,可以分为两大类:

  • 拜占庭错误(Byzantine fault)。这种错误,在不同的观察者看来,会有前后不一致的表现。
  • 非拜占庭错误(non-Byzantine fault)。从字面意思看,是指那些不属于前一类错误的其它错误。

这两类错误的含义并没有字面上那么好理解。

先说说拜占庭错误。在「拜占庭将军问题」中,叛徒的恶意行为固然是属于这一类错误的。在不同的将军看来,叛徒可能发送完全不一致的作战提议。而在计算机系统中,出现故障的节点或部件也可能表现出前后不一致的行为,虽然这并非恶意,但也属于这一类错误。比如信道不稳定,导致节点发送给其它节点的消息发生了随机错误,或者说,消息损坏了(corrupted)。再比如,在数据库系统中,commit之后的数据明明已经同步给磁盘了(通过操作系统的fsync),但由于突然断电等原因,最终数据还是没有真正落盘成功,甚至出现数据错乱。

再看一下非拜占庭错误。Lamport在他关于Paxos的一篇论文中也使用了non-Byzantine这个词(见《Paxos Made Simple》)。但是这个词的命名的确让人有点不好理解。在分布式系统中,如果节点宕机了,或者网络不通了,都会导致某些节点不能工作。其它节点其实没法区分这两种情况,在它看来,只是发现某个节点暂时联系不上了(即接收消息超时了)。至于是因为那个节点本身出问题了,还是网络不通了,或者是消息出现了严重的延迟,是无法区分的。而且,过一会之后,节点可能会重新恢复(或是自己恢复了,或经过了人工干预)。换句话说,对于出现这种错误的节点,我们只是收不到它的消息了,而不会收到来自它的错误消息。相反,只要收到了来自它的消息,那么消息本身是「忠实」的。

可见,拜占庭错误是更强的一类错误。在「拜占庭将军问题」中,叛徒发送前后不一致的作战提议,属于拜占庭错误;而不发送任何消息,属于非拜占庭错误。所以,解决「拜占庭将军问题」的算法,既能处理拜占庭错误,又能处理非拜占庭错误。这听起来稍微有些奇怪,不过这只是命名带来的问题。

总之,「拜占庭将军问题」的解法应该是最强的一类分布式一致性算法,它理论上能够处理任何错误。而Paxos只能处理非拜占庭错误。通常把能够处理拜占庭错误的这种容错性称为「Byzantine fault tolerance」,简称为BFT。