分布式算法小结[2]: Read/Write Register

分布式算法小结[2]: Read/Write Register

书接上回, 随便写写

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


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

读/写寄存器的通信抽象

读/写寄存器抽象是sequential processing中最基础的元素, 甚至是图灵机唯一的"object", 所以如何在分布式环境中重现这个抽象对基于此抽象构建其他分布式算法具有重要意义.

同时, 读写寄存器抽象和replicated state machine, 分布式数据库也有一定的关系, 所以理解这个抽象能做到的极限和不同的模型, 个人认为是理解分布式系统的要素之一.

从构架的角度来看, 读/写寄存器可以显示为下图.

绿框为算法实现部分, 给上层application层提供read(k)和write(k, v) api调用, 给上层一个share memory的抽象(k是内存地址), 比如p1写的某个值可以被p2读到, 也可以被其他process的写覆盖, 就好像所有的process都跑在同一台机器上可以访问同一个内存空间一样.

或者也可以把各个p理解为client, 而绿框则是一个数据库, 提供read(k)和write(k, v) api调用, 就好像提供一个keyValue的数据库.

在CAMPn,t[∅] 模型中的3种读写寄存器类型分别提供了不同的一致性语义

  1. Regular寄存器
  2. Atomic寄存器
  3. Sequential寄存器

开始介绍各种寄存器之前,先说明2点:

  1. 首先任何读和写都是有持续时间的, 这个持续时间就是从开始操作到操作结束为止.
  2. 其次我们要定义concurrent操作, 如果两个或多个操作(读或写)在真实时间上有任何重叠, 则定义它们互为concurrent操作.

Regular Register

那么对于一个分布式的寄存器抽象, Regular寄存器是满足人对于分布式对象的基本逻辑期望的, 它定义为:

  1. 一个读如果不和任何写是concurrent关系, 则读需要读到寄存器当前的内容(即, 上一个成功完成的写入的值)
  2. 一个读如果和多个写操作是concurrent关系, 那么这个读可以返回这多个写的值里的任意一个, 或者寄存器在这些写发生前的值.

1很容易理解, 2则可以理解为, 因为我们的读写可能起作用的点很早(发送操作时无限快,但是发送结果回来很慢), 或者起作用的点很晚(发送操作时很慢, 但是发送结果回来非常快), 所以当一个读和多个写是concurrent关系时, 读到哪个值都是"可以理解"的.

实现简述:

  • 写就给所有其他process发信息, 收到写请求的process要检查写请求的时间戳, 只有新值的时间戳大于本地时间戳时才overrride, 不管有没有override, 都需要ack发写请求的process; 发送写请求的process, 收到多于N/2个ack之后回复app层写成功,
  • 读也要给所有process发请求, 收到多于N/2个回复之后选“时间戳”最大的值返回.
  • 由于regular寄存器对顺序几乎无要求,所以"时间戳"可以有很多选择, 机器本地时间, logic clock都可以.


new/old inversion: Regular寄存器最大的问题

new/old inversion是指读到一个新值之后又可以读到一个老值; 这是由于regular寄存器对读写顺序的基本没什么要求造成的, 这使得regular寄存器很容易实现, 但是只能给予application层有限的保证; 上图是一个例子, 从实现的角度来看, 当一个3节点ABC构成的cluster里, 一个写才刚写了一个节点A的时候, 一个读读到的2个节点中正好有一个节点是A, 那么这个读就可以读到新值, 但是重复读的时候刚好读到的两个节点是BC, 而写消息由于延迟的原因还未抵达BC节点, 那么第二个读反而会读到旧的值;

new/old inversion会造成上层application读到的值不稳定,有时候读到新值有时候读到老值, 这就使得上层有非常多的不确定行为需要分析, 极大的增加了系统设计的复杂度;

强一致条件(Strong consistency condition)

