前言

最近在看《数据密集型应用系统设计》一书,看到一些设计觉得比较有意思,跟之前接触过的东西可以串联在一起,如本文要讲的数据复制,涉及到之前学过的MySQL的主从复制,以及Pulsar中Ledger多副本复制。这些都是关于“数据复制”这一个主题内容,觉得有必要做下系统性总结。

所谓的数据复制,指的是通过互联网在多台机器上保存相同数据的副本,通过数据复制,通常希望达到以下目的:

  • 当部分存储数据的组件出现故障,由于副本的存在,系统依然可以继续工作,从而提高可用性
  • 将数据扩展至多台机器以同时提供数据访问服务,从而提高读吞吐量
  • 使数据在地理位置上更接近用户,从而降低访问延迟

本文主要讨论三种流行的数据复制方法:主从复制、多主节点复制和无主节点复制。几乎所有的数据库都使用上述方法中的某一种,而三种方法各有优缺点,在应用时就需要去做trade-off,分析各种方法的利弊,这也是比较有意思的地方。

同步复制与异步复制

复制非常重要的一个设计选项是同步复制还是异步复制,如图,从节点1的复制是同步的,即主节点需等待直到从节点1确认完成了写入,然后才会向用户报告完成,并且将最新的写入对其它客户端可见。而从节点2的复制是异步的,主节点发送完消息消息之后立即返回,不用等待从节点2的完成确认。
image.png
可以看到,从节点2在接收复制日志之前有一段很长的延迟,通常来说,复制速度会非常快,例如多数数据库系统可以在1秒之内完成所有从节点的更新。但是,系统其实并没有保证一定会在多长时间内完成复制,有些情况下,从节点可能落后主节点几分钟甚至更长时间,例如由于从节点刚从故障中恢复,或者从节点负载很高,或者节点之间的网络出现问题。

同步复制的优点是,一旦向用户确认,从节点可以明确保证完成了与主节点的更新同步,数据已经处于最新版本。万一主节点发生故障,总是可以在从节点继续访问最新数据。缺点是,如果同步的主节点无法完成确认(例如由于从节点发生崩溃,或者网络故障,或任何其它原因),写入就不能认为是成功的,这样,会导致主节点阻塞其后所有的写操作,直到同步副本确认完成。

因此,把所有节点都配置为同步复制不太合适,实践中,如果数据库启用了同步复制,通常意味着其中某一个从节点是同步的,而其它节点则是异步模式。万一同步的从节点变得不可用或性能下降,则将另一个异步的从节点提升为同步模式。这样可以保证至少有两个节点(即主节点和一个同步从节点)拥有最新的数据副本。这种配置有时也成为半同步复制。

主从复制还经常会被配置为全异步模式,此时如果主节点发生失败且不可恢复,则所有尚未复制到从节点的写请求都会丢失,这意味着即使向客户端确认了写操作,却无法保证数据的持久化。但全异步模式的优点是,不管从节点上的数据多滞后,主节点总是可以继续响应写请求,系统的吞吐量更好。

处理节点失效

从节点失效:追赶式恢复

从节点的本地磁盘上都保存了副本收到的数据变更日志。如果从节点发生崩溃,然后顺利重启,根据副本的复制日志,从节点可以知道在发生故障之前所处理的最后一笔事务,然后连接到主节点,请求那笔事务之后中断期间内所有的数据变更。在收到这些数据变更日志之后,将其应用到本地来追赶主节点。之后就和正常一样持续接收来自主节点数据流的变化。

主节点失败:节点切换

处理主节点故障的情况则比较棘手:选择某个从节点将其提升为主节点;客户端也需要更新,这样之后的写请求会发送给新的主节点,然后其它从节点要接受来自新的主节点上的数据变更。

故障切换可以手动进行,采取必要的步骤来创建新的主节点;或者以自动方式进行,自动切换的步骤通常如下:

  1. 确认主节点失效。由于有很多种出错可能性,例如系统崩溃、停电、网络问题等,没有万无一失的方法能够确切地检测到究竟问题出在哪里,所以大多数系统都采用了基于超时的机制:节点间频繁地相互发送心跳存活消息,如果发现某一个节点在一段时间内(例如30s)没有响应,即认为该节点发生失效
  2. 选举新的主节点。可以通过选举的方式(超过多数节点达成共识)来选举新的主节点,或者由之前选定的某控制节点(如redis的sentinel,而sentinel本身又是通过多数节点达成共识来选出主节点)来指定新的主节点。让所有节点同意新的主节点是个典型的共识问题,这块这里暂不深入讲。
  3. 重新配置系统使新主节点生效。客户端现在需要将写请求发送给新的主节点。如果原主节点之后重新上线,可能仍然自认为是主节点,而没有意识到其它节点已经达成共识,迫使其下台。这时系统要确认原主节点降级为从节点,并认可新的主节点。

