分布式系统之三:CAP定理的实践

来源:互联网 发布:图片二维码识别软件 编辑:程序博客网 时间:2024/03/28 20:04

本博文主要介绍CAP定理的现状、与延迟的关系、实践

    本博文内容主要摘自Computer杂志,由InfoQ和IEEE提供的中文版,可能比较枯燥,但是极其有趣,如果您读完此文,会对CAP定理乃至整个分布式系统有个更深入的理解

CAP定理的基础知识

    《分布式系统之二:CAP定理》

CAP定理的现状

    CAP定理的表述很好地服务了它的目的,即开阔设计师的思路,在多样化的取舍方案下设计出多样化的系统。在过去的十几年里确实涌现了不计其数的新系统,也随之在数据一致性和可用性的相对关系上产生了相当多的争论。

    而在当今的服务系统中,P(分区)已经成为了不可割舍的一部分,所以设计师们需要在分区的前提下对数据一致性和可用性做取舍,但具体如何处理分区和恢复一致性,这里面有不计其数的变通方案和灵活度。当代CAP实践应将目标定为针对具体的应用,在合理范围内最大化数据一致性和可用性的“合力”。这样的思路延伸为如何规划分区期间的操作和分区之后的恢复,从而启发设计师加深对CAP的认识,突破过去由于CAP理论的表述而产生的思维局限。

CAP定理与延迟的关系

    CAP理论的经典解释,是忽略网络延迟的,但在实际中延迟和分区紧密相关。CAP从理论变为现实的场景发生在操作的间歇,系统需要在这段时间内做出关于分区的一个重要决定:

    1. 取消操作因而降低系统的可用性

    2. 继续操作,以冒险损失系统一致性为代价

    依靠多次尝试通信的方法来达到一致性,比如Paxos算法或者两阶段事务提交,仅仅是推迟了决策的时间。系统终究要做一个决定;无限期地尝试下去,本身就是选择一致性牺牲可用性的表现。

    因此以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。这就从延迟的角度抓住了设计的核心问题:分区两侧是否在无通信的情况下继续其操作?

    从这个实用的观察角度出发可以导出若干重要的推论。第一,分区并不是全体节点的一致见解,因为有些节点检测到了分区,有些可能没有。第二,检测到分区的节点即进入分区模式——这是优化C和A的核心环节。

    最后,这个观察角度还意味着设计师可以根据期望中的响应时间,有意识地设置时限;时限设得越短,系统进入分区模式越频繁,其中有些时候并不一定真的发生了分区的情况,可能只是网络变慢而已。

    因此,有时候在跨区域的系统,放弃强一致性来避免保持数据一致所带来的高延迟是非常有意义的。Yahoo的PNUTS系统因为以异步的方式维护远程副本而带来数据一致性的问题。但好处是主副本就放在本地,减小操作的等待时间。这个策略在实际中很实用,因为一般来讲,用户数据大都会根据用户的(日常)地理位置做分区。最理想的状况是每一位用户都在他的数据主副本附近。

    而Facebook使用了相反的策略:主副本被固定在一个地方,因此远程用户一般访问到的是离他较近,但可能已经过时的数据副本。不过当用户更新其页面的时候是直接对主副本进行更新,而且该用户的所有读操作也被短暂转向从主副本读取,尽管这样延迟会比较高。20秒后,该用户的流量被重新切换回离他较近的副本,此时副本应该已经同步好了刚才的更新

