一文让你彻底明白分布式系统中的数据复制是怎么一回事?
楔子
本次来聊一聊分布式系统中的数据复制,数据复制意味着在通过网络连接的多台机器上保留相同数据的副本,而这么做的原因无非以下几点:
使得数据与用户在地理上接近(从而减少延迟)即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)扩展可以接受读请求的机器数量(从而提高读取吞吐量)
这里假设你的数据集规模不大,每个节点都可以保存整个数据集的副本。如果数据集过大导致单个节点存不下,就涉及到了数据集的切分(分片),我们以后再聊。
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。但复制的困难之处在于处理复制数据的变更(change),数据一旦改变了,要如何保证所有副本的数据一致性,这也正是本文所要探究的。因此我们接下来将讨论三种流行的变更复制算法:单领导者(single leader),多领导者(multi leader)和无领导者(leaderless),几乎所有分布式数据库都使用这三种方法之一。
此外在复制时需要进行许多权衡,例如是使用同步复制还是异步复制?如何处理失败的副本?这些通常是存储系统中的配置选项,细节虽然因存储系统的不同而有所差异,但原理都是类似的,本文也会讨论这些决策所带来的影响。
领导者与追随者
存储数据副本的每个节点也称为「副本(replica)」,当存在多个副本时,就会不可避免的出现一个问题:如何确保数据落在了所有的副本上?比如我们要修改(还有写入、删除)数据,很明显每一个副本都要修改,否则就会出现副本数据不一致的情况。
很容易想到的一种解决办法是,客户端在修改数据的时候将所有副本中的数据都进行修改,这样不就能保证数据的一致性了吗?没错,这的确是一种解决方案,但问题是这种做法对客户端不够友好,如果有 N 个副本,那么客户端要写入 N 次,而且还要知道这 N 个副本在什么位置。更推荐的做法是,每次修改数据时只写入一个副本,然后系统内部的节点之间对数据进行同步,来保证数据的一致性。最常见的解决方案是被称为「基于领导者的复制(leader-based replication)」,也称「主动 / 被动(active / passive)复制」,或者「主 / 从(master / slave)复制」。其工作原理如下:
多个副本中,有且只有一个副本会被指定为「领导者(leader)」,或者叫「主库(master)」、「首要(primary)」,它是整个分布式系统中的霸道总裁,一切都要以它为准。当客户端要向系统中写入数据时,它必须将请求发给领导者,领导者会将新数据写入本地存储其它副本被称为「追随者(follower)」,或者也被称为「从库(slave)」、「只读副本(read replica)」、「次要(secondary)」、「热备(hot-standby)」。每当领导者将数据写入本地存储时,它也会将数据变更发送给追随者,这个过程就是「复制日志(replication log)」。每个追随者从领导者拉取日志,并相应更新本地的副本,并且顺序和领导者写入的顺序保持一致当客户端想从系统读取数据时,它可以向领导者或追随者查询,但只有领导者才能接收写操作(从客户端的视角来看,追随者都是只读的)
这种复制模式是许多关系数据库的内置功能,如 PostgreSQL(从 9.0 版本开始)、MySQL、Oracle、Data Guard 和 SQL Server 的 AlwaysOn 可用性组。当然它也被用于一些非关系数据库,包括 MongoDB,RethinkDB 和 Espresso。最后,基于领导者的复制并不仅限于数据库:像 Kafka 和 RabbitMQ 高可用队列这样的分布式消息代理也使用它。某些网络文件系统,例如 DRBD 这样的块复制设备也与之类似。
同步复制与异步复制
复制系统的一个重要细节是:复制是同步(synchronously)发生还是异步(asynchronously)发生,在关系型数据库中这通常是一个配置项,其它系统中通常硬编码为其中一个。
我们以 B 站更换头像为例,在某个时间点更换头像的请求会发送给领导者,领导者在收到请求之后的某个时刻又会将数据变更发送给追随者,最后领导者通知客户更新成功。我们用一张图来描述这个过程:

