翻译自Eli Bendersky的系列博客,已获得原作者授权。
本文是系列文章中的序言,本系列文章旨在介绍Raft分布式一致性协议及其Go语言实现。文章的完整列表如下:
Raft是一个相对较新的算法(2014),但是已经在业界取到了广泛的应用。最知名的案例应该就是Kubernetes,其中的分布式键值存储组件etcd就依赖了Raft协议。
本系列文章的写作目的,在于描述Raft协议的一个功能完备且经过严格测试的实现方式,并提供一些Raft工作方式的直观理解。这并不是您学习Raft协议的唯一途径。我假定您至少读过Raft论文; 此外,也强烈建议您花时间仔细研究Raft网站上的资源——观看创作者的一两次演讲,鼓捣一下算法的可视化工具,浏览Ongaro的博士论文以学习更多细节,等等。
不要指望能用一天时间就完全掌握Raft协议。尽管Raft设计得比Paxos更易于理解,但Raft算法仍然是相当复杂的。 它要解决的问题(分布式一致性)是一个难题,因此解决方案自然也不会太简单。
分布式一致性算法可以认为是在解决跨服务器复制一个确定性状态机的问题。这里的状态机一词可以用来表示任意服务。 毕竟,状态机是计算机科学的基础之一,而且一切事物都可以用状态机来表示。 数据库、文件服务器、锁服务器等都可以被看作是复杂的状态机。
考虑用状态机来代表一些服务,有多个客户端可以连接到它,这些客户端会发出请求,并期望得到响应:
只要运行状态机的服务器是稳定可靠的,这个系统就可以正常工作。如果服务器崩溃的话,我们的服务也就不可用了,而这是不能接受的。通常,我们系统的可靠性取决于其运行的服务器。
提高服务可靠性的一种常用方法就是复制。 我们可以在不同的服务器上运行服务的多个实例。 这样就创建了一个服务器集群,这些服务器协同工作以提供服务,并且其中任何一台服务器的崩溃都不会导致服务中断。 服务器间相互隔离[1]可以摒除一些同时影响多台服务器的常见故障,从而进一步提高系统可靠性。
客户端不会再连接单个提供服务的机器,而是会连接整个集群。此外,构成该集群的服务副本之间必须相互通信,以期正确地复制状态:
上图中的每个状态机都是服务的一个副本。其思想就是,所有的状态机同步运行,从客户端请求中获取相同的输入,并执行相同的状态转换。这样就保证即使集群有一些服务器出现故障,也会返回相同的结果给客户端。Raft就是实现这个目的的一种算法。
现在正好澄清一些后文中会频繁使用的术语:
现在我们来看一下上图展示的其中一个状态机。Raft作为一个通用的算法,并不关心服务是如何根据状态机实现的。它的目标是可靠、准确地记录并重现状态机接受的输入序列(Raft术语中也称为指令),给定初始状态和所有输入,就可以完全准确地重放状态机。可以换个角度理解:如果我们有相同状态机的两个独立的副本,并且从相同的起始状态开始向其发出同样的输入序列,那么两个副本最终会停留在相同的状态,并且会产生相同的输出。
这里是使用Raft的一般服务的结构:
这些组件的详细描述如下:
Raft使用的是强领导模型,其中集群中的一个副本作为领导者,其它副本都作为追随者。领导者负责接受客户的请求,复制指令给追随者,并返回响应给客户端。
正常操作情况下,追随者的目的就是简单地复制领导者的日志。一旦领导者出现故障或者网络隔断,会有一个追随者接管领导权,因此服务仍然是可用的。
这个模型是有利有弊的。一个重要的优点就是简单,数据总是vong领导者流向追随者,而且只有领导者响应客户端的请求。这个设计使得Raft协议更容易被分析、测试和调试。缺点就是性能——因为集群中只有一个服务器与客户端进行交互,当客户端请求激增时这会变成系统的瓶颈。对于这个问题,答案通常是:Raft协议不适用于大流量服务。Raft协议更适用于那些以牺牲可用性为代价来保证一致性的低流量服务——我们在容错部分会重新讨论这一点。
前面写过,“客户端不会再连接单个提供服务的机器,而是会连接整个集群”,这句话是什么含义呢?集群就是一组通过网络互连的服务器,所以你如何连接“整个集群”呢?
答案很简单:
第三点中提到的优化在多数情况下都不是必要的。通常来说,在Raft环境中区分“正常运行”和“异常情况”是很有用的。一个服务通常有99.9%的时间都是“正常运行”的,此时,客户端知道领导者是哪一个,因为它们在第一次连接服务的时候就缓存了这些信息。故障场景下肯定会造成混乱(下一节会讨论更多细节),但是也只是很短的时间。我们在下一篇文章中也会详细介绍,Raft集群能够很快地从机器临时故障或网络分区问题中恢复——大多数情况下恢复间隔不到1秒钟。当新的领导者声明领导权以及客户端查找具体的领导者副本时,可能会出现短暂的不可用状态,但是之后集群会恢复到“正常运行模式”。
我们来看一下三个Raft副本的示意图,这次不需要连接客户端:
在这个集群中,我们可以预见什么类型的故障呢?
现代计算机中的每个组件都可能会出现故障,但是为了方便讨论,我们把Raft实例中运行的服务器看作一个原子单元。这样的话,我们会面临两大类的故障:
从服务器A的角度来说,其与服务器B之间相互通信,对于服务器B的故障与A、B间的网络分区是无法区分的。这两种情况的表现是相同的——A接受不到任何B的信息及响应。但是,从系统的角度来说,网络分区的影响更大,因为它们会同时影响多台服务器。在本系列的下一部分,我们会讨论网络分区导致的一些复杂场景。
为了优雅地应对任意网络分区和服务器故障问题,Raft要求集群中的大多数服务器是正常启动的,而且在任意指定时刻都可以为领导者所用。如果有3台服务器,Raft可以允许1台机器故障,对于5台服务器的集群,可以允许2台机器故障; 对于2N+1
台服务器,可以允许N
台服务器出现故障。
这就引出了CAP理论,其实际结论就是,当存在网络分区(实际应用中难以避免的一部分)时,我们必须仔细权衡可用性和一致性。
在这个权衡中,Raft坚定地站在一致性阵营。其设计理念就是防止集群可能达到不一致状态的情况,在这种情况下,不同的客户端可能会得到不同的响应。为此Raft牺牲了部分可用性。
我前面也简单提过,Raft不是为高吞吐量、细粒度的服务设计的。客户端的每一个请求都会触发一系列工作——Raft副本间通信,以期把指令复制到大多数服务并持久化;这些都发生在客户端得到回应之前。
举例来说,你肯定不会设计一个所有客户端请求都经过Raft的复制数据库,这样太慢了。Raft更适合于粗粒度的分布式原语——如实现锁服务器,为更高级别的协议选举领导者,在分布式系统中复制关键配置数据,等等。
本系列中介绍的Raft实现是用Go语言编写的。在我看来,Go语言有三大优势,也是本系列及通用的网络服务选择Go作为实现语言的原因:
net/rpc
,这是一个足以应对此类任务的解决方案,可以快速使用而且不需要引入 (依赖)。感谢您能读到这里!如果您觉得有哪些地方我可以写得更好的,请告诉我。尽管Raft在概念上可能看起来很简单,但是一旦我们编码实现,还是会遇到很多问题。本系列的后续部分将介绍关于Raft算法不同方面的更多细节。
现在你应该已经准备好进入第1部分,我们开始实现Raft吧。
本系列文章通过使用Golang实现Raft协议,不仅直观解释了Raft协议中的一些难点,对于Go语言并发编程的学习也有很大的帮助。
本人在读完原博客之后觉得受益匪浅,在征求作者同意之后,将本系列博客翻译为中文并分享给大家,希望对Go或者Raft有兴趣的同学都能够有所收获。
强烈建议读者在看完一篇文章之后,可以执行作者代码中的测试用例,对照测试输出日志巩固一下对Raft协议的理解。
我在学习过程中,fork了原作者的代码,在原基础上添加了中文注释,也添加了测试用例的输出结果。对于不方便执行测试的读者,可以直接在其中查看测试输出日志。
需者自取,Github地址:github.com/GuoYaxiang/…
举例来说,可以将它们放在不同的机架中,或连接到不同的电源,甚至放置在不同的建筑物中。 大型公司提供的真正重要的服务通常是在全球范围内复制的,副本会分布在不同的区域。 ↩︎