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

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

主节点与从节点

每个保存数据库完整数据集的节点称之为副本。对于每一笔数据写入,所有副本都需要随之更新;否则,某些副本将出现不一致。最常见的解决方案是主从复制,工作原理如下:

  1. 指定某一个副本为主副本。当客户写数据库时,必须将写请求首先发送给主副本,主副本首先将新数据写入本地存储。
  2. 主副本把新数据写入本地存储后,然后将数据更改作为复制的日志或更改流发送给所有从副本。每个从副本获得更改日志之后将其应用到本地,且严格保持与主副本相同的写入顺序。
  3. 客户端从数据库中读数据时,可以在主副本或者从副本上执行查询。只有主副本才可以接受写请求,从副本都是只读的。

同步复制与异步复制

复制非常重要的一个设计选项是同步复制还是异步复制。对于关系数据库系统,同步或异步通常是一个可配置的选项。

如下图,网站用户需要更新首页的头像图片。其基本流程是,客户将更新请求发送给主节点,主节点接收到请求,接下来将数据更新转发给从节点。最后,由主节点来通知客户更新完成。

image.png

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

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

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

主从复制还经常会被配置为全异步模式。此时如果主节点发生失败且不可恢复,则所有尚未复制到从节点的写请求都会丢失。这意味着即使向客户端确认了写操作,却无法保证数据的持久化。但全异步配置的优点则是,不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的吞吐性能更好。

配置新的从节点

如果需要增加新的从节点,如何确保新的从节点和主节点保持数据一致呢?逻辑上的主要操作步骤如下:

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

处理节点失效

从节点失效:追赶式恢复

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

主节点失效:节点切换

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

故障切换可以手动进行,或者以自动方式进行。自动切换的步骤通常如下:

  1. 确认主节点失效。有很多种出错可能性,所以大多数系统都采用了基于超时的机制:节点间频繁地互相发生发送心跳存活消息,如果发现某一个节点在一段比较长时间内(例如 30s)没有响应,即认为该节点发生失效。
  2. 选举新的主节点。可以通过选举的方式(超过多数的节点达成共识)来选举新的主节点,或者由之前选定的某控制节点来指定新的主节点。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险。
  3. 重新配置系统使新主节点生效。客户端现在需要将写请求发送给新的主节点。如果原主节点之后重新上线,这时系统要确保原主节点降级为从节点,并认可新的主节点。

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

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

对于这些问题没有简单的解决方案。因此,即使系统可能支持自动故障切换,有些运维团队仍然更愿意以手动方式来控制整个切换过程。

复制日志的实现

基于语句的复制

主节点记录所执行的每个写请求并将该操作语句作为日志发送给从节点。对于关系数据库,这意味着每个 INSERT、UPDATE 或 DELETE 语句都会转发给从节点,并且每个从节点都会分析并执行这些 SQL 语句,如同它们是来自客户端那样。

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

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

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

基于预写日志传输

无论是日志结构的存储引擎,还是 B-tree 结构的存储引擎,所有对数据库写入的字节序列都被记入日志。因此可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主节点还可以通过网络将其发送给从节点。

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

基于行的逻辑日志复制

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

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

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

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

由于逻辑日志与存储引擎逻辑解耦,因此可以更容易地保持向后兼容,从而使主从节点能够运行不同版本的软件甚至是不同的存储引擎。

复制滞后问题

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

如果一个应用正好从一个异步的从节点读取数据,而该副本落后于主节点,则应用可能会读到过期的信息。这会导致数据库中出现明显的不一致,这种不一致只是一个暂时的状态,如果停止写数据库,经过一段时间之后,从节点最终会赶上并与主节点保持一致。这种效应也被称为最终一致性。

总的来说,副本落后的程度理论上并没有上限。正常情况下,主节点和从节点上完成写操作之间的时间延迟可能不足 1 秒,这样的滞后,在实践中通常不会导致太大影响。但是,如果系统已接近设计上限,或者网络存在问题,则滞后可能轻松增加到几秒甚至几分钟不等。

我们将重点介绍三个复制滞后可能出现的问题,并给出相应的解决思路。

读自己的写

许多应用让用户提交一些数据,接下来查看他们自己所提交的内容。提交新数据须发送到主节点,但是当用户读取数据时,数据可能来自从节点。

对于异步复制存在这样一个问题,如图所示,用户在写入不久即查看数据,则新数据可能尚未到达从节点。对用户来讲,看起来似乎是刚刚提交的数据丢失了。

