实现Raft协议:Part 0 - 介绍

3,169 阅读13分钟

翻译自Eli Bendersky系列博客,已获得原作者授权。

本文是系列文章中的序言,本系列文章旨在介绍Raft分布式一致性协议及其Go语言实现。文章的完整列表如下:

Raft是一个相对较新的算法(2014),但是已经在业界取到了广泛的应用。最知名的案例应该就是Kubernetes,其中的分布式键值存储组件etcd就依赖了Raft协议。

本系列文章的写作目的,在于描述Raft协议的一个功能完备且经过严格测试的实现方式,并提供一些Raft工作方式的直观理解。这并不是您学习Raft协议的唯一途径。我假定您至少读过Raft论文; 此外,也强烈建议您花时间仔细研究Raft网站上的资源——观看创作者的一两次演讲,鼓捣一下算法的可视化工具,浏览Ongaro的博士论文以学习更多细节,等等。

不要指望能用一天时间就完全掌握Raft协议。尽管Raft设计得比Paxos更易于理解,但Raft算法仍然是相当复杂的。 它要解决的问题(分布式一致性)是一个难题,因此解决方案自然也不会太简单。

复制状态机

分布式一致性算法可以认为是在解决跨服务器复制一个确定性状态机的问题。这里的状态机一词可以用来表示任意服务。 毕竟,状态机是计算机科学的基础之一,而且一切事物都可以用状态机来表示。 数据库、文件服务器、锁服务器等都可以被看作是复杂的状态机。

考虑用状态机来代表一些服务,有多个客户端可以连接到它,这些客户端会发出请求,并期望得到响应:

Single state machine with two clients

只要运行状态机的服务器是稳定可靠的,这个系统就可以正常工作。如果服务器崩溃的话,我们的服务也就不可用了,而这是不能接受的。通常,我们系统的可靠性取决于其运行的服务器。

提高服务可靠性的一种常用方法就是复制。 我们可以在不同的服务器上运行服务的多个实例。 这样就创建了一个服务器集群,这些服务器协同工作以提供服务,并且其中任何一台服务器的崩溃都不会导致服务中断。 服务器间相互隔离[1]可以摒除一些同时影响多台服务器的常见故障,从而进一步提高系统可靠性。

客户端不会再连接单个提供服务的机器,而是会连接整个集群。此外,构成该集群的服务副本之间必须相互通信,以期正确地复制状态:

Replicated state machine with two clients

上图中的每个状态机都是服务的一个副本。其思想就是,所有的状态机同步运行,从客户端请求中获取相同的输入,并执行相同的状态转换。这样就保证即使集群有一些服务器出现故障,也会返回相同的结果给客户端。Raft就是实现这个目的的一种算法。

现在正好澄清一些后文中会频繁使用的术语:

  • 服务(Service):是我们将实现的分布式系统中的逻辑任务,比如说,一个键值数据库。
  • 服务器(Server)副本(Replica):在隔离的机器上运行的一个Raft服务实例,可以通过网络连接其它副本或者客户端。
  • 集群(Cluster):一组协作实现分布式服务的Raft服务器,典型的集群规模是3或5。

一致性模块和Raft日志

现在我们来看一下上图展示的其中一个状态机。Raft作为一个通用的算法,并不关心服务是如何根据状态机实现的。它的目标是可靠、准确地记录并重现状态机接受的输入序列(Raft术语中也称为指令),给定初始状态和所有输入,就可以完全准确地重放状态机。可以换个角度理解:如果我们有相同状态机的两个独立的副本,并且从相同的起始状态开始向其发出同样的输入序列,那么两个副本最终会停留在相同的状态,并且会产生相同的输出。

这里是使用Raft的一般服务的结构:

Raft consensus module and log connected to state machine

