分布式数据系统之复制

  前段时间向大家推荐的堪称2018年必读神书《Designing Data-Intensive Applications》,现在已经发型简体中文版了,书名被翻译为《数据密集型应用系统设计》。自己看完英文版后觉得很不错,总体行文偏向于学术风格,虽然不想很多技术书籍那么实用——可以立马用于提高生产率,但是在有过一定的开发和架构经验后,在实践中那些看似那么显而易见的解决方案,或者那些看似极为高端的分布式系统,在书中作者对相关知识点和面都进行了相应的总结和剖析,总会让人有一种拨开云雾见青天的感觉。
  总体感觉书中对于分布式系统数据这一部门写的不错,所以后面几篇文章主要是这一部分相关知识点的学习总结了。
  数据复制是在多台机器上保存多个相同的数据副本,主要是用于实现以下几个目的:让数据更接近访问的用户,降低访问延迟(低延迟);提供冗余挺高可用性,当部分组件出现故障的时候任然可以提供服务(容忍节点故障);扩展多台机器同时提供数据访问能力,提高总体吞吐量(可扩展性)。

一、主从复制

  主从复制是当前最流行的复制模式,其工作流程为:在多个副本中指定一个副本为主副本,当客户需要修改数据库的时候,必须将写请求发送给主副本,主副本首先将数据写入本地存储;接着主副本把数据更改作为复制的日志发送给所有的从副本,每个从副本获得数据更改日志之后将其应用到本地,且严格保证和主副本相同的写入顺序;只读请求可以在主副本或者从副本上执行查询。

1.1 同步复制和异步复制

  同步复制和异步复制是复制机制中一个十分重要的设计选项。同步复制需要主节点等待从节点确认写入完成后才向客户端报告写入操作完成,其好处是能保证一旦客户端收到响应,则所有同步复制的副本都具有最新数据版本,而主节点故障后从节点可以立即替换故障节点,但是缺点是客户响应延时较大,而且一旦同步发生问题,则写入就会被视为失败,而且会阻塞其后主节点的所有请求。
  异步复制的好处就是主节点的吞吐量能够达到最大,但是主节点故障后异步复制系统可能会导致数据丢失,这是一个严重的问题。通常的解决方案还包括半同步复制,即选择一个从节点和主节点进行同步复制,所有其他的节点采用异步复制的方案。此外,有时候我们验证新业务的时候还会采用双写的方案,即一份数据同步的在两个库中写两遍,等后续验证无误后再改回单写操作。

1.2 配置新的从节点

  针对增加新节点或者替换失败节点的时候,新的副本将是一片空白。这个时候通常的做法是将主节点在某个时刻建立一个一致性快照(MySQL通过某些工具可以,而LevelDB直接支持一致性快照访问迭代器),然后将快照数据传递到新节点上进行恢复,随后新节点从快照开始的位置再向主节点请求所有的数据变更,这个操作称之为catch。

1.3 处理节点失效

  对于复制中从节点失效(比如从节点崩溃,网络闪断等),因为从节点会记录自己最后成功应用的日志,所以从节点恢复后只需要从故障点开始再向主节点进行catch就可以了,处理起来相对容易。
  针对主节点失效,处理起来就比较麻烦了,包括的步骤有:确认主节点失效,选择某个从节点将其提升为主节点,客户端的请求目标要定位到新主节点上,其他从节点要从这个新的主节点进行赋值。其实这几个步骤中任何一个都充满了变数,所以这种切换操作通常都是进行人工确认和执行的,当然这也是“分布式一致性协议”大放光彩的地方。

1.4 复制日志的实现

1.4.1 基于语句的复制

  比如主节点执行的INSERT、DELETE、UPDATE这类的变更语句,都会在从节点上重新执行一遍。其好处是简单紧凑,是MySQL5.1之前默认的复制方式。但是其缺点是:
  任何调用非确定性的语句,比如调用NOW、RAND等函数,可能会在副本上产生不同的值,尽管默认情况下数据库会自动检测这类不确定语句并将复制内容替换为执行后的确定结果;语句中如果使用了自增列、条件操作等语句,则要求副本必须按照完全相同的顺序执行,否则可能会带来不相同的操作结果;有副作用的语句(比如触发器、存储过程、自定义函数等)也可能会导致不同的副作用。
  因此,这种复制方式现在已经很少使用了。

1.4.2 基于预写日志的传输

  预写日志(WAL)是数据库存储引擎底层的日志结构,其包含的是诸如哪些磁盘块的哪些字节发生了变动,因此复制方案和存储引擎紧密耦合,而且即使相同的存储引擎也面临着版本兼容性问题。

