项目背景与介绍


项目背景

在当今大规模分布式系统的背景下,需要可靠、高可用性的分布式数据存储系统。
传统的集中式数据库在面对大规模数据和高并发访问时可能面临单点故障和性能瓶颈的问题。
为了解决这些问题,本项目致力于构建一种基于Raft一致性算法的分布式键值存储数据库,以确保数据的一致性、可用性和分区容错性。

目的

学习了Raft算法之后手动实现,并基于此搭建了一个k-v存储的分布式数据库。

解决的问题

一致性: 通过Raft算法确保数据的强一致性,使得系统在正常和异常情况下都能够提供一致的数据视图。
可用性: 通过分布式节点的复制和自动故障转移,实现高可用性,即使在部分节点故障的情况下,系统依然能够提供服务。
分区容错: 处理网络分区的情况,确保系统在分区恢复后能够自动合并数据一致性。

技术栈

Raft一致性算法: 作为核心算法,确保数据的一致性和容错性。
存储引擎: 使用适当的存储引擎作为底层存储引擎,提供高效的键值对操作。目前选择的是跳表,但是可以替换为任意k-v数据库。

项目范围

项目的初始版本将实现基本的Raft协议和键值存储功能。
后续版本可能包括性能优化、安全性增强、监控和管理工具的开发等。

前置知识储备

语言基础,比如:mutex ,什么是序列化和反序列化
RPC相关,至少要知道什么是RPC
c11的部分新特性:auto 、RAII 等
分布式的基础概念:容错、复制等

收获

Raft共识算法的快速理解
基于共识算法怎么搭建一个分布式的k-v数据库
需要注意的是,分布式式的共识算法实现本身是一个比较严谨的过程,因为其本身的存在是为了多个服务器之间通过共识算法达成一致性的状态,从而避免单个节点不可用而导致整个集群不可用,因此在学习过程中必须要考虑不同情况下节点宕机、断网情况下的影响。

最佳使用指南

关注Raft算法本身:首先整个项目最重点也是最难点的地方就是Raft算法本身的理解与实现,其他的部分都是辅助,因此在学习的过程中也最好关注Raft算法本身的实现与Raft类对外暴露的一些接口。
多思考错误情况下的算法正确性:Raft算法本身并不难理解,代码也并不多,但是简单的代码如何保证在复杂情况下的容错呢?需要在完成代码后多思考在代码不同运行阶段如果发生宕机等错误时的正确性。

项目大纲

请添加图片描述
项目大概可以分为以下几个部分:

raft节点:raft算法实现的核心层,负责与其他机器的raft节点沟通,达到 分布式共识 的目的。
raftServer:负责raft节点与k-v数据库中间的协调服务;负责持久化k-v数据库的数据(可选)。
上层状态机(k-v数据库):负责数据存储。
持久层:负责相关数据的落盘,对于raft节点,根据共识算法要求,必须对一些关键数据进行落盘处理,以保证节点宕机后重启程序可以恢复关键数据;对于raftServer,可能会有一些k-v数据库的东西需要落盘持久化。
RPC通信:在 领导者选举、日志复制、数据查询、心跳等多个Raft重要过程中提供多节点快速简单的通信能力。

在多个机器启动后,各个机器之间通过网络通信,构建成一个集群,对这样的集群,其对外表现的就像一台单机的k-v数据库一样,且少数节点出现故障不会影响整个集群的工作。
因此有了Raft算法的集群k-v数据库相对于单机的k-v数据库:

优势:集群有了容错的能力,可以理解成Raft算法可以保证各个机器上的k-v数据库(也称状态机)以相同的顺序执行外部命令。
劣势:容错能力需要算法提供,因此程序会变得复杂;需要额外对数据进行备份;需要额外的网络通信开销。
也是因此,其实上层的k-v数据库可以替换成其他的组件,毕竟只是一个状态机而已。

目前设计的后续主要内容:

1.Raft算法的一些概念性内容,比如:Raft算法是什么?Raft算法怎么完成公式?完成Raft算法需要哪几个主要函数?需要哪几个主要的变量维护?
2.Raft算法的主要函数实现思路及代码,主要函数包括:AppendEntries 、 sendRequestVote 、 sendAppendEntries 、 RequestVote 等
3.其他部分组件,包括:RPC通信组件、k-v数据库、中间沟通数据库和raft节点的raftServer

项目难点

难点就是项目主要的几个功能模块的实现。
Raft算法的理解与实现
RPC通信框架的理解与实现
k-v数据库

简历写法

在简历中应该突出完成功能的主要模块和对其优化的思考,由于时间原因,我在完成这个项目之后没有太多的时间去优化,因此我采用的写法是将主要模块写出来的作用。
在文章书写的过程中,后续我可能会加入一些项目的优化。

基于Raf共识算法的分布式KV存储数据库
项目描述 :本项目是基于Raft共识算法的分布式K-V数据库,具备线性一致性和分区容错性,在少于半数节点发生故障仍可正常对外提供服务。使用个人实现的RPC通信框架MprRpc和跳表数据库SKipListPro完成RPC功能和K-V存储功能。
主要工作:

基于protobuf和自定义协议实现RPC通信框架MprRpc通信框架完成各节点之间的远程调用和数据传递功能:
基于跳表数据结构实现跳表数据库SkipListPro完成K-V存储功能;
实现Raft协议的心跳与选举机制,通过定时线程池触发心跳与选举任务,并维护集群的日志提交状态,
实现日志读写与提交,由领导节点处理客户端的读写请求,并将日志复制至跟随者节点,在超过半数节点复制成功后提交日志,应用命令至状态机并返回响应给客户端:
实现客户端协议,包括在客户端协议中加入由ip和请求序号组成的“请求id”以保证线性一致性,以及客户端重试等功能。

个人收获:

深入了解了分布式系统的相关知识
熟悉了Raft共识算法的原理和实现,并加强了对分布式系统中一致性、容错性等重要概念的理解。
学习了RPC和K-V数据相关原理和实现。

本项目常见问题

Raft

Raft算法的基本原理:解释Raft算法的基本工作原理,包括领导者选举、日志复制和安全性保障。
领导者选举:

如何进行Raft中的领导者选举?
在什么情况下会触发领导者选举?

日志复制:

Raft是如何通过日志复制来保证数据一致性的?
详细描述Raft中的日志复制过程。

安全性保障:Raft是如何确保安全性的?讨论一致性、可用性和分区容错性之间的权衡。
选举超时:什么是选举超时?它的作用是什么?选举超时的时间是如何设置的?
日志条目的提交:Raft中的日志条目是如何提交的?什么条件下才能够提交一个日志条目?
拓扑变更:Raft如何处理集群拓扑的变更?在节点动态加入或退出时,会发生什么?
实际应用:Raft算法在实际场景中的应用有哪些?是否了解一些使用Raft的实际系统案例?
Raft和paxos的比较:与Paxos算法相比,Raft有哪些优势和不同之处?
常见问题和挑战:Raft算法在分布式系统中有哪些常见的问题和挑战?如何处理网络分区的情况?
容错性:Raft算法如何处理节点故障?在集群中的多个节点同时故障时,系统会有什么表现?

RPC

RPC:你的RPC如何设计的?负载均衡有没有做?用的什么算法如何考虑的?服务治理和发现有没有做?怎么做的?你这个RPC框架的序列化和反序列化中protobuf细节有没有了解
测试:在集群数量变多的时候,Raft性能可能会下降,这方面有没有思考过?有没有对性能进行过测试?用的什么工具?怎么测试的?

Raft算法主要概念和主要流程

Raft算法和Paxos算法是两种经典的经典共识算法,相比于Paxos算法,Raft算法通过拆解共识过程,引入Leader election 等机制简化了公式过程,因此Raft算法已经是方便入门的共识算法了。

什么是共识?

共识是容错分布式系统中的一个基本问题。**共识涉及多个服务器对状态机状态(对本项目而言就是上层的k-v数据库)达成一致。**一旦他们对状态机状态做出决定,这个决定就是最终决定(已经被集群共识的值可以保证后面不会被覆盖,Raft的安全性)。
典型的一致性算法在其大部分服务器可用时保持运行; 例如,即使有2台服务器出现故障,5台服务器的集群也可以继续运行。如果更多的服务器出现故障,它们将停止对外提供服务(但永远不会返回不正确的结果)。即小于一半的节点出现故障不会对整个集群的运行造成影响,一半或一半以上的节点出现故障则整个集群停止对外提供服务。

共识算法要满足的性质

实际使用系统中的共识算法一般满足以下特性:
**在非拜占庭条件下保证共识的一致性。**非拜占庭条件就是可信的网络条件,即与你通信的节点的信息都是真实的,不存在欺骗。
在多数节点存活时,保持可用性。“多数”永远指的是配置文件中所有节点的多数,而不是存活节点的多数。多数等同于超过半数的节点,多数这个概念概念很重要,贯穿Raft算法的多个步骤。
**不依赖于绝对时间。**理解这点要明白共识算法是要应对节点出现故障的情况,在这样的环境中网络报文也很可能会受到干扰从而延迟,如果完全依靠于绝对时间,会带来问题,Raft用自定的Term(任期)作为逻辑时钟来代替绝对时间。
**在多数节点一致后就返回结果,而不会受到个别慢节点的影响。**这点与第二点联合理解,只要“大多数节点同意该操作”就代表整个集群同意该操作。对于raft来说,”操作“是储存到日志log中,一个操作就是log中的一个entry。

Raft中的一些重要概念

  1. 状态机:raft的上层应用,可以是k-v数据库(本项目)
  2. 日志、log、entry:日志log:raft保存的外部命令是以日志保存;entry:日志有很多,可以看成一个连续的数组,而其中的的一个称为entry
  3. 提交日志commit:raft保存日志后,经过复制同步,才能真正应用到上层状态机,这个“应用”的过程称为提交
  4. 节点身份:follower、Candidate、Leader :raft集群中不同节点的身份
  5. term:也称任期,是raft的逻辑时钟
  6. 选举:follower变成leader需要选举
  7. 领导人:就是leader
  8. 日志的term:在日志提交的时候,会记录这个日志在什么“时候”(哪一个term)记录的,用于后续日志的新旧比较
  9. 心跳、日志同步:leader向follower发送心跳(AppendEntryRPC)用于告诉follower自己的存在以及通过心跳来携带日志以同步
  10. 日志:Raft算法可以让多个节点的上层状态机保持一致的关键是让 各个节点的日志 保持一致,日志中保存客户端发送来的命令,上层的状态机根据日志执行命令,那么日志一致,自然上层的状态机就是一致的。所以raft的目的就是保证各个节点的日志是相同的。
  11. 节点身份:follower、Candidate、Leader :每个Raft节点也有自己的状态机,由下面三种状态构成:

这里的状态机与前面的状态机语义上有些“重载”,但不是一个东西,如果你无法区分,那么除了这个地方,其他地方的状态机都可以替换成 k-v数据库 以方便理解。
请添加图片描述Leader :集群内最多只会有一个 leader,负责发起心跳,响应客户端,创建日志,同步日志。
Candidate :leader 选举过程中的临时角色,由 follower 转化而来,发起投票参与竞选。
Follower :接受 leader 的心跳和日志同步数据,投票给 candidate。

Raft是一个强Leader 模型,可以粗暴理解成Leader负责统领follower,如果Leader出现故障,那么整个集群都会对外停止服务,直到选举出下一个Leader。如果follower出现故障(数量占少部分),整个集群依然可以运行。

12.Term|任期:
Raft将Term作为内部的逻辑时钟,使用Term的对比来比较日志、身份、心跳的新旧而不是用绝对时间。Term与Leader的身份绑定,即某个节点是Leader更严谨一点的说法是集群某个Term的Leader。Term用连续的数字进行表示。Term会在follower发起选举(成为Candidate从而试图成为Leader )的时候加1,对于一次选举可能存在两种结果:
1.胜利当选:胜利的条件是超过半数的节点认为当前Candidate有资格成为Leader,即超过半数的节点给当前Candidate投了选票。
2.失败:如果没有任何Candidate(一个Term的Leader只有一位,但是如果多个节点同时发起选举,那么某个Term 的Candidate可能有多位)获得超半数的选票,那么选举超时之后又会开始另一个Term(Term递增)的选举。

Raft是如何保证一个Term只有一个Leader的?

因为Candidate变成Leader的条件是获得超过半数选票,一个节点在一个Term内只有一个选票(投给了一个节点就不能再投递给另一个节点),因此不可能有两个节点同时获得超过半数的选票。
发生故障时,一个节点无法知道当前最新的Term是多少,在故障恢复后,节点就可以通过其他节点发送过来的心跳中的Term信息查明一些过期信息。

如果发现自己的Term大于其他节点的Term,那么就会忽略这个消息中携带的其他信息。
当发现自己的Term小于其他节点的Term时,这意味着“自己已经过期”,不同身份的节点的处理方式有所不同:

