分布式一致性算法简介
在讲分布式一致性算法之前,我们来讲一讲一个网站的发展历程
- 系统初期
在一个流量不是很大的情况下,我们只需要一台服务器,上面装一个数据库,起一个应用,就可以跑一个服务,一些非常经典的开源论坛就是这么玩的
- 系统早期 随着越来越多的用户,我们的单服务渐渐无法承载,这时,我们可能不得不经常重启我们的服务,以及受到用户的吐槽,怎么系统又挂了,又502啦。此时,你遭到了领导的威胁,怎么回事啊,用户上不来了,再上不来你不用干了,那么这时,最快最好的方案自然是升级配置或者服务分离,此时,我们将应用程序和数据库分开,使用缓存
随着系统的继续升级,我们发现一台应用服务器无法再满足我们的需要,于是我们拓展了系统,采用了集群的模式来应对压力
- 系统后期 再后来,我们拆解了数据库,从单库主从到了分库分表,拆解了应用,从单机到微服务,使用了各种中间件
我们可以看到,系统后期,我们大量使用了分布式服务器(文件、缓存、数据库等)
那么为什么要用这些分布式服务器?单机的弊端在哪里
Raft算法
简要介绍
Raft is a consensus algorithm for managing a replicated log. It produces a result equivalent to (multi-)Paxos, and it is as efficient as Paxos, but its structure is different from Paxos; this makes Raft more understandable than Paxos and also provides a better foundation for building practical systems. In order to enhance understandability, Raft separates the key elements of consensus, such as leader election, log replication, and safety, and it enforces a stronger degree of coherency to reduce the number of states that must be considered. Results from a user study demonstrate that Raft is easier for students to learn than Paxos. Raft also includes a new mechanism for changing the cluster membership, which uses overlapping majorities to guarantee safety.
Raft 是用来管理复制日志(replicated log)的一致性协议。它跟 multi-Paxos 作用相同,效率也相当,但是它的组织结构跟 Paxos 不同。这使得 Raft 比 Paxos 更容易理解并且更容易在工程实践中实现。为了使 Raft 协议更易懂,Raft 将一致性的关键元素分开,如 leader 选举、日志复制和安全性,并且它实施更强的一致性以减少必须考虑的状态的数量。用户研究的结果表明,Raft 比 Paxos 更容易学习。 Raft 还包括一个用于变更集群成员的新机制,它使用重叠的大多数(overlapping majorities)来保证安全性。
根据作者的自述,我们可以知道,Raft算法的出现很大意义上就源自于Paxos的复杂,无论在实际应用中还是在教学中,Paxos都有较大的难度。
论文地址:In Search of an Understandable Consensus Algorithm
Raft可以做什么
通过 RAFT 提供的一致性状态机,可以解决复制、修复、节点管理等问题,极大的简化当前分布式系统的设计与实现,让开发者只关注于业务逻辑,将其抽象实现成对应的状态机即可。基于这套框架,可以构建很多分布式应用:
- 分布式锁服务,比如 Zookeeper(zab)
- 分布式存储系统,比如分布式消息队列、分布式块系统、分布式文件系统、分布式表格系统等
- 高可靠元信息管理,比如各类 Master 模块的 HA
Raft实现应用
- braft:百度基于C++实现
- Aeron Cluster:基于java实现
- etcd/raft:基于Go语言实现
据说,OceanBase的CTO杨传辉曾经说过:“构建一个分布式数据库存储系统是比较简单的,上层一套raft一致性协议,下层接一个RocksDB引擎,一周时间就能搞定,难得是如何保证系统在后续的运行中性能稳定且可靠”。
Raft角色
- Leader:正常情况下,集群中只会有一个Leader,负责日志同步与响应客户端的请求,与其他Follower保持心跳。Leader从客户端接收日志条目(log entries),把日志条目复制到其他服务器上,并告诉其他的服务器什么时候可以安全地将日志条目应用到他们的状态机中。所有数据都从Leader流向其他服务器。在网络异常的情况下,可能会存在多个Leader,但在网络恢复后,会同步最高Term的Leader
- Follower:响应Leader的消息,响应Candidate的选举邀请,重定向客户端到Follower的请求
- Candidate:发起投票选举,在集群初始化或Leader宕机的时候,发起Leader选举的投票
选举
-
假设我们现在有节点Node A,Node B,Node C
-
集群启动时,所有的节点Term都为0,由于没有Leader,此时触发election timeout
-
由于存在随机时间,率先超时的节点成为Candidate(此时也可能存在多个节点同时成为Candidate)
-
此时假设Node A成为了Candidate,Node A的Term变为1,投给自己一票,Node B、Node C的Term为0
-
此时Node A发送RequestVote消息到其他节点。尚未成为Candidate的并且没有投过票的Node会把票投给Node A,并且Node B、Node C会重置election timeout
-
此时Node A获取了最多的选票,成为了Leader。Leader开始往其他节点发送心跳(不包含日志条目)来维系Leader地位,Follower响应Leader的消息
-
当Leader发送心跳超时或者宕机时,Follower触发了election timeout,率先触发election timeout的Follwer申请成为Candidate并开始新一轮的Leader选举,Term+1
-
假设此时Node A与Node B同时成为了Candidate并向其他Node发出申请,并获得同票,触发新一轮的选举
以上都是理论的网络正常场景,如果此时网络出现异常,让我们看看会发生什么
- 当Node A/B 与Node C/D/E之间出现了网络分区问题,此时整个网络中出现了两个Leader
- 那么这种场景是否会影响服务的正常运行呢?答案是不会,因为Node B只能拿到一票响应,无法达到半数,那么客户端并不会拿到Node B的返回结果
- 现在,网络恢复了正常,Node B发现了更高Term的Node C,此时Node C的日志将会覆盖Node A/B
日志复制
- 在Raft算法中,所有来自客户端的数据变更请求都会被当做一个日志条目追加到节点日志中。
- Raft算法中的日志条目除了操作还有Term,也就是上面提到的任期,Term也会被用于日志比较。
- 日志条目分为两种状态:已追加但未持久化、已持久化。
- Raft算法中会维护一个已持久化的日志条目索引,即commitIndex。小于等于commitIndex的日志条目被认为是已提交的,否则是未持久化的。
- 对了跟踪各个节点的复制进度,Leader会记录每个节点的nextIndex(下一个需要复制的日志条目的索引)和matchIndex(已匹配日志的索引)
- 选出Leader后,Leader会新建各节点的nextIndex和matchIndex,matchIndex首先为0,nextIndex为Leader节点接下来的日志条目索引,通过和各个节点发送AppendEntries消息来更新日志。
- 复制过程中,raft认为Follower节点和Leader节点不会有太大的日志差距,所以会将nextIndex设为最大,然后不断回退,最终匹配日志索引,从匹配点开始,往后与Leader节点不同的日志将被覆盖。
- 当客户端提交了一个数据变更,Leader节点会在自己的日志中追加一条日志,但是不提交(不会新增commitIndex)
- Leader通过AppendEntries向其他节点同步消息,AppendEntries里包含了最新追加的日志。当一半(包含Leader)以上的节点追加日志成功后,Leader节点会持久化日志并推进commitIndex,并且再次通知其他节点持久化日志。
此处留下一个小问题:
- 如果Leader发出了复制消息,只有少数Follower收到了,随即宕机了,还没有将这个commit同步给其他Follower,你觉得这个数据该丢掉吗?
- 下图哪些场景可能会发生?
安全性
前文其实留下了几个小问题,都是针对异常场景的处理,那么在安全性这一节里,我们会解释一下Raft如何针对这些场景做出处理。
选举限制
Raft Leader仅在候选人包含了所有已经提交的日志条目的情况下产生。候选人为了赢得选举必须联系集群中的大部分节点,这意味着每一个已经提交的日志条目在这些服务器节点中肯定存在于至少一个节点上。如果候选人的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么他一定持有了所有已经提交的日志条目。请求投票(RequestVote) RPC 实现了这样的限制:RPC 中包含了候选人的日志信息,然后投票人会拒绝掉那些日志没有自己新的投票请求。
Raft 通过比较两份日志中最后一条日志条目的索引值和任期号定义谁的日志比较新。如果两份日志最后的条目的任期号不同,那么任期号大的日志更加新。如果两份日志最后的条目任期号相同,那么日志比较长的那个就更加新。
节点崩溃
- 非Leader节点崩溃
Raft通过无限期重试来处理这些失败;如果崩溃的服务器重新启动,那么RPC将成功完成。如果服务器在完成RPC之后但在响应之前崩溃,那么它将在重新启动后再次接收相同的RPC。Raft rpc是幂等的,所以这不会造成伤害。例如,如果一个follower接收到一个AppendEntries请求,其中包含已经存在于其日志中的日志条目,那么它将忽略新请求中的那些条目。
- Leader节点崩溃
- Leader未收到请求即宕机:对集群无影响,客户端重试
- Leader复制日志给小部分节点后宕机:如果成功竞选的是复制到日志的节点,将日志同步给所有节点,客户端重试后幂等获取成功消息。如果成功竞选的没有复制到日志的节点,客户端重试后重新写入数据并同步给所有节点
- Leader复制到半数节点但尚未应用到状态机时宕机:竞选成为Leader的一定是拥有Leader日志的节点,对集群无影响
浓缩总结
| 特性 | 解释 |
|---|---|
| 选举安全特性 | 对于一个给定的任期号,最多只会有一个领导人被选举出来 |
| 领导人只附加原则 | 领导人绝对不会删除或者覆盖自己的日志,只会增加 |
| 日志匹配原则 | 如果两个日志在某一相同索引位置日志条目的任期号相同,那么我们就认为这两个日志从头到该索引位置之间的内容完全一致 |
| 领导人完全特性 | 如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有领导人中 |
| 状态机安全特性 | 如果某一服务器已将给定索引位置的日志条目应用至其状态机中,则其他任何服务器在该索引位置不会应用不同的日志条目 |
集群变更问题
到目前为止,我们都假设集群的配置(加入到一致性算法的服务器集合)是固定不变的。但是在实践中,偶尔是会改变集群的配置的,例如替换那些宕机的机器或者改变复制级别。为了让配置修改机制能够安全,那么在转换的过程中不能够存在任何时间点使得两个领导人在同一个任期里同时被选举成功。不幸的是,任何服务器直接从旧的配置直接转换到新的配置的方案都是不安全的。一次性原子地转换所有服务器是不可能的,所以在转换期间整个集群存在划分成两个独立的大多数群体的可能性。
为了保证安全性,配置的更改需要使用二阶段方法。现在常见的实现会有短暂的服务不可用,即一阶段停掉旧的配置,二阶段启用新的配置。在Raft中,第一阶段先切换到过渡配置,我们称之为联合共识。联合共识成功提交后,系统再切换到新配置。
Leader先创建C-old,new日志并把它复制到C-old集群和C-new集群。然后创建C-new日志并把它复制到C-new集群。不存在C-old和C-new可以同时独自进行决策的时间点,因此是安全的。
Leader将使用C-old,new来判断C-old,new对应的日志条目是否已经提交。如果leader这时候故障了,新leader可能是被C-old选举产生的,也可能是被C-old,new产生的,取决于该candidate是否收到了C-old,new日志。不管上述哪种情况,C-new都没有权力单方面选举leader。一旦C-old,new被提交之后,C-old和C-new都无法独自做出决策,领导完整性原则确保只有存储了C-old,new条目的节点才能当选为新的leader。
此处有两个比较有意思的情况:
- 新服务器没有任何日志,复制日志需要较长的时间,这种场景下,Raft对于新加入的服务器移除了投票权,只有当他们的日志条目追赶上了Leader,才被允许进行投票
- Leader不在新的配置中。直到Leader提交了C-new,他才会退位,也就意味着在相当的一段时间了,这个节点管理着不属于他的集群。
此处再留下一个小问题:被移除集群的节点,由于没有收到Leader的心跳,触发了election timeout,发起了VoteRequest,你会如何设计?
日志压缩
Raft的日志在正常操作中不断增长,如何压缩日志,成为了一个问题。快照是最简单的压缩方法,在快照系统中,会将整个系统的状态写入到持久化存储中,然后丢弃之前的日志。
Raft采用了增量压缩的方法,进行日志清理活日志合并树,并且每次对一部分数据进行操作,减少服务压力。
如上图所述,Raft会选择一个已经被累积了大量删除或者覆盖的对象区域,重写其中存活的对象,然后释放这块区域。
那么问题来了。你认为所有节点数据应该一致的场景,是Leader创建快照并同步给其余节点还是各个节点自己创建快照,Leader仅在部分场景同步会更合适?
快照同步
Raft采用了后者
我们考虑过一种替代的基于领导人的快照方案,即只有领导人创建快照,然后发送给所有的跟随者。但是这样做有两个缺点。第一,发送快照会浪费网络带宽并且延缓了快照处理的时间。每个跟随者都已经拥有了所有产生快照需要的信息,而且很显然,自己从本地的状态中创建快照比通过网络接收别人发来的要经济。第二,领导人的实现会更加复杂。例如,领导人需要发送快照的同时并行的将新的日志条目发送给跟随者,这样才不会阻塞新的客户端请求。
还有两个问题影响了快照的性能。首先,服务器必须决定什么时候应该创建快照。如果快照创建的过于频繁,那么就会浪费大量的磁盘带宽和其他资源;如果创建快照频率太低,他就要承受耗尽存储容量的风险,同时也增加了从日志重建的时间。一个简单的策略就是当日志大小达到一个固定大小的时候就创建一次快照。如果这个阈值设置的显著大于期望的快照的大小,那么快照对磁盘压力的影响就会很小了。
第二个影响性能的问题就是写入快照需要花费显著的一段时间,并且我们还不希望影响到正常操作。解决方案是通过写时复制的技术,这样新的更新就可以被接收而不影响到快照。例如,具有函数式数据结构的状态机天然支持这样的功能。另外,操作系统的写时复制技术的支持(如 Linux 上的 fork)可以被用来创建完整的状态机的内存快照(我们的实现就是这样的)。
概念
- Term:在Raft中使用了一个可以理解为任期的概念,用Term作为一个周期,每个Term都是一个连续递增的编号,每一轮选举都是一个Term周期,在一个Term中只能产生一个Leader。
Term是一个逻辑时钟值,Lamport Timestamp的一个变种。
假设多进程要维护一个全局时间,每个进程本地要有一个全局时间的副本。
1)每个进程在事件发生时递增自己本地的时间副本
2)每当进程发送消息时,带上自己本地的时间副本
3)当进程收到消息时,比较消息中的时间值和自己本地的时间副本,选择比较大的时间值加1,并更新自己的时间副本
- election timeout:选举超时,Follower申请成为Candidate的一个时间。为了减少同时申请的概率,是一个150ms-300ms的随机时间
- heartbeat timeout:Leader 为了维护自己的任期,定期通知 Follower 自己还健在,如果心跳超时,Follower
- RequestVote:Candidate发起投票
- AppendEntries RPC: Leader与Follower通信内容
文章来源
In Search of an Understandable Consensus Algorithm
大型网站技术架构_核心原理与案例分析
分布式一致性算法开发实战