分布式算法小结[1]: Broadcast Abstraction

分布式算法小结[1]: Broadcast Abstraction

随便写写

内容主要来自于题图的这本书(Fault-Tolerant Message-Passing Distributed Systems: An Algorithmic Approach), 对于立志于研究分布式系统理论的同学, 非常推荐. 也推荐给觉得看完DDIA之后不过瘾, 想比较系统的入门分布式算法的同学

(分布式算法的经典书都太老了, 且笔者基本都买全了, 就笔者来看, 从综合的角度说, 这本2018年的书是最好最实用和贴近工业界的一本, 可惜amazon还没有任何评分和书评, 毕竟学习理论的是小众)


Why distributed system is hard?

Differently from sequential computing for which there are plenty of high level languages (each with its idiosyncrasies), there is no specific language for distributed computing. Instead, addressing distributed settings is done by the enrichment of sequential computing languages with high level communication abstractions.
—— Raynal, Michel. Fault-Tolerant Message-Passing Distributed Systems (p. 152). Springer International Publishing

分布式系统的特点和难点在于不确定性,这种不确定性主要来自于:

  1. 旧信息: 得到的信息"可能是过老而无用的信息", 没有有效的手段来瞬间得知其他process的状况, 所以要在"不清楚自己现在得到的信息到底有没有过时的不确定性下做决定"
  2. 弱控制: 很多因素无法控制, 比如消息是否丢失什么时候会丢失, 进程是否被杀掉, 机器是否挂了。
  3. 不可靠: 通信是不可靠的, 机器是会挂的, 消息是会丢失的, 时钟是会偏移的; 且到底消息传到了么?到底机器挂了么?都无法准确的检测.

利用不准确的信息, 很弱的控制在不可靠的“基础设施”上构建非常可靠的系统, 这是分布式系统独特的艺术, 也是这个领域的魅力所在. (还有一个我很喜欢的比喻是: 用一块正确率只有99.99%的cpu, 写出正确率100%的程序来.)


这篇总结了几种分布式系统常用的broadcast算法, 从简单到复杂, 提供的服务越来越强, 越来越可靠; broadcast对于实现信息复制有重大意义, 比如分布式数据库从本质上讲都要用broadcast来做数据的replication, 提供越强consistency服务的数据库则需要实现越强的broadcast算法。


系统模型

一个比较好model现实世界的系统模型, CAMPn,t[∅]

  • C = Crash Failure, 你可以理解为机器砸了, 硬盘销毁
  • A= Async,
  • MP = Message passing,
  • n, t = n个process, 最多t个processes可能会crash
  • = 没有特别的系统假设, 默认使用Reliable communication channel, 如果可以假设 t<N/2, 即最多不会挂掉半数以上的process, 那么系统模型变为 CAMPn,t[t< N/2]

Crash Failure 和Async

  • 由于Async模型里需要考虑消息传递和处理时间没有已知上限的问题, 所以这个模型可以很好的模拟现实世界里: 机器挂了,机器重启, , 网络断联,断联重接, 网络被分割(network partition)等问题

Message passing(share nothing) VS. Shared Mem

  • 很好的模拟了目前share nothing互联网环境, 而不是比如超级计算机的多cpu 共享内存的环境

Reliable communication channel

  • 如果通信的双方不死,那么消息一定可以传递到达; 这个假设从虽然看起来很强, 但其实用一个底层点到点通信模型不断的重发信息, 是可以对上层实现一个"可靠"的抽象的, 这也算是一个比较"务实"的假设

FC(Fair Channel)

  • 相对于Reliable channel, FC是不稳定的, 消息会丢失, 并不一定可以一定传到, 但是它又是Fair的, 即如果发送端发送无限次某msg-A, 接收端最终可以接收到这个消息msg-A; 这样就把所有极端情况都囊括在内了;

CAMPn,t[-FC]

  • 如果系统模型需要考虑通信的不稳定性, 则可以使用CAMPn,t[-FC] 模型, 由于FC这个假设比Reliable communication channel的假设要弱, 所以前边加一个负号(-FC)

Faulty/non-faulty process

  • 在算法运行过程中crash掉的process叫做Faulty process, 而一直都没有crash的叫做non-faulty process; 对于Faulty process, 笔者有时也会说"process挂了, 死了, crash了等", 都是一个意思


