导读

对于每一个分布式系统来说,safety和liveness都是核心问题,前者强调节点之间状态要满足某些约束,而后者强调诚实节点的状态不能一直停滞不前。

然而FLP定理说了这样一件事:在消息可能无限延迟的情况下(异步网络),即使系统存在一个可能故障的节点,也不存在一个满足safety又保证在有限时间内终止的确定性共识算法。

所以safety和liveness是一对矛盾,如同线段的两个端点,每一个分布式系统都落在线段某个端点上,而不能同时占据两点。

但实际上,消息不会无限延迟,大部分时间里,消息到达都有一个时间上限。因此共识算法虽然在理论上不是完美的,但在实践中已经足够了。

本文用平白的语言讨论basic-hotstuff对safety和liveness的证明,并配上图例来帮助理解。

回顾basic-hotstuff

basic-hotstuff过程如下:

  • NewView
    • replica通过NewViewMSG把自己的PrepareQC发送给leader
  • prepare阶段:
    • leader收集各节点发送的NewView,选择其中view最高的qc_high, 基于此qc_high提出自己的proposal并且广播。
    • replica收到proposal,通过SAFENODE()判断是否接受。如果接受,将 self.prepareQC = qc ,就返回该proposal及自己对它的签名。
  • PreCommit阶段:
    • leader收集到n-f个签名后计算出qc,将其广播。
    • replica收到qc后,将自己的PrepareQC = qc。并且继续对 <qc.type, qc.view, qc.node> 进行签名。(将type纳入签名可以防止重放攻击)。
  • commit阶段:
    • ledaer收集n-f个pre-commit阶段的签名,计算qc并广播。
    • replica收到qc后,锁定qc.node (self.locked_qc = qc), 继续对<qc.type, qc.view, qc.node> 进行签名。
  • decide阶段:
    • leader收到n-f个 commit阶段的签名,计算qc并且广播,随后leader进行view change.
    • replica收到qc后,执行proposal,并且进行view change。

SAFENODE() 函数是这样的:
image.png
注意, 在调用时,参数qc == node.justify , 也就是node之前达成的最新共识,并非对node本身达成的共识。

safety证明

节点\分支冲突

  1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606399073860-7eb13f45-9251-46b6-b41a-4a27c7070107.png#height=201&id=H7w0Y&margin=%5Bobject%20Object%5D&name=image.png&originHeight=282&originWidth=574&originalType=binary&size=17987&status=done&style=none&width=409)<br />如上图,分支[1, 2]与[1, 2, 3], [1, 2, 4] 不冲突,但是[1, 2, 3]与[1, 2, 4]冲突。<br />节点1与2不冲突,2与3或者4都不冲突,因为都在同一个分支上。但是3和4冲突。

Valid quorum certificate

  1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606405802577-5e4ee606-1075-446f-a76f-48968c444c15.png#height=56&id=qSRMt&margin=%5Bobject%20Object%5D&name=image.png&originHeight=106&originWidth=885&originalType=binary&size=37004&status=done&style=none&width=466)<br />首先定义什么样的qc是有效(valid)的:
  • 门限签名函数 tverify(<qc.type, qc.viewNumber, qc.node>, qc.sig) 返回true,表示qc是safe的。

什么意思,可以理解为节点A收到B发来的QC,通过B的公钥验证B确实对<qc.type, qc.viewNumber, qc.node>
签名了,那这个qc就是有效的。

引理1

  1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606405825873-dc22ae93-ba3b-4671-a987-ec54e9166d8b.png#height=93&id=W6FFT&margin=%5Bobject%20Object%5D&name=image.png&originHeight=165&originWidth=866&originalType=binary&size=46326&status=done&style=none&width=486) <br />任意有效的qc1, qc2, 满足qc1.type = qc2.type。如果qc1.node与qc2.node冲突,那么qc1.viewNumber != qc2.viewNumber.

这个通过反证法证明:假定qc1.viewnumber == qc2.viewnumber成立。

  • type相同,表示这两个qc对应于同一个view的同一个阶段。
  • 某个view的某个阶段中,一个诚实节点只投一次票。
  • 同一个view下同一个阶段,形成一个qc需要超过2/3的诚实节点投票,形成两个qc就说明至少有一个诚实节点投了两次票,这跟诚实节点的定义矛盾。
  • 证毕。