image.png

基于主从复制的系统该如何实现写后读一致性呢?有多种可行的方案,以下例举一二:

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

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

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

单调读

假定用户从不同副本进行了多次读取,如图所示,用户刷新一个网页,读请求可能被随机路由到某个从节点。用户 2345 先后在两个从节点上执行了两次完全相同的查询(先是少量滞后的节点,然后是滞后很大的从节点),则很有可能出现以下情况。第一个查询返回了最近用户 1234 所添加的评论,但第二个查询因为滞后的原因,还没有收到更新因而返回结果是空。

image.png

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

前缀一致读

假如 Poons 先生与 Cake 夫人之间进行了如下对话:

Poons 先生:Cake 夫人,您能看到多远的未来?
Cake 夫人:通常约 10s,Poons 先生。

这两句话之间存在因果关系:Cake 夫人首先是听到了 Poons 先生的问题,然后再去回答该问题。

不过,Cake 夫人所说的话经历了短暂的滞后到达该从节点,但 Poons 先生所说的经历了更长的滞后才到达。对于观察者来说,似乎在 Poon 先生提出问题之前,Cake 夫人就开始了回答问题。

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

image.png

一个解决方案是确保任何具有因果顺序关系的写入都交给一个分区来完成,但该方案真实实现效率会大打折扣。现在有一些新的算法来显式地追踪事件因果关系。

多主节点复制

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

对主从复制模型进行自然的扩展,则可以配置多个主节点,每个主节点都可以接受写操作,后面复制的流程类似:处理写的每个主节点都必须将该数据更改转发到所有其他节点。这就是多主节点复制,此时,每个主节点还同时扮演其他主节点的从节点。

适用场景

多数据中心

为了容忍整个数据中心级别故障或者更接近用户,可以把数据库的副本横跨多个数据中心。而如果使用常规的基于主从的复制模型,主节点势必只能放在其中的某一个数据中心,而所有写请求都必须经过该数据中心。

有了多主节点复制模型,则可以在每个数据中心都配置主节点,如下图所示的基本架构。在每个数据中心内,采用常规的主从复制方案;而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。

image.png

在多数据中心环境下,部署单主节点的主从复制方案与多主复制方案之间的差异:

性能

对于主从复制,每个写请求都必须经由广域网传送至主节点所在的数据中心。这会大大增加写入延迟,并基本偏离了采用多数据中心的初衷(即就近访问)。而在多主节点模型中,每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心

容忍数据中心失效

对于主从复制,如果主节点所在的数据中心发生故障,必须切换至另一个数据中心,将其中的一个从节点被提升为主节点。在多主节点模型中,每个数据中心则可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态。

容忍网络问题

数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对数据中心之间的网络性能和稳定性等更加依赖。多主节点模型则通常采用异步复制,可以更好地容忍此类问题,例如临时网络闪断不会妨碍写请求最终成功。

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

离线客户端操作

另一种多主复制比较适合的场景是,应用在与网络断开后还需要继续工作。每个设备都有一个充当主节点的本地数据库,然后在所有设备之间采用异步方式同步这些多主节点上的副本,同步滞后可能是几小时或者数天,具体时间取决于设备何时可以再次联网。

从架构层面来看,上述设置基本上等同于数据中心之间的多主复制,只不过是个极端情况,即一个设备就是数据中心,而且它们之间的网络连接非常不可靠。

协作编辑

我们通常不会将协作编辑完全等价于数据库复制问题,但二者确实有很多相似之处。当一个用户编辑文档时,所做的更改会立即应用到本地副本(Web浏览器或客户端应用程序),然后异步复制到服务器以及编辑同一文档的其他用户。

处理写冲突

同步与异步冲突检测

如果是主从复制数据库,第二个写请求要么会被阻塞直到第一个写完成,要么被中止。然而在多主节点的复制模型下,这两个写请求都是成功的,并且只能在稍后的时间点上才能异步检测到冲突,那时再要求用户层来解决冲突为时已晚。

理论上,也可以做到同步冲突检测,即等待写请求完成对所有副本的同步,然后再通知用户写入成功。但是,这样做将会失去多主节点的主要优势:允许每个主节点独立接受写请求。如果确实想要同步方式冲突检测,或许应该考虑采用单主节点的主从复制模型。

避免冲突

