在分布式存储系统中,保持系统中多个实例的状态一致是一个很难处理的问题。特别是当系统发生故障时,系统能否始终保持一致性,极大地影响着系统的可用性和数据的可靠性。一个典型的不一致导致的重大事故是这样的:在正常情况下,系统通过数据同步机制来保持各个实例上状态的一致性。当出现实例宕机、网络分区等故障时,该同步机制将无法正常工作。工作,一致性被打破。在这种情况下,如果有多个不一致的状态数据,系统很难自动判断哪个状态数据是“正确的”,也就没有办法自动恢复。更糟糕的是,一旦这种不一致的状态被其他系统读取到,错误的状态就会传递给其他系统,造成不可预知的结果。这种复杂的数据错误即使人工处理也很难恢复。往往需要数小时或数天才能恢复,严重时甚至无法恢复。可见,在发生故障时保持一致性是系统能够快速从故障中恢复的前提,有助于提高系统的可用性。但是,为了保证一致性,在更新数据的时候,往往需要协调参与的模块,保证同步更新。例如,使用各种分布式事务。但这会导致这些模块在可用性方面紧耦合在一起,进而降低系统的可用性。在这种情况下,可用性和一致性之间存在矛盾。本文从高可用的角度重新审视数据一致性问题,探讨如何在可用性和一致性之间取得相对平衡。01如非必要,不增加文案。在考虑如何平衡一致性和可用性之前,最重要的是要认识到在分布式系统中解决一致性问题需要付出非常高的代价。这些成本可能包括:可用性降低、性能下降、用户体验恶化,或者系统的复杂性大大增加。所以不要人为地制造一致性问题。然而很多时候,由于缺乏这方面的意识,我们不经意间给系统制造了不必要的一致性问题,然后付出巨大的代价去解决这个问题,得不偿失。在系统中设计状态数据的多个副本并不少见。常见的多副本设计包括:以不同格式或数据结构存储多个副本。将多个副本存储在不同类型的外部存储器中。在本地磁盘或内存中缓存数据副本。以上都是我们经常使用的设计模式。难道说都是“烂设计”吗?当然不是。架构设计是一门平衡的艺术。架构师在选择某种设计或架构时,必须充分了解当前选择的优势和成本,以确保优势是我们需要的,成本是我们可以接受的。这样的设计是当前场景下的最佳选择。数据增加副本会带来一致性问题,开发者需要付出巨大的代价来维护数据的一致性。因此,在设计过程中,需要仔细考虑与系统增加副本的收益和成本相比,是否值得做出这样的选择。我们需要避免的是在设计过程中未经深思熟虑就随意添加副本的行为。以下是几个常见的错误示例:只是为了写代码时更容易读取数据,随意添加副本。例如,为了方便查询,数据库中表A中的一些字段也存储在表B中。系统中有多个外部存储。为了方便读写,每个外存都保存一份数据副本。例如,集群的元数据存储在ZooKeeper中。为了方便管理控制台的操作,在MySQL中也存储了一份相同的数据。不管系统的实际性能要求如何,为了让系统更快,数据都缓存在Redis和内存中。02一致性与可用性的矛盾在现有的硬件技术条件下,分布式系统中每个节点的更新总会有一个顺序。不可能做到绝对的“同时性”,也不可能保证系统的多个副本在“任何时刻”的状态都是一样的。所以我们这里讨论的一致性是系统整体对外表现出来的一致性。也就是说,分布式系统内部可以存在不一致的状态,但只要这种不一致的状态对外不可见,那么就可以认为系统是一致的。在分布式系统中,几乎不可能同时保证高可用性和一致性。我们将分布式系统抽象为最简单的模型:只有两个有状态节点的系统。然后分析这个最简单模型下的一致性问题:如何保证这两个节点上的状态在任何时刻都是相同的?即使在这样一个最简单的模型下,保持一致性仍然面临以下三个问题。第一个挑战是如何处理更新操作失败。为了保持两个节点上状态的一致性,理论上每次更新状态时都需要同步更新两个节点上的状态。如果一个节点更新操作失败,系统将出现如下不一致:一个节点更新成功,而另一个节点更新失败。在这种情况下,要维护系统的一致性,就需要在系统内部隔离这种不一致状态,让外部系统无法感知,尽快修复不一致状态。要修复这种不一致的状态,一般有两种方法,即重试和回滚。重试是指要求失败的节点重新执行更新操作。如果重试成功,系统将恢复到一致状态。回滚是指让之前更新过的节点进行回滚操作,回到更新前的状态,也可以让系统回到一致的状态。但是重试和回滚的实现成本很高。通过重试解决一致性的前提是重试的更新操作必须是幂等的、原子的。幂等性可以保证多次重试同一个更新操作不会改变状态的正确性;原子性可以避免复杂数据结构的状态失效时只更新部分状态的尴尬情况。如果系统的状态不存储在关系数据库中,则不容易实现幂等性和原子性。回滚的实现也需要保证原子性。另外,为了将状态恢复到更新前的状态,需要在执行更新操作之前记录原始状态。系统还需要考虑如何处理回滚失败。第二个挑战是如果其中一个节点不可用,如何保证系统的一致性。当系统的一个节点不可用时,另一个节点仍然可以提供读写服务。当故障节点恢复时,理论上只要将可用节点的状态数据同步到之前故障的节点,系统就可以恢复到一致的状态。但现实中要实现良好的数据同步,既要做到快速同步又要保证不重不漏,难度和成本相对较高。最简单的方法是同步全量数据,清除故障节点上的状态数据,然后将可用节点上的所有状态数据复制到故障节点上。完全同步比较耗时。如果数据量比较大,就必须使用增量同步。对于增量同步,需要准确定义哪些数据属于“增量数据”。这对于大多数使用多线程并行处理请求的服务来说几乎是不可能的。同时,另一种不得不考虑的极端情况是,如果一段时间内两个节点多次交替不可用,系统将很难确定哪个节点的状态是“正确可信的状态”。不可能恢复系统的一致状态。第三个问题是在网络分区的情况下如何保证系统的一致性。网络分区是指由于网络设备故障导致网络分裂成多个独立的区域。一个典型的场景是两个机房的网络中断,两个机房形成两个互不相连的分区。假设发生网络分区,系统的两个节点恰好位于不同的分区。在这种情况下,虽然没有节点不可用,但节点之间无法进行通信,无法保证系统的一致性。如果系统不能容忍“不一致”,唯一的办法就是在网络分区时停止对外提供服务,这意味着牺牲“可用性”。我们上面讨论的情况是著名的CAP理论的一个典型场景:在网络分区的情况下,只能选择一致性和可用性。鉴于一致性与可用性的冲突,以及实现一致性的高成本,在设计分布式系统时,放弃对严格一致性的约束,让系统适应相对松散的一致性,从而提高一致性,是一种更理性的选择,在可用性和性能之间达到一个相对可接受的平衡。所谓“松散一致性”,是指在隔离和性能方面适当放宽要求后,一系列降级的版本一致性。相比之下,我们前面讨论的一致性也被称为“强一致性”。最终一致性是一种普遍采用的松散共识。比如上面的例子,在网络分区的情况下,如果可以接受最终一致性,系统仍然可以在一个分区提供读写服务,在另一个分区提供只读服务,大大增强了系统的可用性.只要网络故障结束,就可以通过单向数据同步来恢复系统的一致性。03在一致性和可用性之间保持平衡牺牲了强一致性后,当系统出现故障时,由于系统存在多个副本,更容易继续保持可用性。不管是网络故障还是服务器宕机,只要调用者仍然可以访问到一个幸存的副本,系统仍然可以提供服务。BASE提供了一种平衡一致性和可用性的策略。该策略适用范围广,实现难度不高,在一致性和可用性上都有很好的表现。BASE是“基本可用”、“软状态”和“最终一致”的首字母缩写词。其中:基本可用性是对可用性的折衷,即在发生故障时,系统仍然可以提供基本的服务能力,但代价是响应时间变长、部分功能不可用或部分请求失败。软状态和最终一致性是一致性的妥协。具体来说,牺牲原子性和隔离性,让系统存在一个外部可见的“中间状态”,但需要在短时间内恢复到一致状态,才能达成最终共识。在由多个组件组成的分布式系统中,如果某个组件被设计成降低了可用性和一致性的级别,那么依赖于该组件的其他组件或外部服务往往需要额外付费才能兼容这种退化的设计。因此,设计者需要根据系统的实际情况权衡取舍,慎重降低可用性和一致性。基本可用性不代表不可用,最终一致性不代表不一致。下面介绍实践BASE理论的常用方法和常见误区。“最终一致性”允许不一致的中间状态对外可见,但需要在短时间内恢复到一致的状态。这里的“短时间”可以量化吗?要回答这个问题,我们需要讨论系统正常和故障两种情况。当系统正常时,达成最终共识的时间要求“在系统外几乎察觉不到”,具体来说,应该类似于需要同步状态的节点之间的网络延迟。例如,如果系统的节点都部署在同一个数据中心,那么达成最终共识的延迟不应超过几毫秒;对于全球部署的系统,达成最终共识的延迟可能需要数十到数百毫秒。当系统出现网络分区故障时,为了尽可能保证系统的可用性,需要进一步牺牲达成最终共识的延迟。▲图1当系统出现故障时,需要更长的时间才能达成最终共识。为了牺牲一致性,需要保持两条底线:防止脑裂和确保单调的读写。让我们从第一条底线开始:防止裂脑。比如在传统的MySQL主从结构中,如果主库宕机,或者网络分区导致无法访问主库,那么从库中的数据就不应该被更新。无法自动恢复不同副本的数据。这种情况称为“裂脑”。脑裂发生后,理论上无法恢复系统的一致性。在工程实践中,一般需要人工干预。借助数据的业务属性(比如同一个订单的支付操作一定早于发货操作,可以判断“已发货”状态比“已支付”状态更新).可以完成数据一致性修复。需要特别注意的是,状态更新的时间戳不能用来判断状态数据的新旧和恢复一致性。状态数据中记录的时间戳来自客户端或服务端应用所在的多个节点,现有时间同步技术所能保证的误差(10~500ms)太大,使用时间极不合适stamp来判断状态是新的还是旧的。可靠的。手动恢复裂脑的代价往往是“部分数据丢失”和“更长的故障恢复时间”。那么,如何防止脑裂呢?在我看来,关键是确保在失败后可以恢复最终一致性。前提是系统需要有足够的信息来判断最新的状态。只有这样,所有副本的状态才能恢复到这个状态。在系统发生故障时,即使为了保证可用性,更新操作的一致性约束也不应该被违反。这里,“更新操作的一致性约束”是指系统为了保证一致性而对状态更新操作施加的约束。比如最简单的主从模式,只能通过主副本来更新状态。如果由于任何原因不能更新主副本,那么更新将失败,更新操作的可用性将被牺牲。Paxos等一致性协议使用Quorum机制来保证更新操作的一致性。简单来说,每次更新操作必须在超过半数的副本上达成共识才算更新成功。如果系统故障时更新请求不能达成多数共识,则更新也必然失败。接下来,我们讨论单调读写。当最终一致性系统出现故障时,为保证系统的持续可用性,应允许客户端从任何可访问的节点读取状态数据。虽然此时客户端读到的可能不是最新的状态。对于大多数系统,读取短时间内不是最新的状态是可以接受的。我们先来看第一个例子。小明通过手机银行给小华转了100元。小明完成转账操作后,钱其实已经转入了小华的账户。如果这时候因为系统故障,小花手机银行显示账户还没有到账,过一段时间再显示账户,也不是完全不能接受。然后再看第二个例子,也是用小明给小华转钱来解释的。如图2所示,在只有主从副本的最终一致性系统中,转账成功后主副本的状态已经更新,小明转给小华的钱已经到账,小华的账户余额是100元。但是由于同步延迟,副本的转账还没有到账,小花的账户余额还是0元。假设小花的第一个账户查询请求分配到主副本,App显示余额100元。小花再次查询,这次查询请求被分配到副副本,App显示余额为0元!刚到的钱没了!小明也可能出现类似问题,转账成功后查询账号。查询请求被分配到从副本(这是配置了读写分离的数据库集群的默认行为),发现账户余额没有减少。小明以为转账不成功,于是再次发起转账,结果多了100元转账。以上两种情况,外部系统无法判断读取状态是否准确,显然是不能接受的。▲图2状态时序紊乱的问题要避免这两个问题,需要保证客户端视角的一致性。所谓单调读写,就是要求对于每个client,每次读取的状态不能比上次读写的状态旧。简单的说就是“没有时序紊乱”。有两种常见的方式来实现单调读写。第一种方法是保持会话(StickySession),使同一个客户端的请求总是由与其建立会话的特定服务器节点(副本)处理。客户端只与服务器的一个节点交互,自然不会出现“时序紊乱”的问题。保持会话的方式比较简单,很多网关都内置了保持会话的功能。如果系统通过网关对外提供服务,则可以直接使用。即使系统不使用网关,只要在客户端第一次连接成功时将服务器节点的唯一标识(ID)或URL返回给客户端,后续客户端就可以使用这个ID或URL继续访问同一个服务器节点。但是这种会话持久化实现的问题在于它需要在系统出现故障时回退。如果客户端无法连接到会话中的服务器节点,则只能选择连接到其他服务器节点来创建新的会话。在会话切换的过程中,仍然存在时序错乱的可能。幸运的是,只有在会话切换时才会发生时序紊乱,而会话切换只有在系统出现故障时才会发生,发生的概率很低。而且客户端可以感知到会话的切换,从而主动从业务逻辑上做出一些补偿。另外,由于需要维护session,不能使用负载均衡策略,系统的弹性(Elasticity)会受到很大限制,容易出现热点,扩缩容也会受到session的限制。另一种方法是通过记录和比较状态版本号来实现单调读写。系统需要为状态数据维护一个版本号系统。状态版本号是状态的一部分,需要保证每次状态更新对应的版本号单调递增。这个状态版本号的作用是标记状态更新的先后顺序,英文也叫Ephoc或者Logicaltimestamps。客户端需要记录上一次读写状态的版本号,然后在每次读取状态前将当前版本号与之前的版本号进行比较。如果当前版本号不低于之前的版本号,则可以认为这次读取的状态是可信的。否则,需要丢弃读取结果,稍等片刻或连接其他服务器重新尝试获取新版本的状态数据。通过状态版本号可以实现单调读写,可以完美保证客户端视角的一致性,但是服务端的实现比较复杂。04总结让我们回顾一下核心内容。在分布式系统中,平衡可用性和一致性是一个难题。因此,在设计过程中,需要避免不加思索的添加副本的行为。我们建议设计者在设计系统一致性时兼容最终一致性,这样可以大大增加系统在出现故障时保持高可用性的难度,在一致性和可用性之间取得比较好的平衡。但是,系统的最终一致性并不意味着不一致。需要防止系统出现脑裂,通过单调的读写来保证客户端视角的一致性。作者简介:李跃,美团基础技术部高级技术专家,极客时间《后端存储实战课》《消息队列高手课》等专栏作家。曾就职于浪潮集团、当当网、京东零售等公司。多年从事互联网电商行业基础设施领域的架构设计与研发,多次参与双十一、618电商大促。专注于分布式存储、云原生架构下的服务治理、分布式消息和实时计算等技术领域,致力于推动基础设施技术的创新和开源。