这些组件的详细描述如下:

  • 状态机与我们前面所说的相同。它代表任意一种服务:在介绍Raft时常用的例子就是键值存储。
  • 日志(Log)是存储客户端发出的所有指令(输入)的位置。这些指令不会直接应用于状态机,相反,只有当它们被成功复制到大多数服务器时,Raft算法才会提交这些指令。此外。日志是持久的——它保存在抗系统崩溃的稳定存储中,并且在系统崩溃后可以用于重放状态机。
  • 一致性模块是Raft算法的核心。它会接受客户端的指令,确保它们保存在日志中,将指令复制到集群中的其它Raft副本中(上图中的绿色箭头),并且在确定安全的时候将指令提交到状态机中。提交到状态机会将实际修改通知到客户端。

领导者和追随者

Raft使用的是强领导模型,其中集群中的一个副本作为领导者,其它副本都作为追随者。领导者负责接受客户的请求,复制指令给追随者,并返回响应给客户端。

正常操作情况下,追随者的目的就是简单地复制领导者的日志。一旦领导者出现故障或者网络隔断,会有一个追随者接管领导权,因此服务仍然是可用的。

这个模型是有利有弊的。一个重要的优点就是简单,数据总是vong领导者流向追随者,而且只有领导者响应客户端的请求。这个设计使得Raft协议更容易被分析、测试和调试。缺点就是性能——因为集群中只有一个服务器与客户端进行交互,当客户端请求激增时这会变成系统的瓶颈。对于这个问题,答案通常是:Raft协议不适用于大流量服务。Raft协议更适用于那些以牺牲可用性为代价来保证一致性的低流量服务——我们在容错部分会重新讨论这一点。

客户端交互

前面写过,“客户端不会再连接单个提供服务的机器,而是会连接整个集群”,这句话是什么含义呢?集群就是一组通过网络互连的服务器,所以你如何连接“整个集群”呢?

答案很简单:

  • 在访问Raft集群时,客户端知道集群中副本的网络地址。至于它如何知道(例如通过某种服务发现机制)不在本文的讨论范围之内。
  • 客户端一开始会发请求到任意副本,如果这个副本是领导者,它会立即接受请求,而且客户端也会等待完整的响应,此后,客户端会记住这个副本是领导者,以后就不必再次搜索领导者(除非遇到某些故障,如领导者崩溃)。
  • 如果副本表示自己不是领导者,客户端会尝试连接另一个副本。这里可以进行优化,由追随者副本直接告诉客户端哪一个副本是领导者。因为副本间是一直在相互通信的,所以通常知道正确答案,这样可以节省客户端的猜测时间。
  • 还有一种情况下客户端会意识到自己连接的不是领导者,那就是它的请求在一段超时时间内没有提交成功。这可能意味着它连接的副本实际上不是领导者(即使它认为自己是)——它可能跟其它Raft服务器间出现了分割。当超时时间耗尽后,客户端会重新搜索其它的领导者。

第三点中提到的优化在多数情况下都不是必要的。通常来说,在Raft环境中区分“正常运行”和“异常情况”是很有用的。一个服务通常有99.9%的时间都是“正常运行”的,此时,客户端知道领导者是哪一个,因为它们在第一次连接服务的时候就缓存了这些信息。故障场景下肯定会造成混乱(下一节会讨论更多细节),但是也只是很短的时间。我们在下一篇文章中也会详细介绍,Raft集群能够很快地从机器临时故障或网络分区问题中恢复——大多数情况下恢复间隔不到1秒钟。当新的领导者声明领导权以及客户端查找具体的领导者副本时,可能会出现短暂的不可用状态,但是之后集群会恢复到“正常运行模式”。

Raft容错机制和CAP理论

我们来看一下三个Raft副本的示意图,这次不需要连接客户端:

Replicated state machine not showing clients

在这个集群中,我们可以预见什么类型的故障呢?

现代计算机中的每个组件都可能会出现故障,但是为了方便讨论,我们把Raft实例中运行的服务器看作一个原子单元。这样的话,我们会面临两大类的故障:

  1. 服务器崩溃,其中一个服务器在一段时间内停止响应所有的网络请求。崩溃的服务器通常会被重启,并在短暂的中断后重新上线。
  2. 网络分区,由于网络设备或传输介质问题,导致一个或多个服务器与其它服务器和/或客户端断开连接。