处理冲突最理想的策略是避免发生冲突,即如果应用层可以保证对特定记录的写请求总是通过同一个主节点,这样就不会发生写冲突。现实中,由于不少多主节点复制模型所实现的冲突解决方案存在瑕疵,因此,避免冲突反而成为大家普遍推荐的首选方案。

例如,一个应用系统中,用户需要更新自己的数据,那么我们确保特定用户的更新请求总是路由到特定的数据中心,并在该数据中心的主节点上进行读/写。不同的用户则可能对应不同的主数据中心(例如根据用户的地理位置来选择)。从用户的角度来看,这基本等价于主从复制模型。

但是,有时可能需要改变事先指定的主节点,例如由于该数据中心发生故障,不得不将流量重新路由到其他数据中心,或者是因为用户已经漫游到另一个位置,因而更靠近新数据中心。此时,冲突避免方式不再有效,必须有措施来处理同时写入冲突的可能性。

收敛于一致状态

如果每个副本都只是按照它所看到写入的顺序执行,那么数据库最终将处于不一致状态。这绝对是不可接受的,所有的复制模型至少应该确保数据在所有副本中最终状态一定是一致的。因此,数据库必须以一种收敛趋同的方式来解决冲突,实现收敛的冲突解决有以下可能的方式:

  • 给每个写入分配唯一的 ID,例如,一个时间戳,一个足够长的随机数,一个 UUID 或者一个基于键-值的哈希,挑选最高 ID 的写入作为胜利者,并将其他写入丢弃。如果基于时间戳,这种技术被称为最后写入者获胜。虽然这种方法很流行,但是很容易造成数据丢失。
  • 为每个副本分配一个唯一的 ID,并制定规则,例如序号高的副本写入始终优先于序号低的副本。这种方法也可能会导致数据丢失。
  • 以某种方式将这些值合并在一起。例如,按字母顺序排序,然后拼接在一起。
  • 利用预定义好的格式来记录和保留冲突相关的所有信息,然后依靠应用层的逻辑,事后解决冲突(可能会提示用户)。
自定义冲突解决逻辑

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

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

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

什么是冲突?

有些冲突是显而易见的。两个写操作同时修改同一个记录中的同一个字段,并将其设置为不同的值。毫无疑问,这就是一个冲突。

而其他类型的冲突可能会非常微妙,更难以发现。例如一个会议室预订系统,它主要记录哪个房间由哪个人在哪个时间段所预订。这个应用程序需要确保每个房间只能有一组人同时预定(即不得有相同房间的重复预订)。如果为同一个房间创建两个不同的预订,可能会发生冲突。尽管应用在预订时会检查房间是否可用,但如果两个预订是在两个不同的主节点上进行,则还是存在冲突的可能。

拓扑结构

如果存在两个以上的主节点,会有多个可能的同步拓扑结构,如图所示:

image.png

最常见的拓扑结构是全部-至-全部,每个主节点将其写入同步到其他所有主节点。而其他一些拓扑结构也有普遍使用,例如,默认情况下 MySQL 只支持环形拓扑结构,其中的每个节点接收来自前序节点的写入,并将这些写入(加上自己的写入)转发给后序节点。另一种流行的拓扑是星形结构:一个指定的根节点将写入转发给所有其他节点。星形拓扑还可以推广到树状结构。

在环形和星形拓扑中,写请求需要通过多个节点才能到达所有的副本,即中间节点需要转发从其他节点收到的数据变更。为防止无限循环,每个节点需要赋予一个唯一的标识符,在复制日志中的每个写请求都标记了已通过的节点标识符。如果某个节点收到了包含自身标识符的数据更改,表明该请求已经被处理过,因此会忽略此变更请求,避免重复转发

环形和星形拓扑的问题是,如果某一个节点发生了故障,在修复之前,会影响其他节点之间复制日志的转发。可以采用重新配置拓扑结构的方法暂时排除掉故障节点。在大多数部署中,这种重新配置必须手动完成。而对于链接更密集的拓扑(如全部到全部),消息可以沿着不同的路径传播,避免了单点故障,因而有更好的容错性。但另一方面,全链接拓扑也存在一些自身的问题。主要是存在某些网络链路比其他链路更快的情况(例如由于不同网络拥塞),从而导致复制日志之间的覆盖,如下图所示。

image.png

客户端 A 向主节点 1 的表中首先插入一行,然后客户端 B 在主节点 3 上对行记录进行更新。而在主节点 2 上,由于网络原因可能出现意外的写日志复制顺序,例如它先接收到了主节点 3 的更新日志,之后才接收到主节点 1 的插入日志。

