DDIA - 第5章 数据复制

信息是激发创新的力量

        本章目标: 数据远程复制。

第5章 数据复制

        复制主要指通过互联网络在多台机器上保存相同数据的副本。通过数据复制方案,人们希望达到以下目的:

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

        如果复制的数据一成不变,那么复制就非常容易:只需将数据复制到每个节点,一次即可搞定。然而所有的技术挑战都在于处理那些持续更改的数据,而这正是本章讨论的核心。我们将讨论三种流行的复制数据变化的方法:主从复制、多主节点复制和无主节点复制。 几乎所有的分布式数据库都使用上述方法中的某一种,而三种方法各有优缺点。

        复制技术存在许多需要这种考虑的地方,例如采用同步复制还是异步复制,以及如何处理失败的副本等。数据库通常采用可配置选项来调整这些处理策略,虽然在处理细节方面因数据库实现而异,但存在一些通用的一般性原则。
        数据库复制其实是个很古老的话题。因为网络的基本约束条件没有发生本质的改变,可以说自1970年所研究的基本复制原则,时至今日也没有发生太大的变化。在复制滞后问题中,会详细讨论最终一致性,包括读自己的写和单调读等。

1 主节点和从节点

        每个保存数据库完整数据集的节点称之为副本如何确保所有副本之间的数据是一致的?
主从复制的工作原理如下:

  • 指定某一个副本为主副本(或称为主节点)。当客户写数据库时,必须将写请求首先发送给主副本,主副本首先将新数据写入本地存储
  • 其他副本则全部称为从副本(或称为从节点)。主副本把新数据写入本地存储后,然后将数据更改作为复制的日志或更改流发送给所有从副本。每个从副本获得更改日志之后将其应用到本地,其严格保持与主副本相同的写入顺序
  • 客户端从数据库中读数据时,可以在主副本或者从副本上执行查询。再次强调,只有主副本才可以接收写请求;从客户端的角度来看,从副本都是只读的

        主从复制技术也不仅限于数据库,还广泛用于分布式消息队列如Kafka和RabbitMQ,以及一些网络文件系统和复制快设备(如DRBD)

1.1 同步复制与异步复制

        复制非常重要的一个设计选项是同步复制还是异步复制。对于关系数据库系统,同步或异步通常是一个可配置的选项;而其他系统则可能是硬性指定或者只能二选一。
        同步复制和异步复制都存在各自的优缺点,由此衍生处许多其他的复制策略,比如半同步复制,链式复制是同步复制的一种变体,往往要在复制性能和系统可用性两者中进行折中。多副本一致性与共识之间有着密切的联系(即让多个节点对数据状态达成一致)

1.2 配置新的从节点

        当如果出现如下情况时,如需要增加副本数以提高容错能力,或者替换失败的副本,就需要考虑增加新的从节点。但如何确保新的从节点和主节点保持数据一致呢?
        我们可以做到在不停机、数据服务不中断的前提下完成从节点的设置。逻辑上的主要操作步骤如下,建立新的从副本具体操作步骤可能因数据库系统而异:

  1. 在某个时间点对主节点的数据副本产生一个一致性快照,这样避免长时间锁定整个数据库
  2. 将此快照拷贝到新的从节点
  3. 从节点连接到主节点并请求快照点之后所发生的数据更改日志。因为在第一步创建快照时,快照与系统复制日志的某个确定位置相关联,这个位置信息在不同的系统有不同的称呼
  4. 获得日志之后,从节点来应用这些快照点之后所有数据变更,这个过程称之为追赶。接下来,它可以继续处理主节点上新的数据变化

1.3 处理节点失效

        系统中的任何节点都可能因故障或者计划内的维护(例如重启节点以安装内核安全补丁)而导致中断甚至停机。我们的目标是,尽管个别节点会出现中断,但要保持系统总体的持续运行,并尽可能减小节点中断带来的影响。
        那么如何通过主从复制技术来实现系统高可用呢?

1.3.1 从节点失效:追赶式恢复

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

1.3.2 主节点失效:节点切换

        处理主节点故障的情况则比较棘手:选择某个从节点将其提升为主节点;客户端也需要更新,这样之后的写请求会发送给新的主节点,然后其他从节点要接受来自新的主节点上的变更数据,这一过程称之为切换
        自动切换的步骤通常如下:

  1. 确认主节点失效
  2. 选举新的主节点
  3. 重新配置系统使新主节点生效

        然而,上述切换过程依然充满了很多变数:

  • 如果使用了异步复制,且失效之前,新的主节点并未收到原主节点的所有数据;在选举之后,原主节点很快又重新上线并加入到集群,接下来的写操作会发生什么?新的主节点很可能会收到冲突的写请求,这是因为原主节点未意识的角色变化,还会尝试同步其他从节点,但其中的一个现在已经接管成为现任主节点。常见的解决方案是,原主节点上未完成复制的写请求就此丢弃,但这可能会违背数据更新持久化的承诺
  • 如果在数据库之外有其他系统依赖于数据库的内容并在一起协同使用,丢弃数据的方法就特别危险。例如,在GitHub的一个事故中,某个数据并非完全同步的MySQL从节点被提升为主副本,数据库使用了自增计数器将主键分配给新创建的行,但是因为新的主节点计数器落后于原主节点(即二者并非完全同步),它重新使用了已被原主节点分配出去的某些主键,而恰好这些逐渐已被外部Redis所引用,结果出现MySQL和Redis之间的不一致,最后导致了某些私有数据被错误地泄露给了其他用户
  • 在某些故障情况下,可能会发生两个节点同时都自认为是主节点。这种情况被称为脑裂,它非常危险:两个主节点都可能接受写请求,并且没有很好解决冲突的办法,最后数据可能会丢失或者破坏。作为一种安全应急方案,有些系统会采取措施来强制关闭其中一个节点。然而,如果设计或者实现考虑不周,可能会出现两个节点都被关闭的情况
  • 如何设置合适的超时来检测主节点失效呢?主节点失效后,超时时间设置得越长也意味着总体恢复时间就越长。但如果超时设置太短,可能会导致很多不必要的切换。例如,突发的负载峰值会导致节点的响应时间变长甚至超时,或者由于网络故障导致延迟增加。如果系统此时已经出于高负载压力或网络已经出现严重拥塞,不必要的切换操作只会使总体情况变得糟糕

        上述这些问题,包括节点失效、网络不可靠、副本一致性、持久性、可用性与延迟之间各种细微的权衡,实际上正是分布式系统核心的基本问题,对于这些问题没有简单的解决方案。