自动切换存在一些问题。如在发生脑裂情况下,可能会发生两个节点同时都认为自己是主节点,这种情况会导致两个主节点都接受写请求,可能导致数据错误。这就需要一种机制,让已经失效的主节点不继续工作,例如BookKeeper的Fencing机制,具体可看之前写过的Pulsar专题之BookKeeper存储设计探究一文。
除此之外,还有超时时间检测时长的设置长短等问题,这些问题没有简单的解决方案,因此,即使系统可能支持自动故障切换,有些运维团队仍然更愿意以手动的方式来控制整个切换过程。

复制滞后问题

一般来说,出于性能和可用性考虑,不会所有从节点都采用同步复制,会有部分节点采用异步复制,而异步复制存在复制滞后的问题。
如果一个应用正好从一个异步的从节点读取数据,而该副本落后于主节点,则应用可能会读到过期的信息。这种不一致是一个暂时的状态,经过一段时间后,从节点最终会赶上并与主节点保持一致,这种情况也被成为最终一致性。这个“最终”正常情况下可能1秒内,但如果从节点负载很高,或者节点之间的网络出现问题,则滞后可能增加到几秒甚至几分钟不等。

这里将重点介绍三个复制滞后可能出现的问题,并给出响应的解决思路。

读自己的写

image.png
用户在写入不久后查看数据,请求打到了从节点2,但从节点2是异步复制的,新数据尚未达到从节点,会导致读不到刚提交的数据。

对于这种情况,我们需要“写后读一致性”,也成为读写一致性,实现方案如:

  1. 强制走主节点。这里可以有额外减少走主节点频率的规则,如在社交网络的用户首页信息,当前用户从主节点获取,而其它用户的信息走从节点获取
  2. 最近一段时间(如1分钟)更新的数据从主节点读取。如可以将刚更新的记录id放入缓存,每次读取先判断缓存是否存在,存在则走主节点。同时缓存的记录设置对应的过期时间
  3. 客户端记录最新更新时的时间戳,附带在读请求中,服务端根据时间戳,获取大于等于该时间戳的记录。如果记录不够新,则交给另一个副本处理。时间戳可以是逻辑时间戳,或实际系统时钟(但时钟本身存在不一致性问题)
  4. 如果副本分布在多数据中心(例如考虑与用户地理接近),必须先把请求路由到主节点所在的数据中心(该数据中心可能离用户很远)

    单调读

    image.png
    用户2345读两次用户1234写入的数据,每次读的副本不同,可能出现第一次读到了,但第二次读到的是空的情况。

单调读一致性可以确保不会发生这种异常。当读取数据时,单调读保证,如果某个用户依次进行多次读取,不会看到比之前旧的数据。实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读取(而不同的用户可以从不同的副本读取)。

前缀一致性

image.png
如图出现Poons的数据是发生在Cake数据之前,但观察者看到的却是相反的顺序,即因果关系相反了

前缀一致性(也叫因果一致性)可以避免这种情况,也就是一系列按照某个顺序发生的写请求,读取的时候也会按照当时写入的顺序。
这是分区(分片)数据库中出现的一个特殊问题,对于MySQL等不存在分区的数据库,由于事务的存在,总是以相同的顺序写入,读取总是看到一致的序列,不会发生这种反常。而对于分布式数据库来说,不同的分区独立运行,不存在全局写入顺序,这就导致用户从数据库中读数据时,可能会看到数据库的某部分旧值和另一部分新值。

一个解决方案是确保任何具有因果顺序关系的写入都交给一个分区来完成,但该方案的真实实现效率会大打折扣。

多主节点复制

上面主要讲了单个主节点的主从复制架构。主从复制存在一个明显的缺点:系统只有一个主节点,而所有写入都必须经由主节点,如果客户端与主节点的网络发生中断,导致主节点无法连接,主从复制方案就会影响所有的写入操作。
对单个主节点的主从复制模型进行扩展,就得到多个主节点的主从复制模型。每个主节点都可以写入数据,主节点再将数据转发到所有其它节点。同时,每个主节点还作为其它主节点的从节点。

适用场景