本文考虑的系统模型,只考虑固定的机群process数量N(N不变, 成员也不变), 和使用点到点的网络拓扑; (这是为了简化讨论, 现实情况中N和成员会变化的情况需要扩展算法, 而提供app层一个点到点的抽象也简化了问题, 实际网络拓扑总能"模拟"点到点网络即可)


各种广播抽象(Broadcast)

所谓广播抽象你可以认为是指我们的分布式算法实现了一个“中间层”, 它利用底层的收发消息的api(所以我们不考虑网络细节TCP还是UDP等)来向上层app层提供2个高层API, 一个broadcast(v)来广播v给机群的所有节点, 一个delivery()来从底层拿取自己或者别的process曾经broadcast过的v,注意: 这意味着一个msg即使中间层我们的算法逻辑已经收到了, 我们的算法逻辑也可以决定暂时不把msg交付给上层app(当app层调用delivery()时), 这是实现各种delivery顺序的关键。

  • Best effort broadcast
  • URB
  • FIFO-URB
  • CO-URB
  • SCD
  • TO

Best effort broadcast

就是对已知的节点一个一个msg的发送, 不做任何保证, 如果发到一半发送msg的process挂了, 那么会造成机群里的process收到的msg是不一样的; 这是最弱的broadcast类型, 一切看天.


注意: 以下的广播抽象都提供更稳定的保证Reliable Broadcast Communication Abstraction, 关于广播抽象的正式定义请找原书, 笔者只列出笔者觉得最重要的属性.

URB (Uniform Reliable Broadcast)

(由于后边要讨论Broadcast的Quiescent属性, 这里使用CAMPn,t[-FC] 这个模型)

URB-termination-1. 如果一个Non-Faulty(一直没挂) Process broadcast了msg-m, 那么所有Non-Faulty process都肯定最终可以Delivery(拿到)这个msg-m

URB-termination-2. 任何process(注意这里没说是挂了还是没挂的), 只要它曾经delivery过某msg-m, 那么所有其他Non-Faulty process都肯定最终可以Delivery这个msg-m

这2条定义了哪些消息一定要收到=> URB保证了所有正确的process总是能收到完全一样的消息集合A, 死掉的process收到这个集合A的一个子集.

注意: 这两条是后边介绍的所有broadcast都满足的属性, 以后介绍其他broadcast算法时不再说明.

URB实现简述:

broadcast就挨个给其他process发msg, 对于其他process来说第一次收到这个消息msg之后就转发给所有其他process, 所有的发送都不停的发送直到对方ack, 只有当收到t+1个process的ack, 才delivery这个消息给上层app; 因为此时本process知道:

  1. 至少一个non-faulty的process收到了这个消息 (系统假设最多t个process会失败)
  2. 这个non-faulty的process会不停的发信息给所有人直到所有人都ack

而所有process都必须知道这个信息, 才能向上层delivery这个msg, 由1,2知,所有的delivery的msg其他process必定也会delivery.


无法实现的URB

当t > N/2 时, UBR在CAMPn,t[- FC]模型里无法实现, (具体证明略过, 有兴趣的读者请看书), 比如上边的算法就失效了, 因为当t> N/2时, 那么如果一开始t个都挂了, 那么所有的process就需要一直等待(还活着N-t个process, 数量小于N/2, 而算法需要收集到t+1个回复, 这不可能, t>N/2 => t+1>N/2)

Failure Detector abstraction:

t<N/2这个假设很强, 也很不灵活, 而如果能提供必要的信息供系统使用, 就可以隔离关于系统模型的假设; 对于failure detector来说, 算法只要依赖于failure detector提供的关于failure的信息就可以解决问题的话, 对于任意failure detector的实现, 只要能满足它定义的属性, 它们实现的细节是分布式算法不用关心的. 即分布式算法只依赖于这些属性, 而不依赖于具体实现和具体环境的细节. 这就使得算法和系统设计更加模块化。

同时, 使用failure detector可以可以使我们直接研究一个系统需要对 failure 的观察"准确和全面"到何等程度, "最少"需要多少对于failure的信息, 一个分布式系统的问题才能被解决. 这是学术上的failure detector的意义所在,

Failure Detector Class Θ

