前言
数据一致性是分布式服务的一大难题。假设我们能无成本的保证数据一致性,在分布式环境中就可以使用无限扩容来分发流量。那么数据一致性,何为数据 ?往往日志log是一应用的可靠性的保障,例如 mysql的binlog,redis 的aof日志。通过分析日志从机子可以达到和本机子相同的状态。因此数据一致性的问题,化简为日志一致性问题。Raft则是一个用于管理日志一致性的协议。
Raft简介
Raft又称 木筏协议,一种共识算法,旨在替代PaxOS,相比容易理解许多。斯坦福大学的两个博士Diego Ongaro和John Ousterhout两个人以易懂为目标设计的一致性算法,2013以论文方式发布。由于易懂,不从论文发布到不到两年的时间,即出现大量的Raft开源实现。为简化PaxOS的晦涩,它采用分而治之的办法,将算法分为三个子问题:选举(Leader Election)、日志复制(Log Replication)、安全性(Safety)和集群成员动态变更(Cluster Membership Changes)。
Raft选举
Raft 中node 的 3 种角色/状态
- Follower:完全被动,不能发送任何请求,只接受并响应来自 Leader 和 Candidate 的 Message,每个节点启动后的初始状态一定是 Follower;
- Leader:处理所有来自客户端的请求,以及复制 Log 到所有 Followers;
- Candidate:用来竞选一个新 Leader (Candidate 由 Follower 触发超时而来)。
Follower --> Candidate
- Follower没收到心跳的时间 > Heartbeat Timeout
Candidate --> Candidate (选举超时)
- 处于 Candidate 状态的时间 > Election Timeout
Candidate(选举)
- 先建个term并投票给自己
- Candidate 向其它节点 node发投票请求
- Candidate.term小于 node. term,失败并降级为Follower
- Candidate.term大于 node. term,成功如果node也是Candidate则降级,并更新term
- Candidate.term等于node. term,如果node也是Candidate失败,否则4
- 比较上一条日志的term,如果node大则失败,小则成功 ,等于到5
- 比较上一条日志的index,如果node大则失败,否则成功
Candidate --> Follower
- Candidate选举时,发现其它节点term > 自己term.
Candidate --> Leader
- Candidate选举结果 > 总节点数/2
获取Leader结点特性
1.有最大的Term;
2. 如果Term相同,则有最大的Index;
3. 如果Term相同,Index也相同,就看谁最先发起;
Leader(维持)
- 向所有结点发送心跳和日志
Leader --> Follower
- 发现其它节点term > 自己term.
Raft日志复制
日志复制是分布式一致性算法的核心,所谓的一致性,就是集群多节点的数据一致性。
日志的数据结构
- 创建日志时的任期号term(用来检查节点日志是否出现不一致的情况)
- 状态机需要执行的指令(真正的内容)
- 索引index:整数索引表示日志条目在日志中位置
Raft 把每条日志都附加了任期号和下标来保证日志的唯一性。如果 2 个日志的相同的索引位置的日志条目的任期号相同,那么 Raft 就认为这个日志从头到这个索引之间全部相同。
日志的复制过程
- 客户端的向服务端发送请求(指令)。
- follower接收到请求转发给leader
- leader 把这个指令作为一条新的日志条目添加到日志中,然后并行发起 RPC 给其他的服务器,让他们复制这条信息。
- 假如这条日志被安全的复制 > 总结点/2,leader就提交这条日志并返回给客户端。
- 关于日志复制异常的Follower,Raft异步会通过强制 follower 直接复制 leader 的日志解决
日志冲突及解决
下图,所有的 follower 都和 leader 的日志冲突了,leader 的最后一条日志的任期是 6, 下标是 10 ,而其他 follower 的日志都与 leader 不匹配。
一致性操作:
- a follower 不需要删除任何条目,
- b 也不需要删除
- c follower 需要删除最后一个条目
- d follower 需要删除最后 2 个任期为 7 的条目,
- e 需要删除最后 2 个任期为 4 的条目,
- f 则比较厉害,需要删除 下标为 3 之后的所有条目。
具体思路:
- Leader 为每一个 follower 维护一个下标,称之为 nextIndex,表示下一个需要发送给 follower 的日志条目的索引。
- 当一个新 leader 刚获得权力的时候,他将自己的最后一条日志的 index + 1,也就是上面提的 nextIndex 值,如果一个 follower 的日志和 leader 不一致,那么在下一次 RPC 附加日志请求中,一致性检查就会失败(不会插入数据)。
- 如果检查失败,leader 就会把 nextIndex 递减进行重试,直到遇到匹配到正确的日志。当匹配成功之后,follower 就会把冲突的日志全部删除,此时,follower 和 leader 的日志就达成一致。
安全性
Raft在非拜占庭错误情况下,包括网络延迟、分区、丢包、冗余和乱序等错误都可以保证正确,不会返回错误结果,这就是安全性保证。
- Leader选举之后,如果Follower与Leader日志有冲突该如何处理?
答:通过强制 follower直接复制 leader 的日志解决,详情见上文。
- 如果在一个Follower宕机的时候Leader提交了若干的日志条目,然后这个Follower上线后可能会被选举为Leader并且覆盖这些日志条目,如此就会产生不一致?
答:candidate发送出去的投票请求消息必须带上其最后一条日志条目的Index与Term;接收者需要判断该Index与Term至少与本地日志的最后一条日志条目一样新,否则不给投票。因为前一个Leader提交日志条目的条件是日志复制给集群中的过半成员,选举为Leader的条件也是需要过半成员的投票。
- a) S1是Leader,并且部分地复制了index-2;
- b) S1宕机,S5得到S3、S4、S5的投票当选为新的Leader(S2不会选择S5,因为S2的日志较S5新),并且在index-2写入到一个新的条目,此时是term=3(注:之所以是term=3,是因为在term-2的选举中,S3、S4、S5至少有一个参与投票,也就是至少有一个知道term-2,虽然他们没有term-2的日志);
- c) S5宕机,S1恢复并被选举为Leader,并且开始继续复制日志(也就是将来自term-2的index-2复制给了S3),此时term-2,index-2已经复制给了多数的服务器,但是还没有提交;
- d) S1再次宕机,并且S5恢复又被选举为Leader(通过S2、S3、S4投票,因为S2、S3、S4的term=4<5,且日志条目(为term=2,index=2)并没有S5的日志条目新,所以可以选举成功),然后覆盖Follower中的index-2为来自term-3的index-2;(注:此时出现了,term-2中的index-2已经复制到三台服务器,还是被覆盖掉);
- e) 然而,如果S1在宕机之前已经将其当前任期(term-4)的条目都复制出去,然后该条目被提交(那么S5将不能赢得选举,因为S1、S2、S3的日志term=4比S5都新)。此时所有在前的条目都会被很好地提交。
-
如果上述情况©中,term=2,index=2的日志条目被复制到大多数后,如果此时当选的S1提交了该日志条目,则后续产生的term=3,index=2会覆盖它,此时就可能会在同一个index位置先后提交一个不同的日志,这就违反了状态机安全性,产生不一致。
答:为了消除上述场景就规定Leader可以复制前面任期的日志,但是不会主动提交前面任期的日志。而是通过提交当前任期的日志,而间接地提交前面任期的日志。
-
网络分区会不会产生不一致的状况
答:机子是2n+1的个数。如产生分区,机子小于等于n的分区,将不会产生日志数据以及term不再增加。如果分区合并,n分区的机子板本落后也不可能成为Leader
线性一致性
所谓线性一致性,就是在 t1 的时间我们写入了一个值,那么在 t1 之后,我们的读一定能读到这个值,不可能读到 t1 之前的值。
Mysql 主从异步复制,你t1时间向主库插入数据x,t2时间去从库读数据x,是有可能读不到的。而线性一致性系统则是必须读到,即CAP理论中的C.
为什么Raft中没有线性一致性
Raft中有一个状态机,专门用来执行日志,相当于Mysql的解析执行器。状态机执行是要花时间的,Follower没这么快。
怎么实现线性一致性
- Raft log
- 任何的读请求走一次Leader
- Leader 将读请求也记到log里面(像写请求一样)
- 等这个 log 提交之后,在 apply 的时候从状态机里面读取值
点评:非常方便的实现线性 read,但每次read 都需要走 Raft流程,全部依赖Leader, Follower在浪费。
- ReadIndex Read
- 读请进来,将Leader commit index 记录到一个 local 变量 ReadIndex
- 向其他节点发起一次 heartbeat,如果大多数节点返回了对应的 heartbeat response,那么 leader 就能够确定现在自己仍然是 leader。(防止网络分区了,自己不知道)
- 将Leader commit index (Follower通过网络获取)记录到一个 local 变量 ReadIndex
- 等待自己的状态机执行,直到 apply index 超过了 ReadIndex
点评:Follower可通过网络获取ReadIndex。Follower帮助分担了Learder的部份压力,向其他节点发起一次 heartbeat仍是Learder的负担
Lease Read
- 读请进来,将Leader commit index 记录到一个 local 变量 ReadIndex
- heartbeat + election timeout内默认自己是leader。超过时间 向其他节点发起一次 heartbeat并刷新heartbeat
- 将Leader commit index (Follower通过网络获取)记录到一个 local 变量 ReadIndex
- 等待自己的状态机执行,直到 apply index 超过了 ReadIndex
点评:优化了ReadIndex Read ,进一步节省了heartbeat对Leader的负担
Raft存储及日志压缩
在实际的系统中,不能让日志无限增长,否则系统重启时需要花很长的时间进行回放,从而影响可用性。Raft采用对整个系统进行snapshot来解决,snapshot之前的日志都可以丢弃。
集群成员动态变更
集群配额从 3 台机器变成了 5 台,可能存在这样的一个时间点,两个不同的领导者在同一个任期里都可以被选举成功(双主问题),一个是通过旧的配置,一个通过新的配置。
解决方法
Raft解决方法是每次成员变更只允许增加或删除一个成员(如果要变更多个成员,连续变更多次)。
Paxos算法
在Paxos base协议中存在3种角色: acceptor, proposer, learner.
- Proposer向Acceptor提交提案(proposal),提案中含有决议(value)。
- Acceptor审批提案。
- Learner执行通过审批的提案中含有的决议。
Client Proposer Acceptor Learner
| | | | | | |
X-------->| | | | | | Request
| X--------->|->|->| | | Prepare(1)
| |<---------X--X--X | | Promise(1,{Va,Vb,Vc})
| X--------->|->|->| | | Accept!(1,V)
| |<---------X--X--X------>|->| Accepted(1,V)
|<---------------------------------X--X Response
| | | | | | |
- Request, 客户端发送请求
- Prepare,Proposer提交提案编号为n;
- Promise,Acceptor审核提案,如果Acceptor当前提案内容大于等于n,则不接受,小于则成功。
- Promise sucess, Proposer结果成功数是多数,进行Accept
- Promise fail, Proposer结果成功数是少数,提案编号n+1跳回Prepare
- Accept, Proposer将编号为n的提案内容发过去
- Accepted,如果Acceptor中最大编号没超过n,则写入提案及Learner做备份。否则重新到Prepare
- Response,返回结果。
点评:
- 当Proposer高并发的时候,可能出现活锁的现象,谁也提交不了?当发生冲突的时候,进行编号小的进行休眼,迟点发起。影响效率。
- 每次请求都要经2轮RPC通信,影响效率。
因为上述的问题,对Paxos base进行优化变成 Multi Paxos
- 项目启动时,通上Paxos base流程,先对Proposer选出Leader。
- 之前client语法只通过Leader来提案,流程如下
Client P(leader) Acceptor Learner
| | | | | | |
X-------->| | | | | | Request
| X--------->|->|->| | | Accept!(1,V)
| |<---------X--X--X------>|->| Accepted(1,V)
|<---------------------------------X--X Response
| | | | | | |
点评:
Proposer,Acceptor,Learner可以合到一个Server里面,然后通过状态来区分他们。这样Multi Paxos 和Raft模式已经很接近了
ZAB算法
zab同raft一样,也是由Multi Paxos变型过来的。也有三个状态LOOKING/FOLLOWING/LEADING。
- LOOKING: 节点正处于选主状态,不对外提供服务,直至选主结束;
- FOLLOWING: 作为系统的从节点,接受主节点的更新并写入本地日志;
- LEADING: 作为系统主节点,接受客户端更新,写入本地日志并复制到从节点
选主过程:
- LOOKING向其它机子发送(sid,zxid)
sid:机子编号。不同机子不同的编号
zxid:高32为epoch 轮数,相当于Raft中的term;低32位表示自增ID由0开始,相当于Raft中的Index.每次选出新的Leader,epoch会递增,同时zxid的低32位清0。
- 集群其他节点,这些节点会为发起选主的节点进行投票
zxid(A) > zxid(B)
sid(A) > sid(B)
点评:
ZAB和Raft在选主上面不是极其相似的。估计日志复制的细节上面有所差异
我的RaftDemo
/**
*
* 1.electionTime最终时间短的会选为主
* 2.日志存储和读取
*/
public class RaftTest {
public static void main(String[] args) {
List<String> list = Lists.newArrayList("localhost:8771", "localhost:8772", "localhost:8773", "localhost:8774", "localhost:8775");
List<RpcServer> ss = Lists.newArrayList();
for (int i = 0; i < list.size(); i++) {
String url = list.get(i);
RpcServer server = new RpcServer(url);
ss.add(server);
if (i == 0) {
server.setElectionTime(500l);
}
server.start();
}
ThreadUtil.sleep(3000);
//结果大概率是8771
for (int i = 0; i < ss.size(); i++) {
if(i == ss.size() -1) {
i = 0;
ThreadUtil.sleep(2000);
}
RpcServer server = ss.get(i);
if(server.isLeader()){
System.out.println("leader node is " + server.getUrl());
break;
}
}
//让node结点接收一波心跳新
ThreadUtil.sleep(2000);
RpcClient rpcClient = new RpcClient();
ClientKVReq putReq = ClientKVReq.newBuilder()
.type(0)
.key("yoyo")
.value("eee")
.build();
ClientKVAck putRes = rpcClient.sendMassage(putReq);
System.out.println(putRes.getResult());
//让node结点接收一波心跳新
//ThreadUtil.sleep(20000);
ClientKVReq getReq = ClientKVReq.newBuilder()
.type(1)
.key("yoyo")
.build();
ClientKVAck getRes = rpcClient.sendMassage(getReq);
System.out.println(getRes.getResult());
}
}
运行结果:
[localhost:8771] electionTime:500
[localhost:8774] electionTime:6785
[localhost:8772] electionTime:4948
[localhost:8775] electionTime:3759
[localhost:8773] electionTime:4643
[localhost:8772] stepDown become FOLLOWER
[localhost:8774] stepDown become FOLLOWER
[localhost:8774] agree [localhost:8771]
[localhost:8775] stepDown become FOLLOWER
[localhost:8775] agree [localhost:8771]
[localhost:8772] agree [localhost:8771]
[localhost:8773] stepDown become FOLLOWER
[localhost:8773] agree [localhost:8771]
[localhost:8771] become leader
leader node is localhost:8771
LEADER[localhost:8771] write [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8772] write [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8773] write [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8774] write [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8775] write [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
LEADER[localhost:8771] apply [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
ok
FOLLOWER[localhost:8772] apply [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8774] apply [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8773] apply [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8775] apply [{index=0, term=2, command=Command(key=yoyo, value=eee)}]
FOLLOWER[localhost:8772] ReadIndex to [0] with value:eee
eee
资源地址:https://download.youkuaiyun.com/download/y3over/12577334
Raft进阶-SOFAJRaft
SOFAJRaft 是一个基于 Raft 一致性算法的生产级高性能 Java 实现,是从百度的 braft 移植而来,做了一些优化和改进。
《SOFAStack 开源 SOFAJRaft:生产级 Java Raft 算法库》
《SOFAJRaft 选举机制剖析 | SOFAJRaft 实现原理》
《SOFAJRaft 线性一致读实现剖析 | SOFAJRaft 实现原理》
《SOFAJRaft 实现原理 - 生产级 Raft 算法库存储模块剖析》
《SOFAJRaft 实现原理 - SOFAJRaft-RheaKV 是如何使用 Raft 的》
《SOFAJRaft—初次使用》
主要参考
《Raft 日志复制 Log replication》
《编写你的第一个 Java 版 Raft 分布式 KV 存储》
《Raft协议安全性保证》
《TiKV 功能介绍 - Lease Read 》
《RAFT算法详解》
《从Paxos到Zookeeper:分布式一致性原理与实践》