regular寄存器实现简单, 但是行为复杂, 这种行为分析的复杂点在于一个读/写行为必须分解为寄存器收到request和发回reply2个时间点来界定一个操作的开始和结束, 如果我们有N个操作, 那么分析这N个操作, 需要分析2N个事件; 且concurrent概念的存在与concurrent读写操作的行为定义的不确定性都造成了系统分析的复杂度爆炸;

强一致条件是这样一种系统模型, 它忽略系统收到和返回操作的时间, 而只专注于操作真正起作用的时间点(虽然在application层我们无法明确知道这个时间点是什么, 只知道它发生在寄存器收到request之后和发回reply之前), 这就把需要分析的事件从2N降到了N; 更重要的是, 强一致条件规定所有对寄存器的读写只有一个全局顺序, 所有process对其的读和写都只是这个全局顺序的运行结果; 而对于concurrent操作的结果也必须符合这个全局统一顺序;

这样, 从寄存器保证怎么样的全局顺序, application层就可以通过这种保证来推理怎么样的顺序是一定可以得到的, 从而进一步从这个顺序推理出application层应该会得到怎样的行为;

Atomic寄存器和Sequantial寄存器则提供了比较符合人对“寄存器”的认知的两种不同的全局顺序,并且都消除了new/old inversion的问题

Atomic Register (Linearizability)

Atomic寄存器提供的全局顺序必须尊重真实操作发生的严格先后顺序, 即如果一个操作A在真实物理时间中结束于另外一个操作B开始之前, 那么Atomic寄存器必定需要把A排在B之前; 而如果A和B在真实时间中是concurrent的关系, 那么A和B的顺序可以任意排序, 但是这个顺序只有一个(不能在process-X的眼里A先于B, 在process-B的眼里B先于A, 这是与regular寄存器对concurrent操作定义最大的区别),


上图可以看出, 所有操作都在一个全局顺序上缩为一个“作用点”;而原来的concurrent操作{R.write(2)和R.write(3)}的全局顺序允许后开始的操作的"作用点"发生在先开始的操作的“作用点”之前; 所以Atomic寄存器消除了一定的不确定性(真正发生的全局顺序只有一个, 它必须尊重真实时间的操作顺序)的同时, 还是遗留了一些不确定性(只看操作发生的开始和结束时间, 存在多个全局顺序都可以合理解释操作"起效点"的排列)

Atomic寄存器提供了一种很强的抽象, 这种寄存器从外部来看好像只有一份数据一样; 因为任何时间一个读操作返回后, 任何发生于之后的读操作, 不管发生在那里(注意, 这里是Atomic寄存器和sequantial寄存器最大的区别), 必定会得到一个不老于本次读操作的值, 而任何写操作完成后, 任何读操作必定会得到不老于此写操作赋予的值; 或者说, 如果我们真的只用一台机器单线程做一个服务器, 所有client对其的读写应该出现的行为即是Atomic Register可能出现的行为, 任何违反“一台机器单线程做一个服务器行为”的混乱行为都绝对不会出现; 于此同时, 我们克服了单点错误, 达到了一定的高可用性

注意: 这里的高可用性(Availability)不是CAP中的A, Atomic寄存器即是linearizability, 也即是CAP理论中所说的C, CAP中的C和A是不能同时存在的; 所以对于高可用性的定义其实是存在歧义的, 我们在日常讨论中谈到高可用性, 要分清上下文, 到底说的是CAP的A, 还是克服单点错误:single point failure的A

实现简述:

