一个成功的网络服务依赖于数据是正确、一致并且是可用的。对于一个简单的应用而言,它采用数据服务器加应用服务器构成。应用服务器相当于访问代理,并且处理应用的逻辑部分;数据服务器依靠数据库自身提高的功能来满足应用的需要,保证数据的正确、一致性和可用性。
网络服务的一个特点就是并发。随着用户的增加,并发成为应该系统最主要的瓶颈。在使用用户量不多的情况下,单服务器完全可以承受,随着使用用户的增加,我们可以采用各种优化技术,改良架构来解决部分用户增加导致的问题。但随着应用的进一步发展,业务需求和用户服务需求促使必须向分布式系统过渡。过渡带来一个棘手的问题需要解决:如何保证分散的数据在不稳定的网络环境中保持一致。
在进一步分析之前,我们先看看数据操作本身的特性。用一个学校的学生系统为例,当一个新生被录取时,我们需要在系统里添加一条关于该学生的信息记录。作为系统的设计人员,我们肯定不会在一张表中包含学生的所有信息,常用的做法是维护一个学生的基础信息库,然后后勤系统、教务系统、财务系统等都通过外键引用到学生的基础信息库。在初始化一条学生基础信息时,不可避免的需要在其他关联表中做初始化工作。添加学生信息的流程如下:
Step 1: 在基础信息库中添加一条该生的基础信息,比如生源地、性别等。
Step 2:在财务系统中建立该生的财务信息,缴费信息,贷款信息等。
Step 3:在教务系统中初始化一条该生的的教务相关的信息,比如专业、班级等。
Step 4:在后勤系统中初始化该生的信息,比如住宿、餐卡等。
我们期望的结果是,添加学生的操作如果被提交,系统就应该完成所有的4步操作,如果有一部操作失败,整个操作都应该回滚。对于单表的写操作而言,数据库会通过各种级别的锁保证所有的写操作都是顺序正确完成的。如果没有这部分机制,数据可能会丢失修改,用户可能脏读,不可重复读,幽灵数据。因此单表的写操作只会成功或失败(数据库不可用的时候会失败),产后的效果是:如果成功,表中肯定有一条正确的记录,如果失败,表中肯定没有一条错误的记录。但如果要保证几个连续的写操作,要么一起成功要么一起失败,我们就需要引入事务的概念。
原子性( Atomicity ) 事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
一致性(Consistency)在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
隔离性(Isolation)两个事务的执行是互不干扰的,一个事务不可能看到其他事务运行时,中间某一时刻的数据。
持久性(Durability)在事务完成以后,该事务所对数据库所作的更改便持久的保存在数据库之中,并不会被回滚。
来整理一下事务的概念,首先不同的事务是相互隔离的,不会彼此影响;再次事务是有时间开销的,这意味着大事务将导致其他事务等不到及时的处理,对服务的可用性是有影响的。可以看到,ACID强调数据的一致性,而且是严格的强一致性。在保证一致性的背后,牺牲了并发和可用性。
但用户并不会为牺牲的并发和可用性买单,我们需要在一致性的前提下提高并发。基于数据库的改进有两个方向,一是垂直扩展;而是水平扩展。垂直扩展主要通过分库分表来实现,水平扩展主要通过主从库,读写分离来实现。也可以通过应用来软化事务,保证可用性和一致性。但这些改进效果是有限的,随着用户数量的进一步上升,数据库已经到了其使用的极限。我们需要探索新的数据存储使用方式,Google和Amazon等互联网企业最早做出了尝试,他们宣告Nosql时代的到来。
现在我们跳出数据库的思维方式[注1],回到分布式系统中来,眼前面对的是分散在各个物理机器,不同地域的数据。眼前的数据并不一定是结构化的数据(数据库要求数据是结构化的并且遵循一定的Schema),还可能是半结构化和无结构的数据。这进一步让我们确信,现在是应该否认数据库的时候了。
在引入分布式系统的基本原理CAP之前,我们还是看看用户怎么使用分布式系统服务的。分布式系统使用的场合不同,对系统本身的要求也不相同。互联网是Nosql的发源地。其他行业也有大数据的需求,但数据量没有互联网大,也没有同时为全球用户同时提供不同服务的需要。一般不会同时出现每秒数亿次的银行交易请求,但每秒数亿次的网络请求对互联网来说是一件常事。而且互联网上的请求背后隐藏的处理逻辑也是非常复杂的。正是互联网的快速发展,让我们有了足够的数据,足够的能力去分析理解这些数据,进而预测未来。大数据的到来,使互联网成为研究Nosql的阵地。
我们拿售票系统为例,用户的大部分操作可能在浏览行车安排,余票信息上,真正发生购票操作的几率相对较小。在余票信息上我们可以舍去一致性,在用户真正购票时才保证余票信息的一致性。对于某些可预见有稳定退票发生的行业,即使多发售票也是可以接受的。由于放松了对读操作的一致性要求,我们可以使用缓存来减少对数据中心的访问。让数据中心专心解决购票操作,让有限的负荷放到真正产生商业收益的事情上。
在互联网的其他应用上面,也有类似的用户行为。对于大部分处于浏览、查询的请求,不要求数据完全正确一致的。我们只需要在关键交易行为中保证一致即可。
CAP原本是一个猜想,2000年PODC大会的时候大牛Brewer提出的,他认为在设计一个大规模可扩展的网络服务时候会遇到三个特性:一致性(consistency)、可用性(Availability)、分区容错(partition-tolerance)。这三个特性对于网络服务都需要,然而这是不可能都实现的。之后在2002年的时候,麻省理工(MIT)的Seth Gilbert 和Nancy Lynch ,理论上证明 了Brewer猜想是正确的,就此Brewer定理(Theorem)诞生了。
1. 一致性(consistency)与ACID中的C是一样的,指数据违反了某些预设的约束。直观点讲,就是数据的多个副本/备份存在不一致的情景。
2. 可用性(Availability)意味着服务可用,用户的所有操作都有及时的响应。
3. 分区容错(partition-tolerance)是应用或数据在多台机器上时,允许节点失效,系统仍能正常响应用户的请求。分区容错在整个网络瘫痪时肯定不能响应用户请求,但这种事件发生的概率微乎其微。
我们现在来分析为什么CAP不能同时满足。假定网络中的两个节点N1,N2。他们共享同一数据V,其值为V0。N1上有一个算法A,我们可以认为A是安全,无bug,可预测和可靠的。N2上有一个类似的算法B。在这个例子中,A写入V的新值而B读取V的值。
正常情况下,A写数据的过程如下:
(1)A写入新的V值,我们称作v1。
(2)N1发送信息M给N2,更新V的值。
(3)现在B读取的V值将会是V1。
如果N1和N2之间的网络断开,N2上的数据与N1就不一致了。如果M是一个异步消息,那么N1无法知道N2是否收到了消息。即使M是保证能发送的,N1也无法知道是否消息由于网络分区事件的发生而延迟,或N2上的其他故障而延迟。即使将M是同步信息也不能解决问题,因为那将会使得N1上A的写操作和N1到N2的更新事件成为一个原子操作,这将导致同样的等待问题,使得B的读操作不被允许,B的服务得不到满足。
如果我们想A和B的服务是高度可用的,我们就不得不允许A和B读到的数据不一致发生。
用事务的观点来看一下N1和N2的数据持久化问题,a1是写操作,a2是读操作。在一个本地系统中,可以利用数据库中的锁的机制方便地处理,隔离a2中的读操作,直到a1的写成功完成。然而,在分布式的模型中,需要考虑到N1和N2节点,中间的消息同步也要完成才行。除非我们可以控制a2何时发生,我们永远无法保证a2可以读到a1写入的数据。所有加入控制的方法(阻塞,隔离,中央化的管理,等等)会要么影响分区容错性,要么影响A和B的可用性。
我们必须做一些取舍:
1. 放弃Partition Tolerance 。如果你想避免分区问题发生,你就必须要阻止其发生。一种做法是将所有的东西(与事务相关的)都放到一台机器上。或者放在像机柜这类的统一管理的单元上。但100%不出现分区是不可能的,而且放弃分区容错代价昂贵。Haddoop的必然失效假设,Google和Facebook采用自己的硬件网络设备就是一个很好的证明。
2. 放弃Availability 。相对于放弃分区容错性来说,其反面就是放弃可用性。一旦遇到分区事件,受影响的服务需要等待数据一致,因此在等待期间就无法对外提供服务。在多个节点上控制这一点会相当复杂,而且恢复的节点需要处理逻辑,以便平滑地返回服务状态。
3. 放弃Consistency, 或者如同Werner Vogels所提倡的,接受事情会变得“最终一致 (Eventually Consistent)”。许多的不一致性并不比你想的需要更多的工作(意味着持续的一致性或许并不是我们所需要的)。比如网络购书,如果一本库存的书,接到了2个订单,第二个就会成为备份订单。只要告知客户这种情况(请记住这是一种罕见的情况),也许每个人都会高兴的。
4. 引入BASE 。有一种架构的方法称作BASE(Basically Available,Soft-state,Eventually consistent)支持最终一致概念的接受。BASE如其名字所示,是ACID的反面,但如果认为任何架构应该完全基于一种(BASE)或完全基于另一种(ACID),就大错特错了。应该结合自身的使用场景,合理平衡BASE和ACID。
放弃分区容错是不现实的[注2],我们需要在可用性和一致性中做些权衡。我们现在来看第三种和第四种选择。
由于一致性和可用性不可能同时满足,我们只有两个选择:放松一致性,在分区发生时仍然可用;一致性具有优先,意味着某些时候服务不可用,大部分时间仍然可用。不论哪一种选择,客户端开发者需要关注系统的行为,可能都要做些额外的处理。如果系统强调的是一致性,那开发者就需要处理服务不可用的情况,此时的写操作需要重试。如果系统强调的是可用性,所有的写操作都被接受,读者可能读到不正确的数据,开发者需要重新读取。Werner Vogels考虑一致性有两种视角:开发者即客户端,服务器。前者观察数据更新,后者观察数据在系统中更新。
客户端一般有下面的组件构成:
- 存储系统。它在本质上是大规模且高度分布的系统,其创建目的是为了保证耐用性和可用性。
- 进程A。对存储系统进行读写。
- 进程B和C。这两个进程完全独立于进程A,也读写存储系统。
客户端的一致性模型必须处理一个观察者(在此即进程A、B或C)如何以及何时看到存储系统中的一个数据对象被更新。在进程A更新一个数据对象后,我们面对下面3个一致性类型:
- 强一致性。在更新完成后,(A、B或C进行的)任何后续访问都将返回更新过的值。
- 弱一致性。系统不保证后续访问将返回更新过的值,在返回更新过的值之前要先满足若干条件。从更新到所有观察者都看到更新值的时间称为不一致窗口(inconsistency window)。
- 最终一致性(Eventual consistency)。这是弱一致性的一种特殊形式;存储系统保证如果对象没有新的更新,最终所有访问都将返回最后更新的值。如果没有发生故障,不一致窗口的最大值可以根据下列因素确定:比如通信延迟、系统负载、复制涉及的副本数量。
客户端一致性模型有一些重要的变体:
- 因果一致性(Causal consistency)。如果进程A通知进程B它已更新了一个数据项,那么进程B的后续访问将返回更新后的值,且一次写入将保证取代前一次写入。与进程A无因果关系的进程C的访问遵守一般的最终一致性规则。
- “读己之所写”一致性(Read-your-writes consistency)。当进程A自己更新一个数据项之后,它总是访问到更新过的值,绝不会看到旧值。这是因果一致性模型的一个特例。
- 会话一致性(Session consistency)。这是上一个模型的实用版本,它把访问存储系统的进程放到会话的上下文中。只要会话还存在,系统就保证“读己之所写”一致性。如果由于某种原因,会话被中止了,系统将会新启一个会话。系统保证会话之间是独立不重叠的。
- 单调读一致性(Monotonic read consistency)。如果进程已经看到过数据对象的某个值,那么任何后续访问都不会返回在那个值之前的值。
- 单调写一致性(Monotonic write consistency)。系统保证来自同一个进程的写操作顺序执行。要是系统不能保证这种程度的一致性,就非常难以编程了。
每个客户端应用对服务器造成的不一致性都有自己的耐受力,但在任何情况下,客户端应用都应该知道服务器应用提供的一致性水平。有很多改善最终一致性模型的实用方法,比如会话级别的一致性和单调读一致性,它们都为开发人员提供了更好的工具。
最终一致性并不是分布式系统所独有的。其实现在的关系数据库的主从数据库同步也是最终一致性的例子。如果采用同步方式,即是事务处理。如果是异步方式,在主数据库上的写会保持到数据库日志中,然后将日志异步地同步到所有的从数据库上。异步方式可能在日志尚未传输完成就已经宕机,从从数据库上读的数据可能是旧值,与主数据库不一致。为了提供可扩展性和高性能,采用的读写分离技术是一种典型的最终一致性,它的不一致窗口是日志传输的时延。
服务器端的一致性水平取决于如何在数据的各个副本之间传播更新(这是改善吞吐量、提供可伸缩性的典型方式)。只有部分数据副本参与更新操作,作为读操作的一部分与其它副本进行联系时,就会出现弱/最终一致性。发生这种情况的两种常见场景分别是为读伸缩而做大量复制的情况和有复杂数据访问的情况。在大多数这样的系统中,更新是以一种“懒”方式传播到的其它节点的副本上。所有副本都完成更新前的这段时间就是不一致窗口,读取尚未接收到更新的节点是整个系统的薄弱环节。
从服务端角度,如何尽快将更新后的数据分布到整个系统,降低达到最终一致性的时间窗口,是提高系统的可用度和用户体验非常重要的方面。在继续分析之前先定义一些变量:
1. N — 数据复制的份数
2. W — 更新数据是需要保证写完成的节点数
3. R — 读取数据的时候需要读取的节点数
如果W+R>N,写的节点和读的节点重叠,则是强一致性。例如对于典型的一主一备同步复制的关系型数据库,N=2,W=2,R=1,则不管读的是主库还是备库的数据,都是一致的。
如果W+R<=N,则是弱一致性。例如对于一主一备异步复制的关系型数据库,N=2,W=1,R=1,则如果读的是备库,就可能无法读取主库已经更新过的数据,所以是弱一致性。
如果N=W,R=1,任何一个写节点失效,都会导致写失败,因此可用性会降低,但是由于数据分布的N个节点是同步写入的,因此可以保证强一致性。这种情况我们优化了读操作。
如果N=R,W=1,只需要一个节点写入成功即可,写性能和可用性都比较高。但是读取其他节点的进程可能不能获取更新后的数据,因此是弱一致性。这种情况我们优化了写操作。
如果W<(N+1)/2,并且写入的节点不重叠的话,则会存在写冲突。
如果W+R <= N,意味着读节点和写节点没有重合。这对读操作是脆弱的,因为可能所有的读节点都没有做写操作的更新。
对于分布式系统,为了保证高可用性,一般设置N>=3。不同的N,W,R组合,是在可用性和一致性之间取一个平衡,以适应不同的应用场景。
BASE(Basically Available,Eventually consistent)是一种架构方法,大部分互联网应用场景可以考虑采用。它倡导的就是一种平衡和折中,设计时结合应用服务的自身特点合理选型。没有正确的架构,只有适合你的架构。
注1:我们一定要跳出数据库的思维方式。尤其是通过数据库建模,做ORM,再写应用逻辑的习惯,这严重使系统固化到底层数据存储的细节上面,禁锢系统今后的扩展,得不偿失。我也是在看了Bob大叔的《敏捷软件开发:原则、模式与实践》后深有体会。
注2:可用性和一致性的折中也不一定选择可用性。对于某些关键业务数据,一致性更重要。在数据存在未同步,不一致问题时,中断服务也是有的。早期的数据库设计就是这样。
参考文献:
"Brewer's CAP Theorem"的中译文:
http://my.oschina.net/u/138694/blog/110484
"CAP理论"
http://www.cnblogs.com/mmjx/archive/2011/12/19.html
Eventually Consistent
http://www.allthingsdistributed.com/2008/12/eventually_consistent.html
原文链接:https://www.f2er.com/nosql/204368.html