Raft算法是一种分布式一致性算法,由Diego Ongaro和John Ousterhout在2013年提出。它主要用于分布式系统中,保证系统中的数据在多个节点间保持一致性。
Raft算法被广泛应用于众多分布式系统中,尤其是在需要强一致性保证的场景中,例如:
得物的多个内部中间件也是使用Raft算法作为多分片一致性的保证。
长期以来,大部分开发者都是将Raft作为一个黑盒使用,只知道它能保证多分片的一致性,对其运行原理也停留在纸面。当面临Raft性能调优或者奇怪的Raft问题排障的时候则束手无策。
费曼说过:“What I cannot create, I do not understand。” 我们中国先贤也强调“知行合一,以致良知”。如果我们不能亲手编写一次Raft算法,对这个东西就不能算作理解。
二
在着手开始写之前,我们先介绍几个Raft算法中的核心概念。
简单来说就是根据leader发送来的日志执行更改自己的状态
如果说什么是分布式系统理论的基石,那一定“日志”。此处的日志与我们平时在应用中打印的给人阅读的信息不同,它是最简单的存储抽象,是按时间排序的append-only的、有序的记录序列。
“日志”揭示了系统当下正在发生的事实。
关于日志的更深入理解,可以参考博客The Log: What every software engineer should know about real-time data's unifying abstraction,非常全面深刻。
当进入分布式的领域,日志就需要“状态机”的辅助:
关于以相同的顺序获得相同的输入的一点应该会引起人们的注意——这就是日志的用武之地。这是一个非常直观的概念:如果你将两段确定性代码提供给相同的输入日志,它们将产生相同的输出。
所以Raft最重要的任务就是解决如何在分布式系统中使多个副本的日志数据达成一致的问题。
### 比喻:团队合作写书
想象一下,你和几个朋友一起合作写一本书。这本书的每一章都需要大家一起完成,而且每个人手里都有一份草稿(相当于分布式系统中的日志)。为了保证书的内容一致,你们需要遵循一些规则:
1. **确定一个主编(Leader)**:
- 你们团队中选出一个主编(Raft 中的 Leader),主编负责决定书的内容和章节的顺序。
- 其他人(Followers)会听从主编的安排,按照主编的指示来写自己的部分。
2. **写书的规则(日志复制)**:
- 主编每次写一段内容(相当于日志条目),都会把这段内容发给其他人,让大家在自己的草稿上记录下来。
- 只有当大多数人(比如超过一半的团队成员)都确认收到了这段内容,主编才会正式把这段内容加入书中(相当于提交日志)。
3. **保持一致(日志一致性)**:
- 如果某个人的草稿和主编的不一致(比如网络问题导致没收到最新内容),主编会帮助他同步到最新版本。
- 这样,所有人的草稿最终都会保持一致,书的内容也不会出错。
4. **主编出问题了怎么办(Leader 选举)**:
- 如果主编突然失联了(比如网络故障或节点崩溃),团队会重新选出一个新的主编。
- 新主编会检查大家的草稿,确保所有人都同步到最新内容,然后继续带领大家写书。
---
### 关键点对应:
- **书的内容**:系统的状态(State)。
- **草稿**:日志(Log),记录所有操作。
- **主编**:Raft 中的 Leader,负责管理日志复制。
- **团队成员**:Raft 中的 Followers,负责接收并同步日志。
- **大多数人确认**:Raft 中的“多数派”(Quorum),确保日志被安全提交。
- **重新选主编**:Raft 中的 Leader 选举机制,保证系统容错。
---
### 为什么需要日志?
如果你们没有草稿(日志),每个人可能会随意写书,导致内容混乱。而有了草稿,主编可以确保所有人按照相同的顺序写书,最终书的内容一致。
---
### 总结
Raft 就像是一个团队合作写书的规则,确保所有人按照相同的顺序写内容,即使有人掉队或主编换人,最终书的内容也能保持一致。日志就是草稿,记录了所有操作,而状态机就是最终的书,反映了系统的当前状态。
为了实现上述目标,Raft使用强领导模型,即要求集群中的一个副本充当Leader,其他副本充当Follower。
Leader负责根据客户端请求采取行动生成命令,将命令复制给Follower,并将响应返回给客户端。
多个副本根据选举算法推选合法的Leader。
这个方案解决了分布式系统中的以下问题:
这种模型有其优点和缺点:
本系列中介绍的Raft实现是用Elixir编写的。在笔者看来,Elixir具有三个优势,使其成为本系列和网络服务的有前途的实现语言:
这个项目的开发中,笔者借鉴了这个生产级别的开源Raft库——龙舟的实现细节,如果对生产级的Raft实现感兴趣,可以阅读龙舟的代码。
我们的实现大致代码结构如下:
/*
* 提示:该行代码过长,系统自动注释不进行高亮。一键复制会移除系统注释
* . ├── README.md ├── Taskfile.yaml ├── lib │ └── ex_raft │ ├── config.ex │ ├── core # 各个状态下的处理逻辑 │ │ ├── candidate.ex │ │ ├── common.ex │ │ ├── follower.ex │ │ ├── free.ex │ │ ├── leader.ex │ │ └── prevote.ex │ ├── debug.ex │ ├── exception.ex │ ├── guards.ex │ ├── log_store # 日志存储,本节暂时不用 │ │ ├── cub.ex │ │ └── inmem.ex │ ├── log_store.ex │ ├── message_handlers # 各个状态下的消息处理 │ │ ├── candidate.ex │ │ ├── follower.ex │ │ ├── leader.ex │ │ └── prevote.ex │ ├── mock # 日志复制状态机,本节暂时不用 │ │ └── statemachine.ex │ ├── models # 几个分片常用的数据结构 │ │ ├── replica.ex │ │ └── replica_state.ex │ ├── pb # 消息的数据结构 │ │ ├── ex_raft.pb.ex │ │ ├── ex_raft.proto │ │ └── gen.sh │ ├── remote # 节点间通信客户端 │ │ ├── client.ex │ │ └── erlang.ex │ ├── remote.ex │ ├── replica.ex # 分片进程 │ ├── serialize.ex │ ├── server.ex # 节点间以及和客户端通信的服务端 │ ├── statemachine.ex │ ├── typespecs.ex │ └── utils # 工具集 │ ├── buffer.ex │ └── uvaint.ex ├── mix.exs ├── mix.lock ├── test │ ├── ex_raft_test.exs │ ├── log_store.exs │ └── test_helper.exs └── tmp
*/
四
选主实现
这一节中,我们暂时不考虑日志复制的细节,专注实现选主逻辑。
从论文In Search of an Understandable Consensus Algorithm(《寻找一种易于理解的一致性算法(扩展版)》,以下称为“论文”)中,可以得知:“选主”这个过程本身也是个状态机(注意区分此处的状态机和日志复制状态机不是一回事):
在典型的稳态场景中,集群中的一台服务器是Leader,而所有其他服务器都是Follower。
虽然我们希望事情永远这样保持下去,但Raft的目标是容错,所以我们会讨论一些故障场景:例如其中一些服务器crash、网络分区等。
为了实现选主的逻辑,我们要引入一些概念:
任期(Term)
论文中的解释:任期(Term)是分布式系统中共识算法中的一个关键概念,尤其是在领导者选举(Leader Election)和共识达成过程中。任期是时间上的一个划分,用于组织和同步分布式系统中的事件,确保系统中的所有节点能够在一个明确的时间段内就某个领导者或一系列决策达成一致。
简单来说,任期标明了Leader的服役阶段。
任期这个概念有以下特性:
选举计时(Election Timer)
每个Follower都会在本地维持一个选举计时器,在选举计时器到期前,如果没有收到Leader的日志或者心跳,Follower就会认为Leader出了意外,那么就会开始发起选举。这里有几个常见问题:
Q:会不会有多个Follower同时成为候选人?
A:会的,为了避免这种情况出现,我们给选举计时器添加一些随机区间,这是Raft简单的原因之一。Raft使用这种随机化来降低多个Follower同时进行选举的机会。但是即使他们同时成为候选人,任何给定任期内也只有一个人会被选为领导人。在极少数情况下,如果选票分裂,没有候选人能获胜,将进行新的选举。
假设有一个 Raft 集群,包含 3 个节点:A、B、C。
如果没有随机化,假设所有节点的选举超时时间都是 200ms:
Q:如果Follower与集群断开连接(分区)怎么办?
A:这就是网络分区的隐蔽性,因为Follower无法区分谁被分区了。这种情况Follower会开始选举。但这种情况下Follower不会得到任何选票。这个节点可能会在候选状态中持续自旋(每隔一段时间重新启动一个新的选举),直到重新连接到集群。
假设有一个 5 节点的 Raft 集群,节点 1 是 Leader,节点 2、3、4、5 是 Follower。
他会一直选举,处于一种自旋状态,消耗资源,等待恢复
状态机框架
从上述介绍中可以看出,选主这个过程涉及多个状态的轮转,每个状态下对不同消息做不同的反应,所以非常自然我们选择状态机的方式实现。
选举计时
我们这里的计时方式也是借鉴了“龙舟”的风格,即维护一个单位时间很小(1s)的本地时钟,其他超时事件全部用该时钟的整数倍来计算,这种方式配置和编码起来较为简单。
我们在代码中需要判断localTick的次数超出了electionTimeout,即可将自身状态转变为Candidate开启选主。
添加描述rlang OTP有个自带的状态机实现框架:gen_statem,有了这个利器,可以写出很清晰的状态机逻辑:
选举计时
我们这里的计时方式也是借鉴了“龙舟”的风格,即维护一个单位时间很小(1s)的本地时钟,其他超时事件全部用该时钟的整数倍来计算,这种方式配置和编码起来较为简单。
我们在代码中需要判断localTick的次数超出了electionTimeout,即可将自身状态转变为Candidate开启选主。
拉票
开启投票前Candidate需要做这几件事:
完成这些后,就将携带新Term的消息发给所有其他的节点。
由于Raft中任期概念的设计,其他角色在接收到拉票消息时需要考虑以下几种情况:
这种情况较为简单,做Follower即可。就是更新本地的term与消息的一样,不重置,防止永远不能做leader
需要注意的是,在遇到较大Term而转换自身状态的情况下,不用重置选举计时器,原因在注释中写明了:
Not to reset the electionTick value to avoid the risk of having the local node not being to campaign at all. if the local node generates the tick much slower than other nodes (e.g. bad config, hardware clock issue, bad scheduling, overloaded etc), it may lose the chance to ever start a campaign unless we keep its electionTick value here.
简而言之就是防止某个节点一直处于Follower角色,失去选举的机会。
代码中cast_pipein这个函数是指在变更状态为Follower后,继续处理这个request_vote消息。
这种情况,直接丢掉消息就行,任期小的消息在Raft算法中是不用处理掉的。
这种情况就是在情况1转变本地Term的后续,当任期一致时,投不投票也有讲究。
Raft算法中规定,如下情况可以投赞成票:
计票
Candidate在散出request_vote的消息后,会开始等待其他节点的回执,这时候有3种情况:
这种情况说明集群中已经存在一个更高任期的节点,那也不需要继续选举了,直接默认该消息来源节点为Leader。这里和上文中处理更高Term的request_vote消息类似。
大部分节点同意了当前的选举要求,那该节点就成为这个任期的合法Leader
Raft算法不会给Candidate无限的计票窗口,如果一直没有收到足够的选票,就清零计票,重置Term+1,再次发起选举,重复上述的过程,直到有个合法的Leader诞生。
接着上面网络分区的场景讨论,相信许多Raft系统的维护人员会发现,如果某个离群的节点在重新回归后会立即成为Leader,就是这个原因,离群的节点在不停的自旋选举过程中将自己的Term抬高了很多,一但回到集群就会因为Term优势成为Leader。为了解决这个问题,Raft有个prevote的补丁,这里暂不展开讨论。
Leader的工作
当Candidate成功成为Leader后,就需要承担Leader的责任。在Raft算法中,Leader需要持续的向Follower发送心跳消息表明自己的存在
Follower收到心跳后,就可以重置自己的选举计时,起码在这个选举周期内,集群中有个Leader了,不用开启选举
Leader收到心跳回执后,需要标记下Follower的状态,这个状态在集群可用性处理和后续的日志复制中有大用。
日志复制
上面的概念介绍中我们已经阐述过,Raft本质就是多个副本的日志数据达成一致的解决方案。 我们已经实现了选主能力,但是尚未涉及到日志的部分。
接下来,我们将为选主集群添加日志复制能力。为了实现这个目标,我们需要一些新的数据结构和接口协议,同时也会对选主部分逻辑做增强的安全检查处理。
从客户端行为开始
Raft集群的客户端会和集群产生交互,而交互的具体内容则是一个个具体的指令(Command)。例如一个Raft架构的KVDB,客户端会向集群发送类似PUT foo bar的写请求或者是GET foo的读请求,这里的请求都可以算作指令的范畴。
Raft集群在接收到客户端的指令后,根据论文第5.3节,会做如下的动作:
1. 如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们存储了相同的指令。
2. 如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们之前的所有日志块也全部相同。
简单来说,如果2个节点中某个日志Entry的index和Term一样,那么从集群启动到日志记录的当下,集群中所有节点的状态是完全一致的。
这2个特点在论文中被定义为“日志匹配特性”,由这个特性保证了“集群状态可预测性”。
好的!我们可以用一个 故事书 的比喻来理解 Raft 算法中的 日志匹配特性 和 集群状态可预测性。
在Raft 算法中,Entry(日志条目)是日志的基本单位,每个 Entry 包含一个Command(命令),这个 Command 通常会被序列化为二进制格式(binary),
日志仓库
为了实现日志复制,每个节点都需要维护一个自己的日志仓库用于存储Raft的Entry,我们为日志仓库抽象了如下的API:
这里同样采取API和实现分离的设计方式,具体的实现是可拔插的。(按照Elixir的编程习惯,这里用Protocol的方式来定义更优雅)
在我们这个版本的实现中,我们采用了Cubdb作为日志仓库的实现基础。(Cubdb是一个以B+树文件存储为基础的嵌入式KVDB)。具体的实现方式可以参考代码。使用这个存储主要是为了简单考虑,如果考虑性能的话可以模仿龙舟的方式使用wal和memcache结合的方式,大大提升日志存储的吞吐性能。
日志复制分步实现
发起提案
这里提案(Propose)即客户端对Raft集群发起的指令(Raft中读指令要比写指令复杂的多,读指令涉及到一致性相关讨论,感兴趣的可以搜索Raft中read_index的内容)
接下来就是根据当前节点的不同角色来判断:
如果节点为Follower: 就把提案转发到Leader。
如果节点为Leader:进入提案处理流程,本实现中就是向本地节点发一条propose处理消息,这么做是为了复用Term异常处理等逻辑。
leader复制提案
Leader在接收到propose后做如下动作:
这一过程比较简单,不过需要注意的是Leader会维护每个Follower的复制水位,这个水位是通过日志复制和心跳共同维护的。
日志复制会从每个Follower的复制水位开始,直到Leader的最新日志或者最大复制体积。
发送的日志复制请求会携带Leader记录的复制水位index和水位对应的Term,这俩数据是非常重要的,Follower会根据这两个标定来检查日志是否安全。
Follower处理日志复制请求
这种情形说明Leader的复制水位已经落后太多,没必要做更多的动作,只需要回复Leader最新的commit水位即可。
这种情况说明Leader发来的日志可能与Follower的日志有部分重叠,这个时候要非常谨慎,需要仔细对比Leader的日志和本地日志是否匹配
这里的日志匹配的过程分为这么几步:
为何需要在日志复制时做这么严格的任期检查呢?这个问题先保留,后面再细致讨论。
这种情况就比较简单了,日志都跳号了,肯定不能接受,直接回绝即可。
Leader处理复制回执
Leader在接收了Follower的日志复制回执后会有如下情形:
这种为正常情况,Leader会更新Follower的水位,并且发起一次commit的尝试
commit的请求按照论文,必须选择至少半数复制成功的安全水位。
这里取安全水位的做法参考了龙舟的技巧,每次收到回执后将所有节点的match水位排序后取中位数即为半数同意的安全水位,非常巧妙。
值得注意的是,在commit之前我们还特地做了一次Term的检查工作,这里是Raft的安全性检查,后文中会详细讨论。
异常情况下,按照论文的描述,Leader不用做日志回滚,只需要调整复制的起点水位即可。复制的起点水位取本地记录的match水位和Follower返回的合理水位中的较小值(重复了没有问题,不连续才是大问题)。
如果这个过程中Follower的日志已经脏了,这个安全水位开始的日志可以覆盖纠正Follower的日志。
日志复制安全性讨论
在上节的分步实现中,我们多次做了日志的任期检查:
为什么Raft需要不厌其烦地做Term和index的检查呢?
在Raft论文中有如下论述:
在正常的操作中,Leader和Follower的日志保持一致性,所以日志复制RPC的一致性检查从来不会失败。
然而,Leader意外崩溃的情况会使得日志处于不一致的状态(老的Leader可能还没有完全复制所有的日志块)。这种不一致问题会在Leader和Follower的一系列崩溃下加剧。
下图展示了Follower的日志可能和新Leader不同。Follower可能会丢失一些在新的Leader中存在的日志块,也可能拥有一些新Leader没有的日志块,或者两者都发生。丢失或者多出日志块可能会持续多个任期。
当一个Leader成功当选时,Follower可能是任何情况(a-f)。每一个Box表示是一个Entry;里面的数字表示任期号。Follower可能会缺少一些Entries(a-b),可能会有一些未被提交的Entries(c-d),或者两种情况都存在(e-f)。
例如,场景f可能会这样发生,某节点在任期2的时候是Leader,已附加了一些Entries到自己的日志仓库中,但在提交之前就崩溃了;很快这个机器就被重启了,在任期3重新被选为Leader,并且又增加了一些Entries到自己的日志仓库中;在任期2和任期3的日志被提交之前,这个节点又宕机了,并且在接下来的几个任期里一直处于宕机状态。
在Raft算法中,Leader是通过强制Follower直接复制自己的日志来处理不一致问题的。这意味着在Follower中的冲突的日志块会被Leader的日志覆盖。
要使得Follower的日志进入和自身一致的状态,Leader必须找到最后两者达成一致的地方,然后删除Follower从那个点之后的所有日志块(水位重置),并发送自己在水位之后的日志给Follower。
所有的这些操作都在进行日志复制RPC的一致性检查时完成。Leader针对每一个Follower维护了一个nextIndex,这表示下一个需要发送给Follower的日志块的索引。当一个Leader刚刚被选出的时候,它初始化所有的nextIndex值为自己的最后一条日志的index加1(图中的11)。
如果一个Follower的日志和Leader不一致,那么在下一次的日志复制RPC时的一致性检查就会失败。在被Follower拒绝之后,Leader就会减小nextIndex值并进行重试。最终nextIndex会在某个位置使得Leader和Follower的日志达成一致。当这种情况发生,日志复制RPC就会成功,这时就会把Follower冲突的日志块全部删除并且加上Leader的日志。一旦附加日志RPC成功,那么Follower的日志就会和Leader保持一致,并且在接下来的任期里一直继续保持。
通过这种机制,Leader在获得权力的时候就不需要任何特殊的操作来恢复一致性。他只需要进行正常的操作,然后日志就能在日志复制RPC——日志复制回复处理这一过程中自动趋于一致。Leader从来不会覆盖或者删除自己的日志。
Rethink集群选主
在上面的篇幅中,我们讨论过集群选主的过程,一个节点只要拿到集群半数以上的request_vote的成功回执即可成为Leader,而且投票的原则非常简单,只要这个任期我没投过票或者我投过相同的节点即可赞成投票。
不过在添加日志复制后这里会出现一个小问题,例如在下图中:
如果f节点发起选举并得到了大多数成员认可,那么集群大部分节点就会面临日志回退,已经“安全”的日志都变得不再安全,这不符合Raft的设计初衷,所以我们需要在投票时添加一个机制防止类似f节点这类比较“旧”的节点当选为Leader。
论文中有如下论述:
Raft使用投票的方式来阻止一个Candidate赢得选举,除非这个Candidate包含了所有已经提交的日志块。Candidate为了赢得选举必须联系集群中的大部分节点,这意味着每一个已经提交的日志块在这些服务器节点中肯定存在于至少一个节点上。如果Candidate的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么他一定持有了所有已经提交的日志块。请求投票(RequestVote)RPC实现了这样的限制:
RPC中包含了Candidate的日志信息,然后投票人会拒绝掉那些日志没有自己新的投票请求。
简单来说就是:投票的时候如果拉票人的日志没我本地的新,那我就不能投票给你。
代码实现:
处理投票:其他节点处理request_vote消息时,需要检查拉票消息的日志是不是要比本地更新(日志的Term大于本地或者同个Term下的日志index大于本地)
Leader提交过往任期的日志讨论
在日志复制的第5步中,Leader获取了半数以上的安全水位执行提交,不过提交前判断了是否与当前任期一致,如果出现跨任期的情况,就会暂缓这次提交,为什么需要这一步呢?
论文有如下论述:
对Leader来说,只要当前任期的日志被存储到了大多数的服务器上,那么这个日志是可以被提交的。
如果Leader在提交日志块之前崩溃了,未来后续的Leader会继续尝试复制这条日志块。然而,一个Leader不能断定一个之前任期里的日志块被保存到大多数服务器上的时候就一定已经提交了。
下图8展示了一种情况,一条已经被存储到大多数节点上的老日志块,也依然有可能会被未来的Leader覆盖掉。
如图的时间序列展示了为什么Leader无法决定对老任期号的日志块进行提交。
(a) S1是Leader,部分(Follower)复制了index=2的日志块。
(b) S1崩溃了,然后S5在任期3里通过S3、S4和自己的选票赢得选举,然后从客户端接收了一条不一样的日志块放在了索引2处。
(c) S5又崩溃了;S1重新启动,选举成功,开始复制日志。这时来自任期2的那条日志已经被复制到了集群中的大多数机器上,但是还没有被提交。
(d) 如果S1又崩溃了,S5可以重新被选举成功(通过来自S2、S3和S4的选票),然后覆盖了他们在index=2处的日志。
(e) 如果在崩溃之前,S1把自己主导的新任期里产生的日志块复制到了大多数机器上,那么在后面任期里面这些新的日志块就会被提交(因为S5就不可能选举成功)。这样在同一时刻就同时保证了,前的所有老的日志块就会被提交。
简单来说就是:过往任期的“安全”水位不一定是安全的,完全可能在Leader切换后被覆盖,只有当前任期的“安全”水位是安全的,因为其他日志不一致的节点没法在该任期内当选Leader只能被覆盖。
用户状态机
前面的篇幅中我们已经介绍了Raft的2个核心部分:
但是现在的Raft集群还没法集成任何业务,因为缺了和业务息息相关的一环——用户状态机。
接下来,我们将完成Raft拼图的最后一块,将业务能力集成,并尝试用这个完全体的Raft制作一个分布式的KVDB。
什么是用户状态机?
在Raft一致性算法中,业务状态机(也称为应用状态机或用户状态机)指的是实际执行业务逻辑的组件。当Raft处理完日志条目(通常是客户端发送的命令或操作请求)并通过选举和日志复制机制达成一致后,这些日志条目会被提交给状态机进行处理。
业务状态机是确定性的,这意味着对于任何给定的输入,它总是产生相同的结果,无论在哪个节点上运行。这个特性保证了,只要日志条目的顺序相同,所有节点上的状态机经过相同的序列化输入后会达到相同的状态。因此,即使集群中发生故障,只要有一个存活的节点,就可以通过复制其状态来恢复其他节点,从而保持整个系统的状态一致。
在Raft中,业务状态机的职责包括:
需要注意的是,Raft算法本身不关心业务状态机的具体实现细节,它只负责保证日志条目的顺序一致性和可用性。这使得Raft能够适用于各种不同的分布式系统和应用场景,例如数据库、缓存、配置管理系统等。
定义状态机行为
参照上面的概念定义,我们可以定义用户状态机的行为:
我们的状态机约定有如下API:
Raft中是这么定义“快照”的,后文有更加详细的解释:
快照(Snapshot)是一种重要的机制,用于减少系统的状态大小并提高性能。在Raft中,快照的主要目的是记录系统的一个完整状态点,这样就不需要从大量的日志条目中重新计算整个状态。
应用状态机与Raft日志的交互
应用状态机提供的各个API是和Raft的日志复制紧密相关的,我们分开讨论每个API的使用时机。
执行Raft日志中的业务命令
保存快照
快照(Snapshot)是一个重要的优化机制,用于处理日志文件不断增长所带来的存储和性能问题。随着系统的运行,Raft需要记录越来越多的状态转换指令,这些指令以日志条目的形式存储,以便在需要时能够重放这些指令来重建系统状态。
然而,随着日志的增长,以下问题逐渐显现:
为了解决这些问题,Raft引入了快照机制。快照是对系统当前状态的一个完整拷贝,它包含了从系统启动到快照生成时刻的所有状态信息。一旦快照创建完成,它所代表的那一刻之前的所有日志条目就可以被安全地删除,因为快照包含了那些日志条目所能提供的所有信息。
以下是快照在Raft中的一些关键作用:
快照的生成时机通常是根据预设的策略,比如日志条目达到一定数量或存储使用量超过阈值。
在Raft中,快照的生成和传输过程需要谨慎设计,以确保数据一致性和系统的稳定性。
在我们这个玩具实现中,就不实现复杂的自动快照了,实现一个手动快照入口即可。
快照的构成包括2个部分:
Raft无法知道用户状态机想要持久化的数据,所以在保存快照时,携带snapshot_metadata调用用户状态机的快照API。这里我们设计了很简单的快照格式,用一个4字节的header保存snapshot_meta的长度,方便恢复的时候做长度切割。
我们的玩具实现中只是单纯的存在当前节点磁盘,生产中可以根据实际的业务需要将快照存储在三方存储(例如s3)中方便管理。
快照恢复
既然有快照保存,那就一定有快照恢复,快照恢复有以下几种情形:
第二种情形较为复杂,我们就先不讨论了,这里只实现第一种情况:
快照恢复的过程就是快照制作的反过程,我们从存储(磁盘)上读出快照数据,parse为业务快照和metadata,其中:
快照恢复
既然有快照保存,那就一定有快照恢复,快照恢复有以下几种情形:
第二种情形较为复杂,我们就先不讨论了,这里只实现第一种情况:
快照恢复的过程就是快照制作的反过程,我们从存储(磁盘)上读出快照数据,parse为业务快照和metadata,其中:
本文深入探讨了Raft日志复制算法的核心概念和实现细节。从日志复制状态机的基础,到Leader、Follower、Candidate的角色定义,我们逐步揭开了Raft算法的神秘面纱。我们选择了Elixir作为实现语言,展示了其在构建高效、可扩展的分布式系统中的潜力。
通过分步实现日志复制,我们了解了如何发起提案、Leader如何复制提案以及Follower如何处理日志复制请求。安全性讨论让我们认识到了在分布式系统中保证数据一致性的重要性。此外,我们还探讨了Raft算法在集群选主和用户状态机中的常见问题和陷阱,以及如何通过定义状态机行为来执行业务命令。
在实践层面,我们学习了如何编写一个分布式KVDB,并验证了其正确性。这包括了Raft集群的组建、读写操作的执行、以及快照的落盘与恢复。这些实际操作不仅加深了我们对Raft算法的理解,也为我们提供了宝贵的实践经验。
本文仅浅尝辄止地了解了一下Raft的核心知识,制作了一个玩具的Raft实现,而生产级别的Raft框架需要考虑的问题更多了。感兴趣的开发者可以在本文的基础上进阶思考一下这些问题:
随着分布式理论的发展,业界也对Raft做了很多的扩展和升级,例如:
更多奇思妙想可以参考ACM上的论文。
随着技术的不断进步,我们有理由相信Raft算法及其在分布式系统中的应用将会更加广泛。在未来,我们期待看到更多创新的实现和应用场景,以解决日益复杂的分布式计算问题。让我们拭目以待,共同见证分布式技术的未来发展。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。