追随者 1(从库 1)的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,领导者(主库)需要等待追随者 1 的确认,确保追随者 1 已经收到数据变更并且完成了同步。追随者 2 的复制是异步的:领导者发送消息,但不等待追随者的响应。另外在上图中,追随者 2 处理消息前存在一个显著的延迟,通常情况下复制的速度是相当快的,像大多数的数据库系统,都能在一秒钟之内向追随者(对数据库而言,我们更习惯叫从库)同步变更。
同步复制的优点是,追随者保证有和领导者一致的最新数据副本,如果领导者突然失效,我们可以确信这些数据仍然能在追随者上找到。但缺点是,如果同步时追随者没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),领导者就无法处理写入操作,此时必须阻止所有写入,并等待同步副本再次可用。因此将所有从库都设置为同步复制是不切实际的,因为任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中一个追随者是同步的,而其它的则是异步的。
这里我们还要再来总结一下同步复制和异步复制,首先领导者会将数据变更同步给追随者,然后追随者再进行同步。如果拿 MySQL 为例的话,就是主库将 binlog 同步给从库(写入 relay log),然后从库再根据 binlog 来同步数据,确保和主库一致。
同步复制:领导者必须确保数据变更已经发给了追随者,并且追随者也已经同步完毕,才能响应客户端异步复制:领导者只需要将数据变更发送给追随者即可,不需要关心追随者有没有收到,更不关心追随者在收到之后有没有将数据同步成功
所以同步复制和异步复制都有各自的缺点。追随者接收到数据变更并进行同步时,领导者是无法对外提供服务的,它必须等到追随者同步完毕才可以。如果追随者因为某些原因(比如此时磁盘紧张,系统高负荷运行等等)迟迟不能写入成功,那么领导者就会出现长时间无法响应;而异步复制则不会出现这种情况,因为领导者将数据变更发送之后就不管了,但很明显这种做法也是有缺点的,那就是如果追随者没有收到数据变更该怎么办?虽然领导者发了,但因为网络原因导致追随者没有接收到。此时就引出来一个新的复制模式:半复制(semi-synchronous)。
我们说数据同步分为两步:第一步是领导者发送变更数据给追随者;第二步是追随者在收到变更数据之后进行数据同步。同步复制是要求领导者必须等待这两步都完成,才可以响应客户端,异步复制则是两步都不关心。而半复制要求的是:领导者只要确认第一步完成(追随者收到数据变更),即可响应客户端。这样既保证了追随者一定能收到数据变更(这个过程很快),又不会陷入长时间的阻塞。
以 MySQL 为例的话,半复制就是:领导者只要保证 binlog 一定发给了追随者即可(只关心第一步),至于后续追随者通过 binlog 回放来同步数据(也就是第二步)什么时候可以完成,领导者并不关心。
不同的复制方式各有优缺点,我们一会儿还会深入讨论。
设置新的追随者(从库)
有时候需要设置一个新的追随者:也许是为了增加副本的数量,或替换失败的节点,那么如何确保新的从库拥有主库数据的精确副本?首先简单地将数据文件从一个节点复制到另一个节点通常是不够的,因为客户端不断向数据库写入数据,数据总是在不断变化,标准的数据副本会在不同的时间点总是不一样,因此复制的结果可能没有任何意义。
当然,我们可以在追随者同步领导者数据时,将整个分布式系统给锁定住,使其不可用于写入,来使磁盘上的文件保持一致,但是这会违背高可用的目标。不过幸运的是,拉起新的追随者通常并不需要停机。从概念上讲,过程如下所示:
在某个时刻获取领导者的一致性快照(如果可能),而不必锁定整个系统。以数据库为例,大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能还需要第三方工具,例如 MySQL 的 innobackupex将快照复制到新的追随者节点追随者(从库)连接到领导者(主库),并拉取快照之后发生的所有数据变更,这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如 PostgreSQL 将其称为日志序列号(log sequence number,LSN),MySQL 将其称为二进制日志坐标(binlog coordinates)当追随者处理完快照之后积压的数据变更,我们说它赶上(caught up)了领导者,现在它可以继续处理主库产生的数据变化了
建立追随者的实际步骤因存储系统而异,在某些系统中,这个过程是完全自动化的,而在另外一些系统中,它可能是一个需要由管理员手动执行的,有点神秘的多步骤工作流。
处理节点宕机
系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护(例如,重启机器以安装内核安全补丁)。对运维而言,能在系统不中断服务的情况下重启单个节点好处多多。我们的目标是即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。换言之就是实现高可用,那么如何基于复制来实现呢?
追随者失效:追赶恢复
在其本地磁盘上,每个追随者记录从领导者收到的数据变更。如果是追随者崩溃并重新启动,或者是领导者和追随者之间的网络暂时中断,那么这种情况是比较容易恢复的。追随者可以从日志中知道,在发生故障之前处理的最后一个事务。因此追随者可以连接到领导者,并请求在断开连接时发生的所有数据变更,当应用完所有这些变化后,它就赶上了领导者,并可以像以前一样继续接收数据变更流。
领导者失效:故障切换
领导者失效处理起来相当棘手:其中一个追随者需要被提升为新的领导者,需要重新配置客户端,以将它们的写操作发送给新的领导者,其他追随者需要开始拉取来自新领导者的数据变更。这个过程被称为「故障切换(failover)」。
故障切换可以手动进行(通知管理员领导者挂了,并采取必要的步骤来创建新的领导者)或自动进行,其实两者所做的事情是一样的,自动切换无非就是将手动切换所需要的步骤自动帮你做。一般切换会有以下几个步骤组成:
确认领导者失效,有很多事情可能会出错:崩溃,停电,网络问题等等,没有万无一失的方法来检测到底出现了什么问题,所以大多数系统只是简单使用超时(Timeout)机制。节点频繁地相互来回传递消息,并且如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭领导者不算)选择⼀个新的领导者,这可以通过选举过程(领导者由剩余副本以多数选举产生)来完成,或者可以由之前选定的控制器节点(controller node)来指定新的领导者。显然,领导者的最佳人选通常是拥有旧领导者最新数据副本的追随者(最小化数据损失)。另外让所有的节点同意一个新的领导者,是⼀个共识问题,我们以后会讨论分布式系统中的一致性与共识重新配置系统以启用新的领导者,客户端现在需要将它们的写请求发送给新领导者。如果老领导者又回来了,可能仍然认为自己是领导者,没有意识到其他副本其实已经让它下台了。那么此时系统就还要再做一步,也就是让老领导者成为新领导者的追随者
整个过程不难理解,可以参考 Redis 的哨兵机制来仔细感受一下这个过程,当然不只是 Redis,任何一个分布式系统在选择新领导者的时候基本都是这个套路。不过看起来虽然很美好,但其实故障切换背后会隐藏许多麻烦,我们逐个说明:
如果使用异步复制,则新领导者可能没有收到老领导者宕机前最后的写入操作。在选出新领导者后,如果老领导者重新加入集群,新领导者在此期间可能会收到冲突的写入,那这些写入该如何处理?最常见的解决方案是简单丢弃老领导者未复制的写入,这很可能打破客户对于数据持久性的期望如果系统的存储部分需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在 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)
我们知道机械磁盘的随机读写是很慢的,但顺序读写很快,所以一般写操作都是通过追加写的方式。像 kafka、MySQL 虽然采用了不同的日志存储,但它们都是追加写。
对于日志结构存储引擎,日志是主要的存储位置。而日志由多个日志段组成,会在后台压缩,并进行垃圾回收,比如 kafka对于覆写单个磁盘块的 B 树,每次修改都会先写入预写式日志(Write Ahead Log,WAL),以便崩溃后索引可以恢复到一个一致的状态,比如 MySQL
在任何一种情况下,日志都是包含所有数据库(或者其它存储)写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,领导者还可以通过网络将其发送给其追随者。而每一次修改都会先记录到日志中,然后再操作实际的数据,那么该日志就被称为预写式日志(WAL)。比如将 id 为 100 的 age 字段的值从 17 改成 18,这一修改必须先记录到 WAL 中(比如 MySQL 的 binlog),然后才能更改实际的 age 字段。
MySQL、PostgreSQL 和 Oracle 等使用这种复制方法,该方法的主要缺点是日志记录的数据非常底层:WAL 包含哪些磁盘块中的哪些字节发生了更改,这使复制与存储引擎紧密耦合。比如低版本 MySQL 的日志无法被高版本 MySQL 解析。
看上去这可能只是一个微小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许追随者使用比领导者更新的软件版本,则可以先升级追随者,然后执行故障切换,使升级后的节点之一成为新的领导者,从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配(传输 WAL 经常出现这种情况), 则此类升级需要停机。
逻辑日志复制(基于行)
另一种方法是,复制和存储引擎使用不同的日志格式,这样可以使复制日志从存储引擎内部分离出来。这种复制日志被称为逻辑日志,以将其与存储引擎的(物理)数据表示区分开来。关系数据库的逻辑日志通常是以行的粒度描述对数据库表写入的记录序列:
对于插入的行,日志包含所有列的新值对于删除的行,日志包含足够的信息来唯一标识已删除的行,通常是主键,但是如果表上没有主键,则需要记录所有列的旧值对于更新的行,日志包含足够的信息来唯一标识更新的行,以及所有列的新值(或至少所有已更改的列的新值)
修改多行的事务会生成多个这样的日志记录,然后后面跟着一条记录,指出事务已经提交。MySQL的 binlog(当配置 binlog_format=row 时)使用这种方法,记录的是变更之后的纯数据,它的特点就是保证数据的绝对一致性。但缺点就是会使得日志膨胀,比如一个 update 语句更新了 100 万行,那么 binlog 就会记录变更之后的 100 万行数据。如果 binlog_format=statement,那么记录就只是一个 update 语句,就是我们上面说的 "基于语句的复制",而它的缺点显然更致命,就是使用了 rand、uuid、now 等函数之后会导致数据不一致。所以 MySQL 还支持 binlog_format=mixed,而 mixed 从名字上来看你一定已经知道了。
注意:不管 binlog_format 设置为哪一种格式,因为都是在修改数据之前,先将修改的内容追加写入 binlog,所以 binlog 仍然也属于 WAL。
通过逻辑日志与存储引擎内部分离(记录的是纯数据),因此可以更容易地保持向后兼容,从而使领导者和跟随者能够运行不同版本的数据库软件甚至不同的存储引擎。
另外对于外部应用程序来说,基于行的逻辑日志格式也更容易解析,因为发送到外部系统的内容是纯数据。这一点很有用,例如我们要使用数仓对 MySQL 的数据进行分析,那么第一步就是将数据从 MySQL 复制到数据仓库中,通过 Maxwell、Flink CDC、Canal 可以订阅 binlog,然后将数据拷贝过去,因此这意味着 binlog 中记录的应该是数据,如果是 SQL 语句的话,拷贝过去了也没有用。
而订阅变更日志的这种技术被称为变更数据捕获(change data capture,CDC),在保证数据库和缓存一致性的时候,通常会采用这种做法。
基于触发器的复制
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,或许这就是你目前想要的,但在某些情况下你可能需要更多的灵活性。例如,如果你只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者需要冲突解决逻辑,则可能需要将复制移动到应用程序层。
一些工具,如 Oracle Golden Gate,可以通过读取数据库日志,使得其他应用程序可以使用数 据。另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。
触发器允许你注册的数据库系统中发生数据更改(写入事务)时自动执行的自定义应用程序代码,触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上任何业务逻辑处理,然后将数据变更复制到另一个系统去。例如 Databus for Oracle 和 Bucardo for Postgres 就是这样工作的。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库的内置复制更容易出错,也有很多限制。然而由于其灵活性,仍然是很有用的。但是说实话,触发器本身其实用的并不多,了解一下即可。
复制延迟问题
容忍节点故障只是需要复制的一个原因,还有两个原因是可扩展性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
基于领导者的复制要求所有写入都由单个节点(领导者节点)处理,但只读查询可以由任何副本处理。所以对于读多写少的场景(Web 上的常见模式),一个有吸引力的选择是创建很多追随者,并将读请求分散到所有的追随者上去。这样能减小领导者的负载,并允许向最近的副本发送读请求。
在这种扩展体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是这种方法实际上只适用于异步复制,如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置是非常不可靠的。并且不幸的是,当应用程序从异步追随者读取时,如果追随者落后,它可能会看到过时的信息,这会导致系统中出现明显的不一致:同时对领导者和追随者执行相同的查询,可能得到不同的结果,因为并非所有的写入都会立刻反映在追随者中。但这种不一致只是一个暂时的状态,如果停止向系统写入数据并等待一段时间,追随者最终会赶上并与领导者保持一致。出于这个原因,这种效应被称为最终一致性(eventually consistency)。
其实 "最终" 一词故意含糊不清:总的来说,副本落后的程度是没有限制的。在正常的操作中,复制延迟 (replication lag),即写入领导者到反映至追随者之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题,延迟可以轻而易举地超过几秒,甚至几分钟。
因为滞后时间太长引入的不一致性,可不仅是一个理论问题,更是应用设计中会遇到的真实问题。下面将重点介绍三个由复制延迟问题的例子,并简述解决这些问题的一些方法。
读己之写
许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给领导者,但是当用户查看数据时, 可以从追随者读取。如果数据经常被查看,但只是偶尔写入,这是非常合适的。但对于异步复制,问题就来了,如下图所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,那么用户就会不高兴,当然这是可以理解的。