1.4 复制日志的实现

        主从复制技术有着多种不同的实现方法。

1.4.1 基于语句的复制

        最简单的情况,主节点记录所执行的每个写请求(操作语句)并将该操作语句作为日志发送给从节点。听起来很合理也不复杂,但这种复制方式有一些不适用的场景:

  • 任何调用非确定性函数的语句,如NOW()获取当前时间,或RAND()获取一个随机数等,可能会在不同的副本上产生不同的值
  • 如果语句中使用了自增列,或者依赖于数据库的现有数据(例如,UPDATE … WHERE <某些条件>),则所有副本必须按照完全相同的顺序执行,否则可能会带来不同的结果。进而,如果有多个同时并发执行的事务时,会有很大的限制
  • 有副作用的语句(例如,触发器、存储过程、用户定义的函数等),可能会在每个副本上产生不同的副作用

        有可能采取一些特殊措施来解决这些问题,例如,主节点可以在记录操作语句时将非确定性函数替换为执行之后的确定的结果,这样所有节点直接适用相同的结果值。但是,这里面存在太多边界条件需要考虑,因此目前通常首选的是其他复制实现方案。

1.4.2 基于预写日志(WAL)传输

        关于存储引擎的磁盘数据结构,通常每个写操作都是以追加写的方式写入到日志中:

  • 对于日志结构存储引擎,日志是主要的存储方式。日志段在后台压缩并支持垃圾回收
  • 对于采用覆盖写磁盘的B-tree结构,每次修改会预先写入日志,如果系统发生崩溃,通过索引更新的方式迅速恢复到此前一致状态

        PostgreSQL、Oracle以及其它系统等支持这种复制方式。其主要缺点是日志描述的数据结果非常底层:一个WAL包含了哪些磁盘块的哪些字节发生改变,诸如此类的细节。这使得复制方案和存储引擎紧密耦合。如果数据库的存储格式从一个版本改为另一个版本,那么系统通常无法支持主从节点上运行不同版本的软件。

1.4.3 基于行的逻辑日志复制

        另一种方法是复制和存储引擎采用不同的日志格式,这样复制与存储逻辑剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。
        关系数据库的逻辑日志通常是指一系列记录来描述数据表行级别的写请求:

  • 对于行插入,日志包含所有相关列的新值
  • 对于行删除,日志里有足够的信息来唯一标识已删除的行,通常是靠主键,但如果表上没有定义主键,就需要记录所有列的旧值
  • 对于行更新,日志包含足够的信息来唯一标识更新的行,以及所有列的新值(或至少包含所有已更新列的新值)

        如果一条事务涉及多行的修改,则会产生多个这样的日志记录,并在后面跟着一条记录,指出该事务已经提交。
        由于逻辑日志与存储引擎逻辑解耦,因此可以更容易地保持向后兼容,从而使主从节点能够运行不同版本的软件甚至是不同的存储引擎。
        对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统(如用于离线分析的数据仓库),或构建自定义索引和缓存等,基于逻辑日志的复制更有优势。该技术也被称为变更数据捕获。

1.4.4 基于触发器的复制

        到目前为止所描述的复制方法都是由数据库系统来实现的,不涉及任何应用程序代码。通常这是大家所渴望的,不过,在某些情况下,我们可能需要更高的灵活性。例如,只想复制数据的一部分,或者想从一种数据库复制到另一种数据库,或者需要订制、管理冲突解决逻辑(“处理写冲突”),则需要将复制控制交给应用程序层。
        有一些工具,例如Oracle GoldenGate,可以通过读取数据库日志让应用程序获取数据变更。另一种方法则是借助许多关系数据库都支持的功能:触发器存储过程
        触发器支持注册自己的应用层代码,使得当数据库系统发生数据更改(写事务)时自动执行上述自定义代码。通过触发器技术,可以将数据更改记录到一个单独的表中,然后外部处理逻辑访问该表,实施必要的自定义应用层逻辑,例如将数据更改复制到另一个系统。Oracle的Databus和Postgres的Bucardo就是这种技术的典型代表。
        基于触发器的复制通常比其他复制方式开销更高,也比数据库内置复制更容易出错,或者暴露一些限制。然而,其高度灵活性仍有用武之地。

