在看一本很棒的书,《Designing Data-Intensive Applications》。可以说是很多分布式问题的导论了。接下来会有一系列的文章结合书中内容,说说我的归纳、总结和一些思考。由于是一本关于分布式数据库的书,所以里面很多例子会以数据库领域遇到的问题作为引子,但是大部分分布式系统要解决的问题都是类似的。首先,来说说复制(replication)。

复制被认为是容错和提高可用性的有效手段。目前这一技术已经比较成熟,我们先会看看这一技术需要考虑的点,一致性问题,然后再说说具体的实施方案(一主多从、多主复制等)。

复制技术的常见问题

首先来看看,如果要你实现一个有replication的分布式系统,需要考虑什么问题。(当然,不到万不得已,不要这么做。)

我们以一个极其常见的复制系统为例子:一主多从的mysql集群:

  1. 有master和slave节点
  2. 写操作只能发生在master节点
  3. slave的数据和master的数据满足最终一致性
  4. 每次master写入成功后,通过发送replication log或change stream给slave节点,实现复制。

Synchronous VS Asynchronous

一个要考虑的点是:同步复制还是异步复制,如图:

Alt

如上图,leader和follower 1之间使用的是同步复制,它需要等复制成功后,才告诉客户端,写入成功。leader和follower 2之间使用的是异步复制,它在给客户端返回成功之后,才对follower 2进行复制操作。

不难看出同步复制的优缺点:

  • 优点:由于一次写操作必须同时写入leader和follower 1才算是成功,所以同步复制是可以保证从节点和主节点的一致性,且保证从节点的数据总是最新的。
  • 缺点同样明显:一次写操作必须同时写入主从才算成功,如果主节点可用,但从节点不可用,或者有网络抖动,写入都会失败。这其实是降低了可用性。

用CAP理论解释一下,是选择了C(Consistency),一定程度的放弃了A(Available)。

在实际场景中,不会选择全同步复制(那将会大大降低系统的可用性和scalability)。而会选择半同步(semi-synchronous)复制。让客户指定多少个从节点是同步复制,多少个从节点异步复制。(通常是可配置的)

异步复制看起来,在性能和拓展性方面表现比较好,而缺点是:它的durability不如同步复制。如果主节点在同步从节点的时候挂了,且不能恢复,而从节点的数据还没追平主节点,这些数据就会丢失了。

部署从节点

对于有状态的集群,部署一个节点不会像无状态集群那么简单。需要考虑的是这个节点如果追平整个集群的状态,又在整个过程中让整个集群保持可用。一个套路用snapshot的方式,步骤是这样的:

  1. 在部署之前,对master节点拍一个快照(snapshot)。在很多实现里,这是不需要锁库的。
  2. 将这个snapshot发送给新的节点。
  3. 新节点有了snapshot的状态之后,向master节点请求这段时间差的replication log。
  4. 当新节点追平了replication log,就可以上线了。

从节点的故障恢复和部署是类似的,如果replication log里有偏序信息,从节点就能知道故障发生的开始时间,它从这个时间点开始请求master节点的replication log,重复步骤3、4。

Master failover

master的故障要更难搞一些:如果master节点挂了,为了是集群保持可用,必须要有failover手段:在其他从节点里,找到一个,并要求它成为master节点。

一次自动的failover会是这样:

  1. Determining that the leader has failed. 常见的方法是节点之间保持心跳,当多个节点检测到主节点心跳异常时,认为主节点不可用。
  2. Choosing a new leader. 有两种方法可以选择新的master节点。一个是用选举的方式,或者由一个控制节点(control node)指定。如果使用第二种方法的话,集群里就需要新增一种角色(控制节点)。两种方法的目标都是一样的,选择数据上和master节点最接近的从节点,使数据丢失最小化。
  3. Reconfiguring the system to use the new leader. 当完成选择了新的master节点后,将流量引导到新的master节点上。

