分布式系统的一致性模型_java


这篇文章的目的是思考一个大型分布式系统的工程复杂性:需要这个分布式系统在持久性、可用性和性能之间进行权衡,以满足不同应用和不同场景的需求。对于系统设计人员来说,一个尺度就是“不一致窗口”的长度,在此期间,后端分布式系统的内部行为会暴露在客户端的面前。


分布式存储系统是构建其他大型互联网应用的基础之一。因为应用对分布式存储系统的要求是非常严苛的:用户在一致性、扩展性、可用性、性能和成本等方面,对存储服务的要求都是非常高的,在满足这些前提的同时持续服务大量的用户,挑战就更大了。更进一步,如果一个大规模存储集群是跨region部署的,那么挑战还会更大,因为当一个系统高并发地处理海量请求时,通常会发生低概率的“黑天鹅”事件, 所以需要在设计和架构阶段提前考虑。


使用复制技术来保证系统的一致性和高可用性是常用的手段。虽然复制技术解决了我们很多问题,但对开发者来说,他们不是完全透明的。在许多情况下,用户必须要面对复制技术带给他们的一些伤脑经的问题。这些问题中,最重要的一个就是这些存储系统提供了什么样的数据一致性模型,尤其当底层分布式存储系统实现的最终一致性的数据复制时。



背景回顾



理想状况下,只存在一种一致性模型:当执行一个更新之后,所有的观察者都可以查询到这个更新之后的数据(状态)。这个理想的一致性模型在20世纪70年代末首次遇到了挑战,关于这个问题比较有名的一篇文章是“Notes on Distributed Databases”。它罗列了一些数据库系统复制的基本原则,并且对保证数据库一致性的各种技术手段进行了深入的讨论。那个时代,主流的技术方向是想尽一切办法,来让一个分布式系统对用户来说变得像一个单机系统一样。所以那个时候的很多分布式系统,宁愿选择让整个系统不可用,也不愿意破坏一致性承诺。


在20世纪90年代中期,随着大型互联网应用的快速涌现,人们渐渐在实践中发现:一个系统的可用性可能才是最重要的,尽管其他属性也是非常难以取舍的。Eric Brewer教授,时任Inktomi的老板,把这些权衡因素总结在一起发表在2000年的PODC(Principles of Distributed Computing)上。他提出了著名的CAP理论:一个分布式存储系统,在数据一致性、系统可用性和分区容忍性三者之间,只可以同时选择其二,而由于网络分区是无法避免的,所以通常系统只能在数据一致性和系统可用性之间二选一。最终在2002年,CAP理论被两个MIT的家伙证明。(这里我们不讨论CAP的局限和对错,只是陈述这段历史)


一个系统如果不具备网络分区容忍能力,就可以同时具备数据一致性和可用性,实现这样的系统一般都必须采用事务协议。而且实现这种系统的额外前提是:客户端和存储系统必须在同一个环境中,他们同时失败,因此他们不会遭遇到网络分区。但在实际中的一个大规模分布式系统,网络分区是无法避免的,也就是一致性和可用性不能同时具备。因此,一个分布式系统,要么放松数据一种来提升系统可用性,要么首选数据一致性而系统系统的可用性。


无论一个系统是CP还是AP,应用开发者都必须明确知道系统提供的数据一致性行为承诺。如果一个系统强调一致性,那么应用开发者必须面对系统不可用的发生。例如,当一次写入由于系统不可用而导致写入失败,应用开发者必须考虑如果处理这个写入失败的请求。如果一个系统强调可用性,它可能始终接受写入,但是在某些情况下,可能读不到最近完成的写入结果。因此,应用开发者必须考虑清楚:应用程序是否要求始终可以读到最新的更新。相当大的一部分应用程序,都可以接受略微延迟的数据,这种(一致性)服务模型下也可以工作得很好。


原则上讲,一个事务处理系统具备的ACID属性中的C是另外一种一致性定义。在ACID中,一致性的含义是:当一个事务完成后,数据库处于一个一致的状态。例如,当从一个账户转账到另外一个账户时,两个账户的总金额是不会变的。在一个具备ACID属性的系统中,这种一致性通常需要应用开发者写事务代码来完成,数据库只是协助来完成完整性约束。



一致性中的ClientServer



可以从两个不同的视角来看待数据一致性,一个是从开发者的视角:他们观察到的数据更新行为。第二个视角是从系统的角度:一个更新在系统内部是如何流转的,以及可以对更新提供什么样的保证。

Client-side Consistency

开发者视角的一致性模型中包含下面这些组件:

一个存储系统: 现在我们把存储系统当成一个黑盒子,但是它实际上是一个可以高度扩展和大规模分布部署的系统,同时还具备持久性和可用性的一个复杂系统。