leader、Candidate:退回follower并更新term到较大的那个Term
follower:更新Term信息到较大的那个Term

这里解释一下为什么 自己的Term小于其他节点的Term时leader、Candidate会退回follower 而不是延续身份,因为通过Term信息知道自己过期,意味着自己可能发生了网络隔离等故障那么在此期间整个Raft集群可能已经有了新的leader、提交了新的日志,此时自己的日志是有缺失的,如果不退回follower,那么可能会导致整个集群的日志缺失,不符合安全性

安全性:Election Safety :每个 term 最多只会有一个 leader;集群同时最多只会有一个可以读写的 leader
每个Term最多只会有一个leader在前面已经解释过;集群同时最多只会有一个可以读写的 leade 是指集群中由于发生了网络分区,一个分区中的leader会维护分区的运行,而另一个分区会因为没有leader而发生选举产生新的leader,任何情况下,最多只有一个分区拥有绝大部分节点 ,那么只有一个分区能写入日志,这个在[共识算法要满足的性质]一节中已经介绍过。

  1. Leader Append-Only :leader 的日志是只增的。
  2. Log Matching :如果两个节点的日志中有两个 entry 有相同的 index 和 term,那么它们就是相同的 entry。

这是因为在Raft中,每个新的日志条目都必须按照顺序追加到日志中。
在运行过程中会根据这条性质来检查follower的日志与leader的日志是否匹配,如果不匹配的话leader会发送自己的日志去覆盖follower对应不匹配的日志。

  1. Leader Completeness :一旦一个操作被提交了,那么在之后的 term 中,该操作都会存在于日志中。
  2. State Machine Safety :状态机一致性,一旦一个节点应用了某个 index 的 entry 到状态机,那么其他所有节点应用的该 index 的操作都是一致的。

Raft中的一些重要过程

领导人选举相关

前面提到:Raft是一个强Leader 模型,可以粗暴理解成Leader负责统领follower,如果Leader出现故障,那么整个集群都会对外停止服务,直到选举出下一个Leader。
很自然的问题是:节点之间通过网络通信,其他节点(follower)如何知道leader出现故障?follower知道leader出现故障后如何选举出leader?符合什么条件的节点可以成为leader?

  1. 节点之间通过网络通信,其他节点(follower)如何知道leader出现故障?

leader会定时向集群中剩下的节点(follower)发送AppendEntry(作为心跳,hearbeat )以通知自己仍然存活。
可以推知,如果follower在一段时间内没有接收leader发送的AppendEntry,那么follower就会认为当前的leader 出现故障,从而发起选举。
这里 “follower在一段时间内没有接收leader发送的AppendEntry”,在实现上可以用一个定时器和一个标志位来实现,每到定时时间检查这期间有无AppendEntry 即可。

AppendEntry 具体来说有两种主要的作用和一个附带的作用

主要作用:1、心跳 2、携带日志entry及其辅助信息,以控制日志的同步和日志向状态机提交
附带作用:通告leader的index和term等关键信息以便follower对比确认follower自己或者leader是否过期

  1. follower知道leader出现故障后如何选举出leader?

参考【节点身份:follower、Candidate、Leader 】一节中的描述,follower认为leader故障后只能通过:term增加,变成candidate,向其他节点发起RequestVoteRPC申请其他follower的选票,过一段时间之后会发生如下情况:

赢得选举,马上成为leader (此时term已经增加了)
发现有符合要求的leader,自己马上变成follower 了,这个符合要求包括:leader的term≥自己的term
一轮选举结束,无人变成leader,那么循环这个过程,即:term增加,变成candidate,。。。。
赢得选举的条件前面也有过提及,即获得一半以上的选票。

思考:

如果在选举过程中没有“一半以上”选票的限制,会发生什么?会有什么问题?
raft节点的数量要求是奇数,为什么有这个要求?
如果发现一个leader,但是其term小于自己会发生什么?

为了防止在同一时间有太多的follower转变为candidate导致一直无法选出leader, Raft 采用了随机选举超时(randomized election timeouts)的机制, 每一个candidate 在发起选举后,都会随机化一个新的选举超时时间。

  1. 符合什么条件的节点可以成为leader?

这一点也成为“选举限制”,有限制的目的是为了保证选举出的 leader 一定包含了整个集群中目前已 committed 的所有日志当 candidate 发送 RequestVoteRPC 时,会带上最后一个 entry 的信息。 所有的节点收到该请求后,都会比对自己的日志,如果发现自己的日志更新一些,则会拒绝投票给该 candidate,即自己的日志必须要“不旧于”改candidate。

如何判断日志的老旧:

需要比较两个东西:最新日志entry的term和对应的index。index即日志entry在整个日志的索引。
if 两个节点最新日志entry的term不同 term大的日志更新 else 最新日志entry的index大的更新 end
这样的限制可以保证:成为leader的节点,其日志已经是多数节点中最完备的,即包含了整个集群的所有 committed entries。

日志同步、心跳:

在RPC中 日志同步 和 心跳 是放在一个RPC函数(AppendEntryRPC)中来实现的,原因为:
心跳RPC 可以看成是没有携带日志的特殊的 日志同步RPC。

对于一个follower,如果leader认为其日志已经和自己匹配了,那么在AppendEntryRPC中不用携带日志(再携带日志属于无效信息了,但其他信息依然要携带),反之如果follower的日志只有部分匹配,那么就需要在AppendEntryRPC中携带对应的日志。

很自然的问题是:感觉很复杂,为什么不直接让follower拷贝leader的日志?leader如何知道follower的日志是否与自己完全匹配?如果发现不匹配,那么如何知道哪部分日志是匹配的,哪部分日志是不匹配的呢?

  1. 为什么不直接让follower拷贝leader的日志|leader发送全部的日志给follower?

leader发送日志的目的是让follower同步自己的日志,当然可以让leader发送自己全部的日志给follower,然后follower接收后就覆盖自己原有的日志,但是这样就会携带大量的无效的日志(因为这些日志follower本身就有)。
因此 raft的方式是:先找到日志不匹配的那个点,然后只同步那个点之后的日志。

  1. leader如何知道follower的日志是否与自己完全匹配?

在AppendEntryRPC中携带上 entry的index和对应的term(日志的term),可以通过比较最后一个日志的index和term来得出某个follower日志是否匹配。

  1. 如果发现不匹配,那么如何知道哪部分日志是匹配的,哪部分日志是不匹配的呢?

leader每次发送AppendEntryRPC后,follower都会根据其entry的index和对应的term来判断某一个日志是否匹配。
在leader刚当选,会从最后一个日志开始判断是否匹配,如果匹配,那么后续发送AppendEntryRPC就不需要携带日志entry了。
如果不匹配,那么下一次就发送 倒数第2个 日志entry的index和其对应的term来判断匹配,
如果还不匹配,那么依旧重复这个过程,即发送 倒数第3个 日志entry的相关信息
重复这个过程,知道遇到一个匹配的日志。

raft日志的两个特点
raft对于日志可以保证其具有两个特点:

两个节点的日志中,有两个 entry 拥有相同的 index 和 term,那么它们一定记录了相同的内容/操作,即两个日志匹配
两个节点的日志中,有两个 entry 拥有相同的 index 和 term,那么它们前面的日志entry也相同

如何保证这两个特点:

保证第一点:仅有 leader 可以生成 entry
保证第二点:leader 在通过 AppendEntriesRPC 和 follower 通讯时,除了带上自己的term等信息外,还会带上entry的index和对应的term等信息,follower在接收到后通过对比就可以知道自己与leader的日志是否匹配,不匹配则拒绝请求。leader发现follower拒绝后就知道entry不匹配,那么下一次就会尝试匹配前一个entry,直到遇到一个entry匹配,并将不匹配的entry给删除(覆盖)。

注意:raft为了避免出现一致性问题,要求 leader 绝不会提交过去的 term 的 entry (即使该 entry 已经被复制到了多数节点上)。leader 永远只提交当前 term 的 entry, 过去的 entry 只会随着当前的 entry 被一并提交。

优化:寻找匹配加速(可选)

在寻找匹配日志的过程中,在最后一个日志不匹配的话就尝试倒数第二个,然后不匹配继续倒数第三个。。。
leader和follower 日志存在大量不匹配的时候这样会太慢,可以用一些方式一次性的多倒退几个日志,就算回退稍微多了几个也不会太影响,具体实现参考:

后续代码实现时也会使用类似的加速方法,到时候也会再简单的介绍一下。

总结

本节主要讲解了raft算法的主要概念和主要流程,如果流程中有什么问题没有讲清 欢迎大家提问。
下一节的主要内容应该是关于raft的主要流程的函数实现,目前考虑的方法是给出关键函数后对函数进行讲解。
此外,关于日志压缩(快照,snapshot)的部分与raft主要流程并不相关,这里考虑在后续再进行讲解。

raft算法主要流程函数实现

raft类的定义

**重点关注成员变量的作用,**成员函数很多都是辅助功能,重点的函数会在后面详细讲解的。

class Raft :
{
private:
    std::mutex m_mtx;
    std::vector<std::shared_ptr< RaftRpc >> m_peers; //需要与其他raft节点通信,这里保存与其他结点通信的rpc入口
    std::shared_ptr<Persister> m_persister;   //持久化层,负责raft数据的持久化
    int m_me;             //raft是以集群启动,这个用来标识自己的的编号
    int m_currentTerm;    //记录当前的term
    int m_votedFor;       //记录当前term给谁投票过
    std::vector<mprrpc:: LogEntry> m_logs;  日志条目数组,包含了状态机要执行的指令集,以及收到领导时的任期号
    // 这两个状态所有结点都在维护,易失
    int m_commitIndex;
    int m_lastApplied; // 已经汇报给状态机(上层应用)的log 的index

    // 这两个状态是由leader来维护,易失 ,这两个部分在内容补充的部分也会再讲解
    std::vector<int> m_nextIndex; // 这两个状态的下标1开始,因为通常commitIndex和lastApplied从0开始,应该是一个无效的index,因此下标从1开始
    std::vector<int> m_matchIndex;
    enum Status
    {
        Follower,
        Candidate,
        Leader
    };
    // 保存当前身份
    Status m_status;

    std::shared_ptr<LockQueue<ApplyMsg>> applyChan;     // client从这里取日志,client与raft通信的接口
    // ApplyMsgQueue chan ApplyMsg // raft内部使用的chan,applyChan是用于和服务层交互,最后好像没用上
	
    // 选举超时
    std::chrono::_V2::system_clock::time_point m_lastResetElectionTime;
    // 心跳超时,用于leader
    std::chrono::_V2::system_clock::time_point m_lastResetHearBeatTime;

    // 用于传入快照点
    // 储存了快照中的最后一个日志的Index和Term
    int m_lastSnapshotIncludeIndex;
    int m_lastSnapshotIncludeTerm;

public:
    
    void AppendEntries1(const mprrpc::AppendEntriesArgs *args, mprrpc::AppendEntriesReply *reply); //日志同步 + 心跳 rpc ,重点关注
    void applierTicker();     //定期向状态机写入日志,非重点函数
    
    bool CondInstallSnapshot(int lastIncludedTerm, int lastIncludedIndex, std::string snapshot);    //快照相关,非重点
    void doElection();    //发起选举
    void doHeartBeat();    //leader定时发起心跳
    // 每隔一段时间检查睡眠时间内有没有重置定时器,没有则说明超时了
// 如果有则设置合适睡眠时间:睡眠到重置时间+超时时间
    void electionTimeOutTicker();   //监控是否该发起选举了
    std::vector<ApplyMsg> getApplyLogs();
    int getNewCommandIndex();
    void getPrevLogInfo(int server, int *preIndex, int *preTerm);
    void GetState(int *term, bool *isLeader);  //看当前节点是否是leader
    void InstallSnapshot( const mprrpc::InstallSnapshotRequest *args, mprrpc::InstallSnapshotResponse *reply);  
    void leaderHearBeatTicker(); //检查是否需要发起心跳(leader)
    void leaderSendSnapShot(int server);  
    void leaderUpdateCommitIndex();  //leader更新commitIndex
    bool matchLog(int logIndex, int logTerm);  //对应Index的日志是否匹配,只需要Index和Term就可以知道是否匹配
    void persist();   //持久化
    void RequestVote(const mprrpc::RequestVoteArgs *args, mprrpc::RequestVoteReply *reply);    //变成candidate之后需要让其他结点给自己投票
    bool UpToDate(int index, int term);   //判断当前节点是否含有最新的日志
    int getLastLogIndex();
    void getLastLogIndexAndTerm(int *lastLogIndex, int *lastLogTerm);
    int getLogTermFromLogIndex(int logIndex);
    int GetRaftStateSize();
    int getSlicesIndexFromLogIndex(int logIndex);   //设计快照之后logIndex不能与在日志中的数组下标相等了,根据logIndex找到其在日志数组中的位置