用户写入后从旧副本中读取数据,在这种情况下,我们需要读写一致性(read-after-write consistency)、或者叫写后读一致性,来防止这种现象发生,另外也被称为读己之写一致性(read-your-writes consistency)。这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。至于其他用户的更新,可能稍后才会看到。
如何在基于领导者的复制系统中实现读写一致性呢?有各种可能的技术,这里说一些:
读取用户可能已经修改过的内容时,都从领导者读;这就要求有一些方法,不用实际查询就可以知道用户是否修改了某些东西。举个栗子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则是:从领导者读取用户自己的档案,在追随者读取其他用户的档案如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从领导者读取(扩容读就没效果了),在这种情况下可以使用其他标准来决定是否从领导者读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从领导者读客户端可以记住最近一次写入的时间戳,系统需要确保追随者为该用户提供任何查询时,在该时间戳之前的变更都已经传播到了追随者中。如果当前追随者不够新,则可以从另一个追随者当中读取,或者等待追随者追赶上来。注意:时间戳可以是逻辑时间戳(指示写⼊顺序的东西,例如日志序列号)或实际系统时钟(在这种情况下,时钟同步变得至关重要)如果你的副本分布在多个数据中心(出于可用性目的与用户尽量在地理上接近),则会增加复杂性,任何需要由领导者提供服务的请求都必须路由到包含领导者的数据中心
另一种复杂的情况是:如果同一个用户从多个设备请求服务,例如桌面浏览器和移动 APP。这种情况下可能就需要提供跨设备的写后读一致性:如果用户在某个设备上输入了一些信息,然后在另一个设备上 查看,则应该看到他们刚输入的信息。在这种情况下,还有一些需要考虑的问题:
记住用户上次更新时间戳的方法变得更加困难,因为一台设备上运行的程序不知道另一台设备上发生了什么。元数据需要一个数据中心存储如果副本分布在不同的数据中心,很难保证来自不同设备的连接会路由到同一数据中心。例如用户的台式计算机使用家庭宽带连接,而移动设备使用蜂窝数据网络,那么设备的网络路线可能完全不同。如果你的方法需要读领导者,可能首先需要把来自同一用户的请求路由到同⼀个数据中心
单调读
从异步从库读取第二个异常例子是,用户可能会遇到时光倒流(moving backward in time)。
如果用户从不同追随者进行多次读取,就可能发生这种情况。例如下图显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的追随者,然后是一个延迟较大的追随者。如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能发生的。第一个查询返回最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的追随者还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户 2345 可能不知道用户 1234 最近添加了评论。但如果用户 2345 先看见用户 1234 的评论,然后又看到它消失,那么对于用户 2345,就很让人头大了。

