在前面几章,我们讨论了数据系统的各个方面,但仅限于数据存储在单台机器上的情况。现在我们进入更高的层次,在接下来的几章讨论将数据库分布到多台机器的情况。
复制
复制意味着在通过网络连接的多台机器上保留相同数据的副本。我们希望能复制数据,可能出于各种各样的原因:
- 使得数据与用户在地理上接近,从而减少延迟
- 即使系统的一部分出现故障,系统也能继续工作
- 伸缩可以接受读请求的机器数量
本章假设数据集非常小,每台机器都可以保存整个数据集的副本。之后的章节我们还会讨论对单个机器来说太大的数据集的分割。
如果复制中的数据不会随时间而改变,那复制就很简单: 将数据复制到每个节点一次就可以了。
复制的困难之处在于处理复制数据的变更,这就是本章的重点。我们主要讨论单领导者(single leader)的变更复制算法。
领导者与追随者
存储数据库副本的每个节点称为副本(replica)。
当存在多个副本时,就会出现一个问题: 如何确保所有数据都落在了所有的副本上。
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为基于领导者的复制(leader-based replication),也称为主从(master/slave)复制。
工作原理如下:
- 副本之一被指定为领导者(leader),也称为主库(master|primary)。当客户端要向数据库写入时,它必须将请求发送给领导者,领导者会将新数据写入其本地存储
- 其他副本被称为追随者(followers),亦称为从库(slaves)。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为复制日志。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入
- 当客户想要从数据库中读取数据时,它可以向领导者或追随者查询。但只有领导者才能接受写操作
这种复制模式是许多关系数据库的内置功能,例如,PostgreSQL、MySQL 等。
同步复制与异步复制
复制系统的一个重要细节是: 复制是同步(synchronously)发生还是异步(asynchronously)发生。在关系型数据库中这通常是一个配置项,其他系统通常硬编码为其中一个。
在上图示例中,从库 1 的复制是同步的: 在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。从库 2 的复制是异步的: 主库发送消息,但不等待从库的响应。
在这幅图中,从库 2 处理消息前存在一个显著的延迟。通常情况下,复制的速度相当快: 大多数数据库系统能在一秒向从库应用变更,但它们不能提供复制用时的保证。
通常情况下,基于领导者的复制都配置为完全异步,异步复制已经被广泛使用了。
设置新从库
有时候我们可能需要设置一个新的从库: 也许是为了增加副本的数量,或替换失败的节点。
如何确保新的从库拥有主库数据的精确副本呢?客户端不断向数据库写入数据,数据总是在不断变化,标准的数据副本会在不同的时间点总是不一样。复制的结果可能没有任何意义。
我们可以通过锁定数据库,使其在该段时间内不可用于写入来使磁盘上的文件保持一致,但是这会违背高可用的目标。
但是通常,拉起新的从库通常并不需要停机。过程如下:
- 在某个时刻获取主库的一致性快照,而不必锁定整个数据库。大多数数据库都具有这个功能,因为它是备份必需的
- 将快照复制到新的从库节点
- 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置在不同数据库有不同的名称: 例如,PostgreSQL 将其称为日志序列号(log sequence number, LSN),MySQL 将其称为二进制日志坐标(binlog coordinates)
- 当从库处理完快照之后积压的数据变更,就可以继续处理主库产生的数据变化了
处理节点宕机
系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护。那么即使发生宕机,我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。
从库失效: 追赶恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者主库和从库之间的网络暂时中断,从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开期间发生的所有数据变更。等其赶上主库后,就可以像以前一样继续接收数据变更流。
主库失效: 故障切换
主库失效则相对复杂,其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。
故障切换可以手动进行或自动进行。
自动故障切换过程通常由以下步骤组成:
- 确认主库失效。大多数系统只是简单使用超时判断失效
- 选择一个新的主库。一般可以通过选举过程(主库由剩余副本以多数选举产生)来完成。主库的最佳人选通常是拥有旧主库最新数据副本的从库。让所有的节点同意一个新的领导者,是一个共识问题,将在之后章节详细讨论
- 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库。系统需要确保旧主库意识到新主库的存在,并成为一个从库
节点故障、不可靠的网络、对副本一致性、持久性、可用性和延迟的权衡,这些问题实际上是分布式系统中的基本问题,我们将会在之后章节详细讨论。
复制日志的实现
基于主库的复制底层有好几种不同的复制方式,大概分为:
- 基于语句的复制
- 传输预写式日志(WAL)
- 逻辑日志复制(基于行)
- 基于触发器的复制
复制延迟问题
容忍节点故障只是需要复制的一个原因,另一个原因是可伸缩性和延迟。
基于主库的复制要求所有写入都由单个节点处理,但只读查询可以由任何副本处理。所以对于读多写少的场景,我们可以选择创建很多从库,并将读请求分散到所有的从库上去。这样能减小主库的负载,并允许向最近的副本发送读请求。
在这种伸缩体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制。如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。
不过,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致。同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为最终一致性。
因为滞后时间不确定而引入的不一致性,不仅是一个理论问题,更是应用设计中会遇到的真实问题。
我们介绍三个由复制延迟问题产生的例子,并简述解决这些问题的一些方法。
读己之写
用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常。这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺,其他用户的更新可能稍等才会看到。
如何实现读后一致性有各种可能的技术,下面是一些常见方式:
- 读用户可能已经修改过的内容时,都从主库读
- 如果应用中的大部分内容都可能被用户编辑,在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读
- 客户端可以记住最近一次写入的时间戳,系统需要确保从库为该用户提供任何查询时,该时间戳前的变更都已经传播到了本从库中
单调读
从异步从库读取第二个异常例子是,用户可能会遇到时光倒流。
如果用户从不同从库进行多次读取,就可能发生这种情况:
用户首先从新副本读取,然后从旧副本读取。为了防止这种异常,我们需要单调的读取。
单调读保证这种异常不会发生。这是一个比强一致性(strong consistency)更弱,但比最终一致性(eventual consistency)更强的保证。
例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本失败,用户的查询将需要重新路由到另一个副本。
一致前缀读
第三个复制延迟例子违反了因果律。如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。防止这种异常,需要另一种类型的保证: 一致前缀读。
这个保证是说: 如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会以同样的顺序出现。
一种解决方案是,确保任何因果相关的写入都写入相同的分区。
复制延迟的解决方案
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果答案是可以接受,那就没问题。但如果结果对于用户来说是不好体验,那么设计系统来提供更强的保证是很重要的,例如写后读。