    bool sendRequestVote(int server , std::shared_ptr<mprrpc::RequestVoteArgs> args ,  std::shared_ptr<mprrpc::RequestVoteReply> reply,   std::shared_ptr<int> votedNum) ; // 请求其他结点的投票
    bool sendAppendEntries(int server ,std::shared_ptr<mprrpc::AppendEntriesArgs> args , std::shared_ptr<mprrpc::AppendEntriesReply> reply , std::shared_ptr<int> appendNums ) ;  //Leader发送心跳后,对心跳的回复进行对应的处理


    //rf.applyChan <- msg //不拿锁执行  可以单独创建一个线程执行,但是为了同意使用std:thread ,避免使用pthread_create,因此专门写一个函数来执行
    void pushMsgToKvServer(ApplyMsg msg);  //给上层的kvserver层发送消息
    void readPersist(std::string data);    
    std::string persistData();
    void Start(Op command,int* newLogIndex,int* newLogTerm,bool* isLeader ) ;   // 发布发来一个新日志
// 即kv-server主动发起,请求raft(持久层)保存snapshot里面的数据,index是用来表示snapshot快照执行到了哪条命令
    void Snapshot(int index , std::string snapshot );

public:
    void init(std::vector<std::shared_ptr< RaftRpc >> peers,int me,std::shared_ptr<Persister> persister,std::shared_ptr<LockQueue<ApplyMsg>> applyCh);		//初始化

代码梳理(重点关注):

Raft的主要流程:领导选举(sendRequestVote RequestVote ) 日志同步、心跳(sendAppendEntries AppendEntries )
定时器的维护:主要包括raft向状态机定时写入(applierTicker )、心跳维护定时器(leaderHearBeatTicker )、选举超时定时器(electionTimeOutTicker )。
持久化相关:包括哪些内容需要持久化,什么时候需要持久化(persist)
这样看起来关键的就是只有几个函数了。

启动初始化

void Raft::init(std::vector<std::shared_ptr<RaftRpc>> peers, int me, std::shared_ptr<Persister> persister, std::shared_ptr<LockQueue<ApplyMsg>> applyCh) {
    m_peers = peers;     //与其他结点沟通的rpc类
    m_persister = persister;   //持久化类
    m_me = me;    //标记自己,毕竟不能给自己发送rpc吧

    m_mtx.lock();

    //applier
    this->applyChan = applyCh;   //与kv-server沟通
//    rf.ApplyMsgQueue = make(chan ApplyMsg)
    m_currentTerm = 0;   //初始化term为0
    m_status = Follower;   //初始化身份为follower
    m_commitIndex = 0;  
    m_lastApplied = 0;
    m_logs.clear();
    for (int i =0;i<m_peers.size();i++){
        m_matchIndex.push_back(0);
        m_nextIndex.push_back(0);
    }
    m_votedFor = -1;    //当前term没有给其他人投过票就用-1表示

    m_lastSnapshotIncludeIndex = 0;
    m_lastSnapshotIncludeTerm = 0;
    m_lastResetElectionTime = now();
    m_lastResetHearBeatTime = now();

    // initialize from state persisted before a crash
    readPersist(m_persister->ReadRaftState());
    if(m_lastSnapshotIncludeIndex > 0){
        m_lastApplied = m_lastSnapshotIncludeIndex;
        //rf.commitIndex = rf.lastSnapshotIncludeIndex 崩溃恢复不能读取commitIndex
    }

    m_mtx.unlock();
    // start ticker  开始三个定时器
    std::thread t(&Raft::leaderHearBeatTicker, this);
    t.detach();

    std::thread t2(&Raft::electionTimeOutTicker, this);
    t2.detach();

    std::thread t3(&Raft::applierTicker, this);
    t3.detach();
}

从上面可以看到一共产生了三个定时器,分别维护:选举、日志同步和心跳、raft节点与kv-server的联系。相互之间是比较隔离的。

选举leader(初始化中第一个定时器)

主要涉及函数及其流程:
请添加图片描述

electionTimeOutTicker:负责查看是否该发起选举,如果该发起选举就执行doElection发起选举。
doElection:实际发起选举,构造需要发送的rpc,并多线程调用sendRequestVote处理rpc及其相应。
sendRequestVote:负责发送选举中的RPC,在发送完rpc后还需要负责接收并处理对端发送回来的响应。
RequestVote:接收别人发来的选举请求,主要检验是否要给对方投票。

electionTimeOutTicker:
选举超时由于electionTimeOutTicker 维护。

void Raft::electionTimeOutTicker() {
    // Check if a Leader election should be started.
    while (true) {
        m_mtx.lock();
        auto nowTime = now(); //睡眠前记录时间
        auto suitableSleepTime = getRandomizedElectionTimeout() + m_lastResetElectionTime - nowTime;
        m_mtx.unlock();
        if (suitableSleepTime.count() > 1) {
            std::this_thread::sleep_for(suitableSleepTime);
        }

        if ((m_lastResetElectionTime - nowTime).count() > 0) {  //说明睡眠的这段时间有重置定时器,那么就没有超时,再次睡眠
            continue;
        }
        doElection();
    }
}

在死循环中,首先计算距离下一次超时应该睡眠的时间suitableSleepTime,然后睡眠这段时间,醒来后查看睡眠的这段时间选举超时定时器是否被触发,如果没有触发就发起选举。
选举超时定时器是否被触发:选举定时器的触发条件:收到leader发来的appendEntryRPC 、给其他的节点选举投票
在死循环中,首先计算距离上次重置选举计时器的时间加上随机化的选举超时时间,然后线程根据这个时间决定是否睡眠。若超时时间未到,线程进入睡眠状态,若在此期间选举计时器被重置,则继续循环。若超时时间已到,调用doElection() 函数启动领导者选举过程。
doElection :

void Raft::doElection() {
    lock_guard<mutex> g(m_mtx); //c11新特性,使用raii避免死锁


    if (m_status != Leader) {
        DPrintf("[       ticker-func-rf(%d)              ]  选举定时器到期且不是leader,开始选举 \n", m_me);
        //当选举的时候定时器超时就必须重新选举,不然没有选票就会一直卡住
        //重竞选超时,term也会增加的
        m_status = Candidate;
        ///开始新一轮的选举
        m_currentTerm += 1;  //无论是刚开始竞选,还是超时重新竞选,term都要增加
        m_votedFor = m_me; //即是自己给自己投票,也避免candidate给同辈的candidate投
        persist();   
        std::shared_ptr<int> votedNum = std::make_shared<int>(1); // 使用 make_shared 函数初始化 !! 亮点
        //	重新设置定时器
        m_lastResetElectionTime = now();
        //	发布RequestVote RPC
        for (int i = 0; i < m_peers.size(); i++) {
            if (i == m_me) {
                continue;
            }
            int lastLogIndex = -1, lastLogTerm = -1;
            getLastLogIndexAndTerm(&lastLogIndex, &lastLogTerm);//获取最后一个log的term和下标,以添加到RPC的发送

            //初始化发送参数
            std::shared_ptr<mprrpc::RequestVoteArgs> requestVoteArgs = std::make_shared<mprrpc::RequestVoteArgs>();
            requestVoteArgs->set_term(m_currentTerm);
            requestVoteArgs->set_candidateid(m_me);
            requestVoteArgs->set_lastlogindex(lastLogIndex);
            requestVoteArgs->set_lastlogterm(lastLogTerm);
            std::shared_ptr<mprrpc::RequestVoteReply> requestVoteReply = std::make_shared<mprrpc::RequestVoteReply>();

            //使用匿名函数执行避免其拿到锁

            std::thread t(&Raft::sendRequestVote, this, i, requestVoteArgs, requestVoteReply,
                          votedNum); // 创建新线程并执行函数,并传递参数
            t.detach();
        }
    }
}

sendRequestVote:

bool Raft::sendRequestVote(int server, std::shared_ptr<mprrpc::RequestVoteArgs> args, std::shared_ptr<mprrpc::RequestVoteReply> reply,
                           std::shared_ptr<int> votedNum) {


    bool ok = m_peers[server]->RequestVote(args.get(),reply.get());

    if (!ok) {
        return ok;//rpc通信失败就立即返回,避免资源消耗
    }

    lock_guard<mutex> lg(m_mtx);
    if(reply->term() > m_currentTerm){
        //回复的term比自己大,说明自己落后了,那么就更新自己的状态并且退出
        m_status = Follower; //三变:身份,term,和投票
        m_currentTerm = reply->term();
        m_votedFor = -1;  //term更新了,那么这个term自己肯定没投过票,为-1
        persist(); //持久化
        return true;
    } else if ( reply->term()   < m_currentTerm   ) {
        //回复的term比自己的term小,不应该出现这种情况
        return true;
    }

    if(!reply->votegranted()){  //这个节点因为某些原因没给自己投票,没啥好说的,结束本函数
        return true;
    }
  //给自己投票了
    *votedNum = *votedNum + 1; //voteNum多一个
    if (*votedNum >=  m_peers.size()/2+1) {
        //变成leader
        *votedNum = 0;   //重置voteDNum,如果不重置,那么就会变成leader很多次,是没有必要的,甚至是错误的!!!

        //	第一次变成leader,初始化状态和nextIndex、matchIndex
        m_status = Leader;
        int lastLogIndex =   getLastLogIndex();
        for (int i = 0; i <m_nextIndex.size()  ; i++) {
            m_nextIndex[i] = lastLogIndex + 1 ;//有效下标从1开始,因此要+1
            m_matchIndex[i] = 0;               //每换一个领导都是从0开始,见论文的fig2
        }
        std::thread t(&Raft::doHeartBeat, this); //马上向其他节点宣告自己就是leader
        t.detach();

        persist();  
    }
    return true;
}

只有leader才需要维护m_nextIndex和m_matchIndex 。
RequestVote:

void Raft::RequestVote( const mprrpc::RequestVoteArgs *args, mprrpc::RequestVoteReply *reply) {
    lock_guard<mutex> lg(m_mtx);

    Defer ec1([this]() -> void { //应该先持久化,再撤销lock,因此这个写在lock后面
        this->persist();
    });
    //对args的term的三种情况分别进行处理,大于小于等于自己的term都是不同的处理
    //reason: 出现网络分区,该竞选者已经OutOfDate(过时)
    if (args->term() < m_currentTerm) {
        reply->set_term(m_currentTerm);
        reply->set_votestate(Expire);
        reply->set_votegranted(false);
        return;
    }
    //论文fig2:右下角,如果任何时候rpc请求或者响应的term大于自己的term,更新term,并变成follower
    if (args->term() > m_currentTerm) {

        m_status = Follower;
        m_currentTerm = args->term();
        m_votedFor = -1;

        //	重置定时器:收到leader的ae,开始选举,透出票
        //这时候更新了term之后,votedFor也要置为-1
    }

    //	现在节点任期都是相同的(任期小的也已经更新到新的args的term了)
    //	要检查log的term和index是不是匹配的了
    int lastLogTerm = getLastLogIndex();
    //只有没投票,且candidate的日志的新的程度 ≥ 接受者的日志新的程度 才会授票
    if (!UpToDate(args->lastlogindex(), args->lastlogterm())) {

        //日志太旧了
        reply->set_term(m_currentTerm);
        reply->set_votestate(Voted);
        reply->set_votegranted(false);
        return;
    }
   
//    当因为网络质量不好导致的请求丢失重发就有可能!!!!
//    因此需要避免重复投票
    if (m_votedFor != -1 && m_votedFor != args->candidateid()) {
        reply->set_term(m_currentTerm);
        reply->set_votestate(Voted);
        reply->set_votegranted(false);
        return;
    } else {
        //同意投票
        m_votedFor = args->candidateid();
        m_lastResetElectionTime = now();//认为必须要在投出票的时候才重置定时器,
        reply->set_term(m_currentTerm);
        reply->set_votestate(Normal);
        reply->set_votegranted(true);
        return;
    }
}

日志同步和心跳(初始化中第二个定时器)

请添加图片描述

可以从流程图看到,函数实现上我尽量将心跳日志复制的流程统一,方便理解和后期统一修改
理解AppendEntry 相关内容,snapshot的逻辑是类似的。

leaderHearBeatTicker:负责查看是否该发送心跳了,如果该发起就执行doHeartBeat。
doHeartBeat:实际发送心跳,判断到底是构造需要发送的rpc,并多线程调用sendRequestVote处理rpc及其相应。
sendAppendEntries:负责发送日志的RPC,在发送完rpc后还需要负责接收并处理对端发送回来的响应。
leaderSendSnapShot:负责发送快照的RPC,在发送完rpc后还需要负责接收并处理对端发送回来的响应。
AppendEntries:接收leader发来的日志请求,主要检验用于检查当前日志是否匹配并同步leader的日志到本机。
InstallSnapshot:接收leader发来的快照请求,同步快照到本机。

leaderHearBeatTicker:

void Raft::leaderHearBeatTicker() {
    while (true) {

        auto nowTime = now();
        m_mtx.lock();
        auto suitableSleepTime = std::chrono::milliseconds(HeartBeatTimeout) + m_lastResetHearBeatTime - nowTime;
        m_mtx.unlock();
        if (suitableSleepTime.count() < 1) {
            suitableSleepTime = std::chrono::milliseconds(1);
        }
        std::this_thread::sleep_for(suitableSleepTime);
        if ((m_lastResetHearBeatTime - nowTime).count() > 0) { //说明睡眠的这段时间有重置定时器,那么就没有超时,再次睡眠
            continue;
        }
        doHeartBeat();
    }
}

其基本逻辑和选举定时器electionTimeOutTicker一模一样,不一样之处在于设置的休眠时间不同,这里是根据HeartBeatTimeout来设置,而electionTimeOutTicker中是根据getRandomizedElectionTimeout() 设置。
doHeartBeat:
这里目前逻辑写的不统一,发送快照leaderSendSnapShot和发送日志sendAppendEntries的rpc值的构造没有统一,且写在一坨。
可以抽离出来。目前先将就,关注主要逻辑。

void Raft::doHeartBeat() {
    std::lock_guard<mutex> g(m_mtx);
    if (m_status == Leader) {
        auto appendNums = std::make_shared<int>(1); //正确返回的节点的数量
        //对Follower(除了自己外的所有节点发送AE)
        for (int i = 0; i < m_peers.size(); i++) {
            if(i == m_me){ //不对自己发送AE
                continue;
            }
            //日志压缩加入后要判断是发送快照还是发送AE
            if (m_nextIndex[i] <= m_lastSnapshotIncludeIndex) {
				//改发送的日志已经被做成快照,必须发送快照了
                std::thread t(&Raft::leaderSendSnapShot, this, i); 
                t.detach();
                continue;
            }
            //发送心跳,构造发送值
            int preLogIndex = -1;
            int PrevLogTerm = -1;
            getPrevLogInfo(i, &preLogIndex, &PrevLogTerm);  //获取本次发送的一系列日志的上一条日志的信息,以判断是否匹配
            std::shared_ptr<mprrpc::AppendEntriesArgs> appendEntriesArgs = std::make_shared<mprrpc::AppendEntriesArgs>();
            appendEntriesArgs->set_term(m_currentTerm);
            appendEntriesArgs->set_leaderid(m_me);
            appendEntriesArgs->set_prevlogindex(preLogIndex);
            appendEntriesArgs->set_prevlogterm(PrevLogTerm);
            appendEntriesArgs->clear_entries();
            appendEntriesArgs->set_leadercommit(m_commitIndex);
            // 作用是携带上prelogIndex的下一条日志及其之后的所有日志
            //leader对每个节点发送的日志长短不一,但是都保证从prevIndex发送直到最后
            if (preLogIndex != m_lastSnapshotIncludeIndex) {
                for (int j = getSlicesIndexFromLogIndex(preLogIndex) + 1; j < m_logs.size(); ++j) {
                    mprrpc::LogEntry *sendEntryPtr = appendEntriesArgs->add_entries();
                    *sendEntryPtr = m_logs[j];  
                }
            } else {
                for (const auto& item: m_logs) {
                    mprrpc::LogEntry *sendEntryPtr = appendEntriesArgs->add_entries();
                    *sendEntryPtr = item;  
                }
            }
            int lastLogIndex = getLastLogIndex();
            //初始化返回值
            const std::shared_ptr<mprrpc::AppendEntriesReply> appendEntriesReply = std::make_shared<mprrpc::AppendEntriesReply>();

            std::thread t(&Raft::sendAppendEntries, this, i, appendEntriesArgs, appendEntriesReply,
                          appendNums); // 创建新线程并执行b函数,并传递参数
            t.detach();
        }
        m_lastResetHearBeatTime = now(); //leader发送心跳,重置心跳时间,
    }
}


与选举不同的是m_lastResetHearBeatTime 是一个固定的时间,而选举超时时间是一定范围内的随机值。
这个的具体原因是为了避免很多节点一起发起选举而导致一直选不出leader 的情况。
为何选择随机时间而不选择其他的解决冲突的方法具体可见raft论文。

sendAppendEntries

bool
Raft::sendAppendEntries(int server, std::shared_ptr<mprrpc::AppendEntriesArgs> args, std::shared_ptr<mprrpc::AppendEntriesReply> reply,
                        std::shared_ptr<int> appendNums) {

    // todo: paper中5.3节第一段末尾提到,如果append失败应该不断的retries ,直到这个log成功的被store

    bool ok = m_peers[server]->AppendEntries(args.get(), reply.get());

    if (!ok) {
        return ok;
    }

    lock_guard<mutex> lg1(m_mtx);

    //对reply进行处理
    // 对于rpc通信,无论什么时候都要检查term
    if(reply->term() > m_currentTerm){
        m_status = Follower;
        m_currentTerm = reply->term();
        m_votedFor = -1;
        return ok;
    } else if (reply->term() < m_currentTerm) {//正常不会发生
        return ok;
    }

    if (m_status != Leader) { //如果不是leader,那么就不要对返回的情况进行处理了
        return ok;
    }
    //term相等

    if (!reply->success()){
        //日志不匹配,正常来说就是index要往前-1,既然能到这里,第一个日志(idnex = 1)发送后肯定是匹配的,因此不用考虑变成负数
        //因为真正的环境不会知道是服务器宕机还是发生网络分区了
        if (reply->updatenextindex()  != -100) {  //-100只是一个特殊标记而已,没有太具体的含义
            // 优化日志匹配,让follower决定到底应该下一次从哪一个开始尝试发送
            m_nextIndex[server] = reply->updatenextindex();  
        }
        //	如果感觉rf.nextIndex数组是冗余的,看下论文fig2,其实不是冗余的
    } else {
        *appendNums = *appendNums +1;   //到这里代表同意接收了本次心跳或者日志
        
        m_matchIndex[server] = std::max(m_matchIndex[server],args->prevlogindex()+args->entries_size()   );  //同意了日志,就更新对应的m_matchIndex和m_nextIndex
        m_nextIndex[server] = m_matchIndex[server]+1;
        int lastLogIndex = getLastLogIndex();

        if (*appendNums >= 1 + m_peers.size()/2) { //可以commit了
            //两种方法保证幂等性,1.赋值为0 	2.上面≥改为==

            *appendNums = 0;  //置0

            //日志的安全性保证!!!!! leader只有在当前term有日志提交的时候才更新commitIndex,因为raft无法保证之前term的Index是否提交
            //只有当前term有日志提交,之前term的log才可以被提交,只有这样才能保证“领导人完备性{当选领导人的节点拥有之前被提交的所有log,当然也可能有一些没有被提交的}”
            //说白了就是只有当前term有日志提交才会提交
            if(args->entries_size() >0 && args->entries(args->entries_size()-1).logterm() == m_currentTerm){
            
                m_commitIndex = std::max(m_commitIndex,args->prevlogindex() + args->entries_size());
            }

        }
    }
    return ok;
}

m_nextIndex[server] = reply->updatenextindex(); 中涉及日志寻找匹配加速的优化
AppendEntries:

void Raft::AppendEntries1(const mprrpc:: AppendEntriesArgs *args,  mprrpc::AppendEntriesReply *reply) {
    std::lock_guard<std::mutex> locker(m_mtx);

//	不同的人收到AppendEntries的反应是不同的,要注意无论什么时候收到rpc请求和响应都要检查term


    if (args->term() < m_currentTerm) {
        reply->set_success(false);
        reply->set_term(m_currentTerm);
        reply->set_updatenextindex(-100); // 论文中:让领导人可以及时更新自己
         DPrintf("[func-AppendEntries-rf{%d}] 拒绝了 因为Leader{%d}的term{%v}< rf{%d}.term{%d}\n", m_me, args->leaderid(),args->term() , m_me, m_currentTerm) ;
        return; // 注意从过期的领导人收到消息不要重设定时器
    }
    Defer ec1([this]() -> void { this->persist(); });//由于这个局部变量创建在锁之后,因此执行persist的时候应该也是拿到锁的.    //本质上就是使用raii的思想让persist()函数执行完之后再执行
    if (args->term() > m_currentTerm) {
        // 三变 ,防止遗漏,无论什么时候都是三变

        m_status = Follower;
        m_currentTerm = args->term();
        m_votedFor = -1; // 这里设置成-1有意义,如果突然宕机然后上线理论上是可以投票的
        // 这里可不返回,应该改成让改节点尝试接收日志
        // 如果是领导人和candidate突然转到Follower好像也不用其他操作
        // 如果本来就是Follower,那么其term变化,相当于“不言自明”的换了追随的对象,因为原来的leader的term更小,是不会再接收其消息了
    }

    // 如果发生网络分区,那么candidate可能会收到同一个term的leader的消息,要转变为Follower,为了和上面,因此直接写
    m_status = Follower; // 这里是有必要的,因为如果candidate收到同一个term的leader的AE,需要变成follower
    // term相等
    m_lastResetElectionTime = now();    //重置选举超时定时器

    // 不能无脑的从prevlogIndex开始阶段日志,因为rpc可能会延迟,导致发过来的log是很久之前的

    //	那么就比较日志,日志有3种情况
    if (args->prevlogindex() > getLastLogIndex()) {
        reply->set_success(false);
        reply->set_term(m_currentTerm);
        reply->set_updatenextindex(getLastLogIndex() + 1);
        return;
    } else if (args->prevlogindex() < m_lastSnapshotIncludeIndex) { // 如果prevlogIndex还没有更上快照
        reply->set_success(false);
        reply->set_term(m_currentTerm);
        reply->set_updatenextindex(m_lastSnapshotIncludeIndex + 1); 
    }
    //	本机日志有那么长,冲突(same index,different term),截断日志
    // 注意:这里目前当args.PrevLogIndex == rf.lastSnapshotIncludeIndex与不等的时候要分开考虑,可以看看能不能优化这块
    if (matchLog(args->prevlogindex(), args->prevlogterm())) {
        //日志匹配,那么就复制日志
        for (int i = 0; i < args->entries_size(); i++) {
            auto log = args->entries(i);
            if (log.logindex() > getLastLogIndex()) { //超过就直接添加日志
                m_logs.push_back(log);
            } else {  //没超过就比较是否匹配,不匹配再更新,而不是直接截断

                if (m_logs[getSlicesIndexFromLogIndex(log.logindex())].logterm() != log.logterm()) { //不匹配就更新
                    m_logs[getSlicesIndexFromLogIndex(log.logindex())] = log;
                }
            }
        }

        if (args->leadercommit() > m_commitIndex) {
            m_commitIndex = std::min(args->leadercommit(), getLastLogIndex());// 这个地方不能无脑跟上getLastLogIndex(),因为可能存在args->leadercommit()落后于 getLastLogIndex()的情况
        }


        // 领导会一次发送完所有的日志
        reply->set_success(true);
        reply->set_term(m_currentTerm);


        return;
    } else {
        // 不匹配,不匹配不是一个一个往前,而是有优化加速
        // PrevLogIndex 长度合适,但是不匹配,因此往前寻找 矛盾的term的第一个元素
        // 为什么该term的日志都是矛盾的呢?也不一定都是矛盾的,只是这么优化减少rpc而已
        // ?什么时候term会矛盾呢?很多情况,比如leader接收了日志之后马上就崩溃等等
        reply->set_updatenextindex(args->prevlogindex());

        for (int index = args->prevlogindex(); index >= m_lastSnapshotIncludeIndex; --index) {
            if (getLogTermFromLogIndex(index) != getLogTermFromLogIndex(args->prevlogindex())) {
                reply->set_updatenextindex(index + 1);
                break;
            }
        }
        reply->set_success(false);
        reply->set_term(m_currentTerm);

        return;
    }

}
日志寻找匹配加速

这部分在AppendEntries 函数部分。
涉及代码:


// 不匹配,不匹配不是一个一个往前,而是有优化加速
// PrevLogIndex 长度合适,但是不匹配,因此往前寻找 矛盾的term的第一个元素
// 为什么该term的日志都是矛盾的呢?也不一定都是矛盾的,只是这么优化减少rpc而已
// ?什么时候term会矛盾呢?很多情况,比如leader接收了日志之后马上就崩溃等等
reply->set_updatenextindex(args->prevlogindex());

for (int index = args->prevlogindex(); index >= m_lastSnapshotIncludeIndex; --index) {
    if (getLogTermFromLogIndex(index) != getLogTermFromLogIndex(args->prevlogindex())) {
        reply->set_updatenextindex(index + 1);
        break;
    }
}

reply->set_success(false);
reply->set_term(m_currentTerm);

return;

前篇也说过,如果日志不匹配的话可以一个一个往前的倒退。但是这样的话可能会设计很多个rpc之后才能找到匹配的日志,那么就一次多倒退几个数。
倒退几个呢?这里认为如果某一个日志不匹配,那么这一个日志所在的term的所有日志大概率都不匹配,那么就倒退到 最后一个日志所在的term的最后那个命令。

其他

snapshot快照