用户首先从新副本读取,然后从旧副本读取,第一次返回结果,第二次没有返回,引发时光倒流。为了防止这种异常,我们需要单调的读取。单调读(Monotonic reads)是这种异常不会发生的保证,这是一个比强一致性(strong consistency)更弱,但比最终一致性(eventually consistency)更强的保证。当读取数据时,你可能会看到一个旧值;单调读取仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间后退。也就是如果先前读取到较新的数据,后续读取不会得到更旧的数据。
相信实现单调读取方法你已经猜到了,那就是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本中读取)。例如可以基于用户 ID 的散列来选择副本,而不是随机选择副本,但如果该副本失败,那么用户的查询将需要重新路由到另一个副本。
一致前缀读
这个复制延迟的例子违反了因果律,想象一下 Poons 先生和 Cake 夫人之间的以下简短对话:
Mr. Poons:Mrs. Cake,你能看到多远的未来?
Mrs. Cake:通常约十秒钟,Mr. Poons
这两句话之间有因果关系:Cake 夫人听到了 Poons 先生的问题并回答了这个问题。现在想象第三个人正在通过追随者来听这个对话。 Cake 夫人说的内容是从一个延迟很低的追随者读取的,但 Poons 先生所说的内容,是从高延迟的追随者读取的。 于是这个观察者会听到以下内容:
Mrs. Cake:通常约十秒钟,Mr. Poons
Mr. Poons:Mrs. Cake,你能看到多远的未来?
对于观察者来说,看起来好像 Cake 夫人在 Poons 先生发问前就回答了这个问题,这种超能力让人印象深刻,但也会把人搞糊涂。