进程A 一个写入到存储系统或者从存储系统读取数据的进程

进程B和进程C 这两个进程和进程A是独立的,他们也向存储系统写入数据或者从存储系统读取数据。其实是一个Thread还是一个Process,以及他们是否是处于同一个进程中的不同线程,都没有关系。关键的一点是他们之间是相互独立的,需要通讯来共享信息。
Client-side一致性是关于一个存储系统需要处理一个更新什么时候对观察者(这个例子中的进程A、B、C)可见,以及如何处理。下面的例子将示例当进程A更新了一个数据项后,不同的一致性模型下,观察者看到的行为。

强一致: 当一个更新操作完成后,所有后续的访问(进程A、B、C)都会返回更新之后的数据(新版本)。

弱一致: 存储系统不保证后续的访问能够返回更新之后的数据。返回更新后的数据必须满足一定的条件,这个更新执行的时刻T1和所有的观察者都可以查询到更新之后的数据的时刻T2之间,被称为“不一致窗口”。

最终一致: 最终一致性是弱一致性的一种特殊形式,存储系统保证如果没有新的更新产生,最终所有的后续访问都可以返回最新更新之后的数据。如果没有错误发生,“不一致窗口”的最大跨度取决于通讯延时、整个系统的负载、以及在整个复制拓扑中包含的副本个数。最常见的保证最终一致性的系统是DNS(Domain Name System)。对一个名字的更新将根据配置的模式以及一个带超时控制的cache来决定,最终多久这个更新会刷新,所有的客户端都可以看到新版本。


最终一致性还有下面几种重要的变种:

因果一致性: 如果进程A已经通过通讯告诉进程B,它更新了某个数据项,后续进程B的访问就会返回更新之后的版本,B的后续写入也会覆盖早期的数据版本。

• Read-your-writes consistency这是一个非常重要的一致性模型,当一个进程A更新了某个数据项之后,它始终可以读取到更新之后的版本,同时绝对不会查询到之前的某个旧版本。这可以看成因果一致性的一个特殊情况。

• Session consistency这是Read-your-writes模型的一种具体实现版本,当一个进程和存储系统交互的时候,只要session还在,存储系统就保证read-your-writes的一致性。当一个session由于某种原因中断了,新创建的session和旧session之间,是无法保证Read-your-writes一致性的。

• Monotonic read consistency如果一个进程已经查询到某个特定的版本,后续的访问不会再返回某个之前的版本(相对于已经查询到的这个“特定版本”来说)。

• Monotonic write consistency存储系统保证某个进程所有写入的按照写入顺序串行执行,如果一个系统连这一点都保证不了,那么几乎也很难基于这个系统开发应用。


通常这些一致性模型可以组合形成实际存储系统的各种一致性模型。例如,一个系统可以同时是单调读一致性模型和session级别一致性模型。从实现存储系统的视角来看,单调读和read-your-writes是一个最终一致性系统非常重要的两个特性,尽管不是所有的时候都是必须的。这两个特性让开发者可以非常容易地构建应用,同时也允许存储系统适当降低一致性约束的前提下,提高系统的可用性


从上面这些一致性模型的变种可以发现,一个实际的存储系统可以有多种组合场景,这取决于应用开发者处理是否得当


最终一致性不是一个分布式系统的什么神秘特性。许多RDBMS系统都提供主备模型的复制来提高系统的可靠性,复制可以是同步复制、也可以是异步复制。在同步模型中,副本上的更新是事务的一部分;在异步复制中,更新是延迟在副本上执行的,通常通过log shipping的手段。异步复制中,如果主节点在log shipping之前故障,从被提升(选举)出的副本中读取数据,会读取到不一致的旧版本数据。与此同时,RDBMS系统为了提供更好的读扩展性能,有的场景下会开放从备份副本查询数据的能力,这是一个提供最终一致性的典型场景,“不一致窗口”取决于log shipping的频率(这个地方不是很严谨,严格讲是取决于log shipping的频率和备份副本replay log的速度)。


Server-side Consistency


我们首先明确如下的定义:

N = 数据副本的数目

W = 一个更新完成前必须等待ACK的副本数目

R = 一次查询完成前必须等待ACK的副本数目


如果W+R > N,那么writeset和readset一定有重叠的部分,所以可以达到强一致性。在主备模型的RDBMS系统中,实现同步复制,即N=2,W=2,R=1。无论一个client从那个副本读取数据,都可以读取到一致性的数据。在异步复制的场景下,如果备副本的查询能力开放,N=2,W=1,R=1,因此R+W=N,所以无法保证一致性。