  1. 快照是什么?
    当在Raft协议中的日志变得太大时,为了避免无限制地增长,系统可能会采取快照(snapshot)的方式来压缩日志。快照是系统状态的一种紧凑表示形式,包含在某个特定时间点的所有必要信息,以便在需要时能够还原整个系统状态。
    如果你学习过redis,那么快照说白了就是rdb,而raft的日志可以看成是aof日志。rdb的目的只是为了崩溃恢复的加速,如果没有的话也不会影响系统的正确性,这也是为什么选择不详细讲解快照的原因,因为只是日志的压缩而已。
  2. 何时创建快照?
    快照通常在日志达到一定大小时创建。这有助于限制日志的大小,防止无限制的增长。快照也可以在系统空闲时(没有新的日志条目被追加)创建。
  3. 快照的传输
    快照的传输主要涉及:kv数据库与raft节点之间;不同raft节点之间。
    kv数据库与raft节点之间:因为快照是数据库的压缩表示,因此需要由数据库打包快照,并交给raft节点。当快照生成之后,快照内设计的操作会被raft节点从日志中删除(不删除就相当于有两份数据,冗余了)。
    不同raft节点之间:当leader已经把某个日志及其之前的内容变成了快照,那么当涉及这部的同步时,就只能通过快照来发送。
内容补充
  1. 对 m_nextIndex 和 m_matchIndex作用的补充:

m_nextIndex 保存leader下一次应该从哪一个日志开始发送给follower;m_matchIndex表示follower在哪一个日志是已经匹配了的(由于日志安全性,某一个日志匹配,那么这个日志及其之前的日志都是匹配的)

一个比较容易弄错的问题是:m_nextIndex 与m_matchIndex 是否有冗余,即使用一个m_nextIndex 可以吗?
显然是不行的,m_nextIndex 的作用是用来寻找m_matchIndex ,不能直接取代。我们可以从这两个变量的变化看,在当选leader后,m_nextIndex 初始化为最新日志index,m_matchIndex 初始化为0,如果日志不匹配,那么m_nextIndex 就会不断的缩减,直到遇到匹配的日志,这时候m_nextIndex 应该一直为m_matchIndex+1 。
如果一直不发生故障,那么后期m_nextIndex就没有太大作用了,但是raft考虑需要考虑故障的情况,因此需要使用两个变量。

