第五章 复制

本章主要介绍 复制的三种方式 单领导者(single leader)多领导者(multi leader)无领导者(leaderless) 复制。

以及对复制过程中对性能和数据一致性的权衡,同步还是异步等。同时也列举了很多复制中可能出现的问题以及解决办法。但这远远不够,现实世界的复杂性在数据系统中体现的淋漓尽致。

数据复制的目的在于可扩展性,容错,高可用性,延迟等等。这些有助于我们理解为何要将数据库设计成这样。

数据复制本质上来说是将数据分布在多台机器上,这通常有两种方式:

  • 复制:在不同的节点保存数据的相同副本,提供冗余、改善性能
  • 分区:将一个大型数据库拆分成较小的子集,不同的分区指派给不同的节点。

分区和复制是不同的机制,但常常同时使用。

单主复制

领导者和追随者

副本之一被指定为主库,或领导者,客户端的所有写入请求必须发送给主库,主库会将新数据写入存储。

其他副本称为从库,或者追随者;当主库的写入存储后,它也会将数据变更发送所有从库,这称为复制日志,或变更流

每个追随者从领导者那里拉取日志,并相应的更新本地数据库副本,为了保证数据和领导者一致,则必须使用和领导者处理的相同顺序应用所有变更

客户端想要读取数据时,可以从领导者或者追随者那里读取。但是只有领导者才能接受写入。如下图所示

基于单领导者的复制模式

同步复制和异步复制

复制的发生是**同步(synchronize)的还是异步(asynchronously)**的。

假如我们有如下例子,网站用户试图更新他们的个人头像。

同步异步更新示例

基于领导者的复制

从库 1 的复制是同步的,Leader 等待 follow1 复制完成,才向用户报告写入完成,并且其写入是对用户可见的

从库 2 的复制是异步的,follow2 的复制有明显延迟,并且 Leader 不等待follow2 的响应。

通常情况下复制的速度相当快,但是对于复制所需要的时间没有保证。有些情况下从库可能会显著落后于从库:(1)从库从故障中恢复;(2)系统资源耗尽;(3)节点之间存在网络问题

同步复制的优点,从库保证有与主库一致的最新数据副本,主库即使突然失效,从库依然能提供完整的数据。缺点是,如果从库没有响应(网络问题,系统崩溃,),主库就无法写入,必须要等到同步副本可用时才能写入。

因此将所有副本设置为同步是不现实的,通常设置一个副本是同步的,其他副本是异步的。如果同步从库不可用,则使一个异步从库同步。保证至少两个节点上拥有最新的数据副本,这种方式通常称为半同步复制(semi-synchronous)

通常情况下基于领导者的复制会配置为完全异步的。这种情况下,如果主库失效并且不可恢复,则所有尚未复制到从库的写入都会丢失,这意味着即使已经向客户端确认成功,写入也不能保证持久。如果主库可以恢复,则可以根据主库恢复的结果再向从库复制。无论如何,完全异步的复制也有好处,即使所有的从库都落后了,主库也可以继续处理写入。

补充

异步复制已经被广泛应用,异步复制还有一个重要的问题-异步复制延迟

同步复制的变种,链式复制

同时复制的一致性和共识(使几个节点就某个值达成一致)之间有着密切的联系,不过那是另外一个领域了。

复制的几个常见问题

新增从库

如何使从库用户主库的精确副本?

从概念上讲,过程如下:

  1. 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如MySQL的innobackupex 【12】。
  2. 将快照复制到新的从库节点。
  3. 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如,PostgreSQL将其称为 日志序列号(log sequence number, LSN),MySQL将其称为 二进制日志坐标(binlog coordinates)
  4. 当从库处理完快照之后积压的数据变更,我们说它**赶上(caught up)**了主库。现在它可以继续处理主库产生的数据变化了。

节点宕机

从库宕机:追赶恢复

从库记录从主库收到的数据变更。如果从库从崩溃中开始恢复,则从库可以从日志中知道自己处理的最后一个事务。此时从库重新连接到主库,并应用断开连接时发生的所有数据变更。当应用完成后,从库赶上了主库,就可以继续接收数据变更流了。

主库失效:故障切换

主库失效处理起来比较棘手,主要流程如下:其中一个从库需要被提升为新的主库;需要重新配置客户端连接,将其连接到新的主库,并将客户端的写入发送到新的主库,其他从库也需要从新的主库拉取所有的数据变更流。这个过程称为故障切换

