You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

190 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 23Raft分布式系统间如何达成共识
你好,我是微扰君。
今天我们要来谈一谈分布式系统中一个非常重要的问题:分布式共识问题,也就是一致性问题。
我们知道,分布式系统的诞生,主要是为了提供单机无法进行的计算和存储、提高吞吐量、增加容错性等。而在现在的互联网架构下,分布式系统由于大量使用廉价的商用机器,节点故障是不可避免的。
在这种情况下,多个机器如何像一个整体一样工作,是件很困难的事情,这里一致性算法就起到了至关重要的作用。
以分布式KV存储系统为例我们来先搞清楚分布式一致性到底解决的是什么问题。
## 复制状态机
之前在操作系统章节中讲过,数据库常用 redo-log 来实现事务等能力,那当这样的存储系统不再是单机节点,我们通常也需要采用多台机器来存储日志,把同样的日志在不同的节点都存储一份。这样如果有一台节点挂了,整个系统还可以用备份节点来提供日志的能力。
让多台服务器存储相同顺序的多条相同指令,也就是“日志”,可以帮助我们实现复制状态机。
![图片](https://static001.geekbang.org/resource/image/8f/58/8f0efe4c136272909a8a7376f0558758.jpg?wh=1920x1145)
**每一个状态的变更记录都会先在日志中存储并commit之后才apply到状态机中修改对应的状态**,这个设计能在分布式系统中解决许多和容错性相关的问题。
既然涉及多个节点存储同样的一份东西,怎样才能保证多个独立的节点所存储的内容是一致的呢?这就是我们常说的“一致性问题”了。
## 日志一致性问题
客户端通常只会向分布式系统中的某个服务器发起请求,然后由这个服务器的一致性模块,在多个复制状态机之间进行消息的同步,正常情况下,多个节点同步都会成功,这样不同节点的日志自然也都是一致的,所有的节点都会以相同的顺序包含相同的请求,从外界看起来,行为也就像是一台机器一样。
但是在服务器发生故障时,依然要保持正确的复制变成了困难。
一种最暴力的做法可能是定义一个主节点每一次写请求都请求到主节点等主节点一致性模块向集群中的每个节点都成功写入了同样一份数据再返回给客户端成功的消息进行消息的commit。只要有一个失败就不进行消息的commit。
但是我们看这个方案,显然不是很高效。无论是存在慢节点,会导致整体响应被拖的很慢,还是一台节点挂了,会导致整个集群不可用,都是不可接受的。
那我们分析一下在实际系统中,一致性算法通常会存在哪些问题:
1. 在分区、网络延迟、乱序等情况下都需要保证安全性,也就是说需要数据一旦返回,一定是正确的。
2. 可用性问题。部分节点故障,但在集群中大部分节点可运行且能互相通信的情况下,要保证整个系统是可以工作的。
3. 不依赖时钟保证一致性。因为物理时钟在分布式系统中是不可信的,不同节点间的时间并不同步的,而且随时可能因为时钟同步而导致时钟回跳等等。
4. 慢节点,要求不能影响系统整体性能。
接下来我们要学习的Raft算法就很好地考虑了这些问题。
## Raft与Paxos
Raft提出之前在非拜占庭条件下分布式一致性领域里Paxos算法一直占据着统治性的地位但 Paxos 是出了名的难理解,工程实践也比较困难。
拜占庭条件源自一篇论文也是Paxos的作者写的他用一组将军围困一座城市的假想问题描述当点对点通信的节点中出现了恶意节点传播不正确消息时达成共识的困难处境。非拜占庭条件指的就是所有节点都是正常工作只可能出现消息不可达不会有消息错误的情况。拜占庭将军问题本身也非常有趣你感兴趣的话可以自行搜索了解一下。
Paxos的艰深难懂其实也是Raft算法提出的主要动机。Raft和Paxos都是只要有超过一半的服务器可以运行并互相可通信就可以保证整个系统可用。
和Paxos不同的是一致性问题**被Raft明确拆解成了三个比较独立的、更好理解的子问题**并且团队在许多实现细节上做了很多努力和权衡也增强了系统里的许多限制简化了需要考虑的状态尽量让过程和接口的设计变得清晰易懂。比如Raft中就引入了Leader由Leader进行全局消息的把控也不允许日志中存在空洞的情况都是一些比较常见的权衡。
接下来我们就来逐一学习Raft拆解出来用于达成分布式共识的三个子问题领导人选举、日志复制、安全性。
## 子问题一:领导人选举
Raft引入Leader的概念也就是领导人节点的思路和两阶段提交里的协调者性质其实差不多的。
本质上,都是因为在分布式的系统中,各个节点不具备全局的信息,那为了感知到不同节点对请求的响应情况,我们通常就会引入一个主节点,由它进行统一的控制和调度,这样整个分布式的处理逻辑就会变得比较简单。
在Raft中Leader就负责接收客户端的请求由它统一向其他节点同步消息等收到半数的节点Commmit日志的响应后就会把状态应用到状态机并返回。
思路很简单但是Leader节点如何被选出呢
Raft设计了一套节点状态机制每个节点永远处于三个状态之一
* Follower 追随者所有节点初始化或重启的时候都处于Follower状态。它不会接受请求也不会发起请求只响应由Leader发起的AppendEntries和Candidate发起的RequestVote请求。
* Candidate 候选人:是 Follower 晋升为 Leader 的中间状态从语义上就能看出来这个阶段是需要投票的。Follower 如果在一段时间没有收到领导人的消息,就会变成 Candidate 并发起选举,也就是向集群中所有节点发出 RequestVote 请求,如果收到半数以上也就是 (n/2+1) 的通过,就可以成功晋升为新的 Leader 。
![图片](https://static001.geekbang.org/resource/image/c1/80/c1612b3d388b25142eae94c190ffb080.jpg?wh=1920x1145)
* Leader 领导人: 系统大部分时候只有一个节点处于Leader状态如果有两个节点同时处于Leader状态也最多只有一个是真正有效的。Leader会不断的向Follower发起请求告知它们自己还在正常工作。这里的请求就是后面用于复制日志的AppendEntries请求我们马上展开讲解。
每一任新的领导人出现都会带有一个任期term。任期时长不确定只要网络不发生大面积分区而且超过半数的节点和Leader一直可以正常工作这届任期可能就会非常长。
任期编号是单调增的1、2、3……在每一次选举发起的时候产生。
![图片](https://static001.geekbang.org/resource/image/4a/b3/4a6d8a9b3aae7d00b196021a70daa5b3.jpg?wh=1920x1145)
候选人发起选举的时候会把当前的任期加1再发给其他节点投票如果其他Follower收到的请求带的是过期的任期就会直接拒绝这次请求对应的Leader和Follower发现后也会立刻变成Follower状态因为这意味着此时已经出现过一个任期更高的合法Leader了。
大致设计就是这样,但是我们把这套规则应用到真实系统中就会存在一些问题。你可以先暂停思考一下预计会出现哪些问题,如何解决,我们再一起讨论。
首先当同时有多个Candidate产生发起票选**每个Candidate的票数都不足n/2+1的时候会发生什么呢**
这个很简单平局收场Term再加1我们重新选举一轮就可以。所以Candidate也会维护一个定时器用于处理这种超时的情况。网络分区中的Candidate可能会不断的提高自己的Term但是因为它没有任何新的数据被写入网络恢复的时候它自己也能很快感知到这点从而恢复到Follower的身份。
看到这里你可能又想到新问题了:如果碰到了这样的情况,**多个Candidate一起超时又会触发下一轮票选瓜分岂不是永远选不出Leader了**
解决这个问题的方法也很简单常用。我们可以在选举超时时间中引入一定的随机性而不是一个固定值比如150-300ms这样多个Candidate在下一轮超时的时候肯定就会错开发起选举请求的时间了。
当然,这也需要我们保证有良好的网络环境,选取发出-被收到的时间一定要比较短才行。
```scala
广播时间broadcastTime << 选举超时时间electionTimeout
```
不过这里还有一个问题不知道你有没有思考,**为什么我们一定要要求半数以上的票选才能晋升呢**
这其实也是在分布式系统中非常常见的做法。容斥原理我们知道能获得半数以上票选的候选人只可能有一个。所以半数机制如果遇到网络分区网络分区少数的那一方就肯定不会产生Leader同样同一个Term下全局也只会有一个Leader不会出现脑裂也就是同时有多个leader的情况。
## 日志复制
现在Leader选举完成它就要扛起接受客户端请求并复制日志的大旗了主要职责就是发起AppendEntries请求这里的Entries主要指的就是日志记录它可以做两件事。
* 第一就是我们前面说的Leader需要周期地告知其他Follower节点自己还在正常工作。Raft的实现就是让Leader不断地向其他节点发起空的AppendEntries请求。
当Follower收到这样的请求时只要请求的任期没有过期Follower就会接受这个请求知道Leader还正常工作自己也就没有必要揭竿而起成为Candidate了。这个和很多系统中的心跳机制是一样的。
* 第二也是AppendEntries的主要用途——复制日志和字面意思一样就是由Leader发起要求Follower在日志中追加记录的意思。
当Leader接收了客户端的请求它就会并行地请求其他节点带上客户端请求的指令要求其他节点进行复制并返回结果。**当Leader觉得日志被安全地复制了之后才会将指令应用到状态机中并返回客户端**。
什么是安全的复制呢就是指一旦决定这个日志可以被应用到状态机我们也叫“已提交的日志CommittedEntries”即使之后任何节点出现不可用的情况已提交的日志一定不会丢失且最终会被集群中所有正常工作的节点应用到状态机。
![图片](https://static001.geekbang.org/resource/image/46/d2/466c68d02b99e467f27b43d4697177d2.jpg?wh=1920x1145)
而Raft对“已提交”的条件定义也很简单有效**如果一个日志被Leader复制到大多数节点日志就算被提交了**,反之则没有,还有可能被其他更新的任期的日志所覆盖。这一点约束我们稍后马上会展开讨论。
另外Raft在记录日志的时候除了会记录日志任期、具体操作也会给每条记录都赋予一个日志索引这样可以帮助节点定位自己所持有的日志具体是哪些。
### Log Matching
有了index和term的概念Raft通过引入一些约束使得所有的日志始终拥有着“日志匹配”的特性主要是两条规则
1. 不同日志中的两个记录,如果拥有相同的任期和索引,它们的内容相同。
2. 不同日志中的两个记录,如果拥有相同的任期和索引,它们之前的内容也相同。
前面我们已经说了Raft同一个任期里肯定只有一个Leader如果这个Leader写了某条日志它在不同节点上日志的索引一定是相同的。
这是因为**AppendEntries被接收时会执行一致性检查**Leader提交请求的时候会带上自己的prevLogIndex和prevLogTerm表示上一个日志条目的索引和任期。如果Follower发现自己的日志里找不到这个任期和索引对应的条目会拒绝此次AppendEntries请求这个就是Raft协议很关键的一个约束所以当AppendEntrires成功时Leader能保证prevLogIndex之前所有的记录都是相同的。
这个逻辑你仔细顺一下就很清晰了,具体的证明有点像数学归纳法,初始状态是满足日志匹配的,如果执行了一致性检查,那么后续的所有状态,日志匹配特性也都是满足的。
如果Leader和Follower由于崩溃出现日志记录不同的时候Leader就会要求Follower按照自己的日志覆写。Leader为每一个Follower都记录了一个nextIndex的字段表示下次应该发给Follower的日志在Leader刚刚晋升的时候Leader就会将这个值初始化为自己的最后一条日志的索引+1。
如果Follower日志和Leader日志有所冲突Leader会尝试减小nextIndex的值直至两者nextIndex所对应的日志相同此时LeaderAppend的记录就包含了从nextIndex开始的全部日志Follower收到之后就会把不同的部分覆写如果成功Leader也会修正nextIndex的值。
基于这样的约束,**整个覆写的过程一定是单向的只会发生在Follower节点上**Leader从来不会修改自己的日志。
## 安全性
但到目前为止Raft协议还有一个重要的特性没有得到保证就是“领导人完整性”也就是需要保证如果某个日志条目在某个任期号中已经被提交该记录必定出现在更大任期号的所有领导人中。
这是一个非常重要的特性不然会无法保证某个领导人在任期内提交的日志不会被后来者所覆盖。Raft对这个问题作出了另一个简单的限制相比于一些其他的一致性算法显得更加清晰。**限制Candidate提交选举请求的时候必须至少和Follower的日志一样新才可以获得选票**。
这就意味着如果Candidate获得了超过半数的选票说明至少有半数的Follower节点日志条目和自己一样新而所有commit了的记录也一定在半数的节点中出现了。
根据容斥原理半数同意的节点一定会和commit了某个日志的节点有所重叠新的Leader至少拥有了所有被commit日志一样新的日志。这样被commit的日志一定会被更大任期的领导所包含。
但是这里还有一个比较重要的约束Raft要求每个节点进行提交的时候只能提交自己任期的日志而不能提交之前任期的日志也就是说**需要通过提交自己任期日志的方式顺带提交之前任期的日志**。
为什么呢这里就需要考虑一种比较极端的情况在论文中的figure8讨论的就是这个问题我把图片贴在了文稿中。
假设一开始S1成为了任期为2的领导者并开始发送任期2所对应的日志随后在b时刻S1挂了此时S5拿到了S3、S4的选票成为了领导者任期为3进入c时刻。
![](https://static001.geekbang.org/resource/image/22/6e/22c8664e0cf5c7355bda00080318e66e.jpg?wh=2312x1379)
如果S5又宕机且S1又再次获得了选票成为了任期4的领导者。这个时候S1可以复制之前任期为2的日志至S3任期2的日志其实已经被大部分节点所持有了但是我们可以提交吗
* 如果允许提交。假设S1在d时刻中又崩溃了S5再次获得更高任期的选票并当选S5就可以像d那样覆写所有之前任期的日志就会出现已经提交的日志被覆写的情况。所以我们不能允许提交。
* 而另一种情况如果在崩溃之前S1就对多数节点当前任期的日志进行了复制并提交e时刻S5就不再有被选举上的可能性因为多数节点都拥有更新的日志。这个时候任期2的日志自然也被一起提交了。
**总的来说只要我们只允许领导提交任期内的日志且必须确保被大部分节点所复制Raft的数据安全性就是有保证的**,被提交的日志一定是不会被覆写的。
原论文中还提到了一些简单的优化比如日志压缩、采用Chubby和Zookeeper用的快照技术等减少因为日志增长越来越多空间被占用和对应的同步成本问题等等如果你感兴趣可以看看[原论文](https://raft.github.io/raft.pdf)。
## 总结
Raft协议除了各种协议细节今天学习的几个比较有价值的技巧对你工作也很有帮助。
我们为了避免票选被多个同时称为cadidate的节点平分进入无限循环可以在选举超时时间里引入随机性避免多个节点继续在同一时间发起票选请求。分布式系统多个节点存在时往往会采用奇数的节点这样就可以通过少数服从多数的机制在集群中保证同一时间只会有一个主节点了。
Raft将复杂问题拆解成多个明确清晰的子问题分而治之也是一种系统设计的哲学你可以好好体会。
Raft算法逻辑还是比较清晰的但是有很多细节和边界问题需要我们反复琢磨不自己动手实践一遍理解程度一定还是比较有限所以这里也推荐MIT 6.824供你练手学习Lab就是基于Raft和Golang语言实现一个简单的分布式KV存储18年我做过一次当时有几个case没有跑过就弃坑了但整体还是收获很大而且过程颇为有趣祝你也能研究顺利
### 课后作业
最后也留一个思考题给你。Raft在现实的工程实践中还有许许多多的优化不知道你听完了今天的讲解之后有什么觉得可以优化的想法吗
这里给你抛砖引玉一下Leader给Follower同步日志的时候nextIndex是一步步向下尝试的如果中间缺失了很多日志效率其实可能比较低你有什么办法吗
如果你还有想法也欢迎在留言区和我讨论。如果觉得有帮助的话,也请转发给你的朋友一起学习。我们下节课见。