从服务器A的角度来说,其与服务器B之间相互通信,对于服务器B的故障与A、B间的网络分区是无法区分的。这两种情况的表现是相同的——A接受不到任何B的信息及响应。但是,从系统的角度来说,网络分区的影响更大,因为它们会同时影响多台服务器。在本系列的下一部分,我们会讨论网络分区导致的一些复杂场景。

为了优雅地应对任意网络分区和服务器故障问题,Raft要求集群中的大多数服务器是正常启动的,而且在任意指定时刻都可以为领导者所用。如果有3台服务器,Raft可以允许1台机器故障,对于5台服务器的集群,可以允许2台机器故障; 对于2N+1台服务器,可以允许N台服务器出现故障。

这就引出了CAP理论,其实际结论就是,当存在网络分区(实际应用中难以避免的一部分)时,我们必须仔细权衡可用性一致性

在这个权衡中,Raft坚定地站在一致性阵营。其设计理念就是防止集群可能达到不一致状态的情况,在这种情况下,不同的客户端可能会得到不同的响应。为此Raft牺牲了部分可用性。

我前面也简单提过,Raft不是为高吞吐量、细粒度的服务设计的。客户端的每一个请求都会触发一系列工作——Raft副本间通信,以期把指令复制到大多数服务并持久化;这些都发生在客户端得到回应之前。

举例来说,你肯定不会设计一个所有客户端请求都经过Raft的复制数据库,这样太慢了。Raft更适合于粗粒度的分布式原语——如实现锁服务器,为更高级别的协议选举领导者,在分布式系统中复制关键配置数据,等等。

为什么选Go

本系列中介绍的Raft实现是用Go语言编写的。在我看来,Go语言有三大优势,也是本系列及通用的网络服务选择Go作为实现语言的原因:

  1. 并发 :Raft这类算法在本质上是完全并行的,每个副本要执行持续不断的操作(指令),为定时事件运行定时器,还必须响应其它副本和客户端的请求。我之前写过为什么我认为Go是编写这类代码的理想语言
  2. 标准库:Go语言拥有一个强大的工业级标准库,可以轻松编写复杂的网络服务器,而不需要导入和学习任何第三方库。特别是在Raft中,需要面对的第一个问题就是“如何在副本之间发生消息?”,很多人会陷入设计协议和序列化的困境中,或者使用繁重的第三方库。Go语言中有net/rpc,这是一个足以应对此类任务的解决方案,可以快速使用而且不需要引入 (依赖)。
  3. 简单:即使不考虑编程语言,实现分布式一致性就已经足够复杂了。使用任何语言都可以写出清晰、简单的代码,但是在Go语言中,这是默认的习惯写法,这门语言在每个可能的层面上都反对代码的复杂性。

下一步

感谢您能读到这里!如果您觉得有哪些地方我可以写得更好的,请告诉我。尽管Raft在概念上可能看起来很简单,但是一旦我们编码实现,还是会遇到很多问题。本系列的后续部分将介绍关于Raft算法不同方面的更多细节。

现在你应该已经准备好进入第1部分,我们开始实现Raft吧。

译者注

本系列文章通过使用Golang实现Raft协议,不仅直观解释了Raft协议中的一些难点,对于Go语言并发编程的学习也有很大的帮助。

本人在读完原博客之后觉得受益匪浅,在征求作者同意之后,将本系列博客翻译为中文并分享给大家,希望对Go或者Raft有兴趣的同学都能够有所收获。

强烈建议读者在看完一篇文章之后,可以执行作者代码中的测试用例,对照测试输出日志巩固一下对Raft协议的理解。

我在学习过程中,fork了原作者的代码,在原基础上添加了中文注释,也添加了测试用例的输出结果。对于不方便执行测试的读者,可以直接在其中查看测试输出日志。

需者自取,Github地址:github.com/GuoYaxiang/…


  1. 举例来说,可以将它们放在不同的机架中,或连接到不同的电源,甚至放置在不同的建筑物中。 大型公司提供的真正重要的服务通常是在全球范围内复制的,副本会分布在不同的区域。 ↩︎