1.4.3 基于行的逻辑日志复制

  基于行的逻辑日志复制将复制的内容和存储引擎的细节分离开来了,其日志是记录的描述数据表行级别的写请求。如果某一个事务涉及多个行的修改,则会产生多个这样的日志记录,并在随后跟着一条提交记录。当MySQL被配置成基于行复制的时其binlog就是这种格式。
  这种行逻辑日志不仅可以保持跨存储引擎、跨版本的兼容性,而且针对外部应用程序来说,其逻辑日志也可以被解析和利用,该技术称为数据变更捕获,现在个大厂通过监听MySQL的binlog可以做很多的事情。

二、主从复制下的迟滞问题(Replication Lag)

  主从同步延时的问题算是在现实中经常遇到的问题了。通常主服务会直接读写主库的,不过一些辅助服务因为不需要写数据库,同时也做出减轻主库的考虑会去查询从库,那么在某些情形下就会出现问题:如果只是查询并登记状态倒无所谓,延迟较大可能多查几次就确认状态了;但如果需要根据查询结果做出下一步行为的决策,那么复制延迟就可能会导致严重的后果。
  在互联网应用中,我们面对的通常都是写少读多的情形,通过不断增加更多的副本可以大大增加数据库的吞吐量,但是当添加的副本比较多的时候,我们只能使用异步复制的方式来进行数据同步,异步赋值会导致主节点和从节点相同时间查询的结果可能不一致,但是当到达某个时间后他们的对应查询结果就一致了,这被称为最终一致性。

2.1 Reading Your Own Writes

  许多应用允许用户提交一些数据,接下来查看他们自己提交的内容,提交操作肯定是发生在主节点,但是异步复制不能保证该数据何时到达从节点,如果此时用户读取从节点的数据,则可能看似自己提交的数据丢失了,这种情况对用户体验肯定是不友好的,而且可能会导致用户再次尝试重新提交数据。为了处理这种情况,我们需要“写后读一致性”(read-your-writes consistency),保证提交的用户总能看到最新的数据,其他用户能否读到最新数据不作保证。
  在实践中,可能考虑的方案有:
  (1).如果用户可能会修改访问的内容,则让其访问主节点,否则就在从节点读取。比如社交网站的数据只有用户自己可以修改,所以这类应用可以让用户在节点读取自己的数据,而在从节点读取其他用户的数据。
  (2).但是有些应用用户可能修改的数据会比较多,如果将其都导入主节点,则完全丧失了从节点的扩展性,这类应用需要自己设计对应的策略。比如应用可以跟踪某些内容的更新时间,当更改后的一段时间中该数据总是从主库读取;也或者客户端记录最近更新的时间,并在下次请求时候捎带该更新时间,服务端根据情况适当的选择从主库还是从库加载数据。

2.2 单调读 Monotonic Reads

  如果某些数据有多个从节点,而用户的每次请求被路由到不同的节点上,那么这些副本异步复制的不确定性可能导致多次请求出现不一致的情况,尤其是先从某些同步较快的节点返回了新数据,然后又从某些滞后的节点返回了老数据的情形。这种情况下需要提供“单调读一致性”(monotonic reads),这是一种相比强一致性弱,但是比最终一致性强的保证,它保证用户依次进行多次读取的时候,不会看到类似数据回滚的现象,其实通过会话一致性保证相同用户访问同一个数据副本就可以解决这个问题。

2.3 前缀一致读 Consistent Prefix Reads

  数据存放在多个副本上可能导致读取者的角度看来出现因果颠倒的情形,这就需要使用“前缀一致性”(consistent prefix reads)来保证对于一系列按照某个顺序发生的写请求,那么读取这些内容的时候也应该按照当时写入的顺序来获取。这种问题在数据分区中很容易发生,当多个事件被分配到不同的分区中去,而各个分区独立复制的时候就很容易发生这种问题,解决这种问题最简单的是将因果数据落到同一个分区中就可以了,否则的话就需要额外的手段来执行因果关系跟踪了。

三、多主节点复制

3.1 使用场景

  当系统只有一个主节点的时候,如果因为某种原因主节点不可用(同时也没及时做主备切换),那么整个服务就处于不可用的状态了,当在系统中配置多个主节点,每个主节点都可以接受写请求的时候,那么整个系统的可用性就会大大的增加。不过多主复制的情形通常用在多个数据中心的场景下,因为多主复制会带来更大的复杂性,单数据中心内部使用多主会通常得不偿失。
  在多个数据中心部署配置主节点可以容忍整个数据中心的故障或者让用户可以更快的访问数据和服务,让每个数据中心的内部都采用常规的主从复制方案,而在数据中心之间则每个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换和更新,而且数据中心之间数据交换和更新都采用异步的方式来实现。虽然多主复制无论对于性能还是服务可用性都很有优势,但是其最大的缺点是要解决多个数据中心同时修改的时候可能导致的写冲突问题。
  除了大型服务的多数据中心部署的应用情形,在离线客户端应用的类型也可以算作是多主复制的场景,比如客户端在网络断开后还需要继续工作,离线状态下做的任何更改会在下一次设备上线的时候与服务器和其他设备进行同步。这种情况下,每一个设备都充当了一个主节点的角色接收读写请求,而所有设备之间采用异步的方式进行同步,他们在架构上类似于多数据中心之间的多主复制。
  多主节点的常见拓扑结构可以同图所示的三种结构:环形结构、星形结构和全部-全部结构。