  1. 可以思考的问题

锁,能否在其中的某个地方提前放锁,或者使用多把锁来尝试提升性能?
多线程发送,能不能直接在doHeartBeat或者doElection函数里面直接一个一个发送消息呢?

  1. 可以进行优化的地方

线程池,而不是每次rpc都不断地创建新线程
日志
从节点读取日志

  1. 后期内容预告:

剩余辅助函数的逻辑。
持久化:raft哪些变量需要持久化
rpc:如何实现一个简单的rpc通信

Raft重点辅助函数讲解及剩余部分

在上一篇文章结束之后,raft的运行的主要原理已经基本掌握。
在理论方面,主要只剩下:线性一致性、持久化的相关内容。
因此剩下的重点就是代码实现了。
在这一篇中,我们会初步搭建起来一个raft集群,具备选举和复制日志的功能。

持久化

持久化就是把不能丢失的数据保存到磁盘。

  1. 持久化哪些内容?

持久化的内容为两部分:1.raft节点的部分信息;2.kvDb的快照

raft节点的部分信息
m_currentTerm :当前节点的Term,避免重复到一个Term,可能会遇到重复投票等问题。
m_votedFor :当前Term给谁投过票,避免故障后重复投票。
m_logs :raft节点保存的全部的日志信息。

不妨想一想,其他的信息为什么不用持久化,比如说:身份、commitIndex、applyIndex等等。

applyIndex不持久化是经典raft的实现,在一些工业实现上可能会优化,从而持久化。
即applyIndex不持久化不会影响“共识”。

kvDb的快照

m_lastSnapshotIncludeIndex :快照的信息,快照最新包含哪个日志Index
m_lastSnapshotIncludeTerm :快照的信息,快照最新包含哪个日志Term,与m_lastSnapshotIncludeIndex 是对应的。
Snapshot是kvDb的快照,也可以看成是日志,因此:全部的日志 = m_logs + snapshot
因为Snapshot是kvDB生成的,kvDB肯定不知道raft的存在,而什么term、什么日志Index都是raft才有的概念,因此snapshot中肯定没有term和index信息。
所以需要raft自己来保存这些信息。
故,快照与m_logs联合起来理解即可。

为什么要持久化这些信息?

两部分原因:共识安全、优化。
除了snapshot相关的部分,其他部分都是为了共识安全。
而snapshot是因为日志一个一个的叠加,会导致最后的存储非常大,因此使用snapshot来压缩日志。
为什么snashot可以压缩日志?
日志是追加写的,对于一个变量的重复修改可能会重复保存,理论上对一个变量的反复修改会导致日志不断增大。
而snapshot是原地写,即只保存一个变量最后的值,自然所需要的空间就小了。

什么时候持久化?

需要持久化的内容发送改变的时候就要注意持久化。
比如term 增加,日志增加等等。
具体的可以查看代码仓库中的void Raft::persist() 相关内容。

谁来调用持久化?

谁来调用都可以,只要能保证需要持久化的内容能正确持久化。
仓库代码中选择的是raft类自己来完成持久化。因为raft类最方便感知自己的term之类的信息有没有变化。
注意,虽然持久化很耗时,但是持久化这些内容的时候不要放开锁,以防其他线程改变了这些值,导致其它异常。

具体怎么实现持久化?

其实持久化是一个非常难的事情,因为持久化需要考虑:速度、大小、二进制安全。
因此仓库实现目前采用的是使用boost库中的持久化实现,将需要持久化的数据序列化转成std::string 类型再写入磁盘。

当然其他的序列化方式也少可行的。
可以看到这一块还是有优化空间的,因此可以尝试对这里优化优化。

std::string Raft::persistData() {
    BoostPersistRaftNode boostPersistRaftNode;
    boostPersistRaftNode.m_currentTerm = m_currentTerm;
    boostPersistRaftNode.m_votedFor = m_votedFor;
    boostPersistRaftNode.m_lastSnapshotIncludeIndex = m_lastSnapshotIncludeIndex;
    boostPersistRaftNode.m_lastSnapshotIncludeTerm = m_lastSnapshotIncludeTerm;
    for (auto &item: m_logs) {
        boostPersistRaftNode.m_logs.push_back(item.SerializeAsString());
    }

    std::stringstream ss;
    boost::archive::text_oarchive oa(ss);
    oa<<boostPersistRaftNode;
    return ss.str();
}

kvServer

kvServer是干什么的

如果这个有问题,让我们重新回顾一下以前的架构图片:
请添加图片描述
kvServer其实是个中间组件,负责沟通kvDB和raft节点。
那么外部请求怎么打进来呢?
哦吼,当然是Server来负责呀,加入后变成了:
请添加图片描述

kvServer怎么和上层kvDB沟通,怎么和下层raft节点沟通

通过这两个成员变量实现:

   std::shared_ptr<LockQueue<ApplyMsg> > applyChan; //kvServer和raft节点的通信管道
    
    std::unordered_map<std::string, std::string> m_kvDB; //kvDB,用unordered_map来替代

kvDB:使用的是unordered_map来代替上层的kvDB,因此没啥好说的。
raft节点:其中LockQueue 是一个并发安全的队列,这种方式其实是模仿的go中的channel机制。
在raft类中这里可以看到,raft类中也拥有一个applyChan,kvSever和raft类都持有同一个applyChan,来完成相互的通信。

kvServer怎么处理外部请求?

从上面的结构图中可以看到kvServer负责与外部clerk通信。
那么一个外部请求的处理可以简单的看成两步:1.接收外部请求。2.本机内部与raft和kvDB协商如何处理该请求。3.返回外部响应。
接收与响应外部请求
对于1和3,请求和返回的操作我们可以通过http、自定义协议等等方式实现,但是既然我们已经写出了rpc通信的一个简单的实现(源代码可见:这里),那就使用rpc来实现吧。
而且rpc可以直接完成请求和响应这一步,后面就不用考虑外部通信的问题了,好好处理好本机的流程即可。
相关函数是:


    void PutAppend(google::protobuf::RpcController *controller,
                   const ::raftKVRpcProctoc::PutAppendArgs *request,
                   ::raftKVRpcProctoc::PutAppendReply *response,
                   ::google::protobuf::Closure *done) override;

    void Get(google::protobuf::RpcController *controller,
             const ::raftKVRpcProctoc::GetArgs *request,
             ::raftKVRpcProctoc::GetReply *response,
             ::google::protobuf::Closure *done) override;

见名知意,请求分成两种:get和put(也就是set)。
如果是putAppend,clerk中就调用PutAppend 的rpc。
如果是Get,clerk中就调用Get 的rpc。
与raft节点沟通

线性一致性
什么是线性一致性?

一个系统的执行历史是一系列的客户端请求,或许这是来自多个客户端的多个请求。如果执行历史整体可以按照一个顺序排列,且排列顺序与客户端请求的实际时间相符合,那么它是线性一致的。当一个客户端发出一个请求,得到一个响应,之后另一个客户端发出了一个请求,也得到了响应,那么这两个请求之间是有顺序的,因为一个在另一个完成之后才开始。一个线性一致的执行历史中的操作是非并发的,也就是时间上不重合的客户端请求与实际执行时间匹配。并且,每一个读操作都看到的是最近一次写入的值。
要理解这个你需要首先明白:
对于一个操作来说,从请求发出到收到回复,是一个时间段。因为操作中包含很多步骤,至少包含:网络传输、数据处理、数据真正写入数据库、数据处理、网络传输。
那么操作真正完成(数据真正写入数据库)可以看成是一个时间点。
操作真正完成 可能在操作时间段的任何一个时间点完成。我们可以看下下面这个图检验下自己的理解:
请添加图片描述其中W表示写入,R表示读。在写入1和写入的时间片段中,分别Read出了2和1两个数字,而这是符合线性一致性的。
这里讲一讲raft如何做的。
每个 client 都需要一个唯一的标识符,它的每个不同命令需要有一个顺序递增的 commandId,clientId 和这个 commandId,clientId 可以唯一确定一个不同的命令,从而使得各个 raft 节点可以记录保存各命令是否已应用以及应用以后的结果。
即对于每个clinet,都有一个唯一标识,对于每个client,只执行递增的命令。

在保证线性一致性的情况下如何写kv

​ 具体的思想在上面已经讲过,这里展示一下关键的代码实现:

    if (!chForRaftIndex->timeOutPop(CONSENSUS_TIMEOUT, &raftCommitOp)) {//通过超时pop来限定命令执行时间,如果超过时间还没拿到消息说明命令执行超时了。

        if (ifRequestDuplicate(op.ClientId, op.RequestId)) {
            reply->set_err(OK);// 超时了,但因为是重复的请求,返回ok,实际上就算没有超时,在真正执行的时候也要判断是否重复
        } else {
            reply->set_err(ErrWrongLeader);   ///这里返回这个的目的让clerk重新尝试
        }
    } else {
        //没超时,命令可能真正的在raft集群执行成功了。
        if (raftCommitOp.ClientId == op.ClientId &&
            raftCommitOp.RequestId == op.RequestId) {   //可能发生leader的变更导致日志被覆盖,因此必须检查
            reply->set_err(OK);
        } else {
            reply->set_err(ErrWrongLeader);
        }
    }

需要注意的是,这里的命令执行成功是指:本条命令在整个raft集群达到同步的状态,而不是一台机器上的raft保存了该命令。
在保证线性一致性的情况下如何读kv

