Skip to content

Latest commit

 

History

History
155 lines (86 loc) · 19.1 KB

5.1. 数据复制(1).md

File metadata and controls

155 lines (86 loc) · 19.1 KB

本章我们将假设数据规模比较小,集群的每一台机器都可以保存数据集的完整副本。在接下来的第 6 章中,我们放宽这一假设,讨论单台机器无法容纳整个数据集的情况(即必须分区)。在后面的章节中,我们还将讨论复制过程中可能出现的各种故障,以及该如何处理这些故障。

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

复制技术存在许多需要折中考虑的地方,例如采用==同步复制==还是==异步复制==,以及==如何处理失败的副本==等。数据库通常采用可配置选项来调整这些处理策略,虽然在处理细节方面因数据库实现而异,但存在一些通用的一般性原则。本章我们还将在讨论不同选项可能出现的后果。

主节点与从节点

image-20220115161753662

同步复制与异步复制

image-20220115162024912

图 5-2 中,从节点 1 的复制是同步的,即主节点需等待直到从节点 1 确认完成了写入,然后才会向用户报告完成,并且将最新的写入对其他客户端可见。而从节点 2 的复制是异步的:主节点发送完消息之后立即返回,不用等待从节点 2 的完成确认。

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

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

主从复制还经常会被配置为全异步模式。此时如果主节点发生失败且不可恢复,则所有尚未复制到从节点的写请求都会丢失。但全异步配置的优点则是,不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的吞吐性能更好。

配置新的从节点

当需要增加副本数以提高容错能力,或者替换失败的副本,就需要考虑增加新的从节点。但如何确保新的从节点和主节点保持数据一致呢?

简单地将数据文件从一个节点复制到另一个节点通常是不够的。主要是因为客户端仍在不断向数据库写入新数据,数据始终处于不断变化之中,因此常规的文件拷贝方式将会导致不同节点上呈现出不同时间点的数据,这不是我们所期待的。

我们可以做到在不停机、数据服务不中断的前提下完成从节点的设置。逻辑上的主要操作步骤如下:

  1. 在某个时间点对主节点的数据副本产生一个一致性快照,这样避免长时间锁定整个数据库。目前大多数数据库都支持此功能,快照也是系统备份所必需的。而在某些情况下,可能需要第三方工具,如 MySQL 的 innobackupex。
  2. 将此快照拷贝到新的从节点。
  3. 从节点连接到主节点并请求快照点之后所发生的数据更改日志。因为在第一步创建快照时,快照与系统复制日志的某个确定位置相关联,这个位置信息在不同的系统有不同的称呼,如 PostgreSQL 将其称为 "log sequence number"(日志序列号),而 MySQL 将其称为 "binlog coordinates" 。
  4. 获得日志之后,从节点来应用这些快照点之后所有数据变更,这个过程称之为追赶。

处理节点失效

如何通过主从复制技术来实现系统高可用呢?

从节点失效:追赶式恢复

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

主节点失效:节点切换

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

自动切换的步骤通常如下:

  1. 确认主节点失效。大多数系统都采用了基于超时的机制。
  2. 选举新的主节点。可以通过选举的方式(超过多数的节点达成共识)来选举新的主节点,或者由之前选定的某控制节点来指定新的主节点。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险。让所有节点同意新的主节点是个典型的共识问题,会在第 9 章详细讨论。
  3. 重新配置系统使新主节点生效。如果原主节点之后重新上线,可能仍然自认为是主节点,而没有意识到其他节点已经达成共识迫使其下台。这时系统要 确保原主节点降级为从节点,并认可新的主节点。

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

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

复制日志的实现

基于语句的复制

这种复制方式有一些不适用的场景:

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

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

基于预写日志(WAL)传输

在第 3 章中,我们讨论了存储引擎的磁盘数据结构,通常每个写操作都是以追加写的方式写入到日志中,所有对数据库写入的字节序列都被记入日志。因此可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,将其发送给从节点。从节点收到日志进行处理,建立和主节点内容完全相同的数据本。

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

看起来这似乎只是个有关实现方面的小细节,但可能对运营产生巨大的影响。如果复制协议允许从节点的软件版本比主节点更新,则可以实现数据库软件的不停机升级:首先升级从节点,然后执行主节点切换,使升级后的从节点成为新的主节点。相反,复制协议如果要求版本必须严格一致(例如 WAL 传输),那么就势必以停机为代价。

基于行(row-based)的逻辑日志复制

另一种方法是复制和存储引擎采用不同的日志格式,这样复制与存储逻辑剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。

关系数据库的逻辑日志通常是指一系列记录来描述数据表行级别的写请求:

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

如果一个事务涉及多行的修改,则会产生多个这样的日志记录,并在后面跟着一条记录,指出该事务已经提交。MySQL 的二进制日志 binlog(当配置为基于行的复制时)使用该方式。

对于外部应用程序来说,逻辑日志格式也更容易解析。==如果要将数据库的内容发送到外部系统(如用于离线分析的数据仓库),或构建自定义索引和缓存等,基于逻辑日志的复制更有优势==。

基于触发器的复制

触发器支持注册自己的应用层代码,使得当数据库系统发生数据更改(写事务)时自动执行上述自定义代码。通过触发器技术,可以将数据更改记录到一个单独的表中,然后外部处理逻辑访问该表,实施必要的自定义应用层逻辑,例如将数据更改复制到另一个系统。Oracle 的 Databus 和 Postgres 的 Bucardo 就是这种技术的典型代表。

基于触发器的复制通常比其他复制方式==开销更高==,也比数据库内置复制更容易出错,或者暴露一些限制。然而,其==高度灵活性==仍有用武之地。