如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会先看到答案。要防止这种异常,就需要另一种类型的保证:一致前缀读(consistent prefix reads)。这个保证说:如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。
这是分区(partitioned)、也叫分片(sharded)数据库中的一个特殊问题,以后会单独讨论。如果数据库总是以相同的顺序应用写入,则读取总是会看到一致的前缀,所以这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在全局写入顺序:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,同样以后讨论。
复制延迟的解决方案
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果这对于用户来说是不好的体验,那么设计系统来提供更强的保证是很重要的,例如写后读。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。
如前所述,应用程序可以提供比底层数据库更强有力的保证,例如通过领导者进行某种读取。但在应用程序代码中处理这些问题是复杂的,容易出错。如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库"做了正确的事情",那该多好啊。这就是事务(transaction)存在的原因:数据库通过事务提供强大的保证,所以应用程序可以更加简单。
另外单节点事务已经存在了很长时间,然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务。 声称事务在性能和可用性上的代价太高,并断言在可扩展系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,至于更复杂的细节我们同样以后讨论。
多主复制
到目前为止,我们只考虑使用单个领导者的复制架构,这种架构有一个缺点:只有一个领导者,而所有的写入都必须通过它。如果出于任何原因 (例如和领导者之间的网络连接中断)无法连接到领导者, 就无法向数据库等存储系统中写入数据。
而基于领导者的复制模型的自然延伸是允许多个节点接受写入,复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其他节点,我们称之为多领导者配置(也称多主、多活复制)。在这种情况下,每个领导者同时也会扮演其他领导者的追随者,比如有 A、B、C 三个领导者,当数据写入 A 时,那么 B、C 就是 A 的追随者;当数据写入 B 时,那么 A、C 就是 B 的追随者。
多主复制的应用场景
首先在单个数据中心内部使用多个主库是没有太大意义的,因为复杂性要大于带来的好处。
运维多个数据中心
假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。使用常规的基于领导者的复制设置,领导者必须位于其中一个数据中心,且所有写入都必须经过该数据中心。而多领导者配置,可以在每个数据中心都有领导者、或者说主库。下图展示了这个架构,在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的领导者都会将其更改复制到其他数据中心的领导者中。