故障切换通常由如下步骤组成:

  1. 确认主库失效。主库失效可能由很多种原因造成:停电,故障,网络问题等等,没有万无一失的方式来检测出了什么问题。大部分系统只是使用超时(timeout),节点直接通过心跳机制传递消息,如果一个节点在一段时间内(30 秒)没有响应,就认为这个节点出现故障了。
  2. 选择一个新的主库。这通常使用选举过程来完成,主库的最佳候选人通常是拥有最新的数据副本的从库。让所有节点同意一个新的领导者这是一个共识问题。
  3. 重新配置系统启用新的主库。客户端需要将请求发往新的主库,如果老的主库恢复,可能会依旧认为自己是主库,这需要一些机制来确保老的主库认可新的主库,成为一个从库。

我们再来列举一些故障切换时可能忽略的麻烦:

  • 如果使用的是异步复制。老主库宕机时部分写入操作并未同步到从库。新主库在选出后,如果老主库重新加入系统,新主库在此期间可能会收到冲突的写入,这些冲突的写入如何解决?方案 1:丢弃未复制的写入

  • 如果数据库需要外部存储协调,那么丢弃写入内容是非常危险的。GitHub-故障

  • 发生故障时出现了两个节点都认为自己是主库的情况。即发生了脑裂的情况,两个主库都可以接收写入,但却没有冲突解决的机制,那么数据就有可能会被损坏

复制日志的实现

基于语句的复制

主库记录下它执行的每一个写入请求,并将该语句日志发往从库,每个从库解析并执行该语句,就像是客户端执行。

但是有些情况下却不适用:

  • 调用的非确定性函数,时间函数 now() ,rand()等等
  • 使用了自增列。自增列依赖于数据库中现有的数据,则要求副本中执行顺序和主库中完全相同
  • 有副作用的语句。例如触发器,存储过程,用户定义的函数。

绕开这些限制的方式,将不确定性变为确定性,这样从库就可以获取到相同的值。但是边缘 case 实在太多,MySQL 发现如果语句中有任何不确定性,就会切换为基于行的复制。

逻辑复制日志

数据库为了保证事务安全,通常每次修改都会先写入预写式日志(Write ahead log)WAL,日志包含了所有数据库写入,则完全可以用相同的日志在另外一个节点上构建副本。即主库将日志发送给从库,从库就可以建立和主库一模一样的数据副本了。

以行的方式描述对数据库的写入记录序列:

  • 对于插入的行,日志包含所有列的新值
  • 对于删除的行,日志需要足够的信息标识唯一的行。通常是主键。
  • 对于更新的行,日志包含足够的信息标识唯一更新的行,以及所有列的新值

复制延迟问题

复制可以解决如下问题:

  • 单个节点故障
  • 可扩展性(处理更多的请求)
  • 延迟(将副本放到离用户更近的地方)

常见的 web 模式,都是读多写少的场景,写入都是有主库完成,但是只读查询可以由任意一个从库完成。

这种扩展体系通常由异步复制实现,因为同步复制时,单个节点故障或者网络中断则会导致整体服务不可用。

用户从一个从库读取刚刚写入的数据时,如果从库落后,则会读取到过时的信息。如果从主库和从库分别读取,则可能会出现明显不一致的场景。当从库最终赶上了主库,并保持一致。这种效应称为最终一致性

读己之写

读己之写

上图显示,用户提交了一个评论到主库。然后立即查看自己提交的评论。如果新数据未达到副本,则在用户看起来是刚提交的数据丢失了

这需要读写一致性来保证,用户总会看见自己提交的任何更新。但是不保证其他用户的写入。

如何实现?

  • 读取用户可能修改的内容时,都从主库读。比如说用户的个人资料。
  • 客户端记住最近一次写入的时间戳,系统需要保证给用户提供查询时,该时间戳的所有变更都已经同步到本地库。时间戳可以是逻辑时间戳
  • 或者如果一个查询在从库未查询到数据时,则将请求路由到主库中。

单调读

单调读

上图显示了另一种情况,用户 2345先看见了 用户1234的评论,然后再次刷新,发现评论又没有了。

这种情况是由于用户首先查询了一个延迟较小的从库,然后是一个延迟较大的从库。然后用户发现出现了时光倒流的情况。

解决这种情况,需要单调读的保证。单调读仅意味着一个用户顺序的进行读取,则他们不会看到时间倒退。即如果读取了较新的值,则不会读取到较旧的值。