复制滞后问题

主从复制要求所有写请求都经由主节点,而任何副本只能接受只读查询。对于读操作密集的负载(如 Web) ,这是一个不错的选择:创建多个从副本,将读请求分发给这些从副本,从而减轻主节点负载并允许读取请求就近满足。

在这种扩展体系下,只需添加更多的从副本,就可以提高读请求的服务吞吐最。但是,==这种方法实际上只能用于异步复制,如果试图同步复制所有的从副本,则单个节点故障或网络中断将使整个系统无法写入==。而且节点越多,发生故障的概率越高,所以完全同步的配置现实中反而非常不可靠。

不幸的是,如果一个应用正好从一个异步的从节点读取数据,而该副本落后于主节点,则应用可能会读到过期的信息。这会导致数据库中出现明显的不一致:由于并非所有的写入都反映在从副本上,如果同时对主节点和从节点发起相同的查询,可能会得到不同的结果。

当滞后时间太长时,导致的不一致性不仅仅是一个理论存在的问题,而是个实实在在的现实问题。在本节中,我们将重点介绍三个复制滞后可能出现的问题,并给出相应的解决思路。

读自己的写(Reading Your Own Writes)⭐

image-20220115211600467

用户发起写请求,然后在滞后的副本上读数据对用户来讲,看起来似乎是刚刚提交的数据丢失了。

对于这种情况,我们需要 “写后读一致性”,也称为读写一致性。该机制==保证如果用户重新加载页面,他们总能看到自己最近提交的更新==。==但对其他用户则没有任何保证,这些用户的更新可能会在稍后才能刷新看到==。

  • 如果用户访问可能会被修改的内容,从主节点读取;否则,在从节点读取。这背后就要求有一些方法在实际执行查询之前,就已经知道内容是否可能会被修改。例如,社交网络上的用户首页信息通常只能由所有者编辑,而其他人无法编辑。因此,这就形成一个简单的规则:总是从主节点读取用户自己的首页配置文件,而在从节点读取其他用户的配置文件。
  • 如果应用的大部分内容都可能被所有用户修改,那么上述方法将不太有效,它会导致大部分内容都必须经由主节点,这就丧失了读操作的扩展性。此时需要其他方案来判断是否从主节点读取。例如,跟踪最近更新的时间,如果更新后一分钟之内,则总是在主节点读取;并监控从节点的复制滞后程度,避免从那些滞后时间超过一分钟的从节点读取。
  • 客户端还可以记住最近更新时的时间戳,并附带在读请求中,据此信息,系统可以确保对该用户提供读服务时都应该至少包含了该时间戳的更新。如果不够新,要么交由另一个副本来处理,要么等待直到副本接收到了最近的更新。时间戳可以是逻辑时间戳(例如用来指示写入顺序的日志序列号)或实际系统时钟(在这种情况下,时钟同步又称为一个关键点,请参阅第 8 章 “不可靠的时钟”)。
  • 如果副本分布在多数据中心(例如考虑与用户的地理接近,以及高可用性),情况会更复杂些。必须先把请求路由到主节点所在的数据中心(该数据中心可能离用户很远)。

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

单调读(Monotonic Reads)⭐

image-20220115212545856

在前面异步复制读异常的第二个例子里,出现了用户数据向后回滚的奇怪情况。

第一个查询返回了最近用户 1234 所添加的评论,但第二个查询因为滞后的原因,还没有收到更新因而返回结果是空。实际上,第二个查询结果代表了更早时间点的状态。如果第一个查询没有返回任何内容,用户 2345 并不知道用户 1234 最近的评论,情况还不算太糟糕,但当用户 2345 看到了用户 1234 的评论之后,紧接着评论又消失了,他就会感觉很困惑。

==单调读==(monotonic reads)一致性可以确保不会发生这种异常。这是一个比强一致性弱,但比最终一致性强的保证。当读取数据时,单调读保证,如果某个用户依次进行多次读取,则他绝不会看到回滚现象,即在读取较新值之后又发生读旧值的情况。

==实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读取(而不同的用户可以从不同的副本读取)==。例如,==基于用户 ID 的啥希的方法而不是随机选择副本==。但如果该副本发生失效,则用户的查询必须重新路由到另一个副本。

前缀一致读(Consistent Prefix Reads)⭐

image-20220115213128189

==前缀一致读保证,对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。==

==这是分区(分片)数据库中出现的一个特殊问题==,细节将在第 6 章中讨论。如果数据库总是以相同的顺序写入,则读取总是看到一致的序列,不会发生这种反常。然而,在许多分布式数据库中,不同的分区独立运行,因此不存在全局写入顺序。这就导致当用户从数据库中读数据时,可能会看到数据库的某部分旧值和另一部分新值。

==一个解决方案是确保任何具有因果顺序关系的写入都交给一个分区来完成==,但该方案真实实现效率会大打折扣。现在有一些新的算法来显式地追踪事件因果关系, 在本章稍后的 ”Happened-before 关系与并发“ 会继续该问题的探讨。

复制滞后的解决方案

正如前面所讨论的,在应用层可以提供比底层数据库更强有力的保证。例如只在主节点上进行特定类型的读取,而代价则是,应用层代码中处理这些问题通常会非常复杂,且容易出错。

如果应用程序开发人员不必担心这么多底层的复制问题,而是假定数据库在做正确的事情,情况就变得很简单。而这也是事务存在的原因,事务是数据库提供更强保证的一种方式。

单节点上支持事务已经非常成熟,然而,在转向分布式数据库(即支持复制和分区)的过程中,有许多系统却选择放弃支持事务。