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.

229 lines
23 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.

# 04 | Raft协议etcd如何实现高可用、数据强一致的
你好,我是唐聪。
在前面的etcd读写流程学习中我和你多次提到了etcd是基于Raft协议实现高可用、数据强一致性的。
那么etcd是如何基于Raft来实现高可用、数据强一致性的呢
这节课我们就以上一节中的hello写请求为案例深入分析etcd在遇到Leader节点crash等异常后Follower节点如何快速感知到异常并高效选举出新的Leader对外提供高可用服务的。
同时我将通过一个日志复制整体流程图为你介绍etcd如何保障各节点数据一致性并介绍Raft算法为了确保数据一致性、完整性对Leader选举和日志复制所增加的一系列安全规则。希望通过这节课让你了解etcd在节点故障、网络分区等异常场景下是如何基于Raft算法实现高可用、数据强一致的。
## 如何避免单点故障
在介绍Raft算法之前我们首先了解下它的诞生背景Raft解决了分布式系统什么痛点呢
首先我们回想下,早期我们使用的数据存储服务,它们往往是部署在单节点上的。但是单节点存在单点故障,一宕机就整个服务不可用,对业务影响非常大。
随后,为了解决单点问题,软件系统工程师引入了数据复制技术,实现多副本。通过数据复制方案,一方面我们可以提高服务可用性,避免单点故障。另一方面,多副本可以提升读吞吐量、甚至就近部署在业务所在的地理位置,降低访问延迟。
**多副本复制是如何实现的呢?**
多副本常用的技术方案主要有主从复制和去中心化复制。主从复制又分为全同步复制、异步复制、半同步复制比如MySQL/Redis单机主备版就基于主从复制实现的。
**全同步复制**是指主收到一个写请求后,必须等待全部从节点确认返回后,才能返回给客户端成功。因此如果一个从节点故障,整个系统就会不可用。这种方案为了保证多副本的一致性,而牺牲了可用性,一般使用不多。
**异步复制**是指主收到一个写请求后可及时返回给client异步将请求转发给各个副本若还未将请求转发到副本前就故障了则可能导致数据丢失但是可用性是最高的。
**半同步复制**介于全同步复制、异步复制之间,它是指主收到一个写请求后,至少有一个副本接收数据后,就可以返回给客户端成功,在数据一致性、可用性上实现了平衡和取舍。
跟主从复制相反的就是**去中心化复制**它是指在一个n副本节点集群中任意节点都可接受写请求但一个成功的写入需要w个节点确认读取也必须查询至少r个节点。
你可以根据实际业务场景对数据一致性的敏感度设置合适w/r参数。比如你希望每次写入后任意client都能读取到新值如果n是3个副本你可以将w和r设置为2这样当你读两个节点时候必有一个节点含有最近写入的新值这种读我们称之为法定票数读quorum read
AWS的Dynamo系统就是基于去中心化的复制算法实现的。它的优点是节点角色都是平等的降低运维复杂度可用性更高。但是缺陷是去中心化复制势必会导致各种写入冲突业务需要关注冲突处理。
从以上分析中,为了解决单点故障,从而引入了多副本。但基于复制算法实现的数据库,为了保证服务可用性,大多数提供的是最终一致性,总而言之,不管是主从复制还是异步复制,都存在一定的缺陷。
**如何解决以上复制算法的困境呢?**
答案就是共识算法,它最早是基于复制状态机背景下提出来的。 下图是复制状态机的结构引用自Raft paper 它由共识模块、日志模块、状态机组成。通过共识模块保证各个节点日志的一致性,然后各个节点基于同样的日志、顺序执行指令,最终各个复制状态机的结果实现一致。
![](https://static001.geekbang.org/resource/image/3y/eb/3yy3fbc1ab564e3af9ac9223db1435eb.png)
共识算法的祖师爷是Paxos 但是由于它过于复杂难于理解工程实践上也较难落地导致在工程界落地较慢。standford大学的Diego提出的Raft算法正是为了可理解性、易实现而诞生的它通过问题分解将复杂的共识问题拆分成三个子问题分别是
* Leader选举Leader故障后集群能快速选出新Leader
* 日志复制, 集群只有Leader能写入日志 Leader负责复制日志到Follower节点并强制Follower节点与自己保持相同
* 安全性一个任期内集群只能产生一个Leader、已提交的日志条目在发生Leader选举时一定会存在更高任期的新Leader日志中、各个节点的状态机应用的任意位置的日志条目内容应一样等。
下面我以实际场景为案例分别和你深入讨论这三个子问题看看Raft是如何解决这三个问题以及在etcd中的应用实现。
## Leader选举
当etcd server收到client发起的put hello写请求后KV模块会向Raft模块提交一个put提案我们知道只有集群Leader才能处理写提案如果此时集群中无Leader 整个请求就会超时。
那么Leader是怎么诞生的呢Leader crash之后其他节点如何竞选呢
首先在Raft协议中它定义了集群中的如下节点状态任何时刻每个节点肯定处于其中一个状态
* Follower跟随者 同步从Leader收到的日志etcd启动的时候默认为此状态
* Candidate竞选者可以发起Leader选举
* Leader集群领导者 唯一性拥有同步日志的特权需定时广播心跳给Follower节点以维持领导者身份。
![](https://static001.geekbang.org/resource/image/a5/09/a5a210eec289d8e4e363255906391009.png)
上图是节点状态变化关系图当Follower节点接收Leader节点心跳消息超时后它会转变成Candidate节点并可发起竞选Leader投票若获得集群多数节点的支持后它就可转变成Leader节点。
下面我以Leader crash场景为案例给你详细介绍一下etcd Leader选举原理。
假设集群总共3个节点A节点为LeaderB、C节点为Follower。
![](https://static001.geekbang.org/resource/image/a2/59/a20ba5b17de79d6ce8c78a712a364359.png)
如上Leader选举图左边部分所示 正常情况下Leader节点会按照心跳间隔时间定时广播心跳消息MsgHeartbeat消息给Follower节点以维持Leader身份。 Follower收到后回复心跳应答包消息MsgHeartbeatResp消息给Leader。
细心的你可能注意到上图中的Leader节点下方有一个任期号term 它具有什么样的作用呢?
这是因为Raft将时间划分成一个个任期任期用连续的整数表示每个任期从一次选举开始赢得选举的节点在该任期内充当Leader的职责随着时间的消逝集群可能会发生新的选举任期号也会单调递增。
通过任期号可以比较各个节点的数据新旧、识别过期的Leader等它在Raft算法中充当逻辑时钟发挥着重要作用。
了解完正常情况下Leader维持身份的原理后我们再看异常情况下也就Leader crash后etcd是如何自愈的呢
如上Leader选举图右边部分所示当Leader节点异常后Follower节点会接收Leader的心跳消息超时当超时时间大于竞选超时时间后它们会进入Candidate状态。
这里要提醒下你etcd默认心跳间隔时间heartbeat-interval是100ms 默认竞选超时时间election timeout是1000ms 你需要根据实际部署环境、业务场景适当调优否则就很可能会频繁发生Leader选举切换导致服务稳定性下降后面我们实践篇会再详细介绍。
进入Candidate状态的节点会立即发起选举流程自增任期号投票给自己并向其他节点发送竞选Leader投票消息MsgVote
C节点收到Follower B节点竞选Leader消息后这时候可能会出现如下两种情况
* 第一种情况是C节点判断B节点的数据至少和自己一样新、B节点任期号大于C当前任期号、并且C未投票给其他候选者就可投票给B。这时B节点获得了集群多数节点支持于是成为了新的Leader。
* 第二种情况是恰好C也心跳超时超过竞选时间了它也发起了选举并投票给了自己那么它将拒绝投票给B这时谁也无法获取集群多数派支持只能等待竞选超时开启新一轮选举。Raft为了优化选票被瓜分导致选举失败的问题引入了随机数每个节点等待发起选举的时间点不一致优雅的解决了潜在的竞选活锁同时易于理解。
Leader选出来后它什么时候又会变成Follower状态呢 从上面的状态转换关系图中你可以看到如果现有Leader发现了新的Leader任期号那么它就需要转换到Follower节点。A节点crash后再次启动成为Follower假设因为网络问题无法连通B、C节点这时候根据状态图我们知道它将不停自增任期号发起选举。等A节点网络异常恢复后那么现有Leader收到了新的任期号就会触发新一轮Leader选举影响服务的可用性。
然而A节点的数据是远远落后B、C的是无法获得集群Leader地位的发起的选举无效且对集群稳定性有伤害。
那如何避免以上场景中的无效的选举呢?
在etcd 3.4中etcd引入了一个PreVote参数默认false可以用来启用PreCandidate状态解决此问题如下图所示。Follower在转换成Candidate状态前先进入PreCandidate状态不自增任期号 发起预投票。若获得集群多数节点认可确定有概率成为Leader才能进入Candidate状态发起选举流程。
![](https://static001.geekbang.org/resource/image/16/06/169ae84055byya38b616d2e71cfb9706.png)
因A节点数据落后较多预投票请求无法获得多数节点认可因此它就不会进入Candidate状态导致集群重新选举。
这就是Raft Leader选举核心原理使用心跳机制维持Leader身份、触发Leader选举etcd基于它实现了高可用只要集群一半以上节点存活、可相互通信Leader宕机后就能快速选举出新的Leader继续对外提供服务。
## 日志复制
假设在上面的Leader选举流程中B成为了新的Leader它收到put提案后它是如何将日志同步给Follower节点的呢 什么时候它可以确定一个日志条目为已提交通知etcdserver模块应用日志条目指令到状态机呢
这就涉及到Raft日志复制原理为了帮助你理解日志复制的原理下面我给你画了一幅Leader收到put请求后向Follower节点复制日志的整体流程图简称流程图在图中我用序号给你标识了核心流程。
我将结合流程图、后面的Raft的日志图和你简要分析Leader B收到put hello为world的请求后是如何将此请求同步给其他Follower节点的。
![](https://static001.geekbang.org/resource/image/a5/83/a57a990cff7ca0254368d6351ae5b983.png)
首先Leader收到client的请求后etcdserver的KV模块会向Raft模块提交一个put hello为world提案消息流程图中的序号2流程 它的消息类型是MsgProp。
Leader的Raft模块获取到MsgProp提案消息后为此提案生成一个日志条目追加到未持久化、不稳定的Raft日志中随后会遍历集群Follower列表和进度信息为每个Follower生成追加MsgApp类型的RPC消息此消息中包含待复制给Follower的日志条目。
这里就出现两个疑问了。第一Leader是如何知道从哪个索引位置发送日志条目给Follower以及Follower已复制的日志最大索引是多少呢第二日志条目什么时候才会追加到稳定的Raft日志中呢Raft模块负责持久化吗
首先我来给你介绍下什么是Raft日志。下图是Raft日志复制过程中的日志细节图简称日志图1。
在日志图中,最上方的是日志条目序号/索引日志由有序号标识的一个个条目组成每个日志条目内容保存了Leader任期号和提案内容。最开始的时候A节点是Leader任期号为1A节点crash后B节点通过选举成为新的Leader 任期号为2。
日志图1描述的是hello日志条目未提交前的各节点Raft日志状态。
![](https://static001.geekbang.org/resource/image/3d/87/3dd2b6042e6e0cc86f96f24764b7f587.png)
我们现在就可以来回答第一个疑问了。Leader会维护两个核心字段来追踪各个Follower的进度信息一个字段是NextIndex 它表示Leader发送给Follower节点的下一个日志条目索引。一个字段是MatchIndex 它表示Follower节点已复制的最大日志条目的索引比如上面的日志图1中C节点的已复制最大日志条目索引为5A节点为4。
我们再看第二个疑问。etcd Raft模块设计实现上抽象了网络、存储、日志等模块它本身并不会进行网络、存储相关的操作上层应用需结合自己业务场景选择内置的模块或自定义实现网络、存储、日志等模块。
上层应用通过Raft模块的输出接口如Ready结构获取到待持久化的日志条目和待发送给Peer节点的消息后如上面的MsgApp日志消息需持久化日志条目到自定义的WAL模块通过自定义的网络模块将消息发送给Peer节点。
日志条目持久化到稳定存储中后这时候你就可以将日志条目追加到稳定的Raft日志中。即便这个日志是内存存储节点重启时也不会丢失任何日志条目因为WAL模块已持久化此日志条目可通过它重建Raft日志。
etcd Raft模块提供了一个内置的内存存储MemoryStorage模块实现etcd使用的就是它Raft日志条目保存在内存中。网络模块并未提供内置的实现etcd基于HTTP协议实现了peer节点间的网络通信并根据消息类型支持选择pipeline、stream等模式发送显著提高了网络吞吐量、降低了延时。
解答完以上两个疑问后我们继续分析etcd是如何与Raft模块交互获取待持久化的日志条目和发送给peer节点的消息。
正如刚刚讲到的Raft模块输入是Msg消息输出是一个Ready结构它包含待持久化的日志条目、发送给peer节点的消息、已提交的日志条目内容、线性查询结果等Raft输出核心信息。
etcdserver模块通过channel从Raft模块获取到Ready结构后流程图中的序号3流程因B节点是Leader它首先会通过基于HTTP协议的网络模块将追加日志条目消息MsgApp广播给Follower并同时将待持久化的日志条目持久化到WAL文件中流程图中的序号4流程最后将日志条目追加到稳定的Raft日志存储中流程图中的序号5流程
各个Follower收到追加日志条目MsgApp消息并通过安全检查后它会持久化消息到WAL日志中并将消息追加到Raft日志存储随后会向Leader回复一个应答追加日志条目MsgAppResp的消息告知Leader当前已复制的日志最大索引流程图中的序号6流程
Leader收到应答追加日志条目MsgAppResp消息后会将Follower回复的已复制日志最大索引更新到跟踪Follower进展的Match Index字段如下面的日志图2中的Follower C MatchIndex为6Follower A为5日志图2描述的是hello日志条目提交后的各节点Raft日志状态。
![](https://static001.geekbang.org/resource/image/eb/63/ebbf739a94f9300a85f21da7e55f1e63.png)
最后Leader根据Follower的MatchIndex信息计算出一个位置如果这个位置已经被一半以上节点持久化那么这个位置之前的日志条目都可以被标记为已提交。
在我们这个案例中日志图2里6号索引位置之前的日志条目已被多数节点复制那么他们状态都可被设置为已提交。Leader可通过在发送心跳消息MsgHeartbeat给Follower节点时告知它已经提交的日志索引位置。
最后各个节点的etcdserver模块可通过channel从Raft模块获取到已提交的日志条目流程图中的序号7流程应用日志条目内容到存储状态机流程图中的序号8流程返回结果给client。
通过以上流程Leader就完成了同步日志条目给Follower的任务一个日志条目被确定为已提交的前提是它需要被Leader同步到一半以上节点上。以上就是etcd Raft日志复制的核心原理。
## 安全性
介绍完Leader选举和日志复制后最后我们再来看看Raft是如何保证安全性的。
如果在上面的日志图2中Leader B在应用日志指令put hello为world到状态机并返回给client成功后突然crash了那么Follower A和C是否都有资格选举成为Leader呢
从日志图2中我们可以看到如果A成为了Leader那么就会导致数据丢失因为它并未含有刚刚client已经写入成功的put hello为world指令。
Raft算法如何确保面对这类问题时不丢数据和各节点数据一致性呢
这就是Raft的第三个子问题需要解决的。Raft通过给选举和日志复制增加一系列规则来实现Raft算法的安全性。
### 选举规则
当节点收到选举投票的时候,需检查候选者的最后一条日志中的任期号,若小于自己则拒绝投票。如果任期号相同,日志却比自己短,也拒绝为其投票。
比如在日志图2中Folllower A和C任期号相同但是Follower C的数据比Follower A要长那么在选举的时候Follower C将拒绝投票给A 因为它的数据不是最新的。
同时对于一个给定的任期号最多只会有一个leader被选举出来leader的诞生需获得集群一半以上的节点支持。每个节点在同一个任期内只能为一个节点投票节点需要将投票信息持久化防止异常重启后再投票给其他节点。
通过以上规则就可防止日志图2中的Follower A节点成为Leader。
### 日志复制规则
在日志图2中Leader B返回给client成功后若突然crash了此时可能还并未将6号日志条目已提交的消息通知到Follower A和C那么如何确保6号日志条目不被新Leader删除呢 同时在etcd集群运行过程中Leader节点若频繁发生crash后可能会导致Follower节点与Leader节点日志条目冲突如何保证各个节点的同Raft日志位置含有同样的日志条目
以上各类异常场景的安全性是通过Raft算法中的Leader完全特性和只附加原则、日志匹配等安全机制来保证的。
**Leader完全特性**是指如果某个日志条目在某个任期号中已经被提交那么这个条目必然出现在更大任期号的所有Leader中。
Leader只能追加日志条目不能删除已持久化的日志条目**只附加原则**因此Follower C成为新Leader后会将前任的6号日志条目复制到A节点。
为了保证各个节点日志一致性Raft算法在追加日志的时候引入了一致性检查。Leader在发送追加日志RPC消息时会把新的日志条目紧接着之前的条目的索引位置和任期号包含在里面。Follower节点会检查相同索引位置的任期号是否与Leader一致一致才能追加这就是**日志匹配特性**。它本质上是一种归纳法一开始日志空满足匹配特性随后每增加一个日志条目时都要求上一个日志条目信息与Leader一致那么最终整个日志集肯定是一致的。
通过以上的Leader选举限制、Leader完全特性、只附加原则、日志匹配等安全特性Raft就实现了一个可严格通过数学反证法、归纳法证明的高可用、一致性算法为etcd的安全性保驾护航。
## 小结
最后我们来小结下今天的内容。我从如何避免单点故障说起,给你介绍了分布式系统中实现多副本技术的一系列方案,从主从复制到去中心化复制、再到状态机、共识算法,让你了解了各个方案的优缺点,以及主流存储产品的选择。
Raft虽然诞生晚但它却是共识算法里面在工程界应用最广泛的。它将一个复杂问题拆分成三个子问题分别是Leader选举、日志复制和安全性。
Raft通过心跳机制、随机化等实现了Leader选举只要集群半数以上节点存活可相互通信etcd就可对外提供高可用服务。
Raft日志复制确保了etcd多节点间的数据一致性我通过一个etcd日志复制整体流程图为你详细介绍了etcd写请求从提交到Raft模块到被应用到状态机执行的各个流程剖析了日志复制的核心原理即一个日志条目只有被Leader同步到一半以上节点上此日志条目才能称之为成功复制、已提交。Raft的安全性通过对Leader选举和日志复制增加一系列规则保证了整个集群的一致性、完整性。
## 思考题
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
哪些场景会出现Follower日志与Leader冲突我们知道etcd WAL模块只能持续追加日志条目那冲突后Follower是如何删除无效的日志条目呢
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
## 03思考题答案
在上一节课中我给大家留了一个思考题expensive request是否影响写请求性能。要搞懂这个问题我们得回顾下etcd读写性能优化历史。
在etcd 3.0中线性读请求需要走一遍Raft协议持久化到WAL日志中因此读性能非常差写请求肯定也会被影响。
在etcd 3.1中引入了ReadIndex机制提升读性能读请求无需再持久化到WAL中。
在etcd 3.2中, 优化思路转移到了MVCC/boltdb模块boltdb的事务锁由粗粒度的互斥锁优化成读写锁实现“N reads or 1 write”的并行同时引入了buffer来提升吞吐量。问题就出在这个buffer读事务会加读锁写事务结束时要升级锁更新buffer但是expensive request导致读事务长时间持有锁最终导致写请求超时。
在etcd 3.4中实现了全并发读创建读事务的时候会全量拷贝buffer, 读写事务不再因为buffer阻塞大大缓解了expensive request对etcd性能的影响。尤其是Kubernetes List Pod等资源场景来说etcd稳定性显著提升。在后面的实践篇中我会和你再次深入讨论以上问题。