Θ是这样一类failure detector, 它的输出值是还没有crash的process的集合, 它总是保证有一个没crash的(non-faulty)process的判断总是正确的, 而最终, 某时间点后, 它所属出的process绝对正确(没crash的process的判断都是正确的)

Failure Detector Class Θ可以用来代替URB里的 t<n/2的系统假设, 且可以用 t<n/2 的假设来实现(维护一个队列, 每收到一个其他process的msg就把这个process放在队列最前边, 报告还没crash的process时, 取队列最前边n-t个process的Id)

可以看到 Θ 可以很容易的代替上边算法中“收到t+1”个msg这个检查, 因为这个检查是为了保证至少有一个“non-faulty”的process收到了msg, 通过把算法的这一步变为"Θ 报告的没crash的process都收到了本process发的msg", 也同样能达到这个效果, 这样我们就把这个分布式算法和t<N/2这个系统假设隔离开了.

Quiescent Uniform Reliable Broadcast

Quiescent属性是指算法只需要有限次数的消息传递即可.

之前URB的实现是非Quiescent的, 原因就是在于无法区分一个process是死了, 还是消息丢失了, 所以只要有一个process没有ack之前就死了, 其他process就需要一直向其发送msg, 永不停止.

3种达成Quiescent的方法

  1. 使用failure detector P: 这是一种完美的failure detector, 输出crash掉的process的id集合, 并保证可以准确无误地检测failure, 绝不会把没crash的process错误的检测为已经crash了, 这种failure detector允许实现terminating URB, 因为可以完美检测错误, 那么被检测到crash的不用再发信息就好了, 而没crash的好的process一定会最终ack, 所以不但可以实现Quiescent, 算法也可以终止。
  2. 使用failure detector <>P: 这是一种eventually perfect failur detector(符号<>代表eventually的意思), 最终在某未知时间之后不再发生错误, 由于不知道什么时候才能保证对faulty的process的检测完全正确, 所以process要一直注意自己的<>P的输出结果变没变(如果突然有之前检测到是crash的process其实没有crash, 那么就需要开始向其发不断发信息来保证其delivery其他没crash的process已经delivery的msg),所以算法无法停止(需要不断观察<>P的检测结果变化), 但是至少可以达成Quiescent效果(由于可以检测到crash, 所以不会向一个已经crash的process发无限的msg)
  3. 使用failure detector HB(heartbeat): 利用Process间的heartbeat来检测failure, 太久没有heartbeat的process认为crash掉了(非常实际的一种failure detector), URB算法需要给发来heartbeat但是还没ack的process不停的发msg来保证msg的传达, 而对于过久没有发来heartbeat的process认为其已经crash而不再发msg,由于不知道什么时候有"die"的process会重新发来heartbeat, 所以算法无法终止, 但是真的crash了的process不会再发来heartbeat, 所以不会再向其发msg(可以达成Quiescent)

注意1: 只有使用failure detector P, 才能实现Terminating Uniform Reliable Broadcast; <>P和HB由于需要不断的检测是否有“死了的process其实是误判”,而无法停止.

注意2: 在我们目前的互联网环境, P和<>P都是无法实现的(除非改掉物理实现, 以全球之力重铺互联网。。。大概。。 -_- ), 没有完美的failture detector是分布式算法最大的障碍和难点。

FIFO-URB

First In First Out Order: 这是一个加强型的URB, 除了满足URB的条件之外, msg delivery的顺序一定要尊重消息的本地broadcast顺序, 即同一个process A所broadcast的2个信息m1和m2, 所有delivery了这两个msg的process必须按A的broadcast顺序来delivery这两个信息, 不同process broadcast的信息的顺序无所谓.

实现简述: 对broadcast信息的process来说, 发送时要标注这是本process发的第几个信息(比如一个序列号), 对所有process来说, 普通URB delivery条件满足之后, 检查是否发送此msg的process之前所发的信息都收到了(比较自己已经delivery了几个它的msg和本次msg的序列号), 来决定是否向app层递交此消息

CO-URB

Causal Order: 进一步加强了FIFO-URB, 除了尊重本地FIFO order之外, 如果process-A delivery了m1, 然后broadcast了m2, 那么可以理解为m2在因果关系上依赖于m1(casual depend on m1), 那么所有process在delivery m2之前必须delivery m1, 尊重这种delivery顺序的URB就是CO-URB