CAP之惑

    CAP理论经常在不同方面被人误解,对于可用性和一致性的作用范围的误解尤为严重,可能造成不希望看到的结果。如果用户根本获取不到服务,那么其实谈不上C和A之间做取舍,除非把一部分服务放在客户端上运行,即所谓的无连接操作或称离线模式7。离线模式正变得越来越重要。HTML5的一些特性,特别是客户端持久化存储特性,将会促进离线操作的发展。支持离线模式的系统通常会在C和A中选择A,那么就不得不在长时间处于分区状态后进行恢复。

    “一致性的作用范围”其实反映了这样一种观念,即在一定的边界内状态是一致的,但超出了边界就无从谈起。比如在一个主分区内可以保证完备的一致性和可用性,而在分区外服务是不可用的。Paxos算法和原子性多播(atomic multicast)系统一般符合这样的场景。像Google的一般做法是将主分区归属在单一个数据中心里面,然后交给Paxos算法去解决跨区域的问题,一方面保证全局协商一致(global consensus)如Chubby,一方面实现高可用的持久性存储如Megastore。

    分区期间,独立且能自我保证一致性的节点子集合可以继续执行操作,只是无法保证全局范围的不变性约束不受破坏。数据分片(sharding)就是这样的例子,设计师预先将数据划分到不同的分区节点,分区期间单个数据分片多半可以继续操作。相反,如果被分区的是内在关系密切的状态,或者有某些全局性的不变性约束非保持不可,那么最好的情况是只有分区一侧可以进行操作,最坏情况是操作完全不能进行。

    CAP还有一个方面很多人认识不清,那就是放弃一致性其实有隐藏负担,即需要明确了解系统中存在的不变性约束。满足一致性的系统有一种保持其不变性约束的自然倾向,即便设计师不清楚系统中所有的不变性约束,相当一部分合理的不变性约束会自动地维持下去。相反,当设计师选择可用性的时候,因为需要在分区结束后恢复被破坏的不变性约束,显然必须将各种不变性约束一一列举出来,可想而知这件工作很有挑战又很容易犯错。放弃一致性为什么难,其核心还是“并发更新问题”,跟多线程编程比顺序编程难的原因是一样的。

管理分区

    怎样缓和分区对一致性和可用性的影响是对设计师的挑战。其关键是以非常明确、公开的方式去管理分区,不仅需要主动察觉分区的发生,还需要为分区期间所有可能受侵害的不变性约束预备专门的恢复过程和计划。管理分区有三个步骤:

    1. 检测到分区开始

    2. 明确进入分区模式,限制某些操作

    3. 当通信恢复后启动分区恢复过程,目的是恢复一致性,以及补偿在系统分区期间程序产生的错误。


    上图可见分区的演变过程。普通的操作都是顺序的原子操作,因此分区总是在两笔操作之间开始。一旦系统在操作间歇检测到分区发生,检测方一侧即进入分区模式。如果确实发生了分区的情况,那么一般分区两侧都会进入到分区模式,不过单方面完成分区也是可能的。单方面分区要求在对方按需要通信的时候,本方要么能正确响应,要么不需要通信;总之操作不得破坏一致性。但不管怎么样,由于检测方可能有不一致的操作,它必须进入分区模式。采取了quorum决定机制的系统即为单方面分区的例子。其中一方拥有“法定通过节点数”,因此可以执行操作,而另一方不可以执行操作。支持离线操作的系统明显地含有“分区模式”的概念,一些支持原子多播(atomic multicast)的系统也含有这个概念,如Java平台的JGroups。

    当系统进入到分区模式,它有两种可行的策略。其一是限制部分操作,因此会削弱可用性。其二是额外记录一些有利于后面分区恢复的操作信息。系统可通过持续尝试恢复通信来察觉分区何时结束。