我们来比较一下在运维多个数据中心时,单主和多主的适应情况。
性能
在单活配置中,每个写入都必须穿过互联网,进入领导者所在的数据中心。这可能会增加写入时间,并可能违背了设置多个数据中心的初衷。在多活配置中,每个写操作都可以在本地数据中心进行处理,并与其他数据中心异步复制。因此,数据中心之间的网络延迟对用户来说是透明的,这意味着感觉到的性能可能会更好。
容忍数据中心停机
在单主配置中,如果领导者所在的数据中心发生故障,故障切换可以使另一个数据中心中的追随者成为领导者。在多活配置中,每个数据中心都可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。
容忍网络问题
数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对数据中心间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多活配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于 MySQL 的 Tungsten Replicator,用于 PostgreSQL 的 BDR 以及用于 Oracle 的 GoldenGate。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,因此写冲突是必须解决的。关于处理写入冲突,我们一会就来详细讨论。
由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等,都可能会有麻烦。因此多主复制往往被认为是危险的领域,应尽可能避免。
需要离线操作的客户端
多主复制的另一种使用场景是:应用程序在断网之后仍然需要继续工作。例如手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你都需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。
在这种情况下,每个设备都有一个充当领导者的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个数据中心,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多活配好是多么困难的一件事。但有一些工具旨在使这种多领导者配置更容易,例如 CouchDB 就是为这种操作模式而设计的。
协同编辑
实时协作编辑应用程序允许多个人同时编辑⽂档,例如 Etherpad 和 Google Docs 允许多人同时编辑文本文档或电子表格。我们通常不会将协作式编辑视为数据库复制问题,但与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本(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)。虽然这种方法很流行,但是很容易造成数据丢失为每个副本分配一个唯一的 ID,ID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失以某种方式将这些值合并在⼀起,例如按字母顺序排序,然后连接它们,在上图中合并的标题可能类似于 "B/C"在保留所有信息的显式数据结构中记录冲突,并编写解决冲突的应用程序代码
自定义冲突解决逻辑
作为解决冲突最合适的方法可能取决于应用程序,大多数多主复制工具允许使用应用程序代码编写冲突解决逻辑。该代码可以在写入或读取时执行:
写时执行
只要数据库系统检测到复制更改日志中存在冲突,就会调用冲突处理程序。例如,Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户它在后台进程中运行,并且必须快速执行。
读时执行
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可能会提示用户或自动解决冲突,并将结果写回数据库。例如,CouchDB 就以这种方式工作。
请注意,冲突解决通常适用于单个行或文档层面,而不是整个事务。因此,如果你有一个事务会 原子性地进行几次不同的写入,则对于冲突解决而言,每个写入仍需分开单独考虑。
多主复制拓扑
复制拓扑描述了数据写入从一个节点传播到另一个节点的通信路径,如果你有两个领导者,只有一个合理的拓扑结构:领导者 1 必须把他所有的数据都写到领导者 2,反之亦然。有两个以上的领导者,不同的拓扑就有多种可能了。如下图所示:

最普遍的拓扑是全部到全部,其中每个领导者将数据写入所有的其他领导者。当然也会使用更多受限制的拓扑:例如默认情况下,MySQL 仅支持环形拓扑(circular topology),其中每个节点接收来自上一个节点的写入,并将这些写入(加上自己的写入)转发给另一个节点。另一种流行的拓扑结构具有星形的形状,每个指定的根节点将数据写入转发给所有其他子节点,当然星型拓扑可以推广到树。
在圆形和星形拓扑中,数据写入可能需要在到达所有副本之前通过多个节点,因此节点需要转发从其他节点收到的数据更改。为了防止无限复制循环,每个节点会被赋予一个唯一的标识符,并且在复制日志中, 每个写入都被标记了所有已经通过的节点的标识符。当一个节点收到用自己的标识符标记的数据更改时,该数据更改将被忽略,因为节点知道它已经被处理。
循环和星型拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。
另一方面,全能拓扑也可能有问题。特别是一些网络链接可能比其他网络链接更快(比如由于网络拥塞),结果是一些复制消息可能"超过"其他复制消息,如下图所示。