整个过程看似并不复杂,实际实现起来要麻烦很多:

  1. 当原来的master挂了之后,新的master节点有可能没有和原master的数据完全同步。那么当原master恢复,重新加入到集群时,会发生conflict writes。所以,一个常见的做法是:只要master挂了,直接t掉,不会再恢复加入到集群。
  2. 直接放弃原master节点的问题是:在原master中,但没有复制到从节点的数据,也会被放弃。那么问题是,有可能由于数据丢失造成数据不可用甚至泄密。比如:id为13的一行由于master节点的failover被丢弃了,在新的master中重新使用了13作为nextval。造成数据异常。
  3. 在特定的场景里,有可能在第二步(Choosing a new leader)时,选出了两个master节点,那么整个集群就会陷入split brain的困局。一般的一主多从数据库都不会有write conflict的处理,那么两个master对同一条数据写入,就会导致数据不准确。
  4. 使用心跳鉴别master是否在异常情况,同样有危险的陷阱:一般的心跳+timeout的做法,究竟timeout的时间设在多少比较合适呢?如果timeout设置的过小,造成的问题是,一些比较严重的网络抖动会使误认为是master故障,更有甚者,严重的网络抖动使整个集群不停的执行master failover,会使整个集群不可用。

所以在实际场景里,使用自动failover还是人肉failover也是一个trade-off。

一致性问题

根据CAP理论,不难证明,复制必然会带来一致性问题。一般应用的复制都会使用异步复制,异步复制的一致性保证是最终一致的。也就是主从节点之间会有数据不一致的时间窗口,且这个时间窗口的大小是没有限制的,当网络或集群状态抖动时,不一致的时间窗口会增大,我们来看看这时候,会产生什么问题。

read your own writes

Alt

如图,在成功写入数据之后,在复制到从节点之前,如果在这时读取同一条记录,那么有可能读取不到刚刚写入的改动。

出现这样的问题,意味着我们无法将数据库集群当成一个single node看待。那么只能在集群外部做一些优化,比如:最简单的做法,读主库。

但如果实际场景是,数据总是被频繁修改,且总是希望短时间内能读到最近的更新,还用方法1的话,就会牺牲scalability。这时可以考虑在外部做更复杂的判断:

  1. 记录每条记录的updated_at,判断当前请求的时间和最新的updated_at之间的差值,如果在阈值内,读主库,否则,读从库。(但这一做法不能保证100%的一致,因为数据库没有对不一致的时间窗口做任何保证)。
  2. client记录每次写入的timestamp,然后在读取的时候,它告诉数据库:我想读到这个timestamp之前的修改。数据库可以以某种方式记录那个从节点的replication log是追平这个timestamp的,然后把请求路由到满足条件的节点。

Monotonic Reads

第二种由于复制带来的诡异的一致性问题是:用户有可能读到“时间倒流”的数据。

Alt

如图可以看到,这个问题可以说是read-your-writes的升级版,因为它更诡异。在写入成功之后,client读取follower 1的时候可以读到最新的修改,因为follower 1已经复制成功,但是再读取的时候,请求路由到follower 2,由于此时follower 2没有完成复制,导致读不到最近的写入。

有一个一致性保证能专门解决这个问题:Monotonic Reads。它是一个比最终一致性强,但比强一致性弱的一致性保证。它的保证是:当任何client读取到更新的状态之后,它不会再读到比当前状态老的状态。

一种实现方法是:将相同client的流量都路由到同一个从节点上。但有一个问题,如果这个从节点挂了,那么这个client读取其他节点,还是有很低的几率出现问题。

Consistent Prefix Reads

Alt

这图有点不太直观,它其实是描述一个诡异的现象,就是读取的时候,数据库状态变化的顺序和写入的时候,状态变化的顺序不一致。

这需要一个一致性保证:Consistent Prefix Reads。这一个一致性模型保证:读到的数据库状态的变化顺序,总是和写入的顺序一致。

在实际场景中,这三个问题,(特别是第一个)是经常需要考虑的问题,它使我们不能将数据库当成一个single node看待。经常的做法是在应用层处理这些问题。具体不赘。