哪些操作可以执行

    决定限制哪些操作,主要取决于系统需要维持哪几项不变性约束。在给定了不变性约束条件之后,设计师需要决定在分区模式下,是否坚持不触动某项不变性约束,抑或以事后恢复为前提去冒险触犯它。例如,对于“表中键的惟一性”这项不变性约束,设计师一般都选择在分区期间放宽要求,容许重复的键。重复的键很容易在恢复阶段检查出来,假如重复键可以合并,那么设计师不难恢复这项不变性约束。

    说得更概括一点,分区模式给用户界面提出了一种根本性的挑战,即如何传达“任务正在进行尚未完成”的信息。

    而在分区模式的讨论中,我们将关注点放在有明确意义的原子操作而非单纯的读写,其中一个原因是操作的抽象级别越高,对不变性约束的影响通常就越容易分析清楚。大体来说,设计师要建立一张所有操作与所有不变性约束的叉乘表格(cross product),观察并确定其中每一处操作可能与不变性约束相冲突的地方。对于这些冲突情况,设计师必须决定是否禁止、推迟或修改相应的操作。在实践中,这类决定还受到分区前状态和/或环境参数的影响。例如有的系统为特定的数据设立了主节点,那么一般允许主节点执行操作,不允许其他节点操作。

    对分区两侧跟踪操作历史的最佳方式是使用版本向量,版本向量可以反映操作间的因果依赖关系。向量的元素是(节点, 逻辑时间)数值对,分别对应一个更新了对象的节点和它最后更新的时间。对于同一对象的两个给定的版本A和B,当所有结点的版本向量一致有A的时间大于或等于B的时间,且至少有一个节点的版本向量有A的时间较大,则A新于B。

    如果不可能对版本向量排序,那么更新操作是并发的,而且有可能出现不一致的情况。只要知道分区两侧版本向量的沿革。系统不难判断哪些操作的执行顺序是确定的,哪些操作是并发的。最近的研究成果证明,当设计师选择可用性优先,一般最多只能将一致性收紧到这样的程度。

分区恢复

    到了某个时刻,通信恢复,分区结束。由于每一侧在分区期间都是可用的,其状态仍继续向前进展,但是分区会推迟某些操作并侵犯一些不变性约束。分区结束的时刻,系统知道分区两侧的当前状态和历史记录,因为它在分区模式下记录了详尽的日志。当前状态不如历史记录有价值,因为通过历史记录,系统可以判断哪些操作违反了不变性约束,产生了何种外在的后果(如发送了响应给用户)。在分区恢复过程中,设计师必须解决两个问题:

    1. 分区两侧的状态最终必须保持一致

    2. 必须补偿分区期间产生的错误

    通常情况,矫正当前状态最简单的解决方法是回退到分区开始时的状态,以特定方式推进分区两侧的一系列操作,并在过程中一直保持一致的状态。Bayou就是这个实现机制,它会回滚数据库到正确的时刻并按无歧义的、确定性的顺序重新执行所有的操作,最终使所有的节点达到相同的状态15。同样地,并发版本控制系统CVS在合并分支的时候,也是从从一个共享的状态一致点开始,逐步将更新合并上去。。

    大部分系统都存在不能自动合并的冲突。比如,CVS时不时有些冲突需要手动介入,带离线模式的wiki系统总是把冲突留在产生的文档里给用户处理。

    相反,有些系统用了限制操作的办法来保证冲突总能合并。一个例子就是Google Docs将其文本编辑操作精简为应用样式、添加文本和删除文本。因此,虽然总的来说冲突问题不可解,但现实中设计师可以选择在分区期间限制使用部分操作,以便系统在恢复的时候能够自动合并状态。如果要实施这种策略,推迟有风险的操作是相对简单的实现方式。

    还有一种办法是让操作可以交换顺序,这种办法最接近于形成一种解决自动状态合并问题的通用框架。此类系统将线性合并各日志并重排操作的顺序,然后执行。操作满足交换率,意味着操作有可能重新排列成一种全局一致的最佳顺序。不幸的是,只允许满足交换率的操作这个想法实现起来没那么容易。比如加法操作可以交换顺序,但是加入了越界检查的加法就不行了。

    Marc Shapiro及其INRIA同事最近的工作对于可交换顺序的操作在状态合并方面的应用起了很大的促进作用。该团队提出一种从理论上证明可以保证分区后合并的数据类型,称为可交换多副本数据类型(commutative replicated data types,CRDTs)。他们介绍了如何使用此类数据结构来保证分区期间进行的所有操作都是可交换顺序的,并且用“格(lattice)”的数学概念来表示数据,并保证相对于“格”来说,分区期间的所有操作都是单调递增的。最后用一种方法合并状态会汇总分区两边的最大集合。这种方法是对亚马逊购物车合并算法20的形式化总结和改良,合并后的数据是两边购物车的并集,而并运算是一种单调的集合运算。这种策略的坏处是删掉的购物车商品有可能再次出现。

    其实CRDTs完全可以实现同时支持增、删操作的分区耐受集合。此方法的本质是维护两个集合:一个放增加的项目,一个放删除的项目,两集合之差即为真正的集合成员。增集合、删集合分别合并起来都不困难,因而增删集合之差合并起来也不困难。在某个时间点上,系统可以从两个集合中清理掉删除的数据项。假如按照一般的设计,像这种清理操作仅在系统没分区的时候才可行,属于设计师必须在分区期间禁止或推迟的特定操作,但是CRDTs的清理操作并不会对可用性产生外在的影响。因此通过CRDTs来实现状态,设计师既保证了可用性,又保证了分区后系统自动合并状态。