这里涉及到一个因果关系问题,类似于在前面“前缀一致读”所看到的:更新操作一定是依赖于先前完成的插入,因此我们要确保所有节点上一定先接收插入日志,然后再处理更新。在每笔写日志里简单地添加时间戳还不够,主要因为无法确保时钟完全同步,因而无法在主节点 2 上正确地排序所收到日志。

为了使得日志消息正确有序,可以使用一种称为版本向量的技术,稍后将讨论这种技术(参见“检测并发写入”)。需要指出,冲突检测技术在许多多主节点复制系统中的实现还不够完善。

无主节点复制

一些数据存储系统则采用了不同的设计思路:选择放弃主节点,允许任何副本直接接受来自客户端的写请求。

对于某些无主节点系统实现,客户端直接将其写请求发送到多副本,而在其他一些实现中,由一个协调者节点代表客户端进行写入,但与主节点的数据库不同,协调者并不负责写入顺序的维护。

节点失效时写入数据库

假设一个三副本数据库,其中一个副本当前不可用。在基于主节点复制模型下,如果要继续处理写操作,则需要执行切换操作。

对于无主节点配置,则不存在这样的切换操作。用户将写请求并行发送到三个副本,有两个可用副本接受写请求,而不可用的副本无法处理该写请求。如果假定三个副本中有两个成功确认写操作,用户收到两个确认的回复之后,即可认为写入成功。客户完全可以忽略其中一个副本无法写入的情况。

失效的节点之后重新上线,而客户端又开始从中读取内容。由于节点失效期间发生的任何写入在该节点上都尚未同步,因此读取可能会得到过期的数据。

为了解决这个问题,当一个客户端从数据库中读取数据时,它不是向一个副本发送请求,而是并行地发送到多个副本。客户端可能会得到不同节点的不同响应,包括某些节点的新值和某些节点的旧值。可以采用版本号技术确定哪个值更新(参见后面的“检测并发写入”)。

读修复与反熵

复制模型应确保所有数据最终复制到所有的副本。当一个失效的节点重新上线后,它如何赶上中间错过的写请求呢?无主复制模型的数据库通常采用以下两种机制:

读修复

当客户端并行读取多个副本时,可以检测到过期的返回值。例如,用户从副本 3 获得的是版本 6,而从副本 1 和 2 得到的是版本 7。客户端可以判断副本 3 是一个过期值,然后将新值写入到该副本。这种方法主要适合那些被频繁读取的场景。

反嫡过程

一些数据存储有后台进程不断查找副本之间数据的差异,将任何缺少的数据从一个副本复制到另一个副本。与基于主节点复制的复制日志不同,此反嫡过程并不保证以特定的顺序复制写入,并且会引入明显的同步滞后。

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

读写 quorum

我们知道,成功的写操作要求三个副本中至少两个完成,这意味着至多有一个副本可能包含旧值。因此,在读取时需要至少向两个副本发起读请求,通过版本号可以确定一定至少有一个包含新值。如果第三个副本出现停机或响应缓慢,则读取仍可以继续并返回最新值。

把上述道理推广到一般情况,如果有 n 个副本,写入需要 w 个节点确认,读取必须至少查询 r 个节点,则只要 w + r > n,读取的节点中一定会包含最新值。例如在前面的例子中,n = 3,w = 2,r = 2。满足上述这些 r、w 值的读/写操作称之为法定票数读或法定票数写。也可以认为r和w是用于判定读、写是否有效的最低票数。

参数 n、w 和 r 通常是可配置的,一个常见的选择是设置 n 为某奇数,w = r = (n + 1) / 2(向上舍入)。也可以根据自己的需求灵活调整这些配置。例如,对于读多写少的负载,设置 w = n 和 r = 1 比较合适,这样读取速度更快,但是一个失效的节点就会使得数据库所有写入因无法完成 quorum 而失败。

quorum 一致性的局限性

通常,设定 r 和 w 为简单多数(多于 n / 2)节点,即可确保 w + r > n,且同时容忍多达 n / 2 个节点故障。但是,quorum 不一定非得是多数,读和写的节点集中有一个重叠的节点才是最关键的

也可以将 w 和 r 设置为较小的数字,从而让 w + r <= n。此时,读取和写入操作仍会被发送到 n 个节点,但只需等待更少的节点回应即可返回。

