基于版本一致性算法
摘要
本文主要讨论基于版本的一致性算法,实现高可用和低延迟并且Lazy Read式的强数据一致性。可以利用在分布式的文件系统的元数据管理。它主要解决以下内容:
-
避免使用锁提供一致性
-
避免复制日志提供一致性
-
快速故障恢复
-
自由集群扩容或缩容
缺点:无法提供高并发,需要使用其它机制提供高负载能力。
简介
基于版本一致性算法是一个类Paxos算法,File Store是由该算法实现的分布式文件管理系统(以下简称系统),本文以该系统为例说明该算法。
该算法定义了三个阶段:选举期,平稳期和故障期。系统大部分时间是在平稳期,就算是发生故障时,系统也不会立刻进入选举期,只有在有读写操作失败后才会由外部触发新的选举。选举成功后,系统会立刻恢复到平稳期。
该系统中所有服务节点都是平等的Citizen,但他们会在不同时期担任以下三种角色中的一种:
Leader - 负责写操作,通知到所有节点备份和节点状态管理
Follower - 负责任何节点发起的选举,负责数据的备份和读取
Candidate - 负责选举
选举
public bool StartElection()
{
//使用一个新的选举号
this.ElectionNo++;
if (SendMessage("Vote", this))
{
this.Type = CitizenType.Leader;
return true;
}
return false;
}
每次选举最多只能产生一个Leader。为了实现这个要求,每个节点都持有一个线性增长的选举号。节点发起选举时首先将自己的选举号增加1个点,然后发起选举给到所有节点。只有得到大多数节点的同意后选举才算成功,否则选举失败。
所有节点在接收到选举请求时都需要立刻执行选举操作:
public bool DoVote(int election_no, Citizen citizen)
{
if (election_no > this.VotedNo)
{
this.VotedNo = election_no;
this.Type = CitizenType.Citizen;
this.Leader = citizen;
return true;
}
return false;
}
接收到选举号后比较自己已经投出的选举号,如果是新选举号则同意这次选举。
如果同一时间有多个节点发起选举,选民会第一时间更新自己投出的选举号(原子操作)因此不会同时同意两个候选人发起的选举。就算毫秒间先后同意了两个候选人,然后候选人做后续的写操作时也会失败,从而修改自身的错误信息。
备份
只有Leader才有数据的写入权,当一个Leader接受到Client的写请求时:
public bool Write(int index, Bucket bucket)
{
bucket.LeaderNo = this.ElectionNo;
bucket.VersionNo++;
if (SendMessage("Write", bucket))
{
//save data to local
return true;
}
this.Type = CitizenType.Citizen;
return false;
}
Leader首先会把数据加上自己当选的选举号和数据的版本号,然后通知所有节点写入备份,只有得到大多数节点的认可才更新本地信息,否则修正自己的角色。
所有节点在接收到备份请求时都需要立刻执行备份操作:
public bool DoWrite(Bucket bucket, Citizen citizen)
{
if (bucket.LeaderNo < this.VotedNo)
{
return false;
}
if (bucket.LeaderNo> this.VotedNo)
{
this.VotedNo = bucket.LeaderNo;
this.Type = CitizenType.Citizen;
this.Leader = citizen;
}
//save bucket to local disk
return true;
}
首先比较数据的创建者是否为自己投的Leader,只有自己认可的或高于自己认可的Leader的请求才处理,否则不执行任何操作。另外高于自己认可的Leader的请求时,需要同步自己的Leader信息。
读取
因为Leader随时都可能更改,但为了提高并发能力,数据一致性的确认只能在读取时处理,所以数据读取时,首先要确保本地数据是最新的,如果不是最新需要通知其它节点读取最新的。
public Bucket Read(int index)
{
if (!EnsureRecovery(index))
{
return null;
}
if (SendMessage("Read", index))
{
//get bucket from local disk
return new Bucket();
}
else
{
this.Type = CitizenType.Citizen;
return null;
}
}
public bool EnsureRecovery(int index)
{
// get bucket from local disk;
var bucket = new Bucket();
if (this.VotedElectionNo == bucket.LeaderNo)
{
// only indicate it is recoveried
return true;
}
if (SendMessage("Read", index))
{
// get max leader no from majority
var max_leader_no = 0;
this.VotedElectionNo = max_leader_no;
bucket.LeaderNo = max_leader_no;
bucket.VersionNo = 0;
//save bucket to local disk
return true;
}
return false;
}
首先判断数据创建者是否为自己认可的Leader,如果不是自己认可的Leader创建,则立刻通知节点同步数据。需要注意的是,就算是自己认可的Leader创建的数据也不能说明本地的数据已经最新,可能该节点已经失联,也可能该节点在选举和写数据时失联。因此还需要向所有节点发送读数据确认数据已经最新,然后才能返回数据。
所有节点在接收到读取请求时都需要立刻执行读取操作:
public Tuple<bool, Bucket> DoRead(int index, int versionNo, Citizen citizen)
{
//get bucket from local
var bucket = this.buckets[index];
if (versionNo < bucket.LeaderNo)
{
return new Tuple<bool, Bucket>(false, bucket);
}
if (versionNo == bucket.LeaderNo)
{
return new Tuple<bool, Bucket>(true, null);
}
return new Tuple<bool, Bucket>(false, null);
}
读取本地数据并与要读取数据的版本号比较,如果本地版本比较高则返回本地数据。如果版本相同只返回true,否则证明自己的数据已经无效。注意发现数据不同步后,并不需要及时解决数据一致性,直到有读取操作时才去确保数据一致性。
因此写入和读取都需要得到大多数节点的确认,因此不存在数据一致性问题。最差的情况是,多数节点在读取时失联,从而会导致读取失败,这种情况只能由客户端重试。
扩容
为了防止不一致性,该系统的节点扩/缩容需要缓慢进行。
扩容时,首先新节点的加入必须得到所有节点的确认,即所有节点都已经成功更新本地配置。然后确保只有一个节点加入成功后才能增加新的节点。
缩容时,首先是停掉一个节点,然后再通知所有节点移除该节点,并得到所有节点确认。
注意:无论缩容还是扩容都需要得到所有节点的确认,否则不能进行下一步操作。
优化
该系统理论上是强数据一致性的,由上面流程也能看出,所有的读取都需要得到大多数节点的确认,这需要消耗多次的网络连接。因此建议:
-
系统需要尽量保证节点间的通讯稳定和高速
-
系统保存的数据应该尽量少,比较适合保存元数据
-
应对读多写少,而且对数据一致性要求不高的情况下,可以修改大多数读确认的定义
原文链接:http://blog.zhumingwu.cn/2018/08/15/%E5%9F%BA%E4%BA%8E%E7%89%88%E6%9C%AC%E4%B8%80%E8%87%B4%E6%80%A7%E7%AE%97%E6%B3%95.html