实现简述: 每个process维护一个vector来记录自己已经delivery了多少个别的process的msg, 发消息的时候把这个vector附加在消息里, 这样接收方可以对比自己本地的vector和收到的msg的vector来判断此msg causal depend on的msg集合是否都已经收到了;

SCD Broadcast (Set-contraint delivery broadcast)

这是2017年才发现的一种Broadcast, 它的重要意义是

  • 它等同于Atomic Read/Write Register, 也即等同于Linearizability (用分布式Atomic Read/Write Register可以实现SCD Broadcast, 也可以反过来用SCD Broadcast实现Linearizability的Atomic Read/Write Register) .
  • SCD Broadcast比consensus或者Total Order Broadcast提供的保证弱, 但是不同于consensus, 它是可以在CAMPn,t[t< N/2]环境中实现的, 而不需要额外的系统假设.

SCD Broadcast比较重要的是增加了对msg delivery的顺序的特别要求, 首先msg是一个集合一个集合delivery的, 而在同一个Set里的msg不分先后顺序, 但是如果对任意某process-A来说一个msg-m如果是在msg-m' 之后delivery的, 所有其他process有2种选择:

  1. 如果用不同的msg set来delivery m和m' , 那么顺序必须和process-A一样
  2. 或者把m和m'用一个msg set来delivery

满足SCD顺序的例子:

  • p1: {m1, m2}, {m3, m4, m5}, {m6}, {m7, m8}.
  • p2: {m1}, {m3, m2}, {m6, m4, m5}, {m7}, {m8}.

不满足SCD顺序的例子:

  • at p1: {m1, m2}, {m3, m4, m5},...
  • at p2: {m1, m3}, {m2},...

实现简述: 为了简化算法描述, 我们在底层使用一个FIFO的点到点通信channel, 实现算法的关键在于

  1. 所有process都维护一个本地seqNum,
  2. 所有process对每一个收到的某msg-A都维护了一个qdplt.cl的结构, 来记录其他每个process收到msg-A的时, 它的本地seqNum; 所以qdplt.cl可以看作消息的"机群级时间戳", 初始值为无穷大, 在没有收到其他某process-X的汇报之前, 保持qdplt.cl[X]为无穷大。
  3. 所有process在第一次收到这个broadcast的msg-A之后, 都要把它存在一个本地buff里, 且都必须向其他所有process broadcast这个msg-A, 目的是报告自己看到这个msg-A时的本地seqNum, 这样如果没有消息丢失和process crash, 每个process会收到N个msg-A; 对所有process来说, 当从其他不同的process收到重复的msg-A汇报时, 就把别的process汇报的本地时间(seqNum)记录在buff里的msg的qdplt.cl结构里, 比如(qdplt.cl[0]=Infinite(无穷大), 代表0号process还没有汇报收到msg-A, qdplt.cl[10]=100, 代表10号process已经报告收到msg-A, 且收到msg-A时它的本地时间是100, 所有process都要维护这样针对msg-A的一个本地数据结构)
  4. 一个process收到至少N/2个其他process的msg-A(此时知道了至少N/2个其他process看到这个msg的时间), 才能决定把msg-A放在“待delivery”集合里, 此时所有“待delivery”集合里的msg都要和buff里的其他还没有收到的其他msg比较它们的qdplt.cl时间戳.
    1. 如果msg-A有多于N/2个的process在qdplt.cl里的值都比buff里的某msg-B的值小, 那么msg-B必定没有被其他process先于msg-A delivery; (由于msg-B还没凑齐它的t+1个汇报, 它的qdplt.cl里有多于N/2的无穷大; 而由于FIFO的假设, 对于某process-X, 如果收到了msg-A的时间汇报, 而没有msg-B的,那么msg-B必定晚于msg-A被process-X收到) 可以看到msg-A的条件如果满足, 那么msg-B则绝对无法在其他process收到的汇报里满足此条件; 所以msg-B绝对不会早于msg-A delivery. 如果msg-A和所有buff里的消息比较完毕, 以上条件满足, 那么msg-A就可以确定可以delivery给上层app. 而所有满足此条件的msg-A0, msg-A1...可以组成一个set来delivery
    2. 否则msg-A可能需要等待msg-B的t+1个汇报(或者说转发凑齐了)大家聚在一起delivery, 这样msg-A和msg-B的顺序就无所谓了。