这种基本NRW模型存储系统的问题在于,当系统由于故障而无法写入W个节点时,写操作必须失败,从而标记系统的不可用性。有了 N=3 和 W=3,只有两个节点可用,系统将不得不报告写入失败。


在一个需要提供高性能和高可用的分布式存储系统中,副本的数目同时都大于2。一个重点在容灾能力方面的系统,通常选择N=3,W=2,R=2这样的配置。如果一个系统希望提供非常强的查询能力,可以产生更多的副本,N可以是几十甚至上百,同时配置R=1,这样一次查询在某个副本本地就返回了。如果一个存储系统的重点在于一致性,设置W=N,这可能会降低成功写入的概率。一个典型的重容灾轻一致性的存储系统模型为:W=1,依赖延迟log shipping方法来把更新同步到其他副本上去。


如何配置N,W,R取决于应用的场景以及那个路径上的性能需要优化。R=1、N=W是为了优化读取的性能,W=1、R=N是为了优化写入性能。显然,在后面这个模型中,如果发生故障,数据的持久性是无法保障的,同时如果W<(N+1)/2,由于多个writeset可能不重叠,因此可能会出现写入冲突等问题。


弱/最终一致性出现在W+R <= N的情况下,本质上的问题是读和写的集合之间是可能没有交集的。通常,如果你要求你的系统容错且严谨,把R设置为1是没有意义的。只有两种非常特别的情况才考虑把R设置为1,一种是前面提到的通过大量的复制副本来提高读扩展能力,第二种是数据访问模式非常复杂(导致访问多个副本复杂程度更加难以处理)。在一个简单的K/V模型系统中,一种简单的办法是通过比较数据的版本号来确定写入系统的最新数据;但是如果你对系统的访问方式中,包含对数据的批量读,也就是说读返回的是一个数据项集合,那么要确定一个数据集合是否是系统的最新版本时,就是一个非常复杂的问题了。


常见的大多数存储系统中,由于写入集合小于副本集合时,一般会采用延迟更新的方式来更新副本中的数据,对于整个系统来说,把数据从写入集合更新到所有的副本的这个时间窗口,就是前面讨论的不一致窗口。如果W+R <= N,那么应用很有可能从一个尚未更新到最新版本的副本上读取数据。从多个副本读取数据的read scale方案有很多种,由于不同副本和写入副本之间的不一致窗口各不相同,应用看到整个存储系统表现出来的数据一致性行为也各不相同。总的来说取决于如何将请求路由到那个副本的策略,以及存储系统复制的方式


是否可以实现写后读(read-after-writes/read-your-writes)、会话(session)和单调一致性(monotonic),一般取决于客户端和后端存储系统之间如何执行分布式协议。如果每次都是读取同一个副本,那么保证写后读和单调一致性读取相对容易。但这使得管理负载均衡和容错变得稍微困难些,虽然它是一个简单易懂的解决方案。使用会话一致性,暴露了一个限制(session之间的一致性是不确定的),但是至少对于客户端来说,界线已经非常清晰了


有些情况下,客户端期望的是写后读(read-after-writes/read-your-writes)的一致性模型、或者期望的是单调读的一致性模型。实现这个需求的通常做法是给写入增加一个版本号,客户端通过丢弃早于自己已知版本的方式来实现上述需求。试想一下,这样的功能在数据访问中间件,比如DB Proxy中来实现,是不是对客户端会更友好


当集群中的某些副本和其他副本通讯中断时,会发生所谓的“网络分区”,但是可能不同的客户端是可以分别访问到处于网络分区中的两个副本集合的。如果采取常规的多数派方法,可能出现包含W个副本的集合依然在处理客户端更新,而另外一个集合变得不可用,类似的情况也适用于读取集合。如果读写策略导致两个集合有交集,那么处于少数派的集合会变得不可用。网络分区不会频繁发生,但他们一定会发生,无论是跨IDC还是一个IDC内部。


在某些应用程序中,任何分区的不可用性都是不能接受的。 比如,能够访问该分区才能让客户端的操作(行为)得以持续进行(含义有点像分布式协议中的“活性”)。在这种情况下,双方分配一组新的存储节点来接收数据,并且在分区恢复后执行合并操作。例如,在亚马逊内部购物车使用这样一个高可用写的系统;在分区的情况下,即使原始购物车处于其他分区上,客户仍可以继续将商品放到购物车中。当分区恢复后,购物车应用程序帮助存储系统合并购物车。



总结



在非常大型的分布式系统中,数据不一致的问题需要一定程度的容忍。主要原因是:在高并发的情况下提高读写的性能,其次是处理网络分区的情况,因为大多数应用其实只需要访问整个分布式系统的一部分。