补偿错误

    计算分区后状态更难解决的问题是如何弥补分区期间造成的错误。跟踪和限制分区模式下的比操作,这两种措施足以使设计师确知哪些不变性约束可能被违反,然后分别为它们制定恢复策略。一般系统在分区恢复期间检查违反情况,修复工作也必须在这段时间内完成。

    恢复不变性约束的方法有很多,粗陋一点的办法如“最后写入者胜”(因此会忽略部分更新),聪明一点的办法如合并操作和人为跟进事态(human escalation)。人为跟进事态的例子如飞机航班“超售”的情形:可以把乘客登机看作是对之前售票情况的分区恢复,必须恢复“座位数不少于乘客数”这项不变性约束。那么当乘客太多的时候,有些乘客将失去座位,客服最好能设法补偿他们。

    航班的例子揭示了一个外在错误(externalized mistake):假如航空公司没说过乘客一定有座位,这个问题会好解决得多。因此我们看到推迟有风险的操作的又一个理由——到了分区恢复的时候,我们才知道真实的情况。矫正此类错误的核心概念是“补偿(compensation)”;设计师必须设立补偿操作,除了恢复不变性约束,还要纠正外在错误。

    技术上CRDTs只允许局部可验证的不变性约束,所以没有补偿的必要,虽然这种限制降低了CRDTs方法本身的能力。用了CRDTs来处理状态合并的设计方案可以允许暂时违反全局性的不变量约束,分区结束后才合并状态,以及履行必要的补偿。

    恢复外在错误通常要求知道一些有关外在输出的历史信息。以“喝醉酒打电话”为例,一位老兄不记得自己昨晚喝高了的时候打过几个电话,虽然他第二天白天恢复了正常状态,但通话日志上的记录都还在,其中有些通话很可能是错误的。拨出的电话就是这位老兄的状态(喝高了)的外在影响。而由于这位老兄不记得打过什么电话,也就很难补偿其中可能造成的麻烦。

    又以机器为例,电脑可能在分区期间把一份订单执行了两次。如果系统能区分两份一样的订单是有意的还是重复了,它就能取消掉一份重复的订单。如果这次错误产生了外在影响,补偿策略可以是自动生成一封电子邮件,向顾客解释系统意外将订单执行了两次,现在错误已经被纠正,附上一张优惠券下次可以用。假如没有完善的历史记录,就只好靠顾客亲自去发现错误了。

    曾经有人正式研究过将补偿性事务作为处理长寿命事务(long-lived transactions)的一种手段。长时间运行的事务会面临另一种形态的分区决策:是长时间持有锁来保证一致性比较好呢?还是及早释放锁向其他事务暴露未提交的数据,提高并发能力比较好呢?比如在单笔事务中更新所有的员工记录就是一个典型例子。按照一般的方式串行化这笔事务,将导致所有的记录都被锁定,阻止并发。而补偿性事务采取另一种方式,它将大事务拆成多个分别提交的子事务。如果要中止大事务,系统必须发起一笔新的、起纠正作用的事务,逐一撤销所有已经提交的子事务,这笔新事务就是所谓的补偿性事务。

    总的来说,补偿性事务的目的是避免中止其他用了未正确提交数据的事务(即不允许级联取消)。这种方案不依赖串行化或隔离的手段来保障正确性,其正确性取决于事务序列对状态和输出所产生的净影响。那么,经过补偿,数据库的状态究竟是不是相当于那些子事务根本没执行过一样呢?考虑等价必须连外在行为也包括在内;举个例子,把重复扣取的交易款退还给顾客,很难说成等于一开始就没多收顾客的钱,但从结果上看勉强算扯平了。分区恢复也延续同样的思路。虽然服务不一定总能直接撤销其错误,但起码承认错误并做出新的补偿行为。怎样在分区恢复中运用这种思路效果最好,这个问题没有固定的答案。“自动柜员机上的补偿问题”小节以一个很小的应用领域为例点出了一些思考方向。

    当系统中存在分区,系统设计师不应该盲目地牺牲一致性或可用性。运用以上讨论的方法,设计师通过细致地管理分区期间的不变性约束,两方面的性质都可以取得最佳的表现。随着版本向量和CRDTs等比较新的技术逐渐被纳入一些简化其用法的框架,这方面的优化手段会得到比较普遍的应用。但引入CAP实践毕竟不像引入ACID事务那么简单,实施的时候需要对过去的策略进行全面的考虑,最佳的实施方案极大地依赖于具体服务的不变性约束和操作细节。