定理2

  1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606405857073-a568dbbf-3c14-4d0f-a01f-b6992cd562f1.png#height=82&id=PPa54&margin=%5Bobject%20Object%5D&name=image.png&originHeight=122&originWidth=859&originalType=binary&size=33184&status=done&style=none&width=574)<br />**如果两个节点w和b相互冲突,那么w和b不可能同时被一个诚实节点提交**。换句话说,一个诚实节点提交了一个分支A之后,不可能再次提交一个与A冲突的分支B。

通过反证法证明,令qc1表示节点w的commitQC,qc2表示节点b的commitQC,各自的viewnumber是v1和v2。

  • 假设两个节点w和b相互冲突,它们同时被一个诚实节点提交了。
  • 根据引理1,可知v1 != v2, 下面假定v1 < v2。
  • 随后定义了一个集合 E(prepareqc) 和一个qcs,其性质为:

    image.png
    直观地看,qcs就是分支b上第一个与分支w冲突的节点对应的有效prepareQC,
    image.png
    qcs.node必然存在,比如它可以是节点b对应的prepareQC的node。

  • 在所有给qc1签名的节点中,令r是第一个给qcs签名的节点。r一定存在,不然没人给qc1和qcs投票了。

  • 因为qc1.view = v1 < qcs.view = vs, 那么在vs前,r的locked_qc一直为qc1(图中节点w)。
  • 那么在view=vs的prepare阶段,r通过 SAFENODE(qcs.node, qcs.node.justify) 来判断是否接受qcs.node。我们借助上图来说明:
    • qcs.node与w冲突,因此SAFENODE()第一个判断为假
    • qcs.node.justify对应的是节点2,它的view=2 < w.view, 因此SAFENODE()第二个判断为假
  • 所以 SAFENODE() 返回假,因此r不可能接受qcs.node。
  • 因此,不存在这样的r;这与上面的r一定存在相矛盾,因此假设不成立。

Liveness证明

在basic-hotstuff中没有明确定义选主和进入nextView,这两个函数不影响算法的safety,但是可能影响到liveness。
在证明中,假定系统有 n = 3f+1个节点。

match

证明liveness需要用到一个概念匹配(match):

  • qc1与qc2匹配,等价于qc1和qc2都是有效的,而且他们的node和viewnumber都是一致的。

那么在一个view中, prepareQC, precommitQC, lockedQC, commitQC都相互匹配。

引理3

  1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606405892216-ea842464-4ea6-4c94-b7a3-50b5af241b8f.png#height=81&id=i3LCE&margin=%5Bobject%20Object%5D&name=image.png&originHeight=141&originWidth=879&originalType=binary&size=54332&status=done&style=none&width=504)<br />证明很简单:按照算法流程,如果锁定了qc,那么在这个view的prepare阶段有 `n-f = 2f+1` 个给PrepareQC的投票,因为恶意节点最多f个,那么至少有f+1个诚实节点投给了prepareQC,也就是说:至少f+1个正确节点有prepareQC。那么下一个Leader收集的n-f个NewView,**必定包含了之前最新的QC-high。**

但是,不保证NewView中的qc-high是最新的,这要求leader告知所有节点,NewView可能返回一个安全的locked的QC,但不是最新的qc-high,可能产生临时分叉。解决办法:

  • 引入decide阶段,让leader告知n-f个节点最新的qc-high。这是完整的hostuff实现需要的
  • non-leader节点等待一段时间,提高leader抢先进入下一视图的概率。 (hotstuff-consensus没有实现decide阶段leader广播的操作,所以要等一等)

定理4

  1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606405769906-e0dd043a-dc62-4345-8f29-8535f9090e12.png#height=101&id=gR3ab&margin=%5Bobject%20Object%5D&name=image.png&originHeight=151&originWidth=856&originalType=binary&size=51061&status=done&style=none&width=571)<br />如果网络环境进入稳定(GST之后),系统存在一段有界的持续时间T,在T之内所有节点都进入了view=v中,而且此时leader是诚实节点,那么在T内就可以达成共识。