2 复制滞后问题

        由于并非所有的写入都反映在从副本上,如果同时对主节点和从节点发起相同的查询,可能会得到不同的结果。这种不一致只是一个暂时的状态,如果停止写数据库,经过一段时间之后,从节点最终会赶上并与主节点保持一致。这种效应也被称为最终一致性
        “最终”一词有些含糊不清,总的来说,副本落后的程度理论上并没有上限。正常情况下,主节点和从节点上完成写操作之间的时间延迟(复制滞后)可能不足1秒,这样的滞后,在实践中通常不会导致太大影响。但是,如果系统已接近设计上限,或者网络存在问题,则滞后可能轻松增加到几秒甚至几分钟不等。
        当滞后时间太长时,导致的不一致性不仅仅是一个理论存在的问题,而是个实实在在的现实问题。以下为三个复制滞后可能出现的问题,并给出相应的解决思路。

2.1 读自己的写

        许多应用让用户提交一些数据,接下来查看他们自己所提交的内容。异步复制存在这样一个问题,用户在写入不久即查看数据,则新数据可能尚未到达从节点。对用户来讲,看起来似乎是刚刚提交的数据丢失了,显然用户不会高兴。
        对于这种情况,我们需要“写后读一致性”,也称为读写一致性。该机制保证如果用户重新加载页面,他们总能看到自己最近提交的更新。但对其他用户则没有任何保证,这些用户的更新可能会在稍后才能刷新看到。
        基于主从复制的系统该如何实现写后读一致性呢?有多种可行的方案,以下例举一二:

  • 如果用户访问可能会被修改的内容,从主节点读取;否则,在从节点读取
  • 跟踪最新更新的时间,如果更新后一分钟之内,则总是在主节点读取;并监控从节点的复制滞后程度,避免从那些滞后时间超过一分钟的从节点读取(适用于应用的大部分内容都可能被所有用户修改)
  • 客户端还可以记住最近更新时的时间戳,并附带在读请求中,据此信息,系统可以确保对该用户提供读服务时都应该至少包含了该时间戳的更新
  • 如果副本分布在多数据中心(例如考虑与用户的地理接近,以及高可用性),情况会更复杂些。必须先把请求路由到主节点所在的数据中心(该数据中心可能离用户很远)

        如果同一用户可能会从多个设备访问数据,例如一个桌面Web浏览器和一个移动端的应用,情况会变得更加复杂。此时,要提供跨设备的写后读一致性,即如果用户在某个设备上设备上输入一些信息然后在另一台设备上查看,也应该看到刚刚所输入的内容。
        这种情况下需要考虑的问题:

  • 记住用户上次更新时间戳的方法实现起来会比较困难,因为在一台设备上运行的代码完全无法知道在其他设备上发生了什么。此时,元数据必须做到全局共享
  • 如果副本分布在多数据中心,无法保证来自不同设备的连接经过路由之后都到达同一个数据中心。例如,用户的台式计算机使用了家庭宽带连接,而移动设备则适用蜂窝数据网络,不同设备的网络连接线路可能完全不同。如果方案要求必须从主节点读取,则首先需要想办法确保将来自不同设备的请求路由到同一个数据中心

2.2 单调读

        用户数据向后回滚的奇怪情况,用户看到了最新内容之后又读到了过期的内容,好像时间被回拨,此时需要单调读一致性。
        单调读一致性可以确保不会发生这种异常。这是一个比强一致性弱,但比最终一致性强的保证。当读取数据时,单调读保证,如果某个用户依次进行多次读取,则他绝不会看到回滚现象,即在读取较新值之后又发生读旧值的情况。
        实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读取(而不同的用户可以从不同的副本读取)。例如,基于用户ID的哈希方法而不是随机选择副本。但如果该副本发生失效,则用户的查询必须重新路由到另一个副本。