从SCD可以看出分布式算法设计的一些难点:

  1. 一个process不能等所有其他process的信息汇报, 只能等最多N-t个, 否则会有liveness的问题,
  2. 大家看到的N-t个process可能是不一样的process.
  3. 可能有多数派(>N/2个process)决定了m < m', 但是可能有process收到了多数派的decision,但是有些其他process没收到, 这就产生了不确定性 (比如N/2以上的process都决定某个顺序了, 但是这N/2个process里挂了t个, 这时对于其他没收到这t个process信息的process来说, 它们收到的是其他t+1个process的决定, 在他们的视角里, 它们根本不知道有多数派决策产生了)

TO Broadcast (Total Order Broadcast)

Total order broadcast的要求很简单, 所有process delivery任何msg的顺序必须一样.

TO Broadcast是最重要的broadcast(见备注), 但是因为它和consensus等价, 所以最好和consensus一起单独写一篇来说, 这里只简单写下.

一个简单(但是效率低)的实现可以利用consensus协议+UBR broadcast

broadcast()就是调用底层的UBR broadcast

每个process维护ubr_delivery和to_ready_delivery两个列表和一个seqNum, 用来实现delivery操作

  • process需要一直比较ubr_delivery和to_ready_delivery的不同, 如果发现有新信息, 增加本地seqNum, 向第seqNum个consensus算法的实例proposer任意一个不在to_ready_to_delivery里的值, 得到一个consensus值, append在to_ready_to_delivery的后边; 注意得到的consensus值可能并不是自己propose的值, 这代表有其他process已经把这个坑(seqNum所在的consensus实例占用了), 那么算法会回到开始, 由于自己propose的值并没有加入到to_ready_delivery里, 那么这个值会再次被检测并用新的更大的seqNum来propose.
  • 在to_ready_to_delivery这个数据结构里所有没有delivery的msg, 按顺序delivery即可.
  • TO的顺序即 各个consensus算法实例里存的值。

效率高的TO broadcast实现其实可以看作multi-paxos的变种, 也是各大分布式数据库的核心, 因为TO可以很好的实现replicate state machine, 来实现数据的replication而不会有consistent的问题.


一些备注:

  • 可以看到任何可以实现URB的环境, FIFO-URB和CO-URB都可以实现
  • SCD broadcast和atomic register(linearizability)是等价的, 即它们可以相互实现; (有了A可以实现B, 有了B可以实现A)
  • SCD broadcast是比URB要难的, 这在以后介绍Atomic Register的时候可以看出来, 这是由于URB需要Failure Detector Class Θ , 而Atomic Register需要Failure Detector Class ∑ (又叫quorum failure detector), ∑比Θ强, 所以Atomic Register比URB难, 而Atomic Register和SCD broadcast等价, 所以SCD broadcast比URB难.
  • TO broadcast和consensus是等价的
  • 分布式系统里的atomic register 并不是"Universal"的,相对来说read/write register在sequential process的世界里是"Universal"来说(即有了read/write register抽象之后, 可以基于它实现各种其他数据结构),
  • TO broadcast和consensus是分布式环境中的"Universal"存在, 即任意可以用sequential specification定义的分布式抽象, 都可以由TO broadcast和consensus算法实现, 这是分布式系统区别于一般程序语言所提供的数据结构抽象最大的区别; consensus在sequential process的世界是不存在的。
  • consensus在 系统模型CAMPn,t[∅]里是无法实现的, 这就是FLP impossibility.


不过这些都是后话了, 有人看的话, 下一篇会小结一下分布式系统中的read/write register这个抽象。(主要有3种, regular register, atomic register和sequential register)

这篇到此为止. (感觉是不是坑越挖越多了。。。。)

欢迎提问 :)

编辑于 2019-12-02

文章被以下专栏收录

    很多书和论文(特别是论文),写的比较晦涩难懂,有时候看懂了,但是没有把自己当时怎么理解的记录下来,很快就会忘记。我自己已经有习惯在自己的笔记本上记录我的理解,和一些我觉得珍贵的知识了。而且这些记录也是给自己的知识作索引,因为很多时候人无法把所有东西都塞在脑子里边,记住什么东西的细节能在什么地方找到,这才是读书最重要的。 既然自己本身就会记录这些东西,那么不如找一个大家都能看到的地方,分享知识吧,这,就是建立这个专栏的原因了