  if (!chForRaftIndex->timeOutPop(CONSENSUS_TIMEOUT, &raftCommitOp)) {
        int _ = -1;
        bool isLeader = false;
        m_raftNode->GetState(&_, &isLeader);

        if (ifRequestDuplicate(op.ClientId, op.RequestId) && isLeader) {
            //如果超时,代表raft集群不保证已经commitIndex该日志,但是如果是已经提交过的get请求,是可以再执行的。
            // 不会违反线性一致性
            std::string value;
            bool exist = false;
            ExecuteGetOpOnKVDB(op, &value, &exist);
            if (exist) {
                reply->set_err(OK);
                reply->set_value(value);
            } else {
                reply->set_err(ErrNoKey);
                reply->set_value("");

            }
        } else {
            reply->set_err(ErrWrongLeader);  //返回这个,其实就是让clerk换一个节点重试
        }
    } else {
        //raft已经提交了该command(op),可以正式开始执行了
        //todo 这里感觉不用检验,因为leader只要正确的提交了,那么这些肯定是符合的
        if (raftCommitOp.ClientId == op.ClientId && raftCommitOp.RequestId == op.RequestId) {
            std::string value;
            bool exist = false;
            ExecuteGetOpOnKVDB(op, &value, &exist);
            if (exist) {
                reply->set_err(OK);
                reply->set_value(value);
            } else {
                reply->set_err(ErrNoKey);
                reply->set_value("");
            }
        } else {

        }
    }

个人感觉读与写不同的是,读就算操作过也可以重复执行,不会违反线性一致性。
因为毕竟不会改写数据库本身的内容。
以GET请求为例看一看流程

以一个读操作为例看一看流程:首先外部RPC调用GET

void KvServer::Get(google::protobuf::RpcController *controller, const ::raftKVRpcProctoc::GetArgs *request,
                   ::raftKVRpcProctoc::GetReply *response, ::google::protobuf::Closure *done) {
    KvServer::Get(request,response);
    done->Run();
}

然后是根据请求参数生成Op,生成Op是因为raft和raftServer沟通用的是类似于go中的channel的机制,然后向下执行即可。
注意:在这个过程中需要判断当前节点是不是leader,如果不是leader的话就返回ErrWrongLeader ,让其他clerk换一个节点尝试。

// 处理来自clerk的Get RPC
void KvServer::Get(const raftKVRpcProctoc::GetArgs *args, raftKVRpcProctoc::GetReply *reply) {
    Op op;
    op.Operation = "Get";
    op.Key = args->key();
    op.Value = "";
    op.ClientId = args->clientid();
    op.RequestId = args->requestid();


    int raftIndex = -1;
    int _ = -1;
    bool isLeader = false;
    m_raftNode->Start(op, &raftIndex, &_, &isLeader);//raftIndex:raft预计的logIndex ,虽然是预计,但是正确情况下是准确的,op的具体内容对raft来说 是隔离的

    if (!isLeader) {
        reply->set_err(ErrWrongLeader);
        return;
    }


    // create waitForCh
    m_mtx.lock();

    if (waitApplyCh.find(raftIndex) == waitApplyCh.end()) {
        waitApplyCh.insert(std::make_pair(raftIndex, new LockQueue<Op>()));
    }
    auto chForRaftIndex = waitApplyCh[raftIndex];

    m_mtx.unlock(); //直接解锁,等待任务执行完成,不能一直拿锁等待


    // timeout
    Op raftCommitOp;

    if (!chForRaftIndex->timeOutPop(CONSENSUS_TIMEOUT, &raftCommitOp)) {
        int _ = -1;
        bool isLeader = false;
        m_raftNode->GetState(&_, &isLeader);

        if (ifRequestDuplicate(op.ClientId, op.RequestId) && isLeader) {
            //如果超时,代表raft集群不保证已经commitIndex该日志,但是如果是已经提交过的get请求,是可以再执行的。
            // 不会违反线性一致性
            std::string value;
            bool exist = false;
            ExecuteGetOpOnKVDB(op, &value, &exist);
            if (exist) {
                reply->set_err(OK);
                reply->set_value(value);
            } else {
                reply->set_err(ErrNoKey);
                reply->set_value("");
            }
        } else {
            reply->set_err(ErrWrongLeader);  //返回这个,其实就是让clerk换一个节点重试
        }
    } else {
        //raft已经提交了该command(op),可以正式开始执行了
//        DPrintf("[WaitChanGetRaftApplyMessage<--]Server %d , get Command <-- Index:%d , ClientId %d, RequestId %d, Opreation %v, Key :%v, Value :%v", kv.me, raftIndex, op.ClientId, op.RequestId, op.Operation, op.Key, op.Value)
        //todo 这里还要再次检验的原因:感觉不用检验,因为leader只要正确的提交了,那么这些肯定是符合的
        if (raftCommitOp.ClientId == op.ClientId && raftCommitOp.RequestId == op.RequestId) {
            std::string value;
            bool exist = false;
            ExecuteGetOpOnKVDB(op, &value, &exist);
            if (exist) {
                reply->set_err(OK);
                reply->set_value(value);
            } else {
                reply->set_err(ErrNoKey);
                reply->set_value("");
            }
        } else {
            reply->set_err(ErrWrongLeader);
        }
    }
    m_mtx.lock();   //todo 這個可以先弄一個defer,因爲刪除優先級並不高,先把rpc發回去更加重要
    auto tmp = waitApplyCh[raftIndex];
    waitApplyCh.erase(raftIndex);
    delete tmp;
    m_mtx.unlock();
}

RPC如何实现调用

这里以Raft类为例讲解下如何使用rpc远程调用的。
1.写protoc文件,并生成对应的文件,Raft类使用的protoc文件和生成的文件见:这里
2.继承生成的文件的类 class Raft : public raftRpcProctoc::raftRpc
3.重写rpc方法即可:

 // 重写基类方法,因为rpc远程调用真正调用的是这个方法
    //序列化,反序列化等操作rpc框架都已经做完了,因此这里只需要获取值然后真正调用本地方法即可。
    void AppendEntries(google::protobuf::RpcController *controller,
                       const ::raftRpcProctoc::AppendEntriesArgs *request,
                       ::raftRpcProctoc::AppendEntriesReply *response,
                       ::google::protobuf::Closure *done) override;
    void InstallSnapshot(google::protobuf::RpcController *controller,
                         const ::raftRpcProctoc::InstallSnapshotRequest *request,
                         ::raftRpcProctoc::InstallSnapshotResponse *response,
                         ::google::protobuf::Closure *done) override;
    void RequestVote(google::protobuf::RpcController *controller,
                     const ::raftRpcProctoc::RequestVoteArgs *request,
                     ::raftRpcProctoc::RequestVoteReply *response,
                     ::google::protobuf::Closure *done) override;

补充

关于面试
因此除了一些raft的常见问题,我这里总结一下我对该项目秋招面试的感觉:

  1. 虽然最难的地方在raft共识算法本身,但是raft算法算是地基。一些优化的地方可能更问的时间更多。对一个(有时间多个)的细节狠狠的把握下,面试的时候主动提起。
  2. 对第二点大家可以好好体会下,因为这相当于是你的“亮点”,因为raft的基础的东西如果面试官会的话其实他肯定会问,但是基础的共性的东西问完之后,他应该问些啥呢?**基础的问题和答案你好准备,但是之后的问题和答案你就不好准备了呀,与其主动被问,不如主动说,在设计的时候,你想到了一个xxx问题,然后对xxx问题的理解是xxxx。这样的话面试官正好在思考问什么,大多数情况下就会听一听你的。**对这点一个比较有意思的面试就是面试官问我这个项目你有啥收获,我就说对一致性的概念认识更加深刻,认识到了raft中的线性一致性与MySQL中的一致性是两个概念这样xxxx。
  3. 个人认为或许还值得深入思考的点
    snapshot压缩日志的相关内容:类似Redis的aof和rdb、类似lsm的设计考量。
    有没有考虑过日志或者其他文件中途损坏的问题。
    有锁队列、无锁队列怎么实现,如何优化锁粒度提高并发。即:LockQueue类的实现及其优化。

后续内容

文章内容至此raft的主要内容已经结束,后续的话也在考虑写什么内容,大家也可以提点建议。
后续的重点会放在现有代码的完善上面。
可能的后续内容:

RPC的实现原理简单讲解。
跳表如何实现,目前跳表暂时使用的是kv代替~
LockQueue的实现
Defer函数等辅助函数的实现

项目运行

1、如果是系统中有多个版本的protoc只需要,在当前终端修改PATH:
export PATH=/usr/bin:$PATH
设置你想用的那个目录优先即可;

export CPLUS_INCLUDE_PATH=/usr/include/google/protobuf:$CPLUS_INCLUDE_PATH

CPLUS_INCLUDE_PATH:主要用于 C++ 头文件搜索路径。
LIBRARY_PATH:用于静态或动态链接库文件的搜索路径(编译时)。
LD_LIBRARY_PATH:用于运行时链接库文件的搜索路径。
当存在多个版本的库时,容易出现报错,不仅要修改可执行文件的默认路径,还要修改头文件和库的路径

现在运行起来了,但是不明白怎么回事

剩余部分

首先我们看下第五章的架构图,图中的主要部分我们在前几张讲解完毕了,目前还剩下clerk和k-v数据库,而本篇的重点在于补全版图,完成:clerk、kv、RPC原理的讲解。
请添加图片描述

clerk的主要功能及代码

clerk相当于就是一个外部的客户端了,其作用就是向整个raft集群发起命令并接收响应。
在第五篇的kvServer一节中有过提及,clerk与kvServer需要建立网络链接,那么既然我们实现了一个简单的RPC,那么我们不妨使用RPC来完成这个过程。
clerk本身的过程还是比较简单的,唯一要注意的:对于RPC返回对端不是leader的话,就需要另外再调用另一个kvServer的RPC重试,直到遇到leader。
clerk的调用代码在

int main(){
    Clerk client;
    client.Init("test.conf");
    auto start = now();
    int count = 500;
    int tmp = count;
    while (tmp --){
        client.Put("x",std::to_string(tmp));

        std::string get1 = client.Get("x");
        std::printf("get return :{%s}\r\n",get1.c_str());
    }
    return 0;
}

可以看到这个代码逻辑相当简单,没啥难度,不多说了。

让我们看看Init函数吧,这个函数的作用是连接所有的raftKvServer节点,方式依然是通过RPC的方式,这个是raft节点之间相互连接的过程是一样的。

//初始化客户端
void Clerk::Init(std::string configFileName) {
    //获取所有raft节点ip、port ,并进行连接
    MprpcConfig config;
    config.LoadConfigFile(configFileName.c_str());
    std::vector<std::pair<std::string,short>> ipPortVt;
    for (int i = 0; i < INT_MAX - 1 ; ++i) {
        std::string node = "node" + std::to_string(i);

        std::string nodeIp = config.Load(node+"ip");
        std::string nodePortStr = config.Load(node+"port");
        if(nodeIp.empty()){
            break;
        }
        ipPortVt.emplace_back(nodeIp, atoi(nodePortStr.c_str()));   //沒有atos方法,可以考慮自己实现
    }
    //进行连接
    for (const auto &item:ipPortVt){
        std::string ip = item.first; short port = item.second;
        //2024-01-04 todo:bug fix
        auto* rpc = new raftServerRpcUtil(ip,port);
        m_servers.push_back(std::shared_ptr<raftServerRpcUtil>(rpc));
    }
}

接下来让我们看看put函数吧,put函数实际上调用的是PutAppend。

void Clerk::PutAppend(std::string key, std::string value, std::string op) {
    // You will have to modify this function.
    m_requestId++;
    auto requestId = m_requestId;
    auto server = m_recentLeaderId;
    while (true){
        raftKVRpcProctoc::PutAppendArgs args;
        args.set_key(key);args.set_value(value);args.set_op(op);args.set_clientid(m_clientId);args.set_requestid(requestId);
        raftKVRpcProctoc::PutAppendReply reply;
        bool ok = m_servers[server]->PutAppend(&args,&reply);
        if(!ok || reply.err()==ErrWrongLeader){

            DPrintf("【Clerk::PutAppend】原以为的leader:{%d}请求失败,向新leader{%d}重试  ,操作:{%s}",server,server+1,op.c_str());
            if(!ok){
                DPrintf("重试原因 ,rpc失敗 ,");
            }
            if(reply.err()==ErrWrongLeader){
                DPrintf("重試原因:非leader");
            }
            server = (server+1)%m_servers.size();  // try the next server
            continue;
        }
        if(reply.err()==OK){  //什么时候reply errno为ok呢???
            m_recentLeaderId = server;
            return ;
        }
    }
}

这里可以注意。
m_requestId++; m_requestId每次递增。
m_recentLeaderId; m_recentLeaderId是每个clerk初始化的时候随机生成的。
这两个变量的作用是为了维护上一篇所述的“线性一致性”的概念。
server = (server+1)%m_servers.size(); 如果失败的话就让clerk循环节点进行重试。

跳表

https://www.xiaolincoding.com/redis/data_struct/data_struct.html#%E8%B7%B3%E8%A1%A8
如何植入
哦吼,我们尝试一下将卡哥的跳表植入我们的项目中吧。我们首先把文件添加到我们的项目中。
项目提示中告诉我们如果要修改key的类型,需要自定义比较函数,同时需要修改load_file。
我们后面准备使用std::string作为key,所以不用自定义比较函数了诶。
而load_file文件落盘的一部分,就算不说的话我们这边也打算自己落盘。
下面开始改造吧。

下面只会讲解关键的改造,具体涉及的文件修改大家可以查看github的提交记录。
当然目前还没考虑性能问题,只是做到了“可运行”,也许可以针对我们目前的场景做一些比如锁粒度的优化,欢迎大家issue和pr。仓库地址在本文开头。

修改dump和load接口

原来卡哥仓库中的这两个接口的逻辑是直接落盘和从文件中读取数据,我们稍微读读代码。
原来的关键代码:

while (node != NULL) {
    _file_writer << node->get_key() << ":" << node->get_value() << "\n";
    std::cout << node->get_key() << ":" << node->get_value() << ";\n";
    node = node->forward[0];
}

其中_file_writer的定义为: std::ofstream _file_writer;
代码逻辑是在不断遍历的过程中是不断的将数据写入到了磁盘,其中使用了:和\n作为分隔符。
对前面的部分还有映像的小伙伴可能已经反应过来了,这里有数据不安全的问题,即key和value中如果已经存在’:’ \n字符的时候程序可能会发送异常。
为了数据安全,这里采用的方法依然是使用boost的序列化库。
SkipListDump<K, V>类增加的作用就是为了安全的序列化和反序列化。
其定义也非常简单,与raft和kvServer中的序列化方式相同,也是boost库序列化的最简单的方式:

template<typename K, typename V>
class SkipListDump {
public:
    friend class  boost::serialization::access;