由于 w 和 r 配置的节点数较小,读取请求当中可能恰好没有包含新值的节点,因此最终可能会返回一个过期的旧值。好的一方面是,这种配置可以获得更低的延迟和更高的可用性,例如网络中断,许多副本变得无法访问,相比而言有更高的概率继续处理读取和写入。只有当可用的副本数已经低于 w 或 r 时,数据库才会变得无法读/写,即处于不可用状态。

即使在 w + r > n 的情况下,也可能存在返回旧值的边界条件。这主要取决于具体实现,可能的情况包括:

  • 如果采用了 sloppy quorum(参阅后面的“宽松的 quorum 与数据回传”),写操作的 w 节点和读取的 r 节点可能完全不同,因此无法保证读写请求一定存在重叠的节点。
  • 如果两个写操作同时发生,则无法明确先后顺序。这种情况下,唯一安全的解决方案是合并并发写入(参见前面的“处理写冲突”)。如果根据时间戳挑选胜者,则由于时钟偏差问题,某些写入可能会被错误地抛弃。
  • 如果写操作与读操作同时发生,写操作可能仅在一部分副本上完成。此时,读取时返回旧值还是新值存在不确定性。
  • 如果某些副本上已经写入成功,而其他一些副本发生写入失败(例如磁盘已满),且总的成功副本数少于 w,那些已成功的副本上不会做回滚。这意味着尽管这样的写操作被视为失败,后续的读操作仍可能返回新值。
  • 如果具有新值的节点后来发生失效,但恢复数据来自某个旧值,则总的新值副本数会低于 w,这就打破了之前的判定条件。
  • 即使一切工作正常,也会出现一些边界情况,如一致性与共识中所介绍的“可线性化与 quorum”。

建议最好不要把参数 w 和 r 视为绝对的保证,而是一种灵活可调的读取新值的概率。

这里通常无法得到前面的“复制滞后问题”中所罗列的一致性保证,包括写后读、单调读、前缀一致读等,因此前面讨论种种异常同样会发生在这里。如果确实需要更强的保证,需要考虑事务与共识问题。

宽松的 quorum 与数据回传

quorum 并不总如期待的那样提供高容错能力。一个网络中断可以很容易切断一个客户端到多数数据库节点的连接。尽管这些集群节点是活着的,而且其他客户端也确实可以正常连接,但是对于断掉连接的客户端来讲,情况无疑等价于集群整体失效。这种情况下,很可能无法满足最低的 w 和 r 所要求的节点数,因此导致客户端无法满足 quorum 要求。

在一个大规模集群中(节点数远大于 n 个),客户可能在网络中断期间还能连接到某些数据库节点,但这些节点又不是能够满足数据仲裁的那些节点。此时,我们是否应该接受该写请求,只是将它们暂时写入一些可访问的节点中?(这些节点并不在 n 个节点集合中)。

这种方案称之为宽松的仲裁:写入和读取仍然需要 w 和 r 个成功的响应,但包含了那些并不在先前指定的 n 个节点。一旦网络问题得到解决,临时节点需要把接收到的写入全部发送到原始主节点上。这就是所谓的数据回传。

可以看出,sloppy quorum 对于提高写入可用性特别有用:要有任何 w 个节点可用,数据库就可以接受新的写入。然而这意味着,即使满足 w + r > n,也不能保证在读取某个键时,一定能读到最新值,因为新值可能被临时写入 n 之外的某些节点且尚未回传过来。

检测并发写

无主复制数据库允许多个客户端对相同的主键同时发起写操作,即使采用严格的 quorum 机制也可能会发生写冲突。这与多主复制类似,此外,由于读时修复或者数据回传也会导致并发写冲突。

一个核心问题是,由于网络延迟不稳定或者局部失效,请求在不同的节点上可能会呈现不同的顺序。如图所示,对于包含三个节点的数据系统,客户端 A 和 B 同时向主键X发起写请求:

image.png

  • 节点 1 收到来自客户端 A 的写请求,但由于节点失效,没有收到客户端 B 的写请求。
  • 节点 2 首先收到 A 的写请求,然后是 B 的写请求。
  • 节点 3 首先收到 B 的写请求,然后是 A 的写请求。

如果节点每当收到新的写请求时就简单地覆盖原有的主键,那么这些节点将永久无法达成一致。我们知道副本应该收敛于相同的内容,这样才能达成最终一致。但如何才能做到呢?如果不想丢失数据,必须了解很多关于数据库内部冲突处理的机制。

我们已经在前面的“处理写冲突”简要介绍了一些解决冲突的技巧,现在我们来更详细地探讨这个问题。

