Paxos写成人话的话,其实很简单。
背景
基础Paxos算法研究的是一个分布式数据库系统。有多台机器作为数据库,每个数据库分别存储同一个数据,那么怎样从这个分布式数据库系统写入数据以及读取数据呢? 基础Paxos算法只研究单个变量的存取,而且这里的变量是C++里常量的概念,未赋值时可以赋任意值,但一但赋值后值就不能改变。 一个写者可以请求这个分布式数据库往这个变量写入自己想写的值。 Paxos算法能处理多个写者并发写的情况,当然,因为一但赋值后值就不能改变,最终只有一个写者能成功写入自己想写的值,其余的失败。
定义
按照论文里的定义,我们称单台数据库机器为acceptor,写者为proposer,论文中还有learner,不过learner不是Paxos算法核心的部分,暂时忽略。
Paxos算法不考虑拜占庭问题,意思是每个人都是诚实守信的好公民,定了协议一定会按照协议执行,不存在欺骗的行为。
这个系统中每个proposer都知道所有acceptor的存在,自然也知道一共有多少个acceptor,并且其可以直接向多个acceptor发起通讯,获取acceptor的存储状态或者请求accptor写入一个值。
Paxos算法是proposer和acceptor的一个交互协议。让我们先忘掉什么叫分布式一致性,现在我们仅关注下面性质:
性质1: 如果在某一个时刻,系统达成了共识,共识值为v,那么在这之后的任一个时刻,系统仍然达成共识,且共识值仍然为v。
意思就是值一但确定了就不会被更改。 性质1提到了两个概念,系统达成共识,以及共识值。系统达成共识的定义可以有多种,我们先给出一个便于理解的定义。
定义1: 在某时刻系统达成共识当且仅当此时有过半数acceptor存储了同一个值v,同时v称为共识值。
在定义1下,性质1可改写成下面性质2。
性质2: 如果在某一个时刻,有过半数的acceptor存储了同一个值v,那么在这之后的任一个时刻,任意取过半数的acceptor,如果它们存储了同一个值,那么这个值一定是v。
Paxos算法的目标是保证性质1。注意,实际上Paxos算法并不能保证性质2,需要用一个比定义1更强的达成共识的定义,下文会介绍。
acceptor的值只赋值一次的情况
那么这个算法是怎样的呢?我们先看看最简单的场景,只有一个acceptor。这不就是多线程抢锁的问题吗?每个proposer都向acceptor发出下面这个请求1,问题就解决了。
请求1: 如果变量还没被赋值,就赋值成v吧。
上面的v不同proposer可能不同。因为acceptor只有一个,会把并发的多个proposer请求串行化,最终只有最先被acceptor处理的proposer能够赋值成功,其余失败。
让我们推广到多acceptor情景。我们很粗暴地推广上面的算法:每个proposer都向每个acceptor发出请求1。 结果是每个acceptor存储的值一但确定就不会改变,但是因为网络等问题,每个acceptor最先处理的proposer可能不是同一个,导致不同acceptor可能存储了不同的值。
我们发现这个算法是符合性质2的,如果达成了共识,因为acceptor一但存储了值就不会再发生改变,那么当然以后也是达成共识的状态,并且还是同一个值。但是达成共识这个前提条件不一定能满足。这其实不是什么致命的问题,一个变量达不成共识换个变量不就得了(Raft的选举就是这么搞的),但是Leslie Lamport认为还是应该抢救一下,于是Paxos选择了acceptor的值可多次改变的策略。
acceptor的值可多次改变的情况
现在我们讨论acceptor的值可多次改变的情况。acceptor采用完全信任proposer的策略,即proposer叫acceptor设定什么值,acceptor就设定什么值。这样压力就来到了proposer这边。当还没达成共识时,proposer还是可以让acceptor设自己想设的值,但是一但共识达成,为了让性质1正确,proposer就只能让acceptor设共识的值了。可见在proposer提出要设的值前,需要一个读取当前系统状态,判断有没有达成共识的操作。但是并没有简单的读取当前系统状态的方式,proposer只能分别向每个acceptor询问该acceptor当前的状态,再综合统计。于是proposer的算法就变成下面两步:
- 向每个acceptor询问该acceptor当前的存储状态,收集反馈,判断当前系统有没有达成共识
- 如果没有达成共识,向每个acceptor请求写入自己想设的值,如果达成共识,向每个acceptor请求写入共识值
上面的算法有两个问题,一个是原子性的问题,一个是怎样判断系统有没有达成共识的问题。原子性问题是说上面算法第1步与第2步之间存在时间差,如果某个proposer在第1步判断没达成共识,于是执行第2步请求存储自己想设的值v,但是在第1步与第2步之间系统状态改变了,达成共识,值为不等于v的v'',那么第2步的写入就打破了性质1。Paxos算法看起来是上面这两个步骤的变种,不过却没有这两个问题。
Paxos算法
下面我们来描述Paxos算法。 我们称上述的两步算法为proposal。我们对每个proposal加一个编号,要求不同proposal编号不同。我们对上述算法第一步中proposer对acceptor发起的请求称为prepare请求,第二步中的请求称为accept请求。
prepare请求输入为proposal的编号n,输出为编号n'与一个值v,v为acceptor存储的值,n'为让acceptor存储值v的proposal编号。当然,还需要表示acceptor未被赋值的情况。本文的样例代码采用n'等于0来表示这种情况,同时要求每个proposal的编号都大于0。
accept请求的输入为proposal编号n,与请求写入的值v,没有输出。
acceptor需要记录的数据有值v,值v对应的proposal编号v_n,以及见过最大的编号max_n。当收到一个prepare请求时,acceptor会返回v_n与v。同时如果请求的编号n比max_n大时,更新max_n为n。 当收到一个accept请求时,如果请求编号n小于max_n,就拒绝这个请求,否则把请求的编号n与值分别记录到自己的存储v_n与v中,并更新max_n为n。
注意,原论文中max_n只记录最大的prepare请求编号,accept请求编号不会更新max_n的值。但是这是错的。如果严格按原论文的算法,正确性得不到保证。下文提供了这样一个例子。
proposer发起一个编号为n的proposal的步骤是:
- 用n向每个acceptor发起prepare请求,如果没收到过半数回复,则此proposal直接失败,否则找出收到回复里的最大的编号n'以及对应的值v'。
- 向所有acceptor发起accept请求,输入为proposal编号,以及值v,v的确定规则如下:如果所有acceptor均为未被赋值的状态,v为proposal原本想设定的值。如果存在acceptor被赋值,v为第1步中的v'。
这个就是Paxos算法。如果觉得描述得不清楚,下面有一份参考代码。
Paxos算法的正确性
现在我们可以给出Paxos算法中达成共识的定义了。
定义2: 在Paxos算法中,如果有过半数acceptor接受编号为n,值为v的accept请求,那么称系统达成共识,同时称编号为n的proposal达成共识,共识值为v。
注意定义1与定义2是有交集但又互不包含的两个定义。
- 满足定义1不满足定义2:可能存在多个编号的请求,每个请求都请求写入同一个值v,每个请求都只成功写入不过半数的acceptor,但是加起来有过半数的acceptor存储了v。
- 满足定义2不满足定义1:accept请求在不同acceptor中处理的时间不同,导致达成过半数acceptor接受时部分acceptor中的存储已经被新的proposal覆盖。
在定义2下,我们可以把性质1改写成下面性质3:
性质3: 任意两个达成共识的请求拥有相同的共识值。
在证明之前,我们先看看过半数的性质。过半数利用了抽屉原理,性质是任意取两个过半数的acceptor集合,那么至少有一个acceptor同时在这两个集合里。
下面我们用(n, v)来表示编号为n,且在accept请求请求写入值v的proposal。
引理1: 如果(n, v)终会达成共识,任意m>n,编号为m的proposal请求写入的值必为v。
证明: 令(m, u)为编号大于n的第一个成功写入一台acceptor一个不同于v的值u的proposal,即任意编号n<l,l不等于m,要么l请求写入的值为v,要么l在m读取的时候还没成功写入任一台acceptor。所以m的读请求读到的最大编号小于n。 另一方面,因为接受n的accept请求的acceptor集合与编号m收到request回复的acceptor集合必有交集,而且在这些acceptor上,收到的n的accept请求必先于m的request请求,并且在n的accept请求后,所有接受的accept请求编号均大于n,所以m读到的最大编号必不小于n。矛盾。
引理1一个有意思的点在于它并不关心(n, v)在什么时间达成共识,只要从马后炮的角度看,它终将达成共识,就能满足要求,甚至可以出现m先达成共识,n再达成共识的情况。
由引理1很容易推出性质3。
作为辅助理解,下面我们来看看Paxos算法怎样解决上文提到的原子性问题与怎样判断系统有没有达成共识的问题。
判断系统是否达成共识
Paxos算法第1步中,proposer怎样判断系统是否达成共识呢?最简单的情况,proposer收到了所有acceptor的回复,这样直接判断即可。但是Paxos算法具有容错性,认为只要收到过半数acceptor的回复就足够了。当proposer仅收到过半数acceptor的回复时,有下面3种情况。
- 有过半数acceptor未被赋值。这种情况可肯定在所有prepare请求发送前,系统没达成共识,但不能肯定当前时刻下系统没达成共识。
- 有过半数acceptor赋了同一个值v,且对应的编号为同一个n。这种情况能肯定系统已达成共识,值为v。
- 其他情况。这就不好说了,有可能系统没达成共识,也有可能系统已达成共识。
对于情况3,把它当作没达成共识从而尝试写自己想写的值是很危险的,可能会违背性质1,所以一个保守的做法是遇到情况3通通认为系统已达成共识。但是当它达成共识还不够,proposer还要知道共识的值,假如proposer收到的回复中包含多个值,那么proposer应该挑选哪个值呢?Paxos算法的做法是挑选编号最大的值。前文已证明了这种选择的安全性。但是编号最大的proposal,其实可能并不会达成共识,那么本次proposal会帮它一把,重新尝试把它想写的值达成共识。
CAS
再说说原子性的问题。 上文提到如果收到prepare请求回复中,过半数acceptor未被赋值,那么可肯定在所有prepare请求发送前,系统没达成共识。这种情况就可以尝试去写自己想写的值。如果从读到写的这段时间系统保持没达成共识,那么本次proposal才有可能成功,否则一定会失败。像极了CAS操作,分布式的。
数据读取与learner
上面算法仅讨论了怎样写数据与存数据,并没有讨论怎样读数据。 读数据就是判断当前时刻下,系统有没有达成共识,达成共识的话共识值是多少。
一个很稳妥的方法是锁住所有acceptor,让所有acceptor都不能发生写操作,然后再读取每个acceptor的存储情况,读完再解锁。 不一定需要锁住所有的acceptor。 锁acceptor也不一定需要动acceptor,可以去控制proposer不发起proposal。 下面一些情况能明确知道一个时刻下系统的状态:
- 无锁读到过半数acceptor存储的值均为(n, v),则可以确定系统达成共识值v。
- 带锁读到过半数acceptor未存储任何值,则可以确定系统没有达成共识。
其他情况就难以确定系统的状态了。即使系统已经达成共识,也可能存在任意(n, v)都没过半数存储的情况,因为存储正在被新的proposal覆盖。因此,一个思路是对于每一个编号n,找一个机器去存每个acceptor对编号n的accept请求的反馈,这样就不怕acceptor的存储被覆盖了。论文中的learner就是这样的机器。 acceptor在处理完编号n的accept请求后,可以主动把处理结果发给对应的learner。在下面一些情况,learner能确定编号n的proposal的状态。
- 收到过半数acceptor接受n。
- 设总共a个acceptor,收到其中b个acceptor的回复,其中接受n的数量为c,如果a-b+c没过半,则可以确定n没有达成共识。
- 一个特例为c等于0,即收到过半数acceptor拒绝n。
learner存在单点故障问题。关于learner的容灾,论文中有一些讨论,但没给出一套明确的解决方案。 实现上一般learner就是proposer,处理结果作为accept请求的输出返回给proposer。
还有一个思路是,当一个人通过上述其中一个过程确定共识值为v后,就可以盖章,告诉别人我已经确认过,共识值就是v了,你就相信就行了,不用重复确认。因为大家都诚实守信,被告知的人确信这个值就是v,也可以告诉其他人这个值是v。这样就可以减少对acceptor的请求。
论文原算法翻车例子
设有3台acceptor。 用(r,1,-)表示编号1的request请求,读到值为空,用(a,1,3)表示编号1,值为3的accept请求。 用((1,2,3),(3,4,5),(6,7,8))来表示3台acceptor的存储状态,其中(1,2,3)表示最大编号为1,值编号为2,值为3。下面按事件发生顺序列出事件,以及事件发生后各acceptor的状态。
(-,-,-) ((0,0,0), (0,0,0), (0,0,0))
(r,1,-) ((1,0,0), (1,0,0), (0,0,0))
(r,2,-) ((2,0,0), (2,0,0), (0,0,0))
(a,2,2) ((2,0,0), (2,0,0), (0,2,2))
(a,1,1) ((2,0,0), (2,0,0), (0,1,1))
(r,3,1) ((2,0,0), (3,0,0), (3,1,1))
(a,2,2) ((2,2,2), (3,0,0), (3,1,1))
(a,3,1) ((2,2,2), (3,3,1), (3,3,1))
那么(2, 2)与(3, 1)均达成共识。(a,2,2)出现了两次是表示不同acceptor处理这个请求的时间不同,不是说发起了两次(a,2,2)请求。 这里关键问题是(a,2,2)与(a,1,1)两步,一个acceptor写入一个高编号值后仍然可以写入一个低编号值。
Paxos不满足性质2的例子
环境与符号同上。
下面是一个Paxos不满足性质2的例子。每个accept请求都只成功写入一个acceptor。这可能是因为丢包,可能是因为其他写请求的包遭遇网络堵塞还没到,也有可能是因为编号大小的原因。
(-,-,-) ((0,0,0), (0,0,0), (0,0,0))
(r,1,-) ((1,0,0), (1,0,0), (1,0,0))
(a,1,1) ((1,1,1), (1,0,0), (1,0,0))
(r,2,-) ((1,1,1), (2,0,0), (2,0,0))
(a,2,2) ((1,1,1), (2,2,2), (2,0,0))
(r,3,1) ((3,1,1), (2,2,2), (3,0,0))
(a,3,1) ((3,1,1), (2,2,2), (3,3,1))
(r,4,2) ((4,1,1), (4,2,2), (3,3,1))
(a,4,2) ((4,4,2), (4,2,2), (3,3,1))
其中(a,3,1)后过半数存1,(a,4,2)后过半数存2。 这里问题是过半数存储可以靠多个proposal拼凑在一起。
Paxos算法代码描述
下面用C++描述了Paxos算法,代码仅供理解Paxos算法,不供实际应用。
struct PrepareResp {
int n;
int v;
};
class Acceptor {
public:
PrepareResp Prepare(int n) {
if (n > max_n_) {
max_n_ = n;
}
return {v_n_, v_};
}
void Accept(int n, int v) {
if (n < max_n_) {
return;
}
max_n_ = n; // 原论文中没有这一步,但是是必须的
v_n_ = n;
v_ = v;
}
private:
int v_n_ = 0;
int v_ = 0;
int max_n_ = 0;
};
class Proposer {
public:
void Proposal(int n, int v) {
n_ = n;
v_ = v;
if (!Prepare()) {
return;
}
Accept();
}
private:
bool Prepare() {
int resp_cnt = 0;
int max_n = 0;
int max_n_v = 0;
for (auto acceptor : acceptors_) {
auto resp = acceptor->Prepare(n_); // 实际可能失败
++resp_cnt;
if (resp.n > max_n) {
max_n = resp.n;
max_n_v = resp.v;
}
}
if (resp_cnt < acceptors_.size() / 2 + 1) {
return false;
}
if (max_n > 0) {
v_ = max_n_v;
}
return true;
}
void Accept() {
for (auto acceptor : acceptors_) {
acceptor->Accept(n_, v_); // 实际可能失败
}
}
private:
int n_ = 0;
int v_ = 0;
std::vector<Acceptor*> acceptors_;
};
参考文献
Leslie Lamport. 2001. Paxos Made Simple. www.microsoft.com/en-us/resea…