2.3 前缀一致读

        因果反常,事实上实际的顺序由于延迟不同而对第三方影响,导致第三方看到了逻辑混乱的顺序。(没问就答
        分区数据经多副本复制后出现了不同程度的滞后,导致观察者先看到果后看到因
        防止这种异常需要引入另一种保证:前缀一致读。该保证是说,对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。这是分区(分片)数据库中出现的一个特殊问题,细节见第六章。如果数据库总是以相同的顺序写入,则读取总是看到一致的序列,不会发生这种反常。然而,在许多分布式数据库中,不同的分区独立运行,因此不存在全局写入顺序。这就导致当用户从数据库中读数据时,可能会看到数据库的某部分旧值和另一部分新值。
        一个解决方案是确保任何具有因果顺序关系的写入都交给一个分区来完成,但该方案真实实现效率会大打折扣。现在有一些新的算法来显示地追踪事件因果关系,稍后的“Happened-before关系与并发”会继续探讨。

2.4 复制滞后的解决方案

        设计应用系统之前就应该充分的考虑到异步复制可能带来的麻烦,在应用层可以提供比底层数据库更强有力的保证。例如只在主节点上进行特定类型的读取,而代价则是,应用层代码中处理这些问题通常会非常复杂,且容易出错。
        如果应用程序开发人员不必担心这么多底层的复制问题,而是假定数据库在“做正确的事情”,情况就变得很简单。而这也是事务存在的原因,事务是数据库提供更强保证的一种方式。
        单节点上支持事务已经非常成熟。然而,在转向分布式数据库(即支持复制和分区)的过程中,有许多系统却选择放弃支持事务,并声称事务在性能与可用性方面代价过高,然而断言在可扩展的分布式系统中最终的一致性是无法避免的终极选择。关于这样的表述,首先它有一定道理,但情况远不是所说的那么简单,后面会展开讨论,尝试形成一个更为深入的观点。

3 多主节点复制

        多主节点复制,每个主节点还同时扮演其他主节点的从节点。主从复制存在一个明显的缺点:系统只有一个主节点,而所有写入都必须经由主节点。如果由于某种原因,例如与主节点之间的网络中断而导致主节点无法连接,主从复制方案就会影响所有的写入操作。

3.1 适用场景

        在一个数据中心内部适用多主节点基本没有太大意义,其复杂性已经超过所能带来的好处。但是,在以下场景这种配置则是合理的。

3.1.1 多数据中心

        为了容忍整个数据中心级别故障或者更接近用户,可以把数据库的副本横跨多个数据中心。而如果使用常规的基于主从的复制模型,主节点势必只能放在其中的某一个数据中心,而所有写请求都必须经过该数据中心。
        有了多主节点复制模型,则可以在每个数据中心都配置主节点。在每个数据中心内,采用常规的主从复制方案;而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。
        对比一下在多数据中心环境下,部署单主节点的主从复制方案与多主复制方案之间的差异:

  • 性能
            对于主从复制,每个请求都必须经由广域网传送至主节点所在的数据中心。这会大大增加写入延迟,并基本偏离了采用多数据中心的初衷(即就近访问)。而在多主节点模型中,每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心。因此,对上层应用有效屏蔽了数据中心之间的网络延迟,使得终端用户所体验到的性能更好
  • 容忍数据中心失效
            对于主从复制,如果主节点所在的数据中心发生故障,必须切换至另一个数据中心,将其中的一个从节点提升为主节点。在多主节点模型中,每个数据中心则可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态
  • 容忍网络问题
            数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对数据中心之间的网络性能和稳定性等更加依赖。多主节点模型则通常采用异步复制,可以更好地容忍此类问题,例如临时网络闪断不会妨碍写请求最终成功

        尽管多主复制具有上述优势,但也存在一个很大的缺点:不同的数据中心可能会同时修改相同的数据,因而必须解决潜在的写冲突。

3.1.2 离线客户端操作

        应用在与网络断开后还需要继续工作。在离线状态下进行的任何更改,会在下次设备上线时,与服务器以及其他设备同步。
        这种情况下,每个设备都有一个充当主节点的本地数据库(用来接受写请求),然后在所有设备之间采用异步方式同步这些多主节点上的副本,同步滞后可能是几个小时或者数天,具体时间取决于设备何时可以再次联网。
        从架构层面来看,上述设置基本上等同于数据中心之间的多主复制,只不过是个极端情况,即一个设备就是数据中心,而且它们之间的网络连接非常不可靠。多个设备同步日历的例子表明,多主节点可以得到想要的结果,但中间过程依然有很多的未知数。

3.1.3 协作编辑

        我们通常不会将协作编辑完全等价于数据库复制问题,但二者确实有很多相似之处。当一个用户编辑文档时,所做的更改会立即应用到本地副本(Web浏览器或客户端应用程序),然后异步复制到服务器以及编辑同一文档的其他用户。
        如果要确保不会发生编辑冲突,则应用程序必须先将文档锁定,然后才能对其进行编辑。如果另一个用户想要编辑同一个文档,首先必须等到第一个用户提交修改并释放锁。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
        为了加快写作编辑的效率,可编辑的粒度需要非常小。例如,单个按键甚至是全程无锁。然而另一方面,也会面临所有多主复制都存在的挑战,即如何解决冲突。

3.2 处理写冲突

        多主复制的最大问题是可能发生写冲突,这意味着必须有方案来解决冲突。

3.2.1 同步与异步冲突检测

        如果是主从复制数据库,第二个写请求要么会被阻塞直到第一个写完成,要么被中止(用户必须重试)。然而在多节点的复制模型下,这两个写请求都是成功的,并且只能在稍后的时间点上才能异步检测到冲突,那时再要求用户层来解决冲突为时已晚。
        理论上,也可以做到同步冲突检测,即等待写请求完成对所有副本的同步,然后再通知用户写入成功。但是,这样做将会失去多主节点的主要优势:允许每个主节点独立接受写请求。 如果确实想要同步方式冲突检测,或许应该考虑采用单主节点的主从复制模型。

3.2.2 避免冲突

        处理冲突最理想的策略是避免发生冲突,即如果应用层可以保证对特定记录的写请求总是通过一个主节点,这样就不会发生冲突。更新自己的数据时,确保每次路由到特定的数据中心。从用户的角度来看,这基本等价于主从复制模型。
        但是,当数据中心发生故障或者用户漫游至另一个位置。此时,冲突避免方式不再有效,必须有措施来处理同时写入冲突的可能性。

3.2.3 收敛于一致状态

        对于主从复制模型,数据更新符合顺序性原则,即如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。对于多主节点复制模型,由于不存在这样的写入顺序,所以最终值也会变得不确定。
        如果每个副本都只是按照它所看到写入的顺序执行,那么数据库最终将处于不一致状态。因此,数据库必须以一种收敛趋同的方式来解决冲突,这也意味着当所有更改最终被复制、同步之后,所有副本的最终值是相同的。
        实现收敛的冲突解决有以下可能的方式:

  • 给每个写入分配唯一的ID
  • 为每个副本分配一个唯一的ID,并制定规则,例如序号高的副本写入始终优先于序号低的副本。这种方法也可能会导致数据丢失
  • 以某种方式将这些值合并在一起
  • 利用预定义好的格式来记录和保留冲突相关的所有信息,然后依靠应用层的逻辑,事后解决冲突(可能会提示用户)
3.2.4 自定义冲突解决逻辑

        解决冲突最合适的方式可能还是依靠应用层,所以大多数多主节点复制模型都有工具来让用户编写应用代码来解决冲突。可以在写入时或在读取时执行这些代码逻辑:

  • 在写入时执行
            只要数据库系统在复制变更日志时检测到冲突,就会调用应用层的冲突处理程序
  • 在读取时执行
            当检测到冲突时,所有冲突写入值都会暂时保存下来。下一次读取数据时,会将数据的多个版本读返回给应用层。应用层可能会提示用户或自动解决冲突,并将最后的结果返回到数据库。

        注意,冲突解决通常用于单个行或文档,而不是整个事务。因此,如果有一个原子事务包含多个不同请求,每个请求仍然是分开考虑来解决冲突。

3.2.5 什么是冲突?

        有些冲突是显而易见的,而其他类型的冲突可能会非常微妙,更难以实现。

自动冲突解决

        冲突解决的规则可能会变得越来越复杂,且自定义代码很容易出错。一些有意思的研究尝试自动解决并发修改所引起的冲突,下面这些方法值得一看:

  1. 无冲突的复制数据类型(Conflict-free Replicated Datatypes,CRDT)。CRDT是可以由多个用户同时编辑的数据结构,包括map、ordered list、计数器等,并且以内置的合理方式自动地解决冲突
  2. 可合并的持久数据结构(Mergeable persistent data)。它跟踪变更历史,类似于Git版本控制系统,并提出三向合并功能(three-way merge function,CRDT采用双向合并)
  3. 操作转换(Operational transformation)。它是Etherpad和Google Docs等协作编辑应用背后的冲突解决算法。专为可同时编辑的有序列表而设计,如文本文档的字符列表

3.3 拓扑结构

        复制的拓扑结构描述了写请求从一个节点传播到其他节点的通信路径。
        三种模型:环形拓扑、星型拓扑、全部-至-全部型拓扑。最常见的是全部-至-全部型拓扑。
        对于链接紧密的拓扑(如全部到全部),消息可以沿着不同的路径传播,避免了单点故障,因而有更好的容错性。但另一方面,全链接拓扑也存在一些自身的问题。主要是存在某些网络链路比其他链路更快的情况(例如由于不同网络拥塞),从而导致复制日志之间的覆盖。对于多主节点复制,某些副本上可能会出现错误的写请求到达顺序。为了使得日志消息正确有序,可以使用一种称为版本向量的技术。

4 无主节点复制

        放弃主节点,允许任何副本直接接受来自客户端的写请求。其实最早的数据复制系统就是无主节点的(或称为去中心复制,无中心复制),但后来到了关系数据库主导的时代,这个想法被大家选择性遗忘了。
        对于某些无主节点系统实现,客户端直接将其写请求发送到多副本,而在其他一些实现中,有一个协调者节点代表客户端进行写入,但于主节点的数据库不同,协调者并不负责写入顺序的维护。这种设计上的差异对数据库的使用方式有着深刻的影响。

4.1 节点失效时写入数据库

        为了解决写入期间存在某节点下线的情况(客户端写入成功),当一个客户端从数据库中读取数据时,它不是向一个副本发送请求,而是并行地发送到多个副本。客户端可能会得到不同节点的不同相应,包括某些节点的新值和某些节点的旧值。可以采用版本号技术确定哪个值更新。

4.1.1 读修复与反熵

        复制模型应确保所有数据最终复制到所有的副本。当一个失效的节点重新上线之后,它如何赶上中间错过的那些写请求呢?

  • 读修复
            当客户端并行读取多个副本时,可以检测到过期的返回值,然后将新值写入到该过期的副本。这种方法主要适合那些被频繁读取的场景
  • 反熵过程
            一些数据存储有后台进程不断查找副本之间数据的差异,将任何缺少的数据从一个副本复制到另一个副本。与基于主节点复制的复制日志不同,此反熵过程并不保证以特定的顺序复制写入,并且会引入明显的同步滞后

        并不是所有的系统都实现了上述两种方案。请注意,当缺少反熵过程的支持时,由于读时修复只在发生读取时才可能执行修复,那些很少访问的数据就可能在某些副本中已经丢失而无法检测到,从而降低了写的持久性。

4.1.2 读写quorum

        如果有n个副本,写入需要w个节点确认,读取必须至少查询r个节点,则只要w+r>n,读取的节点中一定会包含最新值。满足上述这些r、w值的读/写操作称之为法定票数读(或仲裁读)或法定票数写(或仲裁写)。也可以认为r和w是用于判定读、写是否有效的最低票数。
        在Dynamo风格的数据库中,参数n、w和r通常是可配置的。一个常见的选择是设置n为某奇数(通常为3或5),w=r=(n+1)/2(向上舍入)。也可以根据自己的需求灵活调整这些配置。例如,对于读多写少的负载,设置w=n和r=1比较合适,这样读取速度更快,但是一个失效的节点就会使得数据库所有写入因无法完成quorum而失败。
        仲裁条件w+r>n定义了系统可容忍的失效节点数:

  • 当w<n,如果一个节点不可用,仍然可以处理写入
  • 当r<n,如果一个节点不可用,仍然可以处理读取
  • 假定n=3,w=2,r=2,则可以容忍一个不可用的节点
  • 假定n=5,w=3,r=3,则可以容忍两个不可用的节点
  • 通常,读取和写入操作总是并行发送到所有的n个副本。参数w和参数r只是决定要等待的节点数。即有多少个节点需要返回结果,我们才能判断出结果的正确性

4.2 Quorum一致性的局限性

        如果有n个副本,并且配置w和r,使得w+r>n,可以预期可以读取到一个最新值。之所以这样,是因为成功写入的节点集合和读取的节点集合必然有重合,这样读取的节点中至少有一个具有最新值。
        当w和r配置的节点数较小,读取请求当中可能恰好没有包含新值的节点,因此最终可能会返回一个过期的旧值。好的一方面是,这种配置可以获得更低的延迟和更高的可用性,例如网络中断,许多副本变得无法访问,相比而言有更高的概率继续处理读取和写入。只有当可用的副本数已经低于w或r时,数据库才会变得无法读/写,即处于不可用状态。
        即使在w+r>n的情况下,也可能存在返回旧值的边界条件。这主要取决于具体实现,可能的情况包括:

  • 如果采用了sloppy quorum(宽松的quorum与数据回传),写操作的w节点和读取的r节点可能完全不同,因此无法保证读写请求一定存在重叠的节点
  • 如果两个写操作同时发生,则无法明确先后顺序
  • 如果写操作与读操作同时发生,写操作可能仅在一部分副本上完成。此时,读取时返回旧值还是新值存在不确定性
  • 如果某些副本上已经写入成功,而其他一些副本发生写入失败(例如磁盘已满),且总的成功副本数少于w,那些已成功的副本上不会做回滚。这意味着尽管这样的写操作被视为失败,后续的读操作仍可能返回新值
  • 如果具有新值的节点后来发生失效,但恢复数据来自某个旧值,则总的新值副本数会低于w,这就打破了之前的判定条件
  • 即使一切工作正常,也会出现一些边界情况,如第九章(可线性化与quorum)

        因此,虽然quorum设计上似乎可以保证读取最新值,但现实情况却往往更加复杂。Dynamo风格的数据库通常是针对最终一致性场景而优化的。我们建议最好不要把参数w和r视为绝对的保证,而是一种灵活可调的读取新值的概率。

4.2.1 监控旧值

        从运维角度来看,监视数据库是否返回最新结果非常重要。对于主从复制的系统监控起来和衡量该从节点落后于主节点的程度更为容易。
        然而,对于无主节点复制的系统,并没有固定的写入顺序,因而控制就变得更加困难。而且,如果数据库只支持读时修复(不支持反熵),那么旧值的落后就没有一个上限。例如如果一个值很少被访问,那么所返回的旧值可能非常之古老。
        目前针对无主节点复制系统已经有一些研究,根据参数n,w和r来预测读到旧值的期望百分比。不过,总体讲还不是很普及。即便如此,将旧值监控纳入到数据库标准指标集中还是很有必要。要知道,最终一致性其实是个非常模糊的保证,从可操作性上讲,量化究竟何为“最终”很有实际价值。

4.2.2 宽松的quorum与数据回传

        配置适当quorum的数据库系统可以容忍某些节点故障,也不需要执行故障切换。它们还可以容忍某些节点变慢,这是因为请求并不需要等待所有n个节点的响应,只需w或r节点响应即可。对于需要高可用和低延迟的场景来说,还可以容忍偶尔读取旧值,所有这些特性使之具有很高的吸引力。但是,一个网络中断可以很容易切断一个客户端到多数数据库节点的链接。尽管此时集群节点是活着的,且对与其他用户也的确可以正常链接,但是对于断掉链接的客户端来讲,情况无疑等价于集群整体失效。
        在一个大规模集群中(节点数远大于n个),客户可能在网络中断期间还能连接到某些数据库节点,但这些节点又不是能够满足数据仲裁的那些节点。此时,数据库设计者就面临着一个选择:

  • 如果无法达到w或r所要求quorum,将错误明确地返回给客户端?
  • 或者,我们是否应该接受该写请求,只是将它们暂时写入一些可访问的节点中?注意,这些节点并不在n个节点集合中

        后一种方案称之为放松的仲裁:写入和读取仍然需要w和r个成功的响应,但包含了那些并不在先前指定的n个节点。 一旦网络问题得到解决,临时节点需要把接收到的写入全部发送到原始主节点。这就是所谓的数据回传(或暗示移交)
        可以看出,sloppy quorum对于提高写入可用性特别有用:只要有任何w个节点可用,数据库就可以接受新的写入。然而这意味着,即使满足w+r>n,也不能保证在读取某个键时,一定能读到最新值,因为新值可能被临时写入n之外的某些节点且尚未回传过来。
        因此,sloppy quorum并非传统意义上quorum。而更像是为了数据持久性而设计的一个保证措施,除非回传结束,否则它无法保证客户端一定能从r个节点读到新值。

4.2.3 多数据中心操作

        无主节点复制由于旨在更好地容忍并发写入冲突,网络中断和延迟尖峰等,因此也可适用于多数据中心操作。
        Cassandra和Voldemort在其默认设置的无主节点模型中都支持跨数据中心操作:副本的数量n是包含所有数据中心的节点总数。配置时,可以指定每个数据中心各有多少副本。每个客户端的写入都会发送到所有副本,但客户端通常只会等待来自本地数据中心内的quorum节点数的确认,这样避免了高延迟和跨数据中心可能的网络异常。尽管可以灵活配置,但对远程数据中心的写入由于延迟很高,通常都被配置为异步方式。
        Riak则将客户端与数据库节点之间的通信限制在一个数据中心内,因此n描述的是一个数据中心内的副本数量。集群之间跨数据中心的复制则在后台异步运行,类似于多主节点复制风格。

4.3 检测并发写

        一个核心问题是,由于网络延迟不稳定或者局部失效,请求在不同的节点上可能会呈现不同的顺序。 我们知道副本应该收敛于相同的内容,这样才能达成最终一致。大多数的系统都不能自动处理数据副本之间的一致性,接下来进一步介绍“处理写冲突”的技巧。

4.3.1 最后写入者获胜(丢弃并发写入)

        一种实现最终收敛的方法是,每个副本总是保存最新值,允许覆盖并丢弃旧值。如何去定义“最新”是很重要的
        即使无法确定写请求的“自然顺序”,我们可以强制对其排序。例如,为每个写请求附加一个时间戳,然后选择最新即最大的时间戳,丢弃较早时间戳的写入。这个冲突解决算法被称为最后写入者获胜(last write wins,LWW),它是Cassandra仅有的冲突解决方法,而在Riak中,它是可选方案之一。
        LWW可以实现最终收敛的目标,但是以牺牲数据持久性为代价。如果同一个主键有多个并发写,即使这些并发写都向客户端报告成功(因为完成了写入w个副本),但最后只有一个写入值会存活下来,其他的将被系统默默丢弃。此外,LWW甚至可能会删除那些非并发写,第八章将会讨论“时间戳与事件顺序”。
        在一些场景如缓存系统,覆盖写是可以接受的。如果覆盖、丢失数据不可接受,则LWW并不是解决冲突很好的选择。
        要确保LWW安全无副作用的唯一方法是,只写入一次然后写入值视为不可变,这样就避免了对同一个主键的并发(覆盖)写。例如,Cassandra的一个推荐使用方法就是采用UUID作为主键,这样每个写操作都针对不同的、系统唯一的主键。

4.3.2 Happens-before关系和并发

        如果B知道A,或者依赖于A,或者以某种方式在A基础上构建,则称操作A在操作B之前发生。这是定义何为并发的关键。事实上,我们也可以简单地说,如果两个操作都不在另一个之前发生,那么操作是并发的(或者两者都不知道对方)
        因此,对于两个操作A和B,一共存在三种可能性:A在B之前发生,或者B在A之前发生,或者A和B并发。我们需要的是一个算法来判定两个操作是否并发。如果一个操作发生在另一个操作之前,则后面的操作可以覆盖较早的操作。如果属于并发,就需要解决潜在的冲突问题。

并发性、时间和相对性

        通常如果两个操作“同时”发生,则称之为并发,然而事实上,操作是否在时间上重叠并不重要。由于分布式系统中复杂的时钟同步问题,现实当中,我们很难严格确定它们是否同时发生。
        为更好地定义并发性,我们并不依赖确切的发生时间,即不管物理的实际如何,如果两个操作并不需要意识到对方,我们即可声称它们是并发操作。一些人尝试把这个思路与物理学中狭义相对论联系起来,后者引入了“信息传递不能超越光速”的假定,如果两个事件发生的间隔短于光在它们之间的折返,那么这两个事件不可能有相互影响,因此就是并发。
        在计算机系统中,即使光速快到允许一个操作影响到另一个操作,但两个操作仍可能被定义为并发。例如,发生了网络拥塞或中断,可能就会出现两个操作由于网络问题导致一个操作无法感知另一个,因此两者称为并发。

4.3.3 确定前后关系

        服务器判断操作是否并发的依据主要依靠对比版本号,而并不需要解释新旧值本身(值可以是任何数据结构)。算法的工作流形如下:

  • 服务器为每个主键维护一个版本号,每当主键新值写入时递增版本号,并将新版本号与写入的值一起保存
  • 当客户端读取主键时,服务器将返回所有(未被覆盖的)当前值以及最新的版本号。且要求写之前,客户必须先发送读请求
  • 客户端写主键,写请求必须包含之前读到的版本号、读到的值和新值合并后的集合。写请求的响应可以像读操作一样,会返回所有当前值,这样就可以像购物车例子那样一步步链接起多个写入的值
  • 当服务器收到带有特定版本号的写入时,覆盖该版本号或更低版本的所有值(因为知道这些值已经被合并到新传入的值集合中),但必须保存更高版本号的所有值(因为这些值与当前的写操作属于并发)

        当写请求包含了前一次读取的版本号时,意味着修改的是基于以前的状态。如果一个写请求没有包含版本号,它将与所有其他写入同时进行,不会覆盖任何已有值,其传入的值将包含在后续读请求的返回值列表当中。

4.3.4 合并同时写入的值

        上述算法可以保证不会发生数据丢失,但如果多个操作并发发生,则客户端必须通过合并并发写入的值来继承旧值。Riak称这些并发值为兄弟关系。
        合并本质上与先前讨论的多节点复制冲突解决类似。一个简单的方法是基于版本号或时间戳(即最后写入获胜)来选择其中的一个值,但这意味着会丢失部分数据。合并并发值的合理方式是包含新值和旧值(union操作),合并时去掉了重复值。但是在删除场景下可能会导致错误的结果,如果合并了两个客户端的值,且其中有一个商品被客户端删除掉,则被删除的项目会再次出现在合并的终值中。 为了防止该问题,项目在删除时不能简单地从数据库中删除,系统必须保留一个对应的版本号以恰当的标记该项目需要在合并时被剔除。这种删除标记被称为墓碑
        考虑到在应用代码中合并非常复杂且容易出错,因此可以设计一些专门的数据结构来自动执行合并,例如,Riak支持称为CRDT一系列数据结构,以合理的方式高效自动合并,包括支持删除标记。

4.3.5 版本矢量

        当多个副本同时接受写入时,我们需要为每个副本和每个主键均定义一个版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本看到的版本号。通过这些信息来指示要覆盖哪些值、该保留哪些并发值。
        所有副本的版本号集合称为版本矢量。与单个副本的版本号类似,当读取数据时,数据库副本会返回版本矢量给客户端,而在随后写入时需要将版本信息包含在请求当中一起发送到数据库。Riak将版本矢量编码为一个称之为因果上下文的字符串。版本矢量技术使数据库可以区分究竟应该覆盖写还是保留并发值。
        另外,就像单副本的例子一样,应用程序仍然需要执行合并操作。版本矢量可以保证从某一个副本读取值然后写入到另一个副本,而这些值可能会导致在其他副本上衍生出来新的“兄弟”值,但至少不会发生数据丢失且可以正确合并所有并发值。

小结

        复制或者多副本技术主要服务以下目的:

  • 高可用性:
            即使某台机器(或多台机器,或整个数据中心)出现故障,系统也能保持正常运行
  • 连接断开与容错:
            允许应用程序在出现网络中断时继续工作
  • 低延迟:
            将数据放置在距离用户较近的地方,从而实现更快地交互
  • 可扩展性:
            采用多副本读取,大幅提高系统读操作的吞吐量

        在多台机器上保存多份相同的数据副本,看似只是个很简单的目标,但事实上复制技术是一个非常烧脑的问题。需要仔细考虑并发以及所有可能出错的环节,并小心处理故障之后的各种情形。最最基本的,要处理好节点不可用与网络中断问题,这里甚至还没考虑一些更隐蔽的失效场景,例如由于软件bug而导致的无提示的数据损坏。
        我们主要讨论了三种多副本方案:

  • 主从复制
            所有的客户端写入操作都发送到某一个节点(主节点),由该节点负责将数据更改事件发送到其他副本(从节点)。每个副本都可以接收读请求,但内容可能是过期值
  • 多主节点复制
            系统存在多个主节点,每个都可以接收写请求,客户端将写请求
  • 无主节点复制
            客户端将写请求发送到多个节点上,读取时从多个节点上并行读取,依次检测和纠正某些过期数据

        每种方法都有其优点和缺点。主从复制非常流行,主要是因为它很容易理解,也不需要担心冲突问题。而万一出现节点失效、网络中断或者延迟抖动等情况,多主节点和无主节点复制方案会更加可靠,不过背后的代价则是系统的复杂性和弱一致性保证。
        复制可以是同步的,也可以是异步的,而一旦发生故障,二者的表现差异会对系统行为产生深远的影响。在系统稳定状态下异步复制i性能优秀,但仍然认真考虑一旦出现复制滞后和节点失效两种场景会导致何种影响。万一某个主节点发生故障,而一个异步复制滞后和节点失效两种场景会导致何种影响。万一某个主节点发生故障,而一个异步更新的从节点被提升为新的主节点,要意识到最新确认的数据可能有丢失的风险。
        我们还分析了由于复制滞后引起的一些奇怪效应,并讨论了以下一致性模型,来帮助应用程序处理复制滞后:

  • 写后读一致
            保证用户总能看到自己所提交的最新数据
  • 单调读
            用户在某个事件点读到数据之后,保证此后不会出现比该时间点更早的数据
  • 前缀一致读
            保证数据之间的因果关系,例如,总是以整齐而的顺序先读取问题,然后看到回答

        最后,我们讨论了多主节点和无主节点复制方案所引入的并发问题。即由于多个写可能同时发生,继而可能产生冲突。为此,我们研究了一个算法使得数据库系统可以指定某操作是否发生在另一个操作之前,或者是同时发生。接下来,探讨采用合并并发更新值得方法来解决冲突。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值