客户端 A 向 Leader 1 的表中插入一行,客户端 B 在 Leader 3 上更新该行。然而 Leader 2 能够以不同的顺序接收写入:它可以首先接收更新(从它的角度度来看,是对数据库中不存在的行进行更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。
这是一个因果关系的问题,类似于我们在"一致前缀读"中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,所以 Leader 2 无法百分百正确地排序这些事件。
要正确排序这些事件,可以使用一种称为版本向量(version vectors)的技术,但冲突检测技术在很多的多领导者复制系统中执行得不好。如果你正在使用具有多领导者复制功能的系统,那么应该了解这些问题,仔细阅读文档,并彻底测试你的数据库,以确保它确实提供了你认为具有的保证。
无主复制
我们在本章到目前为止所讨论的复制方法,单主复制、多主复制都是一样的做法:客户端向一个领导者发送写请求,而数据库系统负责将数据写入复制到其他副本。领导者决定写入的顺序,而追随者按相同顺序应用领导者的写入。
而一些数据存储系统采用了不同的方法,其放弃了领导者的概念,并允许任何副本直接接受来自客户端的写入。最早的一些复制数据系统是无领导者的(leaderless),但是在关系数据库主导的时代,这个想法几乎已被忘却。但在亚马逊将其用于其内部的 Dynamo 系统之后,它再一次成为数据库的一种时尚架构。 Riak、Cassandra 和 Voldemort 是由 Dynamo 启发的无领导复制模型的开源数据存储,所以这类数据库也被称为 Dynamo 风格。
在一些无领导者的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个协调者(coordinator)节点代表客户端进行写入。但不同的是协调者不执行特定的写入顺序,我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
当节点故障时写入数据库
假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换。
而在无领导配置中,故障切换不存在。比如下图显示的那样:客户端(用户 1234)并行发送写入到所有的三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。因此,客户简单地忽略了其中一个副本错过了写入的事实。

现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果你从该节点读取数据,则可能会将陈旧(过时)的值视为响应。
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本,读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应,即来自一个节点的最新值和来自另一个节点的陈旧值,版本号用于确定哪个值更新。
读修复和反熵
复制方案应确保最终将所有数据复制到每个副本,在一个不可用的节点重新联机之后,它如何赶上它错过的写入?在 Dynamo 风格的数据存储中经常使用两种机制:
读修复(Read repair)
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如在上图中,用户 2345 获得了来自 Replica 3 的版本号为 6 的值和来自 Replica 1 和 2 的版本号为 7 的值。客户端发现 Replica 3 具有陈旧值,那么会将新值写回。这种方法适用于频繁阅读的值。
反熵过程(Anti-entropy process)
此外一些数据存储具有后台进程,该进程不断查找副本之间的数据差异(随机挑选两个副本),并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
并不是所有的系统都实现了这两种机制,例如 Voldemort 目前没有反熵过程。请注意,如果没有反熵过程, 某些副本中很少读取的值可能会丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。
读写的法定人数
还是上面那张图,我们认为即使仅在三个副本中的两个副本上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样呢?
如果我们知道,每个成功的写操作意味着在三个副本中至少写入两个,那么这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。
更一般地说,如果有 n 个副本,每个写入必须由 w 个节点确认才能被认为是成功的,并且我们必须至少为每个读取查询 r 个节点(在我们的例子中,\(n = 3,w = 2,r = 2\))。只要 \(w + r> n\),我们在读取时就能获得最新的值,因为 r 个读取中至少有一个节点是最新的。遵循这些 r 值和 w 值的读写就被称为法定人数(quorum)的读和写。你可以认为,r 和 w 是有效读写所需的最低票数。
在 Dynamo 风格的数据库中,参数 n,w 和 r 通常是可配置的。一个常见面选择是使 n 为奇数(通常为 3 或 5)并设置 \(w = r =(n + 1)/ 2\)(向上取整)。但是可以根据需要更改数字,例如设置 \(w = n\) 和 \(r = 1\) ,那么写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有"只要有一个失败节点,就会导致所有数据写入失败"的缺点。
集群中可能有多于 n 的节点,(集群的机器数可能多于副本数目),但是任何给定的值只能存储在 n 个节点上。这允许对数据集进行分区,从而支持可以放在一个节点上的数据集更大的数据集。
仲裁条件 \(w + r> n\) 允许系统容忍不可用的节点,如下所示:
- 如果 \(w <n\),如果节点不可用,我们仍然可以处理写入。
- 如果 \(r <n\),如果节点不可用,我们仍然可以处理读取。
- 对于 \(n = 3,w = 2,r = 2\),我们可以容忍一个不可用的节点。
- 对于 \(n = 5,w = 3,r = 3\),我们可以容忍两个不可用的节点,如下图所示。
- 通常,读取和写入操作始终并行发送到所有 n 个副本,而参数 w 和 r 决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功(在所有节点可用的情况下)。

如果 \(w + r > n\),读取 r 个副本,至少有一个副本必然包含了最近的成功写入。但如果节点宕机,少于所需的 w 或 r 节点可用,则写入或读取将返回错误。由于许多原因,节点可能不可用:由于执行操作的错误(比如磁盘已满而无法写入)导致节点关闭(崩溃,关闭电源),由于客户端和服务器之间的网络中断节点,或任何其他原因。但我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。
仲裁一致性的局限性
如果你有 n 个副本,并且你选择 w 和 r,使得 \(w + r> n\),那么你读取的节点中至少有一个具有最新值的节点,因为你写的节点集合和你读过的节点集合一定会有重叠。通常 r 和 w 被选为多数(超过 \(n/2\) )节点,因为这确保了 \(w + r > n\),同时仍然容忍多达 \(n/2\) 个节点故障。但是法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性。
你也可以将 w 和 r 设置为较小的数字,以使 \(w + r≤n\)(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到 n 个节点,但操作成功只需要少量的成功响应。
较小的 w 和 r 更有可能会读取过时的数据,因为你的读取更有可能不包含具有最新值的节点。另一方面, 这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则可以继续处理读取和写入的机会更大(因为 w 和 r 更小)。只有当可达副本的数量低于 w 或 r 时,数据库才分别变得不可用于写入或读取。
但是,即使在 \(w + r> n\) 的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
如果使用松散的法定人数(一会说),w 个写入和 r 个读取落在完全不同的节点上,因此 r 节点和 w 之间不再保证有重叠节点如果两个写入同时发生,不清楚哪⼀个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(一会说)。如果根据时间戳(最后写入胜利)挑选出胜者,则由于时钟偏差,写入可能会丢失如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取是返回旧值还是新值如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于 w 个副本上写入成功,所以整体判定写入失败。但整体写入失败并没有在写入成功的副本上回滚,这意味着如果一个写入虽然报告失败,后续的读取仍然可能会读取这次失败写入的值如果携带新值的节点失败,需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复, 则存储新值的副本数可能会低于 w,从而打破法定人数条件即使⼀切工作正常,有时也会不幸地出现关于时序(timing)的边缘情况
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数 w 和 r 来调整读取陈旧值的概率,但把它们当成绝对的保证是不明智的。更强有力的保证通常需要事务或共识,以后说。
监控陈旧度
从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,你也需要了解复制的健康状况。如果显著落后,应该提醒你,以便你可以调查原因(例如,网络中的问题或超载节点)。
对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,你可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入次数)。通过从领导者的当前位置中减去随从者的当前位置,你可以测量复制滞后量。然而在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么当访问一个很少被读取的值时,可能会返回一个非常老的值,因为只有读的时候才会修复。
目前已经有一些关于衡量无主复制数据库中的复制陈旧度的研究,并根据参数 n,w 和 r 来预测陈旧读取的预期百分比。不幸的是,这还不是很常见的做法,但是将过时测量值包含在数据库的标准度量标准中是一件好事。最终的一致性是故意模糊的保证,但是对于可操作性来说,能够量化"最终"是很重要的。
检测并发写入
Dynamo 风格的数据库允许多个客户端同时写入相同的 Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似,但在 Dynamo 样式的数据库中,在读修复期间也可能会产生冲突。
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,下图显示了两个客户机 A 和 B 同时写入三节点数据存储区中的键 X:
节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入节点 3 首先接收来自 B 的写入,然后从 A 写入

如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如上图中的最终获取请求所示:节点 2 认为 X 的最终值是 B,而其他节点认为值是 A 。为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发⼈员)需要知道很多有关数据库冲突处理的内部信息。
总结
本文我们考察了复制的问题,复制可以用于几个目的:
高可用性: 即使在一台机器(或多台机器,或整个数据中心)停机的情况下也能保持系统正常运行。
断开连接的操作: 允许应用程序在网络中断时继续工作。
延迟: 将数据放置在距离用户较近的地方,以便用户能够更快地与其交互。
可扩展性: 能够处理比单个机器更高的读取量。
尽管是一个简单的目标,在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(甚至不考虑更隐蔽的故障,例如由于软件错误导致的无提示数据损坏)。
我们讨论了复制的三种主要方法:
单主复制:
客户端将所有写入操作发送到单个节点(领导者),该节点将数据更改事件流发送到其他副本(追随者)。读取可以在任何副本上执行,但从追随者读取的数据可能是陈旧的。
多主复制:
客户端发送每个写入到几个领导节点之一,其中任何一个都可以接受写入,然后接收写入的领导者将数据更改事件流发送给其它的领导者以及该领导者的所有追随者。
无主复制:
客户端发送每个写入到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。 每种方法都有优点和缺点,单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价。
复制可以是同步的,也可以是异步的,在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是在复制滞后增加和服务器故障时要弄清楚会发生什么,这一点很重要。如果一个领导者失败了,并且你推动一个异步更新的追随者成为新的领导者,那么最近承诺的数据可能会丢失。
我们还研究了一些可能由复制滞后引起的奇怪效应,并且讨论了一些有助于决定应用程序在复制滞后时的行为的⼀致性模型:
写后读:
用户应该总是看到自己提交的数据。
单调读:
用户在一个时间点看到数据后,后续只能看到更新的数据,不会看到旧数据。
一致前缀读:
用户应该将数据视为具有因果意义的状态:例如,按照正确的顺序查看问题及其答复。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏

浙公网安备 33010602011771号