为了提升系统的可用性、性能、扩展性,我们可以从两个方面着手,
要去建立多个副本。可以放到不同的物理机、机架、机房、地域。一个副本的失效可以让请求转到其他副本。
对数据进行分区。复制多个副本解决了读的性能问题,但是无法解决写的性能问题。根据关键字进行分片,实现数据分布式,进一步提升系统的写性能。
以上两种方式最复杂的问题就是如何保证一致性。一致性问题一直以来都是分布式系统的痛点,因为本身一致性的场景有很多,并不是所有的系统都要求是强一致的,强一致需要极大的成本,我们需要根据系统的容忍度适当放宽一致性的要求。
在很多人看来,银行间转账应该是强一致的,但是你仔细分析一下会发现,小王给小张转账1000元,小王的账户扣除了1000,此时小张并不一定会收到1000元,可能会存在一个不一致的时间窗口。也就是小王的钱扣除了1000元,小张还没收到1000元。另外一个例子,在12306网站买票的功能,也未必是强一致的,如果你在12306上发现一张票还剩余10张,发起请求订了一张票,系统给你返回的可能是“正在排队,剩余10张票,现在有15人在购买”,可能需要你去查询未完成订单,并没有给你及时返回成功或失败的结果。如果有人退了一张票,也没有立即返回到票池中。这里明显也存在不一致的时间窗口。
学术界的一致性模型主要从两个角度去分类:
以数据为中心的一致性模型;
以客户为中心的一致性模型。
以数据为中心的一致性模型
以数据为中心的一致性是从数据存储的角度出发的,包括数据库、文件等。
如上图所示,实际上,一致性是进程和数据之间的规则约定。在下面的示例中,我们假设有多个进程,用p来表示,跟进程对应的有多份存储,a来表示变量,W(a)1来表示进程写入a=1,R(a)1表示读取a的值,结果为1。
以下一致性模型是异步执行的,没有任何同步操作。
a.严格一致性(Strict Consistency)
严格一致性要求任何写操作都能立刻同步到其他所有进程,任何读操作都能读取到最新的修改。要实现这一点,要求存在一个全局时钟,但是,在分布式场景下很难做到,所以严格一致性在实际生产环境中目前无法实现。
如上图,所有的对变量a的读取都能够读取到最新的值,无论是否在一个进程上。
问题:全局时钟难以实现。
b.顺序一致性(Sequential Consistency)
既然全局时钟导致严格一致性很难实现,顺序一致性放弃了全局时钟的约束,改为分布式逻辑时钟实现。顺序一致性是指所有的进程以相同的顺序看到所有的修改。读操作未必能及时得到此前其他进程对同一数据的写更新。但是每个进程读到的该数据的不同值的顺序是一致的。
(一)满足顺序一致性(二)不满足顺序一致性
如图(一)所示,对于变量a的写操作发生在三个不同的进程,如果按照严格一致性,变量a的结果应该是3,在顺序一致性的场景下,有一个确定顺序的过程,由于没有全局时钟,后执行的进程未必最后到达,可能导致最后a的值是1,只要三个进程读取到的顺序是一致的,哪怕最后一次读取到的是1,也是满足顺序一致性的。 但是图(二)非常明显的,三个进程读取出来的顺序是不一致的,p2读取到的结果顺序和p1、p3不同,不能满足顺序一致性。
顺序一致性需要额外实现一个逻辑时钟服务,有额外的性能开销。
c.因果一致性(Causal Consistency)
因果关系是指Lamport在分布式时钟事件序论文中描述的happen-before关系及其传递闭包。因果一致性是一种弱化的顺序一致性。所有进程必须以相同的顺序看到具有潜在因果关系的写操作。不同进程可以以不同的顺序看到并发的写操作。
(一)不满足因果一致性(二)满足因果一致性
如图(一)所示,p2中的读写存在因果关系,W(a)=2可能是先读到了a=1计算后的结果。所以p3中读到的顺序不满足要求,在读到a=2之后,不能再读到a=1。因为图(二)不存在因果关系,所以并不做要求,满足因果一致性。
相比于顺序一致性,因果一致性只强调存在因果关系的操作被看到的顺序一致,没有因果关系的操作被看到的顺序可以不一致。因果一致性并不对并发操作排序,在分布式系统中,如果a和b是并发的,且a和b是两个不相关的操作,没有任何因果关系,那么它们在分布式系统中复制就不必遵循任何顺序了,这样就避免了在它们之间使用因果这种串行化方式。因为串行化会成为系统的性能瓶颈。因果一致性提升了并行的概率。
d.FIFO一致性(FIFO Consistency)
在因果一致性模型上的进一步弱化,FIFO一致性要求所有进程以某个单一进程提出写操作的顺序看到这些写操作,但是不同进程可以以不同的顺序看到不同的进程提出的写操作。通俗来讲,就是要求在一个进程内,所有的写操作必须对外可以被看到的时候,必须是一致的,但是两个进程的写操作的顺序被看到是时候可以不同,是不受保证的,就算是有因果关系也不保证。
(一)满足FIFO一致性(二)不满足FIFO一致性
如图(一)所示,p3在任何时刻读取到的a的值的顺序,只要保证在p1、p2各自的写顺序就可以了,也就是要保证3在1的后面,4在2的后面,哪怕4和3存在因果关系。图(二)的两种情况违背了单进程的写顺序。
FIFO一致性在实际过程中,可以适当变化,根据业务情况把有因果关系的数据放到一个分区,在同一个进程写入。例如某电商价格系统在设置价格时,会写入Kafka,可以根据商品ID分区,在消费阶段,一个分区最多只有一个消费者,在同一个分区Kafka是保证顺序消费的。但是在不同分区没有任何约束。满足业务一致性的前提下,极大的提升了并发处理能力。
以下三个一致性是需要同步变量的。也就是说,当一个进程对变量设置值的时候,不保证其他进程什么时候能够看到值的变化,但是当执行了一次同步操作后,所有进程会看到最新的值。此处不再详细介绍。
a.弱一致性(Weak Consistency)
b.释放一致性(Release Consistency)
c.入口一致性(Entry Consistency)
以用户为中心的一致性模型
在实际业务要求中,很多时候并不要求系统内所有的数据都保持一致,例如在线的日记本,业务只要求基于这一个用户满足一致性即可,不需要关心整体。这就是所谓的以用户为中心的一致性。
以下一致性模型适应的场景为,不会同时发生更新操作,或者同时发生更新操作时,能够比较容易的化解。因为这里的数据更新默认有一个与之关联的所有者,此所有者拥有唯一被允许修改数据的权限,可以按照用户ID进行路由,通过这种方式,可以大概率避免写-写冲突,除非出现重新负载均衡的过程。在读多写少的场景中,例如CDN、CDN,读写比非常悬殊,如果网站的运营人员修改了一张图片,最终用户延迟一段时间看到这个更新实际上问题不大。我们把这种一致性归结为最终一致性。最终一致性是指如果更新的间隔时间比较长,那么所有的副本能够最终达到一致性。
以下示例,我们简化一下场景,假设有一个进程对一份数据的一个副本进行操作,更新要传递给其他副本,注意,这里是一个进程,而前面以数据为中心的一致性模型中的例子是多个进程。
a.单调读一致性(Monotonic-read Consistency)
单调读一致性是指如果一个进程读取数据项a的值,那么该进程对a执行的任何后续读操作总是得到第一次读取的那个值或更新的值。
(一)满足单调读一致性(二)不满足单调读一致性
如图(一),如果变量a的初始值为0,一个进程P在L1副本上做加1操作,得到结果a=1,此时a=1从L1副本传递到L2,进程P又在L2副本做加1操作,但是L2副本中的a并没有立即传递到L1,进程P在L1上读到a仍然是1,然后进程P又在L2上读到a=2,明显比上一次读到的值更新,符合单调读一致性。而图(二)进程P先在L1加1,随后读取a=1,此时变量a的值还没有传递到L2,进程P在L2读到的a=0,读到了更老的版本,显然不符合单调读一致性。
单调读一致性强调任何时刻不能读到比以前读到的数据还旧的数据。实际业务中,某一用户读到了一个审批流程,在页面看到已经进行到了第四步,刷新了一下,可能路由到了另外一个数据副本,又回到了第三步,产生了比较莫名其妙的错误。
b.单调写一致性(Monotonic-write Consistency)
单调写一致性是指一个进程对数据项a执行的写操作必须在该进程对a执行任何后续写操作前完成。
(一)满足单调写一致性(二)不满足单调写一致性
如图(一),如果变量a的初始值为0,一个进程P在L1副本上做加1操作,得到结果a=1,在a=1从L1副本传递到L2之后,进程P又对a做加1操作,此时a=2。满足单调写一致性的要求。图(二)a加1操作并没有及时传递,导致进程P在L2做加1操作时,得到的结果有误。
注意,单调写一致性跟以数据为中心的FIFO一致性类似,但是他们的场景不同,FIFO一致性是多个进程同时去写,而此处强调的是针对一个进程。在实际业务中,例如有一款游戏,用户打怪升级,杀死一个怪物增加100经验值,此时如果不满足单调写一致性,有可能导致用户杀死怪物后,经验值没有增加的问题。
c.写后读一致性(Read-your-writes Consistency)
写后读一致性是指一个进程对数据项a执行一次写操作的结果总是会被该进程对a执行的后续读操作看见。
(一)满足写后读一致性(二)不满足写后读一致性
如图(一),如果变量a的初始值为0,一个进程P在L1副本上做加1操作,得到结果a=1,在a=1从L1副本传递到L2之后,进程P在L2上读到了最新的值,此过程满足写后读一致性。而图(二),读并没有得到最新的结果,导致不满足写后读一致性。
例如一个用户发了一个微博,如果后端mysql采用master-slave结构做读写分离,当并发量比较大时产生了延迟,结果他发完后没有在自己的微博列表看到刚刚发过的微博,这时产生了不好的用户体验。通常我们会规定在写后t时间内读取master的数据,t大于mysql的主从延迟时间。
d.读后写一致性(Writes-follow-reads Consistency)
读后写一致性是指同一进程对数据项a执行的读操作之后的写操作,保证发生在于a读取值相同或比其更新的值上。
(一)满足读后写一致性(二)不满足读后写一致性
如图(一),如果变量a的初始值为0,一个进程P在L1副本上做加1操作,得到结果a=1,在a=1从L1副本传递到L2之后,进程P在L1上读到了最新的值,然后在L2上对a加1操作,得到结果2,此过程满足读后写一致性。而图(二),由于L2没有及时同步a的变化,导致进程P在L2对a加1的结果为1,不满足读后写一致性。
实际业务中,如果一个用户A在聊天室发了一条消息,用户B看到了,然后进行回复,如果副本间没有及时同步,下次请求路由到了另外一个副本,结果发现用户B发的回复还在,用户A发的内容没有了。出现了不好的用户体验。这种情况还不如用户B更晚看到用户A的消息。
业界常用的一致性模型
上面两个分类主要从学术角度进行分类,在实际情况中,大家简化了分类,主要分成了如下三个分类。
n弱一致性(Weak):写入一个数据a成功后,在数据副本上可能读出来,也可能读不出来。不能保证多长时间之后每个副本的数据一定是一致的。
n最终一致性(Eventually):写入一个数据a成功后,在其他副本有可能读不到a的最新值,但在某个时间窗口之后保证最终能读到。可以看做弱一致性的一个特例。这里面的重点是这个时间窗口。
n强一致性(Strong):数据a一旦写入成功,在任意副本任意时刻都能读到a的最新值。
最终一致性还可以继续细分,大家更喜欢把除了弱一致性和强一致性的其他一致性全部归为最终一致性。
弱一致性和最终一致性的副本同步是采用异步的方式,而强一致性一般要求同步更新副本,然后才能返回成功,否则很难满足任意副本任意时刻都能读到最新值,异步的通常意味着更好的吞吐量,但也意味着更复杂的架构,更复杂的开发、调试。同步意味着简单,但也意味着响应时间更长,吞吐量的更低。
Backups,通常不会用在生产环境;
M/S = master/slave,可以异步,也可以同步,写是瓶颈点;
MM - multi-master,可以解决写的问题,复杂度在于如何解决冲突;
2PC - 2 Phase Commit,强一致,性能低,容易死锁;
Paxos,它是完全分布的。没有单一的主协调员。
从图中可以看出,如果要满足强一致性,有两种方案:
2PC。吞吐量低,响应时间高,随着节点数增加,性能指数级下降。且容易造成死锁。
Paxos/Raft。实现成本较高。
所以,通常在满足业务场景的情况下选择最终一致性,例如聊天室只要保证最终一致性下的顺序一致性即可。
参考文献
《分布式系统原理与泛型》——作者: (美)特尼博姆
http://snarfed.org/transactions_across_datacenters_io.html