实现方式:确保每个用户总是从同一个副本读取。

一致前缀读

一致性前缀读

p 说了A,接着 C 回复了 B,但是在第三个观察者看来是C 先说的 B,接着 P 回复了 A。

这个问题出现的原因是,某些分区的复制慢于其他的分区,则观察者就有可能先看见答案,再看见问题。

解决办法:一致前缀读的保证,如果一系列的写入按照某种顺序发生,那么任何人读取这些写入时,也是以相同的顺序出现。

这是分片数据中的问题,不同的分片独立运行,因此不存在全局的时序,用户读取时,则可能看见部分数据是新的部分数据是旧的可能。

一种解决方案是:确保任何因果相关的写入都写入相同的分区。

复制延迟的解决方案

在使用最终一致的系统里面,如果延迟大于 10 秒钟,则应该考虑系统的行为。如果对于用户来说是不好的体验,那提供更强的保证是重要的,例如写后读。如果系统明明是异步复制却假设是同步的,会造成很多不必要的麻烦。

事务提供了强大的保证,应用程序就不必担心这些微妙的问题。单节点的事务已经存在很长时间了,分布式事务虽然有一些方案,但在性能或者可用性上代价太高,很多分布式系统放弃了分布式事务。后面的章节会重新回到这问题。

想起之前的业务场景,一个页面是在用户支付完后前端直接重定向过去的,但是重定向过去之后,异步处理过程可能未开始、未完成、或者异常,每种情况下必须给用户响应,所以会提示用户刷新重试。

多主复制

场景:假设有一个数据库,其副本分散在好几个数据中心,常规的做法是,领导者位于其中一个数据中心,所有写入都经过这个数据中心

多领导复制配置则在每个数据中心配置主库,数据中心内使用常规的主从复制。在数据中心之间,每个数据中心的变更都会被复制到其他数据中心的主库中。

多主复制

比较单主复制和多主复制

  • 性能 :单主情况下,所有写入都会到唯一一个数据中心。多主情况下,每个写入都可以在本地数据中心处理,并与其他数据中心异步复制。性能相对于单主可能会更好。
  • 容忍数据中心停机:单主配置下,主库所在数据中心发生故障,故障切换使另一个数据中心的从库升级为主库。
    多主配置下,每个数据中心独立运行, 当故障发生的数据中心归队时,复制会自动赶上。
  • 容忍网络延迟:单主配置对数据中心之间的连接非常敏感。采用异步复制功能的多活配置能更好的承受网络问题。

多主复制的缺点

不同的数据中心修改相同的数据,写冲突必须要解决。

多主复制虽然在多个数据库都有支持,例如MySQL的Tungsten Replicator ,用于PostgreSQL的BDR。但是都是属于改装的功能,所以常常会有微妙的错误发生,如自增主键、触发器、完整性约束等场景。

适用场景

需要离线操作的客户端

考虑手机、笔记本和其他设备的备忘录应用。你可以随时查看和更改其内容,则设备再次上线时,需要与服务器和其他设备同步。

这种情况下,每个设备都临时充当了领导者的本地数据库,所有设备再次上线时,存在异步的多主复制的过程。

协同编辑

多个用户在 web 浏览器或者应用中编辑协同编辑的文档,所做的更改立即同步到本地的文档副本中,然后异步复制到服务器和同时编辑此文档的其他用户。

如果锁定的范围是整篇文档,则实际上就是单领导复制的模式。

如果为了加锁协作,那么锁定范围必须要小,同时允许多个用户编辑,这就带来了多领导复制的缺点,解决冲突。

处理写入冲突

考虑两个用户同时编辑维基百科的一个条目,引起的冲突。

写入冲突

同步/异步冲突检测

单主数据库中,第二个写入将会被阻塞,等待第一个写入完成,或者第二个写入被终止,让用户重试。

多主数据库中,两个数据写入都是成功的,只是在稍后异步检测到冲突,那时要求用户解决冲突就有点晚了。

原则上可以使冲突检测同步,等待写入被复制到每个数据副本后再通知用户成功。但是这失去了多主复制的优势。

避免冲突

如果所有特定记录的写入都会被路由到同一个数据中心。那么就没有冲突了。

但是当某个数据中心出现故障时,请求被路由到另一个数据中心,这种情况下就必须要处理冲突了。

收敛至一致的状态

单主数据库中:单个字段如果有多个更新,则以最后一次写入为该字段的最终值。