    template<class Archive>
    void serialize(Archive &ar, const unsigned int version) {
        ar & keyDumpVt_;
        ar & valDumpVt_;
    }
    std::vector<K> keyDumpVt_;
    std::vector<V> valDumpVt_;
public:
    void insert(const Node<K, V> &node);
};

skipList增加void insert_set_element(K&,V&);接口

增加的原因是因为这样可以和下层的kvServer的语义配合,kvServer中的set方法的语义是:key不存在就增加这个key,如果key存在就将value修改成新值。
这个作用与insert_element相同类似,insert_set_element是插入元素,如果元素存在则改变其值。
而insert_element是插入新元素,但是存在相同的key不会进行插入。关键代码(存在超链接,看原文)如下:

// if current node have key equal to searched key, we get it
if (current != NULL && current->get_key() == key) {
    std::cout << "key: " << key << ", exists" << std::endl;
    _mtx.unlock();
    return 1;
}

同时我们需要注意,在实现insert_set_element元素的时候应该不能找到这个节点,然后直接修改其值。因为后续可能会有类似“排序”这样的拓展功能,因此目前insert_set_element的实现是删除旧节点,然后再插入的方式来实现。

更多

已知/可能的bug:
dump和load数据库的性能问题,锁安全问题
序列化(kvServer代码中)更优雅的实现:因为kvServer需要调用跳表让其序列化dump,这块没有找到与boost比较好的结合方式。目前方式是增加了一个变量m_serializedKVData,后面可以查看一下是否有更好的方式。
原来代码中数据库快照落盘这里并没有仔细的考量,后面可以考虑做一份。
在装载磁盘的时候应该将数据库重新清空
序列化方式的统一

项目中RPC

本项目使用到的RPC代码高度依赖于protobuf。
RPC 是一种使得分布式系统中的不同模块之间能够透明地进行远程调用的技术,使得开发者可以更方便地构建分布式系统,而不用过多关注底层通信细节,调用另一台机器的方法会表现的像调用本地的方法一样。
那么无论对外表现如何,只要设计多个主机之间的通信,必不可少的就是网络通讯这一步
我们可以看看一次RPC请求到底干了什么?
请添加图片描述
首先看下【准备:请求参数、返回参数(这里返回参数的值没有意义)、调用哪个方法】这一步,这一步需要发起者自己完成,如下:
请添加图片描述
在填充完请求值和返回值之后,就可以实际调用方法了。
我们点进去看看:

void FiendServiceRpc_Stub::GetFriendsList(::PROTOBUF_NAMESPACE_ID::RpcController* controller,
                              const ::fixbug::GetFriendsListRequest* request,
                              ::fixbug::GetFriendsListResponse* response,
                              ::google::protobuf::Closure* done) {
  channel_->CallMethod(descriptor()->method(0),
                       controller, request, response, done);
}

可以看到这里相当于是调用了channel_->CallMethod方法,只是第一个参数变成了descriptor()->method(0),其他参数都是我们传进去的参数没有改变,而这个descriptor()->method(0)存在的目的其实就是为了表示我们到底是调用的哪个方法。
到这里远端调用的东西就齐活了:方法、请求参数、响应参数。
还记得在最开始生成stub的我们写的是:fixbug::FiendServiceRpc_Stub stub(new MprpcChannel(ip, port, true));,因此这个channel_本质上是我们自己实现的MprpcChannel类,而channel_->CallMethod本质上就是调用的MprpcChannel的CallMethod方法。
我们简单看下这个CallMethod方法干了什么?
函数的定义在这里,比较简单:
按照请添加图片描述
这样的方式将所需要的参数来序列化,序列化之后再通过send函数循环发送即可。
可能的改进:在代码中send_rpc_str.insert(0, std::string((char *)&header_size, 4));我们可以看到头部长度固定是4个字节,那么这样的设计是否合理?如果不合理如何改进呢?
到了这一步,所有的报文已经发送到了对端,即接收RPC的一方,那么此时应该在对端进行:
请添加图片描述
这一系列的步骤。

这一系列步骤的主要函数发生在:RpcProvider::OnMessage。

我们看下这个函数干了什么?

首先根据上方序列化的规则进行反序列化,解析出相关的参数。

然后根据你要调用的方法名去找到实际的方法调用即可。

相关函数是在NotifyService函数中中提前注册好了,因此这里可以找到然后调用。

在这个过程中使用了protobuf提供的closure绑定了一个回调函数用于在实际调用完方法之后进行反序列化相关操作。

为啥这么写就算注册完反序列化的回调了呢?肯定是protobuf为我们提供了相关的功能,在后面代码流程中也会看到相对应的过程。

google::protobuf::Closure *done = google::protobuf::NewCallback<RpcProvider, const muduo::net::TcpConnectionPtr &, google::protobuf::Message *>(this, &RpcProvider::SendRpcResponse,conn, response);

真正执行本地方法是在 service->CallMethod(method, nullptr, request, response, done);,为什么这个方法就可以调用到本地的方法呢?
这个函数会因为多态实际调用生成的pb.cc文件中的CallMethod方法。

void FiendServiceRpc::CallMethod(const ::PROTOBUF_NAMESPACE_ID::MethodDescriptor* method,
                             ::PROTOBUF_NAMESPACE_ID::RpcController* controller,
                             const ::PROTOBUF_NAMESPACE_ID::Message* request,
                             ::PROTOBUF_NAMESPACE_ID::Message* response,
                             ::google::protobuf::Closure* done)

我们看下这个函数干了什么?

  switch(method->index()) {
    case 0:
      GetFriendsList(controller,
             ::PROTOBUF_NAMESPACE_ID::internal::DownCast<const ::fixbug::GetFriendsListRequest*>(
                 request),
             ::PROTOBUF_NAMESPACE_ID::internal::DownCast<::fixbug::GetFriendsListResponse*>(
                 response),
             done);
      break;
    default:
      GOOGLE_LOG(FATAL) << "Bad method index; this should never happen.";
      break;
  }

这个函数和上面讲过的FiendServiceRpc_Stub::GetFriendsList方法有似曾相识的感觉。都是通过xxx->index来调用实际的方法。
正常情况下校验会通过,即触发case 0。
然后会调用我们在FriendService中重写的GetFriendsList方法。

  // 重写基类方法
    void GetFriendsList(::google::protobuf::RpcController *controller,
                        const ::fixbug::GetFriendsListRequest *request,
                        ::fixbug::GetFriendsListResponse *response,
                        ::google::protobuf::Closure *done) {
        uint32_t userid = request->userid();
        std::vector<std::string> friendsList = GetFriendsList(userid);
        response->mutable_result()->set_errcode(0);
        response->mutable_result()->set_errmsg("");
        for (std::string &name: friendsList) {
            std::string *p = response->add_friends();
            *p = name;
        }
        done->Run();
    }

这个函数逻辑比较简单:调用本地的方法,填充返回值response。
然后调用回调函数done->Run();,还记得我们前面注册了回调函数吗?

google::protobuf::Closure *done = google::protobuf::NewCallback<RpcProvider,
                                                                const muduo::net::TcpConnectionPtr &,
                                                                google::protobuf::Message *>(this,
                                                                                             &RpcProvider::SendRpcResponse,
                                                                                             conn, response);

在回调真正执行之前,我们本地方法已经触发了并填充完返回值了。

此时回看原来的图,我们还需要序列化返回结果和将序列化后的数据发送给对端。

done->Run()实际调用的是:RpcProvider::SendRpcResponse。

这个方法比较简单,不多说了。

到这里,RPC提供方的流程就结束了。

从时间节点上来说,此时应该对端来接收返回值了,接收的部分在这里,还在 MprpcChannel::CallMethod部分:

    /*
    从时间节点来说,这里将请求发送过去之后rpc服务的提供者就会开始处理,返回的时候就代表着已经返回响应了
    */

    // 接收rpc请求的响应值
    char recv_buf[1024] = {0};
    int recv_size = 0;
    if (-1 == (recv_size = recv(m_clientFd, recv_buf, 1024, 0)))
    {
        close(m_clientFd); m_clientFd = -1;
        char errtxt[512] = {0};
        sprintf(errtxt, "recv error! errno:%d", errno);
        controller->SetFailed(errtxt);
        return;
    }

    // 反序列化rpc调用的响应数据
    // std::string response_str(recv_buf, 0, recv_size); // bug:出现问题,recv_buf中遇到\0后面的数据就存不下来了,导致反序列化失败
    // if (!response->ParseFromString(response_str))
    if (!response->ParseFromArray(recv_buf, recv_size))
    {
        char errtxt[1050] = {0};
        sprintf(errtxt, "parse error! response_str:%s", recv_buf);
        controller->SetFailed(errtxt);
        return;
    }

将接受到的数据按照情况实际序列化成response即可。
这里就可以看出现在的RPC是不支持异步的,因为在MprpcChannel::CallMethod方法中发送完数据后就会一直等待着去接收。
protobuf库中充满了多态,因此推荐大家阅读的时候采用debug的方式。
注:因为目前RPC的网络通信采用的是muduo,muduo支持函数回调,即在对端发送信息来之后就会调用注册好的函数,函数注册代码在:

    m_muduo_server->setMessageCallback(std::bind(&RpcProvider::OnMessage, this, std::placeholders::_1,
                                        std::placeholders::_2, std::placeholders::_3));

这里讲解的RPC其实是比较简单的,并没有考虑:服务治理与服务发现、负载均衡,异步调用等功能。
后续再优化的时候可以考虑这些功能。

辅助功能

这里稍微提一下在整个项目运行中的一些辅助的小组件的实现思路以及一些优化的思路。
这些组件实现版本多样,而且与其他模块没有关联,相对也比较简单,但正是如此,这方面可以多学习一下比较优秀的实现,然后稍微测试测试,面试的时候拿出来说一说。
因为面试的时候面试官很难绝对你的整体设计架构会有多么优秀,更多的是看到你的某个设计细节怎样。

LockQueue的实现

代码在这里。其实就是按照线程池最基本的思路,使用锁和条件变量来控制queue。
那么一个可能的问题就是由于使用了条件变量和锁,可能在内核和用户态会来回切换,有没有更优秀的尝试呢?
比如:无锁队列,使用自旋锁优化,其他。。。。
这里推荐大家可以多试试不同的实现方式,然后测试对比,面试的话很有说法的。

Defer函数等辅助函数的实现

在代码中经常会看到Defer类,这个类的作用其实就是在函数执行完毕后再执行传入Defer类的函数,是收到go中defer的启发。
主要是RAII的思想,如果面试的时候提到了RAII,那么就可以说到这个Defer,然后就牵扯过来了。

怎么使用boost库完成序列化和反序列化的

主要参考BoostPersistRaftNode类的定义和使用。
在本篇正文部分如何植入跳表的部分讲解过了,这里就不重复了。

可做的工作

在代码层面可以做的工作还有很多,主要但不限于包括:

现有实现的更优雅的版本。
可能的性能测试,比如火焰图分析系统的耗时。
一些组件的引入和优化,比如LockQueue更好的实现,日志库,异步RPC等等。最近在星球中不是正好有大佬分享了协程库的实现,在raft中到处都是多线程,那么是否可以引入协程库呢哈哈哈
这里欢迎大家后续对github仓库上的代码进行优化迭代,任何一个小的改动都是欢迎的。
对大家的好处是可以学习到github仓库的协作流程,成为contributor,如果;其次正如正文所言,如果对一个点做的比较细的话,面试的话是很有讲头的。

项目面试解答

Raft

Raft算法的基本原理:
回答要点:解释Raft算法的基本工作原理,包括领导者选举、日志复制和安全性保障。
示例回答:

Raft算法是一种分布式算法,旨在解决分布式系统中的一致性问题,相对于Paxos算法而言更易于理解和实现。
Raft算法将系统中的所有节点分为三类角色:领导者(leader)、跟随者(follower)和候选人(candidate)。其选举机制确保系统中的一个节点被选为领导者(leader),领导者负责处理客户端的请求,并将更新复制到其他节点。
Raft算法的基本原理包括以下几个关键步骤:

1、领导者选举(Leader Election):在系统启动时或者当前领导者失效时,节点会发起选举过程。节点会在一个随机的超时时间内等待收到来自其他节点的心跳消息。如果在超时时间内没有收到心跳消息,节点就会成为候选人并发起选举。候选人向其他节点发送投票请求,并在得到大多数节点的投票后成为新的领导者。
2、日志复制(Log Replication):一旦领导者选举完成,新的领导者就会接收客户端的请求,并将更新的日志条目复制到其他节点。当大多数节点都成功复制了这些日志条目时,更新被认为是提交的,并且可以应用到节点的状态机中。
3、安全性(Safety):Raft算法通过确保在选举中只有一个领导者(单一领导者)、大多数节点的一致性以及只有领导者可以处理客户端请求等方式保证分布式系统的安全性。

通过以上机制,Raft算法确保了分布式系统中的一致性、可用性和分区容错性。
注意:如果这么回答如果面试官懂一些分布式算法的话,那么后续可能会提问Raft与其他分布式算法的关系。

领导者选举:
如何进行Raft中的领导者选举?
回答要点:这里最好结合前面几个章节的流程图,结合自己理解回答。
请添加图片描述Raft中的领导者选举过程如下:

1、候选人状态(Candidate State):
节点在没有检测到领导者的情况下成为候选人,并将自己的任期编号(term)增加1。
候选人向其他节点发送投票请求,并请求其他节点投票支持自己成为新的领导者。
如果候选人在规定时间内收到了大多数节点的选票支持(即获得了大多数节点的投票),则成为新的领导者。
2、选举过程(Election Process):
在发起选举后,候选人会等待一定的随机时间(选举超时时间)来收集其他节点的投票。
如果在这个超时时间内没有收到大多数节点的选票,候选人将会重新开始一个新的选举周期,增加自己的任期编号,并再次发起选举。
3、投票过程(Voting Process):
其他节点收到来自候选人的投票请求后,会检查自己的当前任期编号。如果候选人的任期编号比自己的大,则投票支持候选人,并更新自己的任期编号为候选人的任期编号。
如果其他节点已经投票给了另一个候选人,或者已经投票给了当前领导者,它将拒绝投票。
4、领导者选举完成(Leader Election Complete):
如果候选人收到了大多数节点的投票支持,它就会成为新的领导者。
新的领导者会开始发送心跳消息以维持其领导地位,并开始进行日志复制操作。

在什么情况下会触发领导者选举?
回答要点:一个节点只要长时间没有收到符合条件的leader发送的心跳就会认为leader掉线,就会发起选举。
示例回答:

在Raft算法中,领导者选举会在以下情况下触发:
当系统启动时,所有节点都处于初始状态,没有领导者。
当领导者节点因网络分区、宕机或其他原因失效时,导致系统中没有活跃的领导者。
当节点故障恢复或者被网络分区时,它可能会检测到当前没有领导者,因此会成为候选人并发起选举。

日志复制
Raft是如何通过日志复制来保证数据一致性的?
回答要点:主要是两个机制(特点):
1.Leader Append Entries:领导者追加日志条目,即只有leader可以接受外部请求并将请求打包成日志,并向follower同步自己的日志,这样保证提交过的日志不会被覆盖掉。
2.commit机制,领导者发现大多数节点都已经成功复制了某个日志条目后,该日志条目被视为已经提交,从而保证了数据的一致性。
示例回答:

Raft算法通过日志复制来确保数据一致性。在Raft中,每个节点都维护一个日志(log)来记录状态机中的操作指令。领导者负责接收客户端的写请求,将操作指令追加到自己的日志中,并将这些操作指令发送给其他节点,要求它们复制这些日志条目。
以下是Raft通过日志复制来保证数据一致性的基本流程:

1、Leader Append Entries(领导者追加日志条目):
领导者接收到客户端的写请求后,将这些操作指令追加到自己的日志中。
领导者将这些操作指令组织成一个日志条目(log entry),并向其他节点发送一个追加日志条目的请求(Append Entries RPC)。
2、Follower Log Replication(跟随者日志复制):
跟随者节点接收到领导者发送的追加日志条目的请求后,会按照领导者的日志条目顺序将这些日志条目追加到自己的日志中。
如果跟随者节点成功复制了这些日志条目,则向领导者发送成功响应(Response);如果由于某种原因(例如网络故障)导致复制失败,则向领导者发送失败响应。
3、Commit(提交):
当领导者发现大多数节点都已经成功复制了某个日志条目后,该日志条目被视为已经提交。
领导者将提交的日志条目应用到自己的状态机中,以执行相应的操作指令。

安全性保障:
Raft是如何确保安全性的?讨论一致性、可用性和分区容错性之间的权衡。
回答要点 :这里主要是想考察分布式CAP理论的一个关键:CAP中如果发生故障,只能CP和AP二选一,无法满足CAP的三角,而Raft选择的是CP,即满足一致性。
示例回答:

在权衡一致性、可用性和分区容错性时,Raft算法倾向于优先保证一致性和分区容错性。它通过保证大多数节点的确认和限制领导者选举条件来确保一致性,通过选举机制和日志复制来保证分区容错性。同时,Raft也兼顾了系统的可用性,确保在领导者失效后能够快速进行新的领导者选举,并继续提供服务。

选举超时:
什么是选举超时?它的作用是什么?
回答要点: follower和candidate都会有选举超时的机制。
在follower时:选举超时的意义是发起选举,变成candidate;
在candidate时:candidate会选举超时,如果选举成功就会变成leader;如果选举失败就会变成candidate(选举超时)或者follower(发现合适的leader)。那么选举超时的作用就很明显了,防止无止境的等待导致所有人都成不了leader。
拓展:想一想为什么选举超时时间要每次随机设置而不设置成一个固定的值???
示例回答:

选举超时的作用包括:

1、触发领导者选举:选举超时用于在当前没有活跃领导者或者领导者失效时触发新的领导者选举。当节点在选举超时时间内没有收到来自当前领导者的心跳消息时,会成为候选人并发起选举过程。
2、防止脑裂(Split-Brain):选举超时帮助避免了系统中出现多个领导者的情况,从而避免了脑裂问题的发生。如果系统中的节点在选举超时时间内没有收到来自当前领导者的心跳消息,它们会同时成为候选人并发起选举,但只有一个候选人最终会获得大多数节点的选票,成为新的领导者。
3、确保领导者切换的及时性:选举超时可以确保在领导者失效后,系统能够及时地启动新的领导者选举过程,从而减少服务中断的时间,提高系统的可用性。

选举超时的时间是如何设置的?
**回答要点:**回答要点在上个问题的拓展里面,大家可以先想想。答案是:一个一定范围内的随机值,其要根据心跳时间,rpc延迟,数据操作延迟综合考虑。
范围:一般来说选举超时时间要大于一次完整心跳的日志同步处理时间。
为何随机:选举超时的目的是防止无止境的等待导致所有人都成不了leader,如果超时时间又一样,那么大家又一起选举,又会不断循环,那么一个随机值可以让某些节点早点重新发起选举,防止大家一起选举导致死循环。
示例回答:

选举超时时间的设置通常包括以下考虑因素:
网络延迟和稳定性:选举超时时间需要足够长以允许节点在正常情况下能够收到来自领导者的心跳消息。考虑到网络的延迟和不稳定性,超时时间应该设置得足够长,以避免因网络延迟而误判领导者失效。
系统负载和响应速度:选举超时时间也应考虑系统的负载情况和响应速度。如果系统负载较重或者节点的处理速度较慢,可能需要将选举超时时间设置得稍长一些,以允许节点有足够的时间处理收到的消息。
避免脑裂问题:为了避免系统中出现多个领导者导致的脑裂问题,选举超时时间应该设置得足够随机化,以确保不同节点不会在同一时间内触发选举。

日志条目的提交:
Raft中的日志条目是如何提交的?
回答要点: 要半数以上的节点(包括leader)接收了这个日志,那么才能提交(commit),后续才能apply到状态机。

示例回答:
1、Leader接收客户端请求:
当客户端向Raft系统提交请求时,请求会首先发送到Raft集群中的Leader节点。
2、Leader将请求转换为日志条目:
Leader将接收到的客户端请求转换为一条日志条目,并附加到其本地日志中。
3、Leader广播日志条目:
Leader向其它节点发送包含新日志条目的心跳RPC请求(AppendEntries RPC)。
4、Follower节点接收并附加日志条目:
Follower节点接收到Leader的附加日志请求后,将新的日志条目附加到其本地日志中。
5、Follower节点响应Leader:
Follower节点在成功附加日志后,向Leader发送成功的响应。
6、Leader确认提交:
当Leader收到大多数节点的附加成功响应时,将日志条目视为已提交。
7、Leader提交到状态机:
Leader将已提交的日志条目应用到其状态机中,以执行相应的操作。
8、Leader通知Followers提交:
Leader会通知其它节点已提交的日志索引,以便它们也可以将相应的日志条目提交到其状态机中。
9、Follower提交到状态机:
Follower节点收到Leader的提交通知后,将对应的已提交日志条目应用到其状态机中。

### 回答1: IT运维项目建设是为了提高企业整体信息化水平,在制造、金融、医疗等各个行业中得到广泛应用。这些企业在运营过程中,需要面对IT系统的维护、更新、监管等诸多问题。IT运维项目建设的目的就是提供系统的稳定性、可用性和可维护性,以确保企业信息系统能够稳定运行,保持高效的工作状态。 IT运维项目建设背景可以从以下两个方面进行介绍: 首先,随着信息技术的发展,各行业企业对信息化技术的依赖程度日益提高,同时IT技术的更新和发展也快速推进。企业需要通过IT运维项目建设,持续提升信息系统的安全性、可靠性、稳定性和可维护性,以满足企业信息化发展的要求。 其次,随着国家信息化建设的大力推进,企业要求提高信息化水平以适应产业发展。IT运维项目建设是实现信息化目标的重要手段。通过运维项目的实施,企业IT系统的稳定性得到保障,各项业务得以顺利进行,企业提高信息化水平,提升自身竞争力,加速转型升级步伐。 综上所述,IT运维项目建设是针对企业信息系统运营过程中存在的问题,通过一系列方案和实施手段,确保信息系统的稳定性和可靠性,提升企业信息化水平和竞争力的重要工作。 ### 回答2: 随着信息技术的快速发展,越来越多的企业和机构开始重视IT运维,在企业的信息化建设中发挥越来越重要的作用。但是IT运维项目在建设中存在着一些问题,比如建设周期长、预算超支、实施过程复杂、技术难度大等。因此,为了更好地支撑企业信息化建设的发展,IT运维项目的建设也需要不断创新和优化,从而提高项目的管理水平和技术实力。 IT运维项目建设的背景是多样的,它们可以是企业为适应市场需要而进行信息化转型的产物,也可以是为了满足组织内部管理需要而开展的项目。总的来说,IT运维项目的建设背景和目的都是为了提高企业的竞争力和运营效率。在建设IT运维项目的过程中,需要考虑多方面因素,如项目的可行性分析、技术选型、资源调配、项目管理等。同时,也需要与企业的实际情况相结合,灵活运用适合企业的技术和方法,确保项目的顺利实施。 IT运维项目的建设不仅关系到企业的信息化建设,还涉及到企业的战略规划和目标实现。因此,对于IT运维项目的建设,需要根据企业的实际情况和需求,制定科学合理的规划和实施方案,从而提高IT运维项目的建设质量和效益,为企业的可持续发展创造有利条件。 ### 回答3: IT运维项目建设的背景介绍, 是指将IT领域内的硬、软件结合企业的日常运营管理需求,而进行的一个系统化的建设工程。随着IT技术的迅猛发展和应用广泛,企业的日常运营依赖性大大增强,IT运维的重要性日益凸显,为此各个企业都需要持续地进行IT运维建设来确保业务安全稳定。 近年来,云计算、移动互联网、物联网等技术的兴起,也为IT运维项目建设带来了新的挑战和机遇。IT运维项目建设不仅要关注企业日常生产、业务活动等方面的维护,还要注重系统安全、数据备份、灾难恢复等方面的需求,尽可能地减少企业在运营过程中的失败和损失。 一个好的IT运维项目建设,需要从项目的立项和需求梳理,到技术选型和实施推广,再到运营和维护全程进行有效的规划和管理。这样才能保证项目在实际运营中的处理能力和出错率达到预期目标,确保企业业务的增长和健康发展。 总之,IT运维项目建设背景介绍是为了了解IT运维建设项目的重要性及其应用领域,为企业提高运营效率和减少损失提供保障。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值