实用拜占庭容错PBFT算法详细介绍
本文主要讲述实用拜占庭容错算法(PBFT)的算法部分。
0 摘要
OSDI99这篇论文描述了一种副本复制(replication)算法解决拜占庭容错问题。作者认为拜占庭容错算法将会变得更加重要,因为恶意攻击和软件错误的发生将会越来越多,并且导致失效的节点产生任意行为。(拜占庭节点的任意行为有可能误导其他副本节点产生更大的危害,而不仅仅是宕机失去响应。)而早期的拜占庭容错算法或者基于同步系统的假设,或者由于性能太低而不能在实际系统中运作。这篇论文中描述的算法是实用的,因为该算法可以工作在异步环境中,并且通过优化在早期算法的基础上把响应性能提升了一个数量级以上。作者使用这个算法实现了拜占庭容错的网络文件系统(NFS),性能测试证明了该系统仅比无副本复制的标准NFS慢了3%。
1 概要介绍
网络时代的到来,恶意攻击和软件出错问题越来越常见,很容易出现拜占庭错误。本文提供一种实用拜占庭容错算法(Practical Byzantine-Fault Tolerance),在异步系统下,只要保证错误节点 其中 为错误副本节点数, 为系统总节点数,系统就能在容错前提下,保证安全性和活力(safety & liveness)。
该论文提出一种状态机副本复制的算法(一种允许对执行给定是确定性的 任意计算的服务进行复制的状态机复制的形式,也就是说,当它们处理相同的操作序列时,复制必须产生相同结果的序列。),可以在异步环境下安全运行。之前有些解决拜占庭问题的方法,但是要么就只是理论性,要么就是依赖同步来保证安全性,在恶意攻击下极为脆弱,直到系统自行排除错误节点,要是误认非错误节点就凉了。咱们这算法可厉害,减少通信开销还能在恶意攻击下保证安全性。最后这个算法还落地了,实现了一个文件系统,这个暂且不说。
2 系统模型
系统假设为异步分布式的,通过网络传输的消息可能丢失、延迟、重复或者乱序。作者假设节点的失效必须是独立发生的,也就是说代码、操作系统和管理员密码这些东西在各个节点上是不一样的,谁也不碍着谁。
作者使用了加密技术来防止欺骗攻击和重播攻击,以及检测被破坏的消息。消息包含了公钥签名(其实就是RSA算法)、消息验证编码(MAC)和无碰撞哈希函数生成的消息摘要(message digest)。使用m
表示消息,m i 表示由节点i签名的消息,D(m)表示消息m的摘要。按照惯例,只对消息的摘要签名,并且附在消息文本的后面。并且假设所有的节点都知道其他节点的公钥以进行签名验证。最后大概是这样的:m || sig(D(m))
。
系统允许敌手可以操纵多个失效节点、延迟通讯、甚至延迟正确节点。但是不能无限期地延迟正确的节点,并且算力有限不能破解加密算法。例如,不能伪造正确节点的有效签名,不能从摘要数据反向计算出消息内容,或者找到两个有同样摘要的消息。
3 服务属性
该算法被用来实现带有状态和一些操作的确定性复制服务。操作包括:
- 对服务状态部分内容的简单读写
- 使用状态和操作参数执行任意的确定性计算(执行一个操作必然会得到一个结果)
整个大致流程就是:客户端通过发送请求,来调用操作并阻塞等待回复。复制的服务由副本实现。
3.1 安全性
整个复制过程是线性化的,也就是说这类似一个集中式实现,一次执行一个操作。但是这个安全性也受到错误副本节点数量的限制,在超出范围的情况下,安全性也是保不住的。因为错误的副本会破坏整体的状态。这个安全性是通过非错误副本以一致的方式观察错误副本的形式执行。
当然若是客户端叛变或者错误,系统的安全性不足以防范这种现象。比如说出错的客户端可以将垃圾数据写入某个共享文件,这种是需要通过访问控制开放分层权限来解决。又或者说,客户的请求是修改客户端权限,这种操作会被其他客户端观察到。
3.2 活性
该算法不依赖于同步来提供安全性。因此,它必须依靠同步来提供活力,也就是客户最终会收到对他们请求的回复。但是这也是有要求的,一方面错误节点的数量要符合上边的等式,另一方面它的时延不能无限长。这个时延指的是,从第一次发送消息一直到目的地收到消息之间的时间。
3.3 为什么是上方的等式??
因为在同n-f个节点通讯后系统必须做出正确判断,由于f个副本有可能失效而不发回响应。但是,有可能f个失效的节点是好节点,f个坏节点依然发送请求。尽管如此,系统仍旧需要好节点的返回数量大于坏节点的返回数量,即n-2f>f,因此得到n>3f。
3.4 未解决问题
该算法并没有解决错误感知的隐私问题:一个错误的副本可能会向攻击者泄露信息。副本需要明文信息才能有效地执行这些操作。后期考虑使用秘密共享方案。
4 算法
4.1 概述
PBFT算法是状态机复制的一种形式。它将服务建模为在分布式系统中跨不同节点复制的状态机。每个状态机副本维护服务状态并实现服务操作。
我们定义有R
个副本,其角标序号是从0 - R-1
。R = 3f+1
,其中,f
是最大的错误副本个数。实际上R
也可以比3f+1
的值大,但是出于性能最大化考虑,若是R
增大,势必会导致沟通的成本增加,从而降低系统的性能。
这些副本是通过一系列称为view的配置移动,一个视图中,有一个是primary(主节点),其他的是backups(备份节点)。视图都是按序编号,一个视图中primary的编号p
,遵从 p = v mod |R|
,其中v是本视图的编号。当primary失败了,整个视图都会更改。
算法的大致流程如下:
- 客户端发送一个调用服务操作的请求给primary
- primary广播请求给其他backups
- 副本(primary+backups)执行请求,并给客户端一个答复
- 客户端只要等到
f+1
个来自不同副本的同样答复,这个操作就结束了
状态机复制的两点要求:
- 确定性(状态+操作参数 = 确定性计算)
- 始于同一个状态
由于这两点要求,算法保证所有非错误副本对于执行请求的全局顺序达成一致。
话不多说,先上图
4.2 客户端
客户端c通过向primary发送请求消息<REQUEST,o,t,c >c,请求状态机操作的执行o。
- 状态机操作
o
- 时间戳
t
:确保客户端请求执行的这一语义无误;时间戳是按序的,保证后边请求的时间戳高于前边 - 客户端
c
副本发送给客户端的每条消息都包含当前视图号,以便可以找到当前primary。客户端利用点对点方式,发送请求给当前primary,primary就会广播给backups。
副本将关于请求的答复直接发给客户端,回复消息为:<REPLY,v,t,c,i,r >i
v
:当前视图号t
:响应请求的时间戳i
:副本号r
:执行请求操作后的结果
在接受结果r之前,客户端将等待f+1
个来自不同副本的有效签名和相同的t
与r
,这就保证了结果的有效性。
假如客户端没有很快收到响应,它会广播所有副本。就会面临如下情况:(这点后边会说)
- 请求已经被处理过:副本只需重新发送回复(副本会记住发给每个客户端的最后一条回复消息)
- 请求没有被处理过:不是primary的副本会把请求转发给primary (可能primary有问题) 如果primary没有将请求广播到组中,那么它最终会被足够多的副本怀疑是错误的,从而导致视图更改。
4.3 正常操作
每个副本的状态有:服务状态、消息日志(副本已接受的消息),副本当前视图的数字。
当primary p
收到客户端发送的请求m
,它会启动一个三部分协议,自动将请求广播给副本。primary会立即启动协议,除非协议正在处理的消息数量超过了给定的最大值,会采用缓存方式。可减少消息流量和重负载下的CPU开销。
这三部分分别是:pre-prepare
、prepare
和commit
。
前俩阶段确保同一视图发送的请求进行完全排序(即使是提出排序的primary错误也一样)。
后俩阶段确保不同视图的请求是有序的。
4.3.1 pre-prepare阶段
pre-prepare
阶段:primary为请求分配一个序列号n
,广播一个预准备消息(请求m
是附加在后边)给所有backups,并把这个消息加入它的日志里。这个消息为:<<PRE-PREPARE,v,n,d>p,m>
- v:发送消息的视图
- n:请求分配的序列号(id)
- m:客户端的请求消息
- d:m的摘要
请求为啥没包含在预准备消息中?这样看起来就比较小。因为两点:
- 预准备消息证明请求是在视图
v
中被赋予了序号n
,从而在视图变更的过程中可以追索。 - 将全排序请求的协议与传输请求到副本的协议拆开来
副本收到的预准备消息的条件:
- 请求和预准备消息签名都正确、
m
的摘要d
- 当前视图号是
v
- 没接收过在同一
v
中且编号同为n
, 但是摘要不一样的pre-prepare消息 h < n(id) < H
防止恶意primary故意选择大的序列号,从而耗尽序列号空间,还有一个垃圾收集(后边有讲)
4.3.2 prepare阶段
如果backup i
收到了pre-prepare阶段发送的消息,它将进入prepare
阶段。广播一个消息给所有其他副本并且把这**俩消息(自己上一阶段接收的消息+自己这一阶段发送的消息)**都加入自己的日志里。这个消息的形式为:<PREPARE,v,n,d,i>i
接受其他副本的要求:在prepare阶段,一个副本(包括primary)收到消息,如果它的签名是正确的,视图号和副本当前视图号一致,且h < n(id) < H
,就把消息添加到日志中。
定义prepared(m,v,n,i)
为真,当且仅当副本i
已插入其日志,日志包含:
- 请求
m
- 在视图
v
中,序列号为n
的请求m
的pre-prepare 2f
来自不同从节点(只有backup)的对应于pre-prepare的prepare消息(算上自己的那个prepare消息)。
通过检查prepare
与pre-prepare
是否具有相同的视图v、消息序列号n和摘要d来验证是否对应。
为啥要2f个: 因为prepare消息主节点不参与发送, 这样全网3f+1个节点刨去主节点和恶意节点(f+1)个, 剩下的是2f个好节点。
pre-prepare
和prepare
阶段保证所有正常节点对同一个视图中的请求排序达成一致。
证明:如果prepared(m,v,n,i)
为真,那么prepared(m',v,n,i)
就是假,对于任何非错误副本j
(也包括i = j
),并且任何m'
,都有D(m) != D(m')
。这是因为,prepared(m,v,n,i)
和R = 3f + 1
表明至少f+1
个非错误副本已经发送一个视图v
中序列号为n
的请求m
的pre-prepare
或prepare
。因此,对于prepared(m',v,n,i)
要为真的话,在那些副本里至少有一个需要发送两个冲突的prepare
,这俩有相同的视图号和序列号,不同的摘要。但是这个是不太可能的,因为副本是非错误的。
4.3.3 commit阶段
当prepared(m,v,n,i)
为真,副本i
广播消息给其他副本,该消息为:<COMMIT,v,n,D(m),i >i。副本接到消息,签名正确,视图号和当前视图号一致,h < n(id) < H
,就把消息添加到日志中。
条件:我们对committed
和committed-local
有如下定义:
committed(m,v,n)
为真,当且仅当prepared(m,v,n,i)
对于f+1
个非错误副本里所有的i
,都是正确的committed-local(m,v,n,i)
为真,当且仅当prepared(m,v,n,i)
为真,且i
接受了2f+1
个与请求m
的pre-prepare
阶段匹配的不同副本提交(包括它自己)。commit
和pre-prepare
匹配是指:具有相同的视图、序列号和摘要。
commit
阶段确保以下不变: 对于某些非错误副本i
,如果committed-local(m,v,n,i)
为真,那么committed(m,v,n)
也为真。这个不变式和视图更改协议保证了所有正常副本对本地确认的请求的序号达成一致,即使这些请求在每个节点的确认处于不同的视图。更进一步地讲,这个不变式保证了任何正常节点的本地确认最终会确认f+1
个更多的正常副本。
每个副本i
,只有在committed-local(m,v,n,i)
是正确的,才会执行m
请求的操作。而且i
的状态反映了所有序列号较低的请求都是顺序执行。这就确保了所有非故障副本的按序执行,保证了算法的安全性。在执行请求的操作之后,副本向客户端发送一个应答。副本会丢弃最近应答之前的请求,以保证请求只会被执行一次。
4.4 垃圾回收机制
本节讨论用于从日志中丢弃消息的机制。副本节点在删除自己的消息日志前,需要确保至少f+1
个正常副本节点执行了消息对应的请求,并且可以在视图变更时向其他副本节点证明。如果某些副本丢失了消息,它需要通过传输全部或部分服务状态来更新。
消息log不可能无限大,因此需要设定checkpoint
,以实现过时消息的清除。
当一个序列号n
可以被某个常数(例如100)整除(n%100 = 0
)的请求被执行时,它们会周期性地生成证明过程。请求执行后得到的状态称作checkpoint
,确认每个副本都已经执行完第n
个消息了。这样就可以清除掉比n
还要早的消息了。
当一个副本i
生成一个checkpoint
,它会向其他的副本广播消息:<CHECKPOINT,n,d,i >i
n
是最近一个影响状态的请求序号d
是状态的摘要
每个副本收集到2f+1
个不同checkpoint(序列号为n,有相同的摘要)消息后,这2f+1
个消息就是这个检查点的正确性证明。它就会变成stable checkpoint , 然后清除掉比n小的消息。然后将接收消息的窗口调整为(n, n+100)。
4.5 视图转换
我们前边说到,由于主节点失效时,客户端最终会将请求发送到所有其他副本节点。每个节点收到客户端请求后,如果该请求没有执行过,副本节点判断自己是否为主节点,不是的话就会把请求转发给主节点,同时启动一个定时器。
当backup i
检测到计时器超时时,它就会进入view-change流程,首先停止接受消息,并广播view-change消息给所有副本。view-change消息包含如下内容:<VIEW-CHANGE,v+1,n,C,P,i >i
- i:backup编号id
- n:消息序列号,backup
i
中已知最新的一个stable checkpoint所确定的序列号 - C是对应n的
2f+1
个有效checkpoint的消息集合,这个消息能证明checkpoints
的正确性 - P:一个Pm组成的集合,
m
表示消息,m的序号是大于n的,Pm表示消息为m的达成prepared(m, v, n', i)
状态的消息集合。Pm内容包含关于m
的1个pre-prepare消息和2f
条不同副本的prepared消息集合。这些副本有同样的视图号,序列号和m的摘要。
当view+1
所对应的primary
收到了2f
个有效的view-change消息,它就会广播<NEW-VIEW, v+1, V, O>p 消息给其他副本;
- V:是一个集合,其中包含primary接收的有效view-change消息和primary发送给
view+1
的view-change消息。 - O:是一个pre-prepare消息的集合
O由如下计算得出:
- primary根据收到的view-change消息判断,最低的check-point
min-s
(在view中最新稳定checkpoint 序列号)和最高的check-pointmax-s
(在view中prepare消息里最高序列号) - primary为视图v+1为min-s和max-s之间的每个序列号n创建一个新的pre-prepare消息。有两种情况:
(1)在P中存在Pm集合,其序列号为n
(2)没有这样的集合
在第一种情况中,创建一个pre-prepare消息,<PRE-PREPARE, v+1, n, d>p。d是pre-prepare消息中序列号n的请求摘要,其视图号在V中最高。
在第二种情况中,创建新的<PRE-PREPARE, v+1, n, d_null>p。d_null是一个特殊的null请求的摘要。一个空请求在经过协议处理后,和其他请求一致,但是它的执行操作是空。
4.6 safety
if prepared(m,v,n,i) is true then prepared(m’,v,n,j) is false for any non-faulty replica j(including i=j) and any m’ such that D(m’)!=D(m)
prepared(m,v,n,i)和R = 3f + 1表明至少f+1个非错误副本已经发送一个视图v中序列号为n的请求m的pre-prepare或prepare。因此,对于prepared(m’,v,n,i)要为真的话,在那些副本里至少有一个需要发送两个冲突的prepare,这俩有相同的视图号和序列号,不同的摘要,显然这在非错误副本节点来看,是不可能的。
The view-change protocol ensures that non-faulty replicas also agree on the sequence number of requests that commit locally in different views at different replicas. committed(m,v,n) is true if and only if prepared(m,v,n,i) is true for all i in some set of f+1 non-faulty replicas. if committed-local(m,v,n,i) is true for some non-faulty i then committed(m,v,n) is true.
若是commit-local为真,根据之前的定义,副本i
接受了2f+1个commit ,除去错误副本节点,里边至少会有f+1个非错误副本的commit ,反推之前,这f+1个非错误副本经历过prepared,再根据commit部分的定义,可以推出committed 也为真。
4.7 liveness
- replica广播view-change进入v+1,等待2f+1个view-change消息后启动计时器T。如果在T到时前没有收到new-view(新的primary是异常节点),则进入v+2,计时器时间扩大成2T,这是为了避免view改变的过快
- 在定时器过期前,如果收到f+1个view-change消息,并且比自身的view number大,则它会发送一个view-change消息,view number值是这些数值里头最小的,防止下一个view启动太晚
- f+1个view-change消息才能触发view变更,而且primary的轮流选择方案也限定连续最多f轮选择就能选举到正常的primary,错误副本不可能通过强制改变view顺序来阻碍进程
5 优化
5.1 降低通信
1. 避免发送big result;
客户端请求指定一个副本来发送结果;所有其他副本都发送只包含结果摘要的答复。一方面可以在客户端检查正确性,另一方面可以减少网络带宽消耗和大量的CPU开销。如果客户端没有从指定的副本接收到正确的结果,它将像往常一样重新发送请求,请求所有副本发送完整的应答。
2. 将整个过程五个步骤改为四个;
减少流程步骤,replica提前返回reply。当replica收到足够prepare消息,达到了prepared(m,v,n,i) 状态,将要进入commit阶段。这时,如果小于该消息序号n的之前的消息都已经执行生成了当前状态,则replica执行该请求,并且直接返回到client,同时继续后续的commit阶段。
客户端等待2f+1匹配的暂定回复(prepare)。如果接收到这么多请求,则保证最终提交请求。否则,客户端重新发送请求并等待f+1非暂定的回复(reply)。
一个暂时执行的请求可能会中止,如果有一个视图改变,它被一个空请求代替。在这种情况下,副本将其状态恢复到新视图消息中的最后一个稳定检查点或最后一个检查点状态(取决于哪个具有更高的序列号)。
3. 改进只读操作的性能;
只读操作不修改服务状态。对于read-only请求,client直接发送到所有replica,replica直接返回结果到client。client收集2f+1个相同结果的回复,否则按照正常流程重新发起请求。
他们副本只有在暂定状态中所反映的所有请求均已提交后才会发出答复;这对于防止客户端观察未提交状态是必要的。
参考文献
[1] Castro M, Liskov B. Practical Byzantine Fault Tolerance.
[2] Castro M, Liskov B. Practical Byzantine Fault Tolerance and Proactive Recovery.