多主配置中,写入是没有顺序的,如果每个副本数据库按照它看见的顺序写入,则数据库最终会处于不一致的状态。

这是不能被接受的,数据库的所有副本最终必须是一致的,这就要求副本在复制完成时收敛到一致的状态,也就是一个相同的最终值。

实现冲突合并解决的途径:

  • 给每个写入一个唯一的 Id,以 Id 最大的写入为最终值,丢弃其他写入。容易造成数据丢失。
  • 给每个副本分配一个唯一的 Id,ID 更大的副本具有更高的优先级。容易造成数据丢失。
  • 以某种方式将这些值合并在一起,类似于 B/C ,然后将数据连接在一起
  • 在保留所有信息的显式数据结构中记录冲突,并编写解决冲突的应用程序代码,或者提示用户的方式。

自定义冲突解决逻辑

最适合解决冲突的可能是应用程序本身

  • 写时执行:只要系统检测到复制时存在冲突,就会调用冲突处理程序。但是这个通知通常不能提示用户
  • 读时执行:检测到冲突时,所有冲突都会写入存储,读取时会将多个版本同时返回给用户,应用程序可能自动处理冲突或者用户手动处理冲突,然后写回数据库

自动解决冲突

  • 无冲突复制数据类型(Conflict-free replicated datatypes)
  • 可合并的持久数据结构(Mergeable persistent data structures),类似于 Git 的版本控制系统,使用三项合并
  • 可执行的转换(operational transformation),Google docs

多主复制拓扑

多主复制拓扑

最普遍的形式是(c)全部到全部的领导者节点的复制。接下来是受限的几种形式,星型拓扑,几个指定的主节点将所有写入转发给其他所有节点;最受限是环形拓扑

写入在复制到副本时会通过多个主节点,节点需要转发来自于其他节点的变更,为了防止无限复制循环,每个节点都需要赋予一个唯一的标识。并且在复制日志中,每个写入都会标记哪些节点已经被复制过了。

环形拓扑和星型拓扑结构,在某个节点发生故障时,则可能会中断其他节点之间的复制通道。

全部到全部的复制虽然容错性更好,但是也可能会出现下面的问题。

全部到全部写入冲突

客户端 A 先插入数据到 Leader1 ,然后复制到Leader2,Leader3 上,由于某些原因 Leader3 先完成,然后客户端B更新的刚插入的数据,然后复制到 Leader1,Leader2 上,由于 Leader收到的写入消息顺序也是不固定的,就有可能出现,更新写入先于插入写入到达,要解决这种冲突,需要对写入进行正确排序,见 检测并发写入

无主复制

常见的数据库充,通常是客户端向主库发送写请求,数据库系统负责将写入复制到其他副本,主库决定写入的顺序,从库按照相同的顺序应用写入。

一些存储系统放弃主库的概念,允许任何副本直接接受来自客户端的写入。例如 亚马逊的Dynamo系统

具体的实现上,客户端将写入直接发送到几个副本中,另一些情况下一个协调者代表客户端进行写入,但是协调者不执行任何特定的写入顺序。如下图所示

无主复制的模式

客户端并行发送写入到三个副本,其中两个副本接受了写入,另外一个副本不在线,同时假设两个副本的写入是足够的,客户端收到两个确认后认为写入是成功的。并且忽略了失败的副本。

当副本 3 再次上线时,客户端开始从副本 3 读取数据,但是副本 3 的数据是陈旧的,为了解决这个问题,客户端从数据库中读取数据时,会将请求发送给多个副本,不同的副本可能返回不同的响应,根据数据的版本号来确认哪个值更新。

如何让落后的副本赶上错过的写入?

有如下机制:

  • 读修复:客户端并行读取多个副本,如果有陈旧的数据副本,客户端将新值写回到副本中,适用于频繁读的场景
  • 反熵过程:一些数据存储具有后台进程,该进程不断的查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。这个过程与基于领导者的复制不同。该进程不会以特定的顺序写入。

读取的法定人数

在上图的三个副本中,我们认为有2 个副本成功写入即可认为本次写入成功,因此至多只有一个副本的数据陈旧的。那么我们在读取数据的时候,至少从两个副本中读取,则可以确认至少有一个副本的数据是最新的。如果第三个副本故障,则读取仍然可以读取到最新值。