啥意思?这个定理讨论了hotstuff进行view-change时的一些保证,就是在GST之后,上一轮消息可以传到下一轮,在若干个GST之后proposal会被提交。

证明过程:

  • GST之后,所有节点都同步在view=v+1,leader也是诚实节点,这是前提。
  • 假设在先前的view中,诚实节点中最高的lockedqc=qc,那么根据引理3,至少有f+1个诚实节点有匹配的prepareQC
  • 在NewView阶段,leader收集n-f个newViewMsg,那么f+1个诚实节点会把qc发给leader, 因此leader一定收到了qc。

    1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1606411513585-dfb648a7-dffc-4b4b-901a-790df722d8ae.png#height=443&id=MYizl&margin=%5Bobject%20Object%5D&name=image.png&originHeight=680&originWidth=970&originalType=binary&size=49203&status=done&style=none&width=632)
  • 随后leader基于qc提出自己的proposal并且进行广播。那么根据前提,所有诚实的replica都会对其进行 SAFENODE() 检查,如果判断成立,那么所有诚实节点都会接受这个proposal。

  • 这样,不断重复,总有一天会有三个连续proposal的确认,从而提交前面所有proposal。

那么SAFENODE()能否通过呢?论文中的论述是SAFENODE()的第二个分支也就是 qc.viewNumber > locked.viewNumber必定成立,从而SAFENODE()必定为真。

但是笔者举了个例子发现这个论断不一定满足:前一个view产生了lockedQC但是没有产生commitQC就进入下一个view,新leader发过来一个proposal其父节点是上一个view的proposal,那么此时判断不成立,因为qc == lockedQC.

笔者起初认为是自己没有理解论证过程,但是花了一些时间发现不对劲。后来查阅文献发现https://www.jianshu.com/p/0458a3bd9dc2这篇文章有人已对此提出质疑:27行的判断不一定成立,例子与笔者所想相近。但这不影响最终的结果,因为qc==lockedQC, 那么说明prop延伸自lockedQC.node, 因此第一个判断为真,因此SAFENODE()仍然为真。

上文的作者给出了一个修改的证明, SAFENODE(prop, prop.justify)中满足: prop.justify.viewNumber >= lockedQC.viewNumber。

那么被锁定的qc.node会被丢弃吗?不会

  • 上一轮共识已经生成了commitQC=qc,那qc已被提交执行。
  • 如果上一轮共识中qc被锁定但未提交,那会不会出现下面这种情况?

image.png
如上图,a锁定在节点3, b在view=4时形成了prepareQC。a不会切换到分支[2, 4], 因为 SAFENODE() 两个判断都为假。随后4还没进入下一阶段就发生了view change。新leader c基于节点4提出了proposal 5:
image.png
节点a收到proposal 5,进行 SAFENODE() 判断,第一个判断为假,但是第二个判断为真,因此进行分支切换,丢弃节点3。
这是不可能出现的,由引理3得知,a锁定在node 3,那么在node 3的prepare阶段有至少f+1个诚实节点投给了prepareQC, 剩下 2f < n-f 个节点,这些节点可能没收到node3, 但是它们全部加起来也不能给node 4生成prepareQC,因此不会出现后面的情况。
那么什么时候出现分支切换?如上图,如果3没有走到locked,那么就会出现分支切换。因为没有走到lock阶段,b的locked_qc.node = node 2。

其他

view\height与node的对应关系?

准确的表述是:

  1. 一个处于locked\committed的proposal,必定对应唯一一个view\height。
  2. 已锁定分支上的节点的view严格单调递增(不一定连续)。
  3. view相同的多个proposal,最多只有一个能形成qc。

1、2可通过safety算法中的两个fencing token来保证:

  • vheight,只有大于vheight的节点才可能被节点接受。
  • locked_node.height, 新节点的height只有大于这个height才可能被锁定。那么 v1 < v2 < v3 ... 这种严格单调递增数列,一定是单射。

举个例子说明3:

  • 第一种,就是这些节点p1, p2是冲突的。这可以由引理1保证。
  • 第二中,类似 3<-4<-4<-4 这条分支,思路还是引理1的反证法,任意两个quorum的交集至少有f+1个节点,其中至少有一个诚实节点它不会投给第二个4。但是第二个4仍然可以用来更新其前置节点的状态。