复制意味着在通过网络连接的多台机器上保留相同数据的副本。
我们希望能复制数据,可能出于各种各样的原因:
- 使得数据与用户在地理上接近(从而减少延迟)
- 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
- 伸缩可以接受读请求的机器数量(从而提高读取吞吐量)
本文将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在让自己再认识一下分区吧将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的 变更(change)。
我们将讨论三种流行的变更复制算法:单领导者(single leader,单主),多领导者(multi leader,多主) 和 无领导者(leaderless,无主)。几乎所有分布式数据库都使用这三种方法之一。
在复制时需要进行许多权衡:例如,使用同步复制还是异步复制?如何处理失败的副本
?这些通常是数据库中的配置选项,细节因数据库而异,但原理在许多不同的实现中都类似。本文会讨论这些决策的后果。
领导者与追随者
存储了数据库拷贝的每个节点被称为 副本(replica) 。当存在多个副本时,会不可避免的出现一个问题:如何确保所有数据都落在了所有的副本上?
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 基于领导者的复制(leader-based replication) (也称 主动/被动(active/passive) 复制或 主/从(master/slave) 复制),如图所示。它的工作原理如下:
- 其中一个副本被指定为 领导者(leader),也称为 主库(master|primary) 。当客户端要向数据库写入时,它必须将请求发送给该 领导者,其会将新数据写入其本地存储。
- 其他副本被称为 追随者(followers),亦称为 只读副本(read replicas)、从库(slaves)、备库( secondaries) 或 热备(hot-standby)。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为 复制日志(replication log) 或 变更流(change stream)。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照与领导者相同的处理顺序来进行所有写入。
- 当客户想要从数据库中读取数据时,它可以向领导者或任一追随者进行查询。
但只有领导者才能接受写入操作(从客户端的角度来看从库都是只读的)
。
这种复制模式是许多关系数据库的内置功能,如 PostgreSQL(从 9.0 版本开始)、MySQL、Oracle Data Guard和 SQL Server 的 AlwaysOn 可用性组。 它也被用于一些非关系数据库,包括 MongoDB、RethinkDB 和 Espresso。最后,基于领导者的复制并不仅限于数据库:像 Kafka和 RabbitMQ 高可用队列这样的分布式消息代理也使用它。某些网络文件系统,例如 DRBD 这样的块复制设备也与之类似。
同步复制与异步复制
复制系统的一个重要细节是:复制是 同步(synchronously) 发生的还是 异步(asynchronously) 发生的。(在关系型数据库中这通常是一个配置项,其他系统则通常硬编码为其中一个)。
想象一个场景:网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时间点,主库又会将数据变更转发给自己的从库。最终,主库通知客户更新成功
下图显示了系统各个组件之间的通信:用户客户端、主库和两个从库。时间从左向右流动。请求或响应消息用粗箭头表示。
示例中,从库 1 的复制是同步的:在向用户报告写入成功并使结果对其他用户可见之前,主库需要等待从库 1 的确认
,确保从库 1 已经收到写入操作。而从库 2 的复制是异步的:主库发送消息,但不等待该从库的响应
。
在这幅图中,从库 2 处理消息前存在一个显著的延迟。通常情况下,复制的速度相当快:大多数数据库系统能在不到一秒内完成从库的同步,但它们不能提供复制用时的保证。有些情况下,从库可能落后主库几分钟或更久,例如:从库正在从故障中恢复,系统正在最大容量附近运行,或者当节点间存在网络问题时。
同步复制的优点是,从库能保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中 一个 从库是同步的,而其他的从库则是异步的。如果该同步从库变得不可用或缓慢,则将一个异步从库改为同步运行**。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 半同步(semi-synchronous)**。
通常情况下,基于领导者的复制都配置为完全异步。在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。这意味着即使已经向客户端确认成功,写入也不能保证是 持久(Durable) 的。然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
弱化的持久性可能听起来像是一个坏的折衷,但异步复制其实已经被广泛使用了,特别是在有很多从库的场景下,或者当从库在地理上分布很广的时候。
设置新从库
有时候需要设置一个新的从库:也许是为了增加副本的数量,或替换失败的节点。如何确保新的从库拥有主库数据的精确副本
?
简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入数据,数据总是在不断地变化,标准的文件复制会看到数据库的不同部分在不同的时间点的内容,其结果可能没有任何意义。
可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。幸运的是,设置新从库通常并不需要停机。从概念上讲,其过程如下所示:
- 在某个时刻获取主库的一致性快照(如果可能,不必锁定整个数据库)。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如用于 MySQL 的 innobackupex。
将快照复制到新的从库节点。 - 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称,例如 PostgreSQL 将其称为 日志序列号(log sequence number,LSN),MySQL 将其称为 二进制日志坐标(binlog coordinates)。
- 当从库处理完快照之后积累的数据变更,我们就说它 赶上(caught up) 了主库,现在它可以继续及时处理主库产生的数据变化了。
建立从库的实际步骤因数据库而异。在某些系统中,这个过程是完全自动化的,而在另外一些系统中,它可能是一个需要由管理员手动执行的、有点神秘的多步骤工作流。
处理节点宕机
系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护(例如,重启机器以安装内核安全补丁)。对运维而言,能在系统不中断服务的情况下重启单个节点好处多多。我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。
如何通过基于领导者的复制实现高可用?
从库失效:追赶恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开期间发生的所有数据变更。当应用完所有这些变更后,它就赶上了主库,并可以像以前一样继续接收数据变更流。
主库失效:故障切换
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为 故障切换(failover)。
故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动的故障切换过程通常由以下步骤组成:
- 确认主库失效。有很多事情可能会出错:崩溃、停电、网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 超时(Timeout) :节点频繁地相互来回传递消息,如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
- 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 控制器节点(controller node) 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 共识 问题,可以参考Raft协议图解,缺陷以及优化当然这只是一种经典的共识算法。
- 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
故障切换的过程中有很多地方可能出错:
- 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入,那这些写入该如何处理
?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望
(比如kafka的ISR机制,会造成消息丢失)。 - 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在 GitHub 的一场事故中,一个过时的 MySQL 从库被提升为主库。数据库使用自增 ID 作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的 ID 作为主键。这些主键也在 Redis 中使用,主键重用使得 MySQL 和 Redis 中的数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中。
- 发生某些故障时可能会出现两个节点都以为自己是主库的情况。这种情况称为 脑裂(split brain),非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制,那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 ,但设计粗糙的机制可能最后会导致两个节点都被关闭。
- 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障切换。例如,临时的负载峰值可能导致节点的响应时间增加到超出超时时间,或者网络故障也可能导致数据包延迟。如果系统已经处于高负载或网络问题的困扰之中,那么不必要的故障切换可能会让情况变得更糟糕。
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换。
节点故障、不可靠的网络、对副本一致性、持久性、可用性和延迟的权衡,这些问题实际上是分布式系统中的基本问题
。
复制日志的实现
基于领导者的复制在底层是如何工作的?实践中有好几种不同的复制方式,所以先简要地看一下。
基于语句的复制
在最简单的情况下,主库记录下它执行的每个写入请求(语句,即 statement)并将该语句日志发送给从库。对于关系数据库来说,这意味着每个 INSERT、UPDATE 或 DELETE 语句都被转发给每个从库,每个从库解析并执行该 SQL 语句,就像直接从客户端收到一样。
虽然听上去很合理,但有很多问题会搞砸这种复制方式:
任何调用 非确定性函数(nondeterministic) 的语句,可能会在每个副本上生成不同的值
。例如,使用 NOW() 获取当前日期时间,或使用 RAND() 获取一个随机数。- 如果语句使用了 自增列(auto increment),或者依赖于数据库中的现有数据(例如,UPDATE … WHERE <某些条件>),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
- 有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定性的。
的确有办法绕开这些问题 —— 例如,当语句被记录时,主库可以用固定的返回值替换掉任何不确定的函数调用,以便所有从库都能获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。
基于语句的复制在 5.1 版本前的 MySQL 中被使用到。因为它相当紧凑,现在有时候也还在用。但现在在默认情况下,如果语句中存在任何不确定性,MySQL 会切换到基于行的复制(稍后讨论)。 VoltDB 使用了基于语句的复制,但要求事务必须是确定性的,以此来保证安全。
传输预写式日志(WAL)
深度探索存储与检索中,我们讨论了存储引擎如何在磁盘上表示数据,我们也发现了通常会将写操作追加到日志中:
- 对于日志结构存储引擎(请参阅 “SSTables 和 LSM 树”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
- 对于覆写单个磁盘块的 B 树,每次修改都会先写入 预写式日志(Write Ahead Log, WAL),以便崩溃后索引可以恢复到一个一致的状态。
在任何一种情况下,该日志都是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给从库。
通过使用这个日志,从库可以构建一个与主库一模一样的数据结构拷贝。
这种复制方法在 PostgreSQL 和 Oracle 等一些产品中被使用到。其主要缺点是日志记录的数据非常底层:WAL 包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件
。
看上去这可能只是一个小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障切换,使升级后的节点之一成为新的主库,从而允许数据库软件的零停机升级。如果复制协议不允许版本不匹配(传输 WAL 经常出现这种情况),则此类升级需要停机
。
逻辑日志复制(基于行)
另一种方法是对复制和存储引擎使用不同的日志格式,这样可以将复制日志从存储引擎的内部实现中解耦出来。这种复制日志被称为逻辑日志(logical log),以将其与存储引擎的(物理)数据表示区分开来。
关系数据库的逻辑日志通常是以行的粒度来描述对数据库表的写入记录的序列:
- 对于插入的行,日志包含所有列的新值。
- 对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
- 对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
修改多行的事务会生成多条这样的日志记录,后面跟着一条指明事务已经提交的记录。 MySQL 的二进制日志(当配置为使用基于行的复制时)使用了这种方法
。感兴趣可以参考
由于逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统,例如复制到数据仓库进行离线分析,或建立自定义索引和缓存,这一点会很有用。这种技术被称为 数据变更捕获(change data capture)。
基于触发器的复制
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果你只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果你需要冲突解决逻辑,则可能需要将复制操作上移到应用程序层。
一些工具,如 Oracle Golden Gate,可以通过读取数据库日志,使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。
触发器允许你将数据更改(写入事务)发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上一些必要的业务逻辑,就可以将数据变更复制到另一个系统去。例如,Databus for Oracle和 Bucardo for Postgres就是这样工作的。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。然而由于其灵活性,它仍然是很有用的。
复制延迟问题
容忍节点故障只是需要复制的一个原因。正如前面提到的,其它原因还包括可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)
。
基于领导者的复制要求所有写入都由单个节点处理,但只读查询可以由任何一个副本来处理。所以对于读多写少的场景(Web 上的常见模式),一个有吸引力的选择是创建很多从库,并将读请求分散到所有的从库上去。这样能减小主库的负载,并允许由附近的副本来处理读请求。
在这种读伸缩(read-scaling)的体系结构中,只需添加更多的从库,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制 —— 如果尝试同步复制到所有从库,则单个节点故障或网络中断将导致整个系统都无法写入
。而且节点越多越有可能出现个别节点宕机的情况,所以完全同步的配置将是非常不可靠的。
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 最终一致性(eventual consistency)。
最终一致性中的 “最终” 一词有意进行了模糊化:总的来说,副本落后的程度是没有限制的。在正常的操作中,复制延迟(replication lag),即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题时,延迟可以轻而易举地超过几秒,甚至达到几分钟。
因为滞后时间太长引入的不一致性,不仅仅是一个理论问题,更是应用设计中会遇到的真实问题。接下来将重点介绍三个在复制延迟时可能发生的问题实例,并简述解决这些问题的一些方法。
读己之写
许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给主库,但是当用户查看数据时,可以通过从库进行读取。如果数据经常被查看,但只是偶尔写入,这是非常合适的。
但对于异步复制,问题就来了。如下图所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,所以他们不高兴是可以理解的。
在这种情况下,我们需要 写后读一致性(read-after-write consistency),也称为 读己之写一致性(read-your-writes consistency)。这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺:其他用户的更新可能稍等才会看到。它保证用户自己的输入已被正确保存。
如何在基于领导者的复制系统中实现写后读一致性?有各种可能的技术,这里说一些:
- 对于用户 可能修改过 的内容,总是从主库读取;这就要求得有办法不通过实际的查询就可以知道用户是否修改了某些东西。举个例子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则就是:总是从主库读取用户自己的档案,如果要读取其他用户的档案就去从库。
如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从主库读取(读伸缩就没效果了)
。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。- 客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟(在这种情况下,时钟同步变得至关重要)。
- 如果你的副本分布在多个数据中心(为了在地理上接近用户或者出于可用性目的),还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。
另一种复杂的情况发生在同一位用户从多个设备(例如桌面浏览器和移动 APP)请求服务的时候。这种情况下可能就需要提供跨设备的写后读一致性:如果用户在一个设备上输入了一些信息,然后在另一个设备上查看,则应该看到他们刚输入的信息。
在这种情况下,还有一些需要考虑的问题:
- 记住用户上次更新时间戳的方法变得更加困难,因为一个设备上运行的程序不知道另一个设备上发生了什么。
需要对这些元数据进行中心化的存储
。 如果副本分布在不同的数据中心,很难保证来自不同设备的连接会路由到同一数据中心
。(例如,用户的台式计算机使用家庭宽带连接,而移动设备使用蜂窝数据网络,则设备的网络路由可能完全不同)。如果你的方法需要读主库,可能首先需要把来自该用户所有设备的请求都路由到同一个数据中心。
单调读
在从异步从库读取时可能发生的异常的第二个例子是用户可能会遇到 时光倒流(moving backward in time)。
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,图 5-4 显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页时每个请求都被路由到一个随机的服务器,这种情况就很有可能发生)。第一个查询返回了最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取到该写入内容。实际上可以认为第二个查询是在比第一个查询更早的时间点上观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户 2345 可能不知道用户 1234 最近添加了评论。但如果用户 2345 先看见用户 1234 的评论,然后又看到它消失,这就会让人觉得非常困惑了。
单调读(monotonic reads)可以保证这种异常不会发生。这是一个比 强一致性(strong consistency) 更弱,但比 最终一致性(eventual consistency) 更强的保证。当读取数据时,你可能会看到一个旧值;单调读仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间回退,也就是说,如果已经读取到较新的数据,后续的读取不会得到更旧的数据。
实现单调读的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)
。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本出现故障,用户的查询将需要重新路由到另一个副本。
复制延迟的解决方案
多主复制
到目前为止,我们只考虑了使用单个主库的复制架构。虽然这是一种常见的方法,但还有其它一些有趣的选择。
基于领导者的复制有一个主要的缺点:只有一个主库,而且所有的写入都必须通过它 。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
基于领导者的复制模型的自然延伸是允许多个节点接受写入
。复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据变更转发给所有其他节点。我们将其称之为 多领导者配置(multi-leader configuration,也称多主、多活复制,即 master-master replication 或 active/active replication)。在这种情况下,每个主库同时是其他主库的从库。
多主复制的应用场景
在单个数据中心内部使用多个主库的配置没有太大意义,因为其导致的复杂性已经超过了能带来的好处。但在一些情况下,这种配置也是合理的。
运维多个数据中心
假如你有一个数据库,副本分散在好几个不同的数据中心(可能会用来容忍单个数据中心的故障,或者为了在地理上更接近用户)。如果使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心
。
多主配置中可以在每个数据中心都有主库
。下图展示了这个架构。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
我们来比较一下在运维多个数据中心时,单主和多主的适应情况:
- 性能:在单主配置中,每个写入都必须穿过互联网,进入主库所在的数据中心。这可能会增加写入时间,并可能违背了设置多个数据中心的初心。
在多主配置中,每个写操作都可以在本地数据中心进行处理,并与其他数据中心异步复制
。因此,数据中心之间的网络延迟对用户来说是透明的,这意味着感觉到的性能可能会更好。 - 容忍数据中心停机:在单主配置中,如果主库所在的数据中心发生故障,故障切换必须使另一个数据中心里的从库成为主库。
在多主配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上
。 - 容忍网络问题:数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对数据中心之间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如 图 5-6 中的 “冲突解决(conflict resolution)”)。接下来会继续介绍。
由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。比如自增主键、触发器、完整性约束等都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免
。
需要离线操作的客户端
多主复制的另一种适用场景是:应用程序在断网之后仍然需要继续工作。
例如,考虑手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。
在这种情况下,每个设备都有一个充当主库的本地数据库
(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。
从架构的角度来看,这种设置实际上与数据中心之间的多主复制类似,每个设备都是一个 “数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多主复制用好是多么困难的一件事。
协同编辑
实时协作编辑应用程序允许多个人同时编辑文档。我们通常不会将协作式编辑视为数据库复制问题,但它与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本(Web 浏览器或客户端应用程序中的文档状态),并异步复制到服务器和编辑同一文档的任何其他用户。
如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
但是,为了加速协作,你可能希望将更改的单位设置得非常小(例如单次按键),并避免锁定。这种方法允许多个用户同时进行编辑,但同时也带来了多主复制的所有挑战,包括需要解决冲突。
处理写入冲突
多主复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
例如,考虑一个由两个用户同时编辑的维基页面,如下图所示。用户 1 将页面的标题从 A 更改为 B,并且用户 2 同时将标题从 A 更改为 C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突。单主数据库中不会出现此问题。
同步与异步冲突检测
在单主数据库中,第二个写入将被阻塞并等待第一个写入完成,或者中止第二个写入事务并强制用户重试。另一方面,在多主配置中,两个写入都是成功的,在稍后的某个时间点才能异步地检测到冲突。那时再来要求用户解决冲突可能为时已晚。
原则上,可以使冲突检测同步 - 即等待写入被复制到所有副本,然后再告诉用户写入成功。但是,通过这样做,你将失去多主复制的主要优点:允许每个副本独立地接受写入。如果你想要同步冲突检测,那么你可能不如直接使用单主复制。
避免冲突:
处理冲突的最简单的策略就是避免它们:如果应用程序可以确保特定记录的所有写入都通过同一个主库,那么冲突就不会发生。由于许多的多主复制实现在处理冲突时处理得相当不好,避免冲突是一个经常被推荐的方法。
例如,在一个用户可以编辑自己数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的主库进行读写。不同的用户可能有不同的 “主” 数据中心(可能根据用户的地理位置选择),但从任何一位用户的角度来看,本质上就是单主配置了。
但是,有时你可能需要更改被指定的主库 —— 可能是因为某个数据中心出现故障,你需要将流量重新路由到另一个数据中心,或者可能是因为用户已经迁移到另一个位置,现在更接近其它的数据中心。在这种情况下,冲突避免将失效,你必须处理不同主库同时写入的可能性。
收敛至一致的状态:
单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在上图中,在主库 1 中标题首先更新为 B 而后更新为 C;在主库 2 中,首先更新为 C,然后更新为 B。两种顺序都不比另一种“更正确”。
如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库 1 的 C 和主库 2 的 B。这是不可接受的,每个复制方案都必须确保数据最终在所有副本中都是相同的。因此,数据库必须以一种 收敛(convergent) 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
实现冲突合并解决有多种途径:
- 给每个写入一个唯一的 ID(例如时间戳、长随机数、UUID 或者键和值的哈希),挑选最高 ID 的写入作为胜利者,并丢弃其他写入。如果使用时间戳,
这种技术被称为 最后写入胜利(LWW, last write wins)
。虽然这种方法很流行,但是很容易造成数据丢失。我们将在本文末尾的 检测并发写入 一节更详细地讨论 LWW。 - 为每个副本分配一个唯一的 ID,ID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
- 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在上图中,合并的标题可能类似于 “B/C”)。
- 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
自定义冲突解决逻辑
解决冲突的最合适的方法可能取决于应用程序,大多数多主复制工具允许使用应用程序代码编写冲突解决逻辑。该代码可以在写入或读取时执行:
写时执行
只要数据库系统检测到复制更改日志中存在冲突,就会调用冲突处理程序。例如,Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户 —— 它在后台进程中运行,并且必须快速执行。
读时执行
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突,并将结果写回数据库。例如 CouchDB 就以这种方式工作。
请注意,冲突解决通常适用于单行记录或单个文档的层面,而不是整个事务。因此,如果你有一个事务会原子性地进行几次不同的写入,对于冲突解决而言,每个写入仍需分开单独考虑。
无主复制
到目前为止所讨论的复制方法 —— 单主复制、多主复制 —— 都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入
。最早的一些的复制数据系统是 无主的(leaderless),但是在关系数据库主导的时代,这个想法几乎已被忘却。在亚马逊将其用于其内部的 Dynamo 系统之后,它再一次成为数据库的一种时尚架构。Riak,Cassandra 和 Voldemort 是受 Dynamo 启发的无主复制模型的开源数据存储,所以这类数据库也被称为 Dynamo 风格。
在一些无主复制的实现中,客户端直接将写入发送到几个副本中,而另一些情况下,由一个 协调者(coordinator) 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
当节点故障时写入数据库
假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于领导者的配置中,如果要继续处理写入,则可能需要执行故障切换)。
另一方面,在无主配置中,不存在故障转移。下图演示了会发生了什么事情:客户端(用户 1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭期间发生的任何写入都不在该节点上。因此,如果你从该节点读取数据,则可能会从响应中拿到陈旧的(过时的)值。
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅把它的请求发送到一个副本:读请求将被并行地发送到多个节点
。客户可能会从不同的节点获得不同的响应,即来自一个节点的最新值和来自另一个节点的陈旧值。版本号将被用于确定哪个值是更新的。
读修复和反熵
复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上它错过的写入?
在 Dynamo 风格的数据存储中经常使用两种机制:
读修复(Read repair)
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,用户 2345 获得了来自副本 3 的版本 6 值和来自副本 1 和 2 的版本 7 值。客户端发现副本 3 具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
反熵过程(Anti-entropy process)
此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本
。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
并不是所有的系统都实现了这两种机制,例如,Voldemort 目前没有反熵过程。请注意,如果没有反熵过程,很少被读取的值可能会从某些副本中丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复
。
想要了解更多无主复制的细节请参考https://github.com/Vonng/ddia/blob/master/ch5.md
总结
我们考察了复制的问题。复制可以用于几个目的:
- 高可用性:即使在一台机器(或多台机器,或整个数据中心)停机的情况下也能保持系统正常运行
- 断开连接的操作:允许应用程序在网络中断时继续工作
- 延迟:将数据放置在地理上距离用户较近的地方,以便用户能够更快地与其交互
- 可伸缩性:通过在副本上读,能够处理比单机更大的读取量
尽管是一个简单的目标 - 在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(这还不包括更隐蔽的故障,例如由于软件错误导致的静默数据损坏)。
我们讨论了复制的三种主要方法:
- 单主复制:客户端将所有写入操作发送到单个节点(主库),该节点将数据更改事件流发送到其他副本(从库)。读取可以在任何副本上执行,但从库的读取结果可能是陈旧的。
- 多主复制:客户端将每个写入发送到几个主库节点之一,其中任何一个主库都可以接受写入。主库将数据更改事件流发送给彼此以及任何从库节点。
- 无主复制:客户端将每个写入发送到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点、网络中断和延迟峰值的情况下,多主复制和无主复制可以更加健壮,其代价是难以推理并且仅提供非常弱的一致性保证。
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制延迟增加和服务器故障时会发生什么,这一点很重要。如果主库失败后你将一个异步更新的从库提升为新的主库,那么最近提交的数据可能会丢失。