为了容忍整个数据中心级别故障、让服务更接近用户,可以把数据库的副本横跨多个数据中心。如图所示
image.png
用户请求与自己最近的数据中心,数据中心内采用常规的主从复制方案,而在数据中心之间,由各个数据中心的主节点来负责同其它数据中心的主节点进行数据的交换。
在多数据中心环境下,可以对比单主节点的主从复制方案与多主复制方案之间的差异:

  • 性能:多主复制能实现就近访问,性能更好
  • 容忍数据中心失效:对于主从复制,如果主节点所在的数据中心发生故障,必须切换到另外一个数据中心,将其中的一个从节点提升为主节点。在多主复制中,每个数据中心独立运行。
  • 容忍网络问题:数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对于用户来说如果主节点在另外一个数据中心,就依赖数据中心之间的网络性能和稳定性。多主节点模型的写操作都是就近,不同数据中心间的数据采用异步复制,可以更好地容忍此类问题。

多主复制存在一个很大的缺点:不同数据中心可能会同时修改相同的数据,存在潜在的写冲突。对于写冲突一般是以收敛的方式去解决,如可以按最后写入当作成功、合并冲突值、将冲突返回给应用层去处理等方式。
此外对于一些数据库功能(如自增主键等)在主从复制模型下可能会出现一些副作用冲突。

无主节点复制

前面讲的主从复制和多主复制,都是基于这样一种核心思路,即客户端先向某个节点(主节点)发送写请求,然后数据库系统负责将写请求复制到其它副本。由主节点决定写操作的顺序,从节点按照相同的顺序来应用主节点所发送的写日志。
而无主节点复制采用了不同的设计思路:选择放弃主节点,允许任何副本直接接受来自客户端的写请求。典型使用该设计思路的如NewSQL类型的数据库,如亚马逊的Dynamo系统、TiDB等,以及分布式日志系统BookKeeper等。

写入读取过程

浅谈分布式数据系统设计-数据复制 - 图6
这里以副本3节点失效为例看看无主节点复制的写入过程的特点。用户1234往三个节点写入数据,副本1和2可以正常写入,但节点3失败了,这时完全可以认为写入成功,忽略掉其中一个副本无法写入的情况。
当失效的节点3重新上线,由于失效期间发生的写入在该节点尚未同步,这是客户端如果读取节点3可能会得到过期的数据。
为了解决这个问题,当一个客户端从数据库中读取数据时,不是只从一个副本读取数据,而是并行地读取多个副本,采用版本号技术确定哪个值为最新的。

读写quorum

在上面的例子中,三个副本有两个以上处理完成,写入即认为成功;读取时至少向两个副本发起读请求,通过版本号可以确定至少包含一个最新值。这里就涉及到写入成功的策略控制。
如果有n个副本,写入需要w个节点确认,读取必须至少查询r个节点,则只要 w + r > n,读取的节点中一定会包含最新值。例如n = 3, w= 2, r = 2。

但这种方式可能存在返回旧值的边界条件,可能的情况包括:

  • 如果两个写操作同时发生,像多主复制的情况,则无法明确先后顺序,出现写冲突。这种情况下的处理策略与上面类似,如最后写入者获胜(丢弃并发写入)、合并同时写入的值等。
  • 如果写操作与读操作同时发生,写操作可能仅在一部分副本上完成,此时读取时返回旧值还是新值存在不确定性
  • 如果某些副本上已经写入成功,而其它一些副本发生写入失败,且总的成功副本数少于w,那些已成功的副本上不会做会滚。这意味着尽管这样的写操作认为是失败的,后续的读操作仍可能返回新值。这种情况下可能是以异步的形式修复数据,达到最终一致。

总结

本文主要讲了数据复制相关的东西。数据复制就是在多台机器上保存多份相同的数据副本,看似是个很简单的目标,但实际上复制技术是一个非常复杂的问题,需要仔细考虑并发以及所有可能出错的缓解。

我们主要讲了三种多副本方式:主从复制、多主节点复制以及无主节点复制。每种方式都有各自的优缺点。主从复制非常流程,因为它很容易理解,也不需要处理冲突问题。而万一出现节点失效、网络中断或者延迟抖动等情况,多主节点和无主节点复制的方案会更加可靠,不过背后的代价则是系统的复杂度和弱一致性保证等。

本文是对《数据密集型应用系统设计》一书的数据复制章节的总结,同时也结合了自身对数据复制的理解。这个话题可以延伸到事务、一致性保证等话题,后续再继续学习总结。

参考链接