multi-leader
  环形结构中每个节点接收来自前序节点的写入,并将这些写入(包括自己的写入)转发给后续节点,而MySQL默认就是这种环形拓扑;星形节点是由一个指定的根节点将某些节点的写入请求转发给其他所有的节点。环形结构和星形结构都容易产生单点问题,即某一个节点发生了故障,在修复之前会影响其他节点之间之间的复制日志的转发。同时,因为这两种拓扑中写请求需要经过多个节点才能够到达所有的副本,为了防止产生无限循环,每个节点需要赋予一个唯一标识并在复制日志中记录每个写请求的已经通过的节点标识号,当接收的节点发现复制日志包含了自己的标识号时就丢弃这次请求,防止重复转发甚至导致死循环。
  全部-全部的拓扑结构看似解决了单点性问题,但是其存在的一个问题是在某些网络链路比其他链路更快的时候,如果客户端在多个节点上先后执行某些写操作,该写操作不能够保证按照客户端的请求顺序应用到所有的节点上面。

3.2 写冲突

  写冲突可以算是多主复制所面临的最大问题。针对主从复制的情况,第二个写请求要么阻塞直到第一个写请求完成后继续,要么在这个过程中被终止(然后重试),但是多主拓扑下写请求都是成功的,只能在稍后的时间点同步的时候才能检测到冲突的发生。当然我们也有同步冲突检测的方案,即在写请求完成后对所有副本进行同步,然后再通知用户写入成功或者冲突发生,但是这种方式对性能的巨大损失将会使多主结构失去存在的意义。
  (1) 避免冲突
  如果在应用层可以保证对特定记录的写请求总是在同一个节点上处理,那么就不会发生写冲突,而且这是现实中首选的解决方案,从用户的角度看来这类似于主从复制模型。不过如果用户对应的节点无法访问,不得不将该用户的请求路由到其他节点(数据中心)的时候,这种方案就没有用了。
  (2) 收敛于一致状态
  主从复制模型下可以保证数据更新符合顺序性原则,这种情况下对同一个数据的更新将能保证最后一次写操作决定该字段的最终值。对于主-主结构,如果在多个节点上对该数据执行写入,那么写入时候他们都在自己的节点上拥有自己的写入值,但是该字段必须最终在所有节点上的值都是一致的,即数据库必须以一种收敛趋同的方式解决写入冲突,不过在多个节点执行写入时全局的写入顺序是无法保证的,这个趋同的最终值也是不确定的。解决收敛冲突的可能方式有:
  a.副本总是保存最新值,这种策略的难点是需要寻找一个明确的方法来确定哪一个写入是最新的。在分布式系统下无法明确得到写请求的真实顺序,但是可以采取某种约定强制对其排序,比如每个写入操作分配一个全局唯一递增的ID,当冲突的时候选择最高的ID写入者为胜,也或者直接采用写请求的本地时间戳来排序(当然这也不一定可靠),这种技术称为最后写入者获胜。这种策略是以实现数据收敛为目标的,但是某些写请求可能会返回成功,但实际数据却被丢失掉了,只针对于覆盖写、丢失数据可以接受的应用场景下是可接受。
  b.以某种形式将冲突数据合并在一起,比如购物车应用求并集的应用情形,这完全依赖于业务特点。即便是购物车求并集,也需要考虑客户某个删除操作,如果只是删除某个副本的数据,那么并集后该数据就又回来了,所以这类操作就需要使用额外的方式来处理了。
  c.利用预先定义好的格式来记录冲突,并依靠应用层的逻辑解决冲突。
  (3) 自定义冲突逻辑
  针对上面的自定义冲突解决逻辑,可以预先设定解决代码,这样在系统复制的时候检测到冲突后,自动调用应用层的冲突处理程序执行处理;或者在复制检测到冲突的时候,先将冲突信息暂时保留下来,然后在下次访问该数据的时候提示用户或者自动解决,并将最终解决的结果再写回到数据库。
  这里还有一个根据版本号信息追踪数据请求顺序的操作,还是挺有意思的,通过这种方式服务器可以跟踪数据的更改路径和因果关系,其算法的流程描述为:
  a. 服务端为每一个key维持了版本号信息,每当产生写请求的时候会递增该版本号,存储数据的时候会存储版本号和对应的值信息;
  b. 客户端在修改数据之前必须先执行读操作,此时服务端会将所有没有被覆写的版本号和值给客户端;
  c. 当客户端需要写该key对应的值的时候,需要指明自己是基于哪个版本号改写的,然后将该版本号的值和自己的新值做某些冲突处理,然后返回新值和对应的之前版本号信息;
  d. 当服务端收到该响应的时候,可以安全的覆写该版本号和所有低于该版本号的值,但是高于该版本号的值必须保留,实际上这是一种写冲突发生的情况。