自动柜员机上的补偿问题

    以自动柜员机(ATM)的设计来说,强一致性看似符合逻辑的选择,但现实情况是可用性远比一致性重要。理由很简单:高可用性意味着高收入。不管怎么样,讨论如何补偿分区期间被破坏的不变性约束,ATM的设计很适合作为例子。

    ATM的基本操作是存款、取款、查看余额。关键的不变性约束是余额应大于或等于零。因为只有取款操作会触犯这项不变性约束,也就只有取款操作将受到特别对待,其他两种操作随时都可以执行。

    ATM系统设计师可以选择在分区期间禁止取款操作,因为在那段时间里没办法知道真实的余额,当然这样会损害可用性。现代ATM的做法正相反,在stand-in模式下(即分区模式),ATM限制净取款额不得高于k,比如k为$200。低于限额的时候,取款完全正常;当超过限额的时候,系统拒绝取款操作。这样,ATM成功将可用性限制在一个合理的水平上,既允许取款操作,又限制了风险。

    分区结束的时候,必须有一些措施来恢复一致性和补偿分区期间系统所造成的错误。状态的恢复比较简单,因为操作都是符合交换率的,补偿就要分几种情况去考虑。最后的余额低于零违反了不变性约束。由于ATM已经把钱吐出去了,错误成了外部实在。银行的补偿办法是收取透支费并指望顾客偿还。因为风险已经受到限制,问题并不严重。还有一种情况是分区期间的某一刻余额已经小于零(但ATM不知道),此时一笔存款重新将余额变为正的。银行可以追溯产生透支费,也可以因为顾客已经缴付而忽略该违反情况。

    总而言之,因为通信延迟的存在,银行系统不依靠一致性来保证正确性,而更多地依靠审计和补偿。“空头支票诈骗”也是类似的例子,顾客赶在多家分行对账之前分别取出钱来然后逃跑。透支的错误过后才会被发现,对错误的补偿也许体现为法律行动的形式。

    英文原文链接 《CAP Twelve Years Later:How the "Rules" Have Changed》

    中文原文链接 《CAP理论十二年回顾:"规则"变了》

    

    由于博主知识有限,如有误,请指正点评,欢迎交流

 

 

0 0