作者:仕禄
分布式系统设计是一项十分复杂且具有挑战性的事情。其中,数据复制与一致性更是其中十分重要的一环。数据复制领域概念庞杂、理论性强,如果对应的算法没有理论验证大概率会出错。如果在设计过程中,不了解对应理论所解决的问题以及不同理论之间的联系,势必无法设计出一个合理的分布式系统。
本系列文章分上下两篇,以《数据密集型应用系统设计(DDIA)》(下文简称《DDIA》)为主线,文中的核心理论讲解与图片来自于此书。在此基础上,加入了日常工作中对这些概念的理解与个性化的思考,并将它们映射到Kafka中,跟大家分享一下如何将具体的理论应用于实际生产环境中。
1. 简介
1.1 简介——使用复制的目的
在分布式系统中,数据通常需要被分散在多台机器上,主要为了达到以下目的:
- 扩展性,数据量因读写负载巨大,一台机器无法承载,数据分散在多台机器上可以有效地进行负载均衡,达到灵活的横向扩展。
- 容错、高可用,在分布式系统中,单机故障是常态,在单机故障下仍然希望系统能够正常工作,这时候就需要数据在多台机器上做冗余,在遇到单机故障时其他机器就可以及时接管。
- 统一的用户体验,如果系统客户端分布在多个地域,通常考虑在多个地域部署服务,以方便用户能够就近访问到他们所需要的数据,获得统一的用户体验。
数据的多机分布的方式主要有两种,一种是将数据分片保存,每个机器保存数据的部分分片(Kafka中称为Partition,其他部分系统称为Shard),另一种则是完全的冗余,其中每一份数据叫做一个副本(Kafka中称为Replica),通过数据复制技术实现。在分布式系统中,两种方式通常会共同使用,最后的数据分布往往是下图的样子,一台机器上会保存不同数据分片的若干个副本。本系列博文主要介绍的是数据如何做复制,分区则是另一个主题,不在本文的讨论范畴。
图1 常见数据分布
复制的目标需要保证若干个副本上的数据是一致的,这里的“一致”是一个十分不确定的词,既可以是不同副本上的数据在任何时刻都保持完全一致,也可以是不同客户端不同时刻访问到的数据保持一致。一致性的强弱也会不同,有可能需要任何时候不同客端都能访问到相同的新的数据,也有可能是不同客户端某一时刻访问的数据不相同,但在一段时间后可以访问到相同的数据。因此,“一致性”是一个值得单独抽出来细说的词。在下一篇文章中,我们将重点介绍这个词在不同上下文之间的含义。
此时,大家可能会有疑问,直接让所有副本在任意时刻都保持一致不就行了,为啥还要有各种不同的一致性呢?我们认为有两个考量点,第一是性能,第二则是复杂性。
性能比较好理解,因为冗余的目的不完全是为了高可用,还有延迟和负载均衡这类提升性能的目的,如果只一味地为了地强调数据一致,可能得不偿失。复杂性是因为分布式系统中,有着比单机系统更加复杂的不确定性,节点之间由于采用不大可靠的网络进行传输,并且不能共享统一的一套系统时间和内存地址(后文会详细进行说明),这使得原本在一些单机系统上很简单的事情,在转到分布式系统上以后就变得异常复杂。这种复杂性和不确定性甚至会让我们怀疑,这些副本上的数据真的能达成一致吗?下一篇文章会专门详细分析如何设计算法来应对这种复杂和不确定性。
1.2 文章系列概述
本系列博文将分为上下两篇,第一篇将主要介绍几种常见的数据复制模型,然后介绍分布式系统的挑战,让大家对分布式系统一些稀奇古怪的故障有一些感性的认识。第二篇文章将针对本篇中提到的问题,分别介绍事务、分布式共识算法和一致性,以及三者的内在联系,再分享如何在分布式系统中保证数据的一致性,进而让大家对数据复制技术有一个较为全面的认识。此外,本系列还将介绍业界验证分布式算法正确性的一些工具和框架。接下来,让我们一起开始数据复制之旅吧!
2. 数据复制模式
总体而言,最常见的复制模式有三种,分别为主从模式、多主节点模式、无主节点模式,下面分别进行介绍。
2.1 最简单的复制模式——主从模式
简介
对复制而言,最直观的方法就是将副本赋予不同的角色,其中有一个主副本,主副本将数据存储在本地后,将数据更改作为日志,或者以更改流的方式发到各个从副本(后文也会称节点)中。在这种模式下,所有写请求就全部会写入到主节点上,读请求既可以由主副本承担也可以由从副本承担,这样对于读请求而言就具备了扩展性,并进行了负载均衡。但这里面存在一个权衡点,就是客户端视角看到的一致性问题。这个权衡点存在的核心在于,数据传输是通过网络传递的,数据在网络中传输的时间是不能忽略的。
图2 同步复制与异步复制如上图所示,在这个时间窗口中,任何情况都有可能发生。在这种情况下,客户端何时算写入完成,会决定其他客户端读到数据的可能性。这里我们假设这份数据有一个主副本和一个从副本,如果主副本保存后即向客户端返回成功,这样叫做异步复制(1)。而如果等到数据传送到从副本1,并得到确认之后再返回客户端成功,称为同步复制(2)。这里我们先假设系统正常运行,在异步同步下,如果从副本承担读请求,假设reader1和reader2同时在客户端收到写入成功后发出读请求,两个reader就可能读到不一样的值。
为了避免这种情况,实际上有两种角度的做法,第一种角度是让客户端只从主副本读取数据,这样,在正常情况下,所有客户端读到的数据一定是一致的(Kafka当前的做法);另一种角度则是采用同步复制,假设使用纯的同步复制,当有多个副本时,任何一个副本所在的节点发生故障,都会使写请求阻塞,同时每次写请求都需要等待所有节点确认,如果副本过多会极大影响吞吐量。而如果仅采用异步复制并由主副本承担读请求,当主节点故障发生切换时,一样会发生数据不一致的问题。
很多系统会把这个决策权交给用户,这里我们以Kafka为例,首先提供了同步与异步复制的语义(通过客户端的acks参数确定),另外提供了ISR机制,而只需要ISR中的副本确认即可,系统可以容忍部分节点因为各种故障而脱离ISR,那样客户端将不用等待其确认,增加了系统的容错性。当前Kafka未提供让从节点承担读请求的设计,但在高版本中已经有了这个Feature。这种方式使系统有了更大的灵活性,用户可以根据场景自由权衡一致性和可用性。
主从模式下需要的一些能力
增加新的从副本(节点)1. 在Kafka中,我们所采取的的方式是通过新建副本分配的方式,以追赶的方式从主副本中同步数据。2. 数据库所采用的的方式是通过快照+增量的方式实现。
a.在某一个时间点产生一个一致性的快照。
b.将快照拷贝到从节点。
c.从节点连接到主节点请求所有快照点后发生的改变日志。
d.获取到日志后,应用日志到自己的副本中,称之为追赶。
e.可能重复多轮a-d。
处理节点失效
从节点失效——追赶式恢复
针对从节点失效,恢复手段较为简单,一般采用追赶式恢复。而对于数据库而言,从节点可以知道在崩溃前所执行的最后一个事务,然后连接主节点,从该节点将拉取所有的事件变更,将这些变更应用到本地记录即可完成追赶。
对于Kafka而言,恢复也是类似的,Kafka在运行过程中,会定期项磁盘文件中写入checkpoint,共包含两个文件,一个是recovery-point-offset-checkpoint,记录已经写到磁盘的offset,另一个则是replication-offset-checkpoint,用来记录高水位(下文简称HW),由ReplicaManager写入,下一次恢复时,Broker将读取两个文件的内容,可能有些被记录到本地磁盘上的日志没有提交,这时就会先截断(Truncate)到HW对应的offset上,然后从这个offset开始从Leader副本拉取数据,直到认追上Leader,被加入到ISR集合中
主节点失效——节点切换
主节点失效则会稍稍复杂一些,需要经历三个步骤来完成节点的切换。
- 确认主节点失效,由于失效的原因有多种多样,大多数系统会采用超时来判定节点失效。一般都是采用节点间互发心跳的方式,如果发现某个节点在较长时间内无响应,则会认定为节点失效。具体到Kafka中,它是通过和Zookeeper(下文简称ZK)间的会话来保持心跳的,在启动时Kafka会在ZK上注册临时节点,此后会和ZK间维持会话,假设Kafka节点出现故障(这里指被动的掉线,不包含主动执行停服的操作),当会话心跳超时时,ZK上的临时节点会掉线,这时会有专门的组件(Controller)监听到这一信息,并认定节点失效。
- 选举新的主节点。这里可以通过通过选举的方式(民主协商投票,通常使用共识算法),或由某个特定的组件指定某个节点作为新的节点(Kafka的Controller)。在选举或指定时,需要尽可能地让新主与原主的差距最小,这样会最小化数据丢失的风险(让所有节点都认可新的主节点是典型的共识问题)--这里所谓共识,就是让一个小组的节点就某一个议题达成一致,下一篇文章会重点进行介绍。
- 重新配置系统是新的主节点生效,这一阶段基本可以理解为对集群的元数据进行修改,让所有外界知道新主节点的存在(Kafka中Controller通过元数据广播实现),后续及时旧的节点启动,也需要确保它不能再认为自己是主节点,从而承担写请求。
问题
虽然上述三个步骤较为清晰,但在实际发生时,还会存在一些问题:
- 假设采用异步复制,在失效前,新的主节点与原主节点的数据存在Gap,选举完成后,原主节点很快重新上线加入到集群,这时新的主节点可能会收到冲突的写请求,此时还未完全执行上述步骤的第三步,也就是原主节点没有意识到自己的角色发生变化,还会尝试向新主节点同步数据。这时,一般的做法是,将原主节点上未完成复制的写请求丢掉,但这又可能会发生数据丢失或不一致,假设我们每条数据采用MySQL的自增ID作为主键,并且使用Redis作为缓存,假设发生了MySQL的主从切换,从节点的计数器落后于主节点,那样可能出现应用获取到旧的自增ID,这样就会与Redis上对应ID取到的数据不一致,出现数据泄露或丢失。
- 假设上面的问题,原主节点因为一些故障永远不知道自己角色已经变更,则可能发生“脑裂”,两个节点同时操作数据,又没有相应解决冲突(没有设计这一模块),就有可能对数据造成破坏。
- 此外,对于超时时间的设定也是个十分复杂的问题,过长会导致服务不可用,设置过短则会导致节点频繁切换,假设本身系统处于高负载状态,频繁角色切换会让负载进一步加重(团队内部对Kafka僵尸节点的处理逻辑)。
异步复制面临的主要问题——复制滞后
如前文所述,如果我们使用纯的同步复制,任何一台机器发生故障都会导致服务不可写入,并且在数较多的情况下,吞吐和可用性都会受到比较大的影响。很多系统都会采用半步复制或异步复制来在可用性和一致性之间做权衡。在异步复制中,由于写请求写到主副本就返回成功,在数据复制到其他副本的过程中,如果客户端进行读取,在不同副本读取到的数据可能会不一致,《DDIA》将这个种现象称为复制滞后(Replication Lag),存在这种问题的复制行为所形成的数据一致性统称为最终一致性。未来还会重点介绍一下一致性和共识,但在本文不做过多的介绍,感兴趣的同学可以提前阅读《Problems with Replication Lag》这一章节。
2.2 多主节点复制
前文介绍的主从复制模型中存在一个比较严重的弊端,就是所有写请求都需要经过主节点,因为只存在一个主节点,就很容易出现性能问题。虽然有从节点作为冗余应对容错,但对于写入请求实际上这种复制方式是不具备扩展性的。
此外,如果客户端来源于多个地域,不同客户端所感知到的服务相应时间差距会非常大。因此,有些系统顺着传统主从复制进行延伸,采用多个主节点同时承担写请求,主节点接到写入请求之后将数据同步到从节点,不同的是,这个主节点可能还是其他节点的从节点。复制模式如下图所示,可以看到两个主节点在接到写请求后,将数据同步到同一个数据中心的从节点。此外,该主节点还将不断同步在另一数据中心节点上的数据,由于每个主节点同时处理其他主节点的数据和客户端写入的数据,因此需要模型中增加一个冲突处理模块,最后写到主节点的数据需要解决冲突。
图3 多主节点复制
使用场景
a. 多数据中心部署
一般采用多主节点复制,都是为了做多数据中心容灾或让客户端就近访问(用一个高大上的名词叫做异地多活),在同一个地域使用多主节点意义不大,在多个地域或者数据中心部署相比主从复制模型有如下的优势:
- 性能提升:性能提升主要表现在两个核心指标上,首先从吞吐方面,传统的主从模型所有写请求都会经过主节点,主节点如果无法采用数据分区的方式进行负载均衡,可能存在性能瓶颈,采用多主节点复制模式下,同一份数据就可以进行负载均衡,可以有效地提升吞吐。另外,由于多个主节点分布在多个地域,处于不同地域的客户端可以就近将请求发送到对应数据中心的主节点,可以最大程度地保证不同地域的客户端能够以相似的延迟读写数据,提升用户的使用体验。
- 容忍数据中心失效:对于主从模式,假设主节点所在的数据中心发生网络故障,需要发生一次节点切换才可将流量全部切换到另一个数据中心,而采用多主节点模式,则可无缝切换到新的数据中心,提升整体服务的可用性。
b. 离线客户端操作
除了解决多个地域容错和就近访问的问题,还有一些有趣的场景,其中一个场景则是在网络离线的情况下还能继续工作,例如我们笔记本电脑上的笔记或备忘录,我们不能因为网络离线就禁止使用该程序,我们依然可以在本地愉快的编辑内容(图中标记为Offline状态),当我们连上网之后,这些内容又会同步到远程的节点上,这里面我们把本地的App也当做其中的一个副本,那么就可以承担用户在本地的变更请求。联网之后,再同步到远程的主节点上。
图4 Notion界面
c. 协同编辑
这里我们对离线客户端操作进行扩展,假设我们所有人同时编辑一个文档,每个人通过Web客户端编辑的文档都可以看做一个主节点。这里我们拿美团内部的学城(内部的Wiki系统)举例,当我们正在编辑一份文档的时候,基本上都会发现右上角会出现“xxx也在协同编辑文档”的字样,当我们保存的时候,系统就会自动将数据保存到本地并复制到其他主节点上,各自处理各自端上的冲突。
另外,当文档出现了更新时,学城会通知我们有更新,需要我们手动点击更新,来更新我们本地主节点的数据。书中说明,虽然不能将协同编辑完全等同于数据库复制,但却是有很多相似之处,也需要处理冲突问题。
冲突解决
通过上面的分析,我们了解到多主复制模型最大挑战就是解决冲突,下面我们简单看下《DDIA》中给出的通用解法,在介绍之前,我们先来看一个典型的冲突。
a. 冲突实例
图5 冲突实例
在图中,由于多主节点采用异步复制,用户将数据写入到自己的网页就返回成功了,但当尝试把数据复制到另一个主节点时就会出问题,这里我们如果假设主节点更新时采用类似CAS的更新方式时更新时,都会由于预期值不符合从而拒绝更新。针对这样的冲突,书中给出了几种常见的解决思路。
b. 解决思路
1. 避免冲突
所谓解决问题最根本的方式则是尽可能不让它发生,如果能够在应用层保证对特定数据的请求只发生在一个节点上,这样就没有所谓的“写冲突”了。继续拿上面的协同编辑文档举例,如果我们把每个人的都在填有自己姓名表格的一行里面进行编辑,这样就可以最大程度地保证每个人的修改范围不会有重叠,冲突也就迎刃而解了
2. 收敛于一致状态
然而,对更新标题这种情况而言,冲突是没法避免的,但还是需要有方法解决。对于单主节点模式而言,如果同一个字段有多次写入,那么最后写入的一定是最新的。ZK、KafkaController、KafkaReplica都有类似Epoch的方式去屏蔽过期的写操作,由于所有的写请求都经过同一个节点,顺序是绝对的,但对于多主节点而言,由于没有绝对顺序的保证,就只能试图用一些方式来决策相对顺序,使冲突最终收敛,这里提到了几种方法:
给每个写请求分配Uniq-ID,例如一个时间戳,一个随机数,一个UUID或Hash值,最终取最高的ID作为最新的写入。如果基于时间戳,则称作最后写入者获胜(LWW),这种方式看上去非常直接且简单,并且非常流行。但很遗憾,文章一开始也提到了,分布式系统没有办法在机器间共享一套统一的系统时间,所以这个方案很有可能因为这个问题导致数据丢失(时钟漂移)。
每个副本分配一个唯一的ID,ID高的更新优先级高于地域低的,这显然也会丢失数据。当然,我们可以用某种方式做拼接,或利用预先定义的格式保留冲突相关信息,然后由用户自行解决。
3. 用户自行处理
其实,把这个操作直接交给用户,让用户自己在读取或写入前进行冲突解决,这种例子也是屡见不鲜,Github采用就是这种方式。
这里只是简单举了一些冲突的例子,其实冲突的定义是一个很微妙的概念。《DDIA》第七章介绍了更多关于冲突的概念,感兴趣同学可以先自行阅读,在下一篇文章中也会提到这个问题。
c. 处理细节介绍
此外,在书中将要结束《复制》这一章时,也详细介绍了如何进行冲突的处理,这里也简单进行介绍。这里我们可以思考一个问题,为什么会发生冲突?通过阅读具体的处理手段后,我们可以尝试这样理解,正是因为我们对事件发生的先后顺序不确定,但这些事件的处理主体都有重叠(比如都有设置某个数据的值)。通过我们对冲突的理解,加上我们的常识推测,会有这样几种方式可以帮我们来判断事件的先后顺序。
1. 直接指定事件顺序
对于事件发生的先后顺序,我们一个最直观的想法就是,两个请求谁新要谁的,那这里定义“最新”是个问题,一个很简单的方式是使用时间戳,这种算法叫做最后写入者获胜LWW。
但分布式系统中没有统一的系统时钟,不同机器上的时间戳无法保证精确同步,那就可能存在数据丢失的风险,并且由于数据是覆盖写,可能不会保留中间值,那么最终可能也不是一致的状态,或出现数据丢失。如果是一些缓存系统,覆盖写看上去也是可以的,这种简单粗暴的算法是非常好的收敛冲突的方式,但如果我们对数据一致性要求较高,则这种方式就会引入风险,除非数据写入一次后就不会发生改变。
2. 从事件本身推断因果关系和并发
上面直接简单粗暴的制定很明显过于武断,那么有没有可能时间里面就存在一些因果关系呢,如果有我们很显然可以通过因果关系知道到底需要怎样的顺序,如果不行再通过指定的方式呢?例如:
图6 违背因果关系示例
这里是书中一个多主节点复制的例子,这里ClientA首先向Leader1增加一条数据x=1,然Leader1采用异步复制的方式,将变更日志发送到其他的Leader上。在复制过程中,ClientB向Leader3发送了更新请求,内容则是更新Key为x的Value,使Value=Value+1。
原图中想表达的是,update的日志发送到Leader2的时间早于insert日志发送到Leader2的时间,会导致更新的Key不存在。但是,这种所谓的事件关系本身就不是完全不相干的,书中称这种关系为依赖或者Happens-before。
我们可能在JVM的内存模型(JMM)中听到过这个词,在JMM中,表达的也是多个线程操作的先后顺序关系。这里,如果我们把线程或者请求理解为对数据的操作(区别在于一个是对本地内存数据,另一个是对远程的某处内存进行修改),线程或客户端都是一种执行者(区别在于是否需要使用网络),那这两种Happens-before也就可以在本质上进行统一了,都是为了描述事件的先后顺序而生。
书中给出了检测这类事件的一种算法,并举了一个购物车的例子,如图所示(以餐厅扫码点餐的场景为例):
图7 扫码点餐示例
图中两个客户端同时向购物车里放东西,事例中的数据库假设只有一个副本。
- 首先Client1向购物车中添加牛奶,此时购物车为空,返回版本1,Value为[牛奶]。
- 此时Client2向其中添加鸡蛋,其并不知道Client1添加了牛奶,但服务器可以知道,因此分配版本号为2,并且将鸡蛋和牛奶存成两个单独的值,最后将两个值和版本号2返回给客户端。此时服务端存储了[鸡蛋] 2 [牛奶]1。
- 同理,Client1添加面粉,这时候Client1只认为添加了[牛奶],因此将面粉与牛奶合并发送给服务端[牛奶,面粉],同时还附带了之前收到的版本号1,此时服务端知道,新值[牛奶,面粉]可以替换同一个版本号中的旧值[牛奶],但[鸡蛋]是并发事件,分配版本号3,返回值[牛奶,面粉] 3 [鸡蛋]2。
- 同理,Client2向购物车添加[火腿],但在之前的请求中,返回了[鸡蛋][牛奶],因此和火腿合并发送给服务端[鸡蛋,牛奶,火腿],同时附带了版本号2,服务端直接将新值覆盖之前版本2的值[鸡蛋],但[牛奶,面粉]是并发事件,因此存储值为[牛奶,面粉] 3 [鸡蛋,牛奶,火腿] 4并分配版本号4。
- 最后一次Client添加培根,通过之前返回的值里,知道有[牛奶,面粉,鸡蛋],Client将值合并[牛奶,面粉,鸡蛋,培根]联通之前的版本号一起发送给服务端,服务端判断[牛奶,面粉,鸡蛋,培根]可以覆盖之前的[牛奶,面粉]但[鸡蛋,牛奶,火腿]是并发值,加以保留。
通过上面的例子,我们看到了一个根据事件本身进行因果关系的确定。书中给出了进一步的抽象流程:
- 服务端为每个主键维护一个版本号,每当主键新值写入时递增版本号,并将新版本号和写入值一起保存。
- 客户端写主键,写请求比包含之前读到的版本号,发送的值为之前请求读到的值和新值的组合,写请求的相应也会返回对当前所有的值,这样就可以一步步进行拼接。
- 当服务器收到有特定版本号的写入时,覆盖该版本号或更低版本号的所有值,保留高于请求中版本号的新值(与当前写操作属于并发)。
有了这套算法,我们就可以检测出事件中有因果关系的事件与并发的事件,而对于并发的事件,仍然像上文提到的那样,需要依据一定的原则进行合并,如果使用LWW,依然可能存在数据丢失的情况。因此,需要在服务端程序的合并逻辑中需要额外做些事情。
在购物车这个例子中,比较合理的是合并新值和旧值,即最后的值是[牛奶,鸡蛋,面粉,火腿,培根],但这样也会导致一个问题,假设其中的一个用户删除了一项商品,但是union完还是会出现在最终的结果中,这显然不符合预期。因此可以用一个类似的标记位,标记记录的删除,这样在合并时可以将这个商品踢出,这个标记在书中被称为墓碑(Tombstone)。
2.3 无主节点复制
之前介绍的复制模式都是存在明确的主节点,从节点的角色划分的,主节点需要将数据复制到从节点,所有写入的顺序由主节点控制。但有些系统干脆放弃了这个思路,去掉了主节点,任何副本都能直接接受来自客户端的写请求,或者再有一些系统中,会给到一个协调者代表客户端进行写入(以Group Commit为例,由一个线程积攒所有客户端的请求统一发送),与多主模式不同,协调者不负责控制写入顺序,这个限制的不同会直接影响系统的使用方式。
处理节点失效
假设一个数据系统拥有三个副本,当其中一个副本不可用时,在主从模式中,如果恰好是主节点,则需要进行节点切换才能继续对外提供服务,但在无主模式下,并不存在这一步骤,如下图所示:
图8 Quorum写入处理节点失效
这里的Replica3在某一时刻无法提供服务,此时用户可以收到两个Replica的写入成功的确认,即可认为写入成功,而完全可以忽略那个无法提供服务的副本。当失效的节点恢复时,会重新提供读写服务,此时如果客户端向这个副本读取数据,就会请求到过期值。为了解决这个问题,这里客户端就不是简单向一个节点请求数据了,而是向所有三个副本请求,这时可能会收到不同的响应,这时可以通过类似版本号来区分数据的新旧(类似上文中并发写入的检测方式)。这里可能有一个问题,副本恢复之后难道就一直让自己落后于其他副本吗?这肯定不行,这会打破一致性的语义,因此需要一个机制。有两种思路:
- 客户端读取时对副本做修复,如果客户端通过并行读取多个副本时,读到了过期的数据,可以将数据写入到旧副本中,以便追赶上新副本。
- 反熵查询,一些系统在副本启动后,后台会不断查找副本之间的数据diff,将diff写到自己的副本中,与主从复制模式不同的是,此过程不保证写入的顺序,并可能引发明显的复制滞后。
读写Quorum
上文中的实例我们可以看出,这种复制模式下,要想保证读到的是写入的新值,每次只从一个副本读取显然是有问题的,那么需要每次写几个副本呢,又需要读取几个副本呢?这里的一个核心点就是让写入的副本和读取的副本有交集,那么我们就能够保证读到新值了。
直接上公式: 。其中N为副本的数量,w为每次并行写入的节点数,r为每次同时读取的节点数,这个公式非常容易理解,就不做过多赘述。不过这里的公式虽然看着比较直白也简单,里面却蕴含了一些系统设计思考:
- 一般配置方法,取
- w,r与N的关系决定了能够容忍多少的节点失效
- 假设N=3, w=2, r=2,可以容忍1个节点故障。
- 假设N=5,w=3, r=3 可以容忍2个节点故障。
- N个节点可以容忍可以容忍个节点故障。
- 在实际实现中,一般数据会发送或读取所有节点,w和r决定了我们需要等待几个节点的写入或读取确认。
Quorum一致性的局限性
看上去这个简单的公式就可以实现很强大的功能,但这里有一些问题值得注意:
- 首先,Quorum并不是一定要求多数,重要的是读取的副本和写入副本有重合即可,可以按照读写的可用性要求酌情考虑配置。
- 另外,对于一些没有很强一致性要求的系统,可以配置w+r <= N,这样可以等待更少的节点即可返回,这样虽然有可能读取到一个旧值,但这种配置可以很大提升系统的可用性,当网络大规模故障时更有概率让系统继续运行而不是由于没有达到Quorum限制而返回错误。
- 假设在w+r>N的情况下,实际上也存在边界问题导致一些一致性问题:
首先假设是Sloppy Quorum(一个更为宽松的Quorum算法),写入的w和读取的r可能完全不相交,因此不能保证数据一定是新的。
如果两个写操作同时发生,那么还是存在冲突,在合并时,如果基于LWW,仍然可能导致数据丢失。
如果写读同时发生,也不能保证读请求一定就能取到新值,因为复制具有滞后性(上文的复制窗口)。
如果某些副本写入成功,其他副本写入失败(磁盘空间满)且总的成功数少于w,那些成功的副本数据并不会回滚,这意味着及时写入失败,后续还是可能读到新值。
虽然,看上去Quorum复制模式可以保证获取到新值,但实际情况并不是我们想象的样子,这个协议到最后可能也只能达到一个最终的一致性,并且依然需要共识算法的加持。
2.4 本章小结
以上我们介绍了所有常见的复制模式,我们可以看到,每种模式都有一定的应用场景和优缺点,但是很明显,光有复制模式远远达不到数据的一致性,因为分布式系统中拥有太多的不确定性,需要后面各种事务、共识算法的帮忙才能去真正对抗那些“稀奇古怪”的问题。到这里,可能会有同学就会问,到底都是些什么稀奇古怪的问题呢?相比单机系统又有那些独特的问题呢?下面本文先来介绍分布式系统中的几个最典型的挑战(Trouble),让一些同学小小地“绝望”一下,然后我们会下一篇文章中再揭晓答案。
3. 分布式系统的挑战
这部分存在的意义主要想让大家理解,为什么一些看似简单的问题到了分布式系统中就会变得异常复杂。顺便说一声,这一章都是一些“奇葩”现象,并没有过于复杂的推理和证明,希望大家能够较为轻松愉悦地看完这些内容。
3.1 部分失效
这是分布式系统中特有的一个名词,这里先看一个现实当中的例子。假设老板想要处理一批文件,如果让一个人做,需要十天。但老板觉得有点慢,于是他灵机一动,想到可以找十个人来搞定这件事,然后自己把工作安排好,认为这十个人一天正好干完,于是向他的上级信誓旦旦地承诺一天搞定这件事。他把这十个人叫过来,把任务分配给了他们,他们彼此建了个微信群,约定每个小时在群里汇报自己手上的工作进度,并强调在晚上5点前需要通过邮件提交最后的结果。于是老版就去愉快的喝茶去了,但是现实却让他大跌眼镜。
首先,有个同学家里信号特别差,报告进度的时候只成功报告了3个小时的,然后老板在微信里问,也收不到任何回复,最后结果也没法提交。另一个同学家的表由于长期没换电池,停在了下午四点,结果那人看了两次表都是四点,所以一点都没着急,中间还看了个电影,慢慢悠悠做完交上去了,他还以为老板会表扬他,提前了一小时交,结果实际上已经是晚上八点了。还有一个同学因为前一天没睡好,效率极低,而且也没办法再去高强度的工作了。结果到了晚上5点,只有7个人完成了自己手头上的工作。
这个例子可能看起来并不是非常恰当,但基本可以描述分布式系统特有的问题了。在分布式的系统中,我们会遇到各种“稀奇古怪”的故障,例如家里没信号(网络故障),不管怎么叫都不理你,或者断断续续的理你。另外,因为每个人都是通过自己家的表看时间的,所谓的5点需要提交结果,在一定程度上旧失去了参考的绝对价值。因此,作为上面例子中的“老板”,不能那么自信的认为一个人干工作需要10天,就可以放心交给10个人,让他们一天搞定。
我们需要有各种措施来应对分派任务带来的不确定性,回到分布式系统中,部分失效是分布式系统一定会出现的情况。作为系统本身的设计人员,我们所设计的系统需要能够容忍这种问题,相对单机系统来说,这就带来了特有的复杂性。
3.2 分布式系统特有的故障
不可靠的网络
对于一个纯的分布式系统而言,它的架构大多为Share Nothing架构,即使是存算分离这种看似的Share Storage,它的底层存储一样是需要解决Share Nothing的。所谓Nothing,这里更倾向于叫Nothing but Network,网络是不同节点间共享信息的唯一途径,数据的传输主要通过以太网进行传输,这是一种异步网络,也就是网络本身并不保证发出去的数据包一定能被接到或是何时被收到。这里可能发生各种错误,如下图所示:
图9 不可靠的网络
- 请求丢失
- 请求正在某个队列中等待
- 远程节点已经失效
- 远程节点无法响应
- 远程节点已经处理完请求,但在ack的时候丢包
- 远程接收节点已经处理完请求,但回复处理很慢
本文认为,造成网络不可靠的原因不光是以太网和IP包本身,其实应用本身有时候异常也是造成网络不可靠的一个诱因。因为,我们所采用的节点间传输协议大多是TCP,TCP是个端到端的协议,是需要发送端和接收端两端内核中明确维护数据结构来维持连接的,如果应用层发生了下面的问题,那么网络包就会在内核的Socket Buffer中排队得不到处理,或响应得不到处理。
- 应用程序GC。
- 处理节点在进行重的磁盘I/O,导致CPU无法从中断中恢复从而无法处理网络请求。
- 由于内存换页导致的颠簸。
这些问题和网络本身的不稳定性相叠加,使得外界认为的网络不靠谱的程度更加严重。因此这些不靠谱,会极大地加重上一章中的 复制滞后性,进而带来各种各样的一致性问题。
应对之道
网络异常相比其他单机上的错误而言,可能多了一种不确定的返回状态,即延迟,而且延迟的时间完全无法预估。这会让我们写起程序来异常头疼,对于上一章中的问题,我们可能无从知晓节点是否失效,因为你发的请求压根可能不会有人响应你。因此,我们需要把上面的“不确定”变成一种确定的形式,那就是利用“超时”机制。这里引申出两个问题:
1. 假设能够检测出失效,我们应该如何应对?
a. 负载均衡需要避免往失效的节点上发数据(服务发现模块中的健康检查功能)。
b. 如果在主从复制中,如果主节点失效,需要出发选举机制(Kafka中的临时节点掉线,Controller监听到变更触发新的选举,Controller本身的选举机制)。
c. 如果服务进程崩溃,但操作系统运行正常,可以通过脚本通知其他节点,以便新的节点来接替(Kafka的僵尸节点检测,会触发强制的临时节点掉线)。
d. 如果路由器已经确认目标节点不可访问,则会返回ICMP不可达(ping不通走下线)。
2. 如何设置超时时间是合理的?
很遗憾地告诉大家,这里面实际上是个权衡的问题,短的超时时间会更快地发现故障,但同时增加了误判的风险。这里假设网络正常,那么如果端到端的ping时间为d,处理时间为r,那么基本上请求会在2d+r的时间完成。但在现实中,我们无法假设异步网络的具体延迟,实际情况可能会更复杂。因此这是一个十分靠经验的工作。
3.2 不可靠的时钟
说完了“信号”的问题,下面就要说说每家的“钟表”——时钟了,它主要用来做两件事:
- 描述当前的绝对时间
- 描述某件事情的持续时间
在DDIA中,对于这两类用途给出了两种时间,一类成为墙上时钟,它们会返回当前的日期和时间,例如clock_gettime(CLOCK_REALTIME) 或者System.currentTimeMills,但这类反应精确时间的API,由于时钟同步的问题,可能会出现回拨的情况。因此,作为持续时间的测量通常采用单调时钟,例如clock_gettime(CLOCK_MONOTONIC) 或者System.nanoTime。高版本的Kafka中把请求的相应延迟计算全部换成了这个API实现,应该也是这个原因。
这里时钟同步的具体原理,以及如何会出现不准确的问题,这里就不再详细介绍了,感兴趣的同学可以自行阅读书籍。下面将介绍一下如何使用时间戳来描述事件顺序的案例,并展示如何因时钟问题导致事件顺序判断异常的:
图10 不可靠的时钟
这里我们发现,Node1的时钟比Node3快,当两个节点在处理完本地请求准备写Node2时发生了问题,原本ClientB的写入明显晚于ClientA的写入,但最终的结果,却由于Node1的时间戳更大而丢弃了本该保留的x+=1,这样,如果我们使用LWW,一定会出现数据不符合预期的问题。
由于时钟不准确,这里就引入了统计学中的置信区间的概念,也就是这个时间到底在一个什么样的范围里,一般的API是无法返回类似这样的信息的。不过,Google的TrueTime API则恰恰能够返回这种信息,其调用结果是一个区间,有了这样的API,确实就可以用来做一些对其有依赖的事情了,例如Google自家的Spanner,就是使用TrueTime实现快照隔离。
如何在这艰难的环境中设计系统
上面介绍的问题是不是挺“令人绝望”的?你可能发现,现在时间可能是错的,测量可能是不准的,你的请求可能得不到任何响应,你可能不知道它是不是还活着......这种环境真的让设计分布式系统变得异常艰难,就像是你在100个人组成的大部门里面协调一些工作一样,工作量异常的巨大且复杂。
但好在我们并不是什么都做不了,以协调这件事为例,我们肯定不是武断地听取一个人的意见,让我们回到学生时代。我们需要评选一位班长,肯定我们都经历过投票、唱票的环节,最终得票最多的那个人当选,有时可能还需要设置一个前提,需要得票超过半数。
映射到分布式系统中也是如此,我们不能轻易地相信任何一台节点的信息,因为它有太多的不确定,因此更多的情况下,在分布式系统中如果我们需要就某个事情达成一致,也可以采取像竞选或议会一样,大家协商、投票、仲裁决定一项提议达成一致,真相由多数人商议决定,从而达到大家的一致和统一,这也就是后面要介绍的分布式共识协议。这个协议能够容忍一些节点的部分失效,或者莫名其妙的故障带来的问题,让系统能够正常地运行下去,确保请求到的数据是可信的。
下面给出一些实际分布式算法的理论模型,根据对于延迟的假设不同,这里介绍三种系统模型。
1. 同步模型
该模型主要假设网络延迟是有界的,我们可以清楚地知道这个延迟的上下界,不管出现任何情况,它都不会超出这个界限。
2. 半同步模型(大部分模型都是基于这个假设)
半同步模型认为大部分情况下,网络和延迟都是正常的,如果出现违背的情况,偏差可能会非常大。
3. 异步模型
对延迟不作任何假设,没有任何超时机制。而对于节点失效的处理,也存在三种模型,这里我们忽略恶意谎言的拜占庭模型,就剩下两种。
1.崩溃-终止模型(Crash-Stop):该模型中假设一个节点只能以一种方式发生故障,即崩溃,可能它会在任意时刻停止响应,然后永远无法恢复。
2.崩溃-恢复模型:节点可能在任何时刻发生崩溃,可能会在一段时间后恢复,并再次响应,在该模型中假设,在持久化存储中的数据将得以保存,而内存中的数据会丢失。
而多数的算法都是基于半同步模型+崩溃-恢复模型来进行设计的。
Safety and Liveness
这两个词在分布式算法设计时起着十分关键的作用,其中安全性(Safety)表示没有意外发生,假设违反了安全性原则,我们一定能够指出它发生的时间点,并且安全性一旦违反,无法撤销。而活性(Liveness)则表示“预期的事情最终一定会发生”,可能我们无法明确具体的时间点,但我们期望它在未来某个时间能够满足要求。在进行分布式算法设计时,通常需要必须满足安全性,而活性的满足需要具备一定的前提。
4. 总结
以上就是第一篇文章的内容,简单做下回顾,本文首先介绍了复制的三种常见模型,分别是主从复制、多主复制和无主复制,然后分别介绍了这三种模型的特点、适用场景以及优缺点。接下来,我们用了一个现实生活中的例子,向大家展示了分布式系统中常见的两个特有问题,分别是节点的部分失效以及无法共享系统时钟的问题,这两个问题为我们设计分布式系统带来了比较大的挑战。如果没有一些设计特定的措施,我们所设计的分布式系统将无法很好地满足设计的初衷,用户也无法通过分布式系统来完成自己想要的工作。以上这些问题,我们会下篇文章《Replication(下):事务,一致性与共识》中逐一进行解决,而事务、一致性、共识这三个关键词,会为我们在设计分布式系统时保驾护航。
5. 作者简介
仕禄,美团基础研发平台/数据科学与平台部工程师。