几种replication方案

single master

上文的例子都是用单个master的mysql集群进行举例的。它的问题也在上面讨论了。

直觉上看,一主多从的架构的问题就是单点问题,尽管有failover,但是如上文所说,failover的过程也是一个很容易出问题的环节。

所以有人想到了,multi-master的架构。

multi master

顾名思义,多个master节点,多节点允许写入。看起来很美好,实际上带来的麻烦远远比解决的问题多,所以基本上不会考虑这种方案,除非一种情况:异地多活(或者说多数据中心)。

Alt

想想看在异地多活的架构中,两个机房的距离有可能是几百公里。如果只有一个master的话,会使上面讨论的single-master的问题严重加剧,并出现严重的写性能问题(跨机房写入的话,需要走internet,这将是30 - 50ms)。难以实现。所以需要multi-master的架构,解决这几个问题:

  1. 写性能问题:两边机房都有master,写入的流量都写入本机房的master节点中。
  2. 对机房级别故障的容错:实际上异地多活最大的价值就在于此。多于一个master,可以保证即使一边机房挂掉,起码50%的流量都可以正常访问。
  3. 对网络问题的容错:如上文,跨机房的流量是需要走internet的。internet的网络不如内网可靠。如果是single master的话,当两个机房通信出现问题时,master节点所在的机房能正常运行,而另外一个机房将会: 1. 写入操作全部失败;2. 读取到的数据是stale的。

conflict

multi-master会引入新的问题,其中最主要也是最难搞的,就是数据冲突(conflict)。下面会列举一个解决方法:

  1. Conflict avoidance. 最简单的方法就是避免冲突,也就是:对于同一条记录,只能有一个master可以进行写入。当然,数据库本身没有这样的能力提供限制,只能在上游加上限制。并将流量正确的路由到合适的机房。

    这个做法在datacenter-failover的时候会有一个麻烦:对于同一条数据而言,只有一个master节点,另一个master节点由于不能写入这条记录,实际上它是slave节点。所以datacenter-failover的时候,实际上是类似master failover的。master failover的问题,同样有机会出现。

解决冲突的方法还有很多,不深入讨论了。

leaderless

无主节点的存储集群是和一主多从、多主多从截然不同的一条路。最有名的实现是AWS的Dynamo数据库,它的工作原理是这样的:

  1. 集群内每个节点,都是相同角色:既可以读,也可以写。
  2. 一次写入,需要 同步 随机写入多个节点。其余的节点会异步复制。
  3. 一次读取,需要 同步 读取多个节点。

leaderless的做法是一个截然不同的思路。它在读写的时候,都有冗余处理,使得在节点出现问题的时候,不需要failover,就能优雅的容错:

Alt

如图:在写入的过程中,replica 3不可用了,这时最新的写入没有写入到这个几点。然而,在读取的时候,client不会读到stale的数据:

client需要同时读取多个节点,当同时读取到replica 1 / 2 /3的数据时,很容易检测到3的数据是过期的。3的数据会被丢弃,只返回最新的数据。同时,client会异步的修复3的这一条数据。保证一致性。

另一种fail detection(或者说stale detection?)是,集群内有一个background的进程不停的比较各个节点的数据,检测数据异常,并更新。

Quorums for reading and writing

一次写入要写入多少个节点才算成功?一个读取要读取多少个节点才算成功?这两个参数都是可配置的。那么,该怎么选择这两个参数的大小,值得好好讨论。

假设总节点数为n,读取节点数为r,写入节点数为w。那么直观上只要三者满足:

r + w > n

即可保证不会读到过期的数据。在满足r + w > n的条件下,r和w越接近,集群越能tolerant fail-node,理想的情况下,有n/2个节点挂了,集群还是available。

但实际场景中,选择r和w使得r + w <= n也是可行的。这样有可能读到stale data。但如果对一致性要求不高的场景,是能在多次读取只有,使所有节点的数据达到一致的。

参考

  • 《Designing Data-Intensive Applications》