四、无主节点复制

  无论是主从复制还是主主复制,都依赖于客户端请求特定节点执行写请求,而某些存储实现则放弃主节点的思路,允许任何副本直接接受来自客户端的写请求,他们通常将写入请求发送到多个副本上,通过某种算法保证数据的可靠性,也是因为这种设计没有主节点的概念,自然也就没有了主节点失效和主备切换的问题。这类系统以Quorum NWR模型最为常见,而工业上的典型莫过于Amazon Dynamo了,其设计细节可以参见之前的一篇《Amazon Dynamo论文阅读笔记》。

4.1 NWR系统策略

  对于NWR策略的系统,假定集群中有n个副本,写入需要w个节点确认,读取必须至少查询r个节点,则只需要w+r>n,就可以保证读取到的结果中一定包含最新值,而专业术语上将r、w称之为读取、写入操作的法定票数,代表了读取、写入操作是否成功的最低副本数,其核心思路是保证读取和写入操作至少有一个重叠节点。上述的法定票数是可以配置的,通常取n为奇数同时w=r=(n+1)/2,但是对于特定场景的应用可以调整w和r的取值:比如极端情况下读多写少的应用场景,则可以配置w=n和r=1就可以保证读请求能快速高效的得到响应,但是其缺陷是若有一个节点故障则所有的写入都会被视为失败;而假定n=5、w=3、r=2,则系统可以容忍多达两个不可用的节点存在,整个集群任然正常提供服务。
  当正常工作的节点部分失效后,满足条件下集群仍然可以提供服务,但是当这些节点重新上线的时候,失效期间发生的任何写入都没有同步到该节点上,客户端从这些节点上可能会得到过期的数据,虽然客户端从多个节点上读取数据并取最新值能够保证得到最新的数据,但是必须设法让这些节点同步最新的数据,否则难以容忍其他节点失效后,整个集群不至于丢失数据。通常的方法有两种:
  (1) 读修复
  当客户端读取多个副本的时候,可以顺便检测到节点返回的过期值,此时客户端可以将最新值写入到这些返回过期值的副本上面去,这种方法适合那些读取十分频繁的场景。
  (2) 反熵
  额外开启一些后台进程不断查找各副本之间数据的差异,将那些缺少的数据从一个副本同步到另外一个副本上去。这种方法明显是执行的异步数据检查修复,所以不能保证以特定的顺序复制写入,而且会有明显的同步滞后现象。
  无主复制不同于主从复制,这类设计的数据滞后是无法测量的,如果没有反熵机制,这类数据的延迟可能是无限久的。

4.2 NWR策略的问题

  基于NWR策略的Quorum虽然从原理上看来很容易理解,但是在实现上却往往更加复杂,主要是在进行多副本读取写入时会有各种情况需要考虑:(1)如果多个写操作同时发生,则无法判断写入的先后顺序;(2)如果读操作和写操作同时发生,写操作可能只在一部分节点上成功,而并发的读操作返回的内容不确定;(3)对于一些副本写入成功但另外一些节点写入失败的情况,当总结点少于w的时候那些成功的副本不会做回滚操作,那意味着写操作被视为失败,但是后续读还是可能会读到写入的值;(4)当具有新值的节点失效后,如果使用旧数据进行恢复,那么新值的副本数就可能未达到w。
  有时候为了实现高可用、低延迟的服务,可以对之前约定的w+r>n的条件做适当的放松。比如对于读请求,如果读取的副本数目r不满足票数,除了可以明确地错误返回给客户端,如果应用对可能过期的数据也能接受的话,则可以使用这些返回结果;对于写入操作,当w不满足票数的时候,除了直接返回客户端错误之外,也可以将这些数据先临时登记到一个临时存储上,当故障解决后临时存储将这些写入数据全部发送到集群上去,这就是所谓的数据回传,实现这种机制的情况下不能保证读操作能够读到最新的值,因为新值可能还在临时存储中,尚没有发送到集群上来。

本文完!

参考