最后写入者获胜

一种实现最终收敛的方法是,每个副本总是保存最新值,允许覆盖并丢弃旧值。那么,假定每个写请求都最终同步到所有副本,只要我们有一个明确的方法来确定哪一个写入是最新的,则副本可以最终收敛到相同的值。

这个想法其实有些争议,关键点在于前面所提到关于如何定义“最新”。不过即使无法确定写请求的“自然顺序”,我们可以强制对其排序。例如,为每个写请求附加一个时间戳,然后选择最新即最大的时间戳,丢弃较早时间戳的写入。这个冲突解决算法被称为最后写入者获胜(last write wins,LWW)。

LWW 可以实现最终收敛的目标,但是以牺牲数据持久性为代价。如果同一个主键有多个并发写,即使这些并发写都向客户端报告成功,但最后只有一个写入值会存活下来,其他的将被系统默默丢弃。在一些场景如缓存系统,覆盖写是可以接受的。如果覆盖、丢失数据不可接受,则 LWW 并不是解决冲突很好的选择。

要确保 LWW 安全无副作用的唯一方法是,只写入一次然后写入值视为不可变,这样就避免了对同一个主键的并发写。例如,Cassandra 的一个推荐使用方法就是采用 UUID 作为主键,这样每个写操作都针对的不同的、系统唯一的主键。

Happens-before 关系和并发

如果 B 知道 A,或者依赖于 A,或者以某种方式在 A 基础上构建,则称操作 A 在操作 B 之前发生。这是定义何为并发的关键。事实上,我们也可以简单地说,如果两个操作都不在另一个之前发生,那么操作是并发的。

因此,对于两个操作 A 和 B,一共存在三种可能性,我们需要的是一个算法来判定两个操作是否并发。如果一个操作发生在另一个操作之前,则后面的操作可以覆盖较早的操作。如果属于并发,就需要解决潜在的冲突问题。

确定前后关系

我们来看一个确定操作并发性的算法,即两个操作究竟属于并发还是一个发生在另一个之前。简单起见,我们先从只有一个副本的数据库开始。

下图的例子是两个客户端同时向购物车添加商品。初始时购物车为空。然后两个客户端向数据库共发出五次写入操作:

image.png

  1. 客户端1首先将牛奶加入购物车。这是第一次写入该主键的值,服务器保存成功然后分配版本1,服务器将值与版本号一起返回给该客户端1。
  2. 客户端2将鸡蛋加入购物车,此时它并不知道客户端1已添加了牛奶,而是认为鸡蛋是购物车中的唯一物品。服务器为此写入并分配版本2,然后将鸡蛋和牛奶存储为两个单独的值,最后将这两个值与版本号2返回给客户端2。
  3. 客户端1也并不意识上述步骤2,想要将面粉加入购物车,且以为购物车的内容应该是[牛奶,面粉],将此值与版本号1一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的新值要取代先前值[牛奶],但值[鸡蛋]则是新的并发操作。因此,服务器将版本3分配给[牛奶,面粉]并覆盖版本1的[牛奶],同时保留版本2的值[鸡蛋],将二者同时返回给客户端1。
  4. 客户端2想要加入火腿,也不知道客户端1刚刚加了面粉。其在最后一个响应中从服务器收到的两个值是[牛奶]和[蛋],现在合并这些值,并添加火腿形成一个新的值[鸡蛋,牛奶,火腿]。它将该值与前一个版本号2一起发送到服务器。服务器检测到版本2会覆盖[鸡蛋],但与[牛奶,面粉]是同时发生,所以设置为版本4并将所有这些值发送给客户端2。
  5. 最后,客户端1想要加培根。它以前在版本3中从服务器接收[牛奶,面粉]和[鸡蛋],所以合并这些值,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号3来覆盖[牛奶,面粉],但与[鸡蛋,牛奶,火腿]并发,所以服务器会保留这些并发值。

上面操作之间的数据流可以通过下图展示。箭头表示某个操作发生在另一个操作之前,即后面的操作“知道”或是“依赖”于前面的操作。在这个例子中,因为总有另一个操作同时进行,所以每个客户端都没有时时刻刻和服务器上的数据保持同步。但是,新版本值最终会覆盖旧值,且不会发生已写入值的丢失。

image.png

服务器判断操作是否并发的依据主要依靠对比版本号,而并不需要解释新旧值本身。算法的工作流程如下:

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

与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道