根据以上的例子,我们假定总副本数为 n,每次写入至少有w 个副本写入才算本次写入成功,而读取必须读取 r 个节点才能读取到最新值,所以只要满足 w + r > n w+ r > n w+r>n ,我们就能保证读取时至少有一个节点的值是最新的。W 的值称为法定人数quorum)。r和 w 是进行有效读写所需的最小票数。通常情况下 n为奇数。并设置 w = r = ( n + 1 ) / 2 w = r =(n + 1)/ 2 w=r=n+1/2(向上取整),因为这样可以容忍更多的节点不可用,同时两者的值不同对写入或者读取的性能都有影响。

当然,如果少于w 或 r 个节点可用,则写入或者读取会返回错误。节点可能由于很多原因不可用,但是客户端只关心节点是否返回了成功的响应,而不需要区分不同的错误类型。

复制失败

仲裁一致性的局限性

w + r > n w+r>n w+r>n 时,期望读取总能返回一个写入的最新值,因为写入的节点和读取的节点必须重叠。通常情况下 w w w r r r 都是大于 n / 2 n/2 n/2的,

这样可以容忍至多 n / 2 n/2 n/2个节点故障。

法定人数 w w w 可以配置小于 n / 2 n/2 n/2,这样写入只需要少数节点返回响应即可认为是成功的。但是$ r $ 如果也配置的很小,那么就有较大可能会读取到陈旧的值,但这允许更低的延迟和更高的可用性:如果许多副本同时不可用,则可以继续处理读取和写入,只有当副本数分别小于 w 和 r 时,数据库才会不可读取或写入。

即使 w w w r r r 都是大于 n / 2 n/2 n/2的,也会有很多边缘情况需要考虑:

  • 如果 w 和 r 没有重叠,很有可能读取到陈旧的值。
  • 如果两个写入同时发生,如何挑选出最终的写入。如果按照时间戳大小选取,则由于时钟偏差,写入可能会丢失。
  • 写操作和读操作同时发生,部分副本写入了新值,部分节点还未写入,读取的值不能确定是新值还是旧值
  • 写操作在某些节点上失败,导致写入成功的节点数小于 W,所以整体写入需要判定为失败,在写入成功的节点数据未回滚之前,读取的值都有可能是脏数据。
  • 如果写入新值的副本读取失败,则需要读取其他带有旧值的副本。并且其(写入新值的副本)数据需要从写入旧值的节点进行恢复,如果存储新值的副本数小于 W,从而打破了法定人数条件
  • 即使满足仲裁条件,也有可能会出现时序问题
  • 没有关于复制延迟的保证,可能会出现复制延迟出现的问题

解决写入冲突

最后写入胜利

由于是不同的客户端同时操作同一个 key,这些写入是并发的,因此并不知道哪个操作在前,哪个在后。所以我们可以对写入进行排序,比如按照时间戳排序,那么时间戳大的,就会被认为是后写入的,就会覆盖之前写的值。但是有丢数据的可能性。

还有一种方式是,只写入一次,然后视为不可变。

“此前发生”的关系和并发

作者试图向我们解释 “此前发生”和 并发

如果有 A 和 B 两个操作,B 的操作是依赖 A 的结果的,我们说 B因果依赖于 A

如果A 和 B 同时执行,A 不知道 B 的发生,B 也不知道 A 的发生,我们称 A 和 B 是并发

作者用一个购物车的例子捕获**“此前发生”关系**

说明依赖关系

购物车从空的开始,两个客户端并发的操作购物车。

每次写入到服务器后都会给当前的数据分配一个版本号,此后无论是读取还是写入都需要把当前数据的版本号带上,这样保证了可以合并相同的值,而不会出现重复添加的问题。同时基于版本号,添加的不同项也可以被很好的合并起来。

但是注意删除购物车的其中一项,并不是一件容易的事情,例如client2 在 版本 4 中删除了 鸡蛋,但是 client1 在版本5的写入中会继续带上 鸡蛋(版本 5 是基于版本 3的 ),那么在服务端合并的时候并不知道鸡蛋是新加的还是删除的。所以删除的时候,只能将数据项标记为删除

版本向量

我们可以看到购物车的读取和写入都是带有版本号的,所有副本的版本号集合称为版本向量

版本号用来捕捉操作之间的依赖顺序。但是在多个副本的并发写入时,每个数据仅有版本号是不够的,还需要在每个副本中使用版本号。副本之间的版本号用来跟踪覆盖哪些值,以及可保留合并的值。

版本向量可以解决覆盖写入和并发写入,以及合并数据。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值