1. 直接实现

  • 每个process维护一个wsn(writer sequence number),
  • 所有的写在写之前要先拿到所有>N/2的其他process的wsn, 然后用得到的最大的wsn加1后的值作为本次写的seqNum来广播写的值; 收到写请求的process对比本地时间戳, 只有发来的值的seqNum比本地的值的seqNum大才更新本地数据, 不管有没有更新数据, 回复ack给发送方; 发write请求的process等待N/2个ack之后返回; 可以看到如果一个写A完成, 任何新的写必定有比A大的seqNum;
  • 所有的读要读>N/2的其他process的本地值, 选出seqNum最大的那个作为要返回的值, 但是在返回之前要先广播这个值给其他所有process, 其他process按处理写请求的处理方式处理此请求; 发送方拿到>N/2个ack后返回; 可以看到读是有"副作用"的(又叫做read repair), 每个读使得至少>N/2的process具有比本次读到的值相同(比如被读引起的写更新了)或更新的值(已经有更新的值); 所以任何之后的读必定会读到不老于上次读所读到的值; 对于写同理, 一个"完成的写"必定保证了N>2的节点有不老于本次写的值; 所以读必定不会读到老值;

2.用SCD broadcast实现Atomic Register (关于SCD broadcast参见上篇 分布式算法小结[1]: Broadcast Abstraction)

  • 每个process维护一个wsn(writer sequence number), 这个wsn随着从底层收到SCD broadcast的信息而不断增长;
  • 写=> 用SCD broadcast广播一个sync消息, 从SCD中收到(delivery)自己的sync信息之后(假设是msg set-x), 用wsn+1来SCD broadcast自己的写请求, 等待收到自己的写请求(假设是msg set-y)后返回; 这个过程中, 从任何SCD层收到的msg set都取seqNum最大的和本地seqNum比较, 如果本地seqNum比较老, 则把新值写入到本地;
  • 读=> 发sync, 等自己收到sync, 读本地;

大概讲下为什么这样可以实现Atomic寄存器:

  • 对于读来说: 如果一个"读"严格发生在一个"写"之后, 那么请求"写"的process-X必定已经SCD delivery了自己的"写请求"之后才SCD delivery某process-Y的"读的sync请求"; 那么对于这个发送"读的sync请求"的process-Y来说, SCD保证了它不可能拿到自己的sync之后才拿到process-X"写"的信息; 所以它必定可以读到不老于这个"写"的的值;
  • 对于写来说: 如果一个"写a2"严格发生在另外一个"写a1"之后, 那么发送"写a1"的process-X必定在SCD delivery"写a1"之后才delivery"写a2"的第一步sync请求, 所以对于发送"写a2"的process-Y来说它收到自己的sync之后, 必定已经拿到了"写a1"的值和seqNum, 所以"写a2"必定有高于"写a1"的seqNum;
  • 对于发送"写a2"的process-Y来说, 它必定delivery自己的sync请求之后才delivery自己的"写a2"请求; 所以对于系统中的其他任意节点, 要么按照"写a1", "sync of写a2", "写a2"的顺序delivery; 要么把这些所有信息放在一个msg set里delivery, 无论哪种情况, 其他process都不会用a1覆盖a2, 而绝对用a2覆盖a1;

注意: 由于也可以用atomic register来实现SCD broadcast(算法比较无聊也没太大实际意义, 略过不谈), 所以atomic register和SCD broadcast是等价的


一些备注:

  • Atomic寄存器的读写latency是和网络传输时间成正比的, 不存在只读本地或者只写本地的快读或者快写算法可以实现atomic寄存器; (相比之下sequential寄存器存在快读或者快写的算法)
  • 在CAMPn,t[t ≥ n/2]的环境里, Atomic寄存器是无法实现的;
  • Atomic寄存器是composible的, 因为只有一个真实世界物理时间, 所以对一组Atomic寄存器来说, 它们的所有读写操作的全集也必定尊重物理时间顺序.
  • 相比之下, sequential寄存器不是天然composible的, 这是因为每个sequential寄存器只尊重 对自己access的本地sequential order, 而跨多个寄存器的本地读写顺序有可能有冲突; (后话, 且听下回分解)


下一篇再把Sequential寄存器补完,这篇到此为止。

编辑于 2019-12-05

文章被以下专栏收录

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