你可能出于以下几个原因希望复制数据:
- 使数据在地理上更接近用户(从而减少访问延迟)
- 即使系统的部分组件出现故障,也能让系统继续工作(从而提高可用性)
- 扩展能够处理读查询的机器数量(从而提高读吞吐量)
单主复制
每次写入数据库都需要由每个副本处理;否则,副本将不再包含相同的数据。最常见的解决方案称为 基于领导者的复制、主备复制 或 主动/被动复制。
同步复制与异步复制
如果同步追随者变得不可用或缓慢,异步追随者之一将变为同步。这保证了你至少在两个节点上拥有最新的数据副本:领导者和一个同步追随者。这种配置有时也称为 半同步。
设置新的副本
过程如下所示:
- 在某个时间点获取领导者数据库的一致快照——如果可能,不锁定整个数据库。大多数数据库都有此功能,因为备份也需要它。在某些情况下,需要第三方工具,例如用于 MySQL 的 Percona XtraBackup。
- 将快照复制到新的追随者。
- 追随者连接到领导者并请求自快照拍摄以来发生的所有数据变更。这要求快照与领导者复制日志中的确切位置相关联。该位置有各种名称:例如,PostgreSQL 称之为 日志序列号;MySQL 有两种机制,binlog 位点 和 全局事务标识符(GTID)。
- 当追随者处理了自快照以来的数据变更积压后,我们说它已经 追上进度。它现在可以继续处理领导者发生的数据变更。
处理节点故障
自动故障转移过程通常包括以下步骤:
- 确定领导者已失效。 可能会出现许多问题:崩溃、停电、网络故障等。
- 选择新的领导者。 这可以通过选举过程完成(由剩余副本中的多数选出领导者),也可以由预先设定的 控制器节点 任命 。
- 将系统重新配置为使用新的领导者。 客户端现在需要把写请求发送到新领导者。如果旧领导者恢复,它可能仍然以为自己是领导者,并不知道其他副本已经让它下台。系统需要确保旧领导者降级为追随者,并识别新的领导者
故障转移最重要的是选择一个最新的追随者作为新的领导者——如果使用同步或半同步复制,这将是旧领导者在确认写入之前等待的追随者。使用异步复制,你可以选择具有最大日志序列号的追随者。这最小化了故障转移期间丢失的数据量:丢失几分之一秒的写入可能是可以容忍的,但选择落后几天的追随者可能是灾难性的。
复制日志的实现
基于语句的复制
这种复制方法可能会出现各种问题:
- 任何调用非确定性函数的语句,例如 NOW() 获取当前日期和时间或 RAND() 获取随机数,可能会在每个副本上生成不同的值。
- 如果语句使用自增列,或者如果它们依赖于数据库中的现有数据(例如,UPDATE … WHERE <某条件>),它们必须在每个副本上以完全相同的顺序执行,否则它们可能会产生不同的效果。当有多个并发执行的事务时,这可能会受到限制。
- 具有副作用的语句(例如,触发器、存储过程、用户定义的函数)可能会导致每个副本上发生不同的副作用,除非副作用是绝对确定的。
预写日志(WAL)传输
主要缺点是日志在非常低的级别描述数据:WAL 包含哪些字节在哪些磁盘块中被更改的详细信息。这使得复制与存储引擎紧密耦合。如果数据库从一个版本更改其存储格式到另一个版本,通常不可能在领导者和追随者上运行不同版本的数据库软件。
逻辑(基于行)日志复制
修改多行的事务会生成多个这样的日志记录,后跟指示事务已提交的记录。MySQL 除了 WAL 之外还保留一个单独的逻辑复制日志,称为 binlog(当配置为使用基于行的复制时)。PostgreSQL 通过将物理 WAL 解码为行插入/更新/删除事件来实现逻辑复制
多主复制
单主复制模型的自然扩展是允许多个节点接受写入。复制仍然以相同的方式进行:每个处理写入的节点必须将该数据变更转发给所有其他节点。我们称之为 多主 配置(也称为 主动/主动 或 双向 复制)。在这种设置中,每个领导者同时充当其他领导者的追随者。
跨地域运行
想象你有一个数据库,在几个不同的地区有副本(也许是为了能够容忍整个地区的故障,或者是为了更接近你的用户)。这被称为 地理分布式、地域分布式 或 地域复制 设置。使用单主复制,领导者必须在 一个 地区,所有写入都必须通过该地区。
在多主配置中,你可以在 每个 地区都部署一个领导者。
多主复制拓扑
复制拓扑 描述了写入从一个节点传播到另一个节点的通信路径。最通用的拓扑是 全对全,如所示,其中每个领导者将其写入发送到每个其他领导者。然而,也使用更受限制的拓扑:例如 环形拓扑,其中每个节点从一个节点接收写入并将这些写入(加上其自己的任何写入)转发到另一个节点。另一种流行的拓扑具有 星形 形状:一个指定的根节点将写入转发到所有其他节点。星形拓扑可以推广到树形。
不同拓扑的问题
更密集连接的拓扑(如全对全)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。
另一方面,全对全拓扑也可能有问题。特别是,一些网络链路可能比其他链路更快(例如,由于网络拥塞),结果是一些复制消息可能会"超越"其他消息
更新依赖于先前的插入,因此我们需要确保所有节点首先处理插入,然后处理更新。简单地为每个写入附加时间戳是不够的,因为时钟不能被信任足够同步以在领导者 2 上正确排序这些事件,为了正确排序这些事件,可以使用一种称为 版本向量 的技术
同步引擎与本地优先软件
另一种适合多主复制的情况是,如果你有一个需要在与互联网断开连接时继续工作的应用程序。从架构的角度来看,这种设置与地区之间的多主复制非常相似,达到了极端:每个设备是一个"地区",它们之间的网络连接极其不可靠。
实时协作、离线优先和本地优先应用
此外,许多现代 Web 应用程序提供 实时协作 功能。这再次导致多主架构:每个打开共享文件的 Web 浏览器选项卡都是一个副本,你对文件进行的任何更新都会异步复制到打开同一文件的其他用户的设备。即使应用程序不允许你在离线时继续编辑文件,多个用户可以进行编辑而无需等待服务器的响应这一事实已经使其成为多主。
离线编辑和实时协作都需要类似的复制基础设施:应用程序需要捕获用户对文件所做的任何更改,并立即将它们发送给协作者(如果在线),或本地存储它们以供稍后发送(如果离线)。此外,应用程序需要接收来自协作者的更改,将它们合并到用户的文件本地副本中,并更新用户界面以反映最新版本。如果多个用户同时更改了文件,可能需要冲突解决逻辑来合并这些更改。
支持此过程的软件库称为 同步引擎。允许用户在离线时继续编辑文件的应用程序(可能使用同步引擎实现)称为 离线优先。术语 本地优先软件 指的是不仅是离线优先的协作应用程序,而且即使制作软件的开发人员关闭了他们的所有在线服务,也被设计为继续工作
同步引擎的利弊
同步引擎方法有许多优点:
- 在本地拥有数据意味着用户界面的响应速度可以比必须等待服务调用获取某些数据时快得多。
- 允许用户在离线时继续工作是有价值的,特别是在具有间歇性连接的移动设备上。
- 与在应用程序代码中执行显式服务调用相比,同步引擎简化了前端应用程序的编程模型。
- 为了实时显示其他用户的编辑,你需要接收这些编辑的通知并相应地有效更新用户界面。同步引擎与 响应式编程 模型相结合是实现此目的的好方法 。
处理写入冲突
冲突避免
冲突的一种策略是首先避免它们发生。例如,如果应用程序可以确保特定记录的所有写入都通过同一领导者,那么即使整个数据库是多主的,也不会发生冲突。这种方法在同步引擎客户端离线更新的情况下是不可能的,但在地域复制的服务器系统中有时是可能的
冲突避免的另一个例子:想象你想要插入新记录并基于自增计数器为它们生成唯一 ID。如果你有两个领导者,你可以设置它们,使得一个领导者只生成奇数,另一个只生成偶数。这样你可以确保两个领导者不会同时为不同的记录分配相同的 ID。
最后写入胜利(丢弃并发写入)
如果无法避免冲突,解决它们的最简单方法是为每个写入附加时间戳,并始终使用具有最大时间戳的值。这种方法称为 最后写入胜利(LWW),因为具有最大时间戳的写入可以被认为是"最后"的。
LWW 的真正含义是:当同一记录在不同的领导者上并发写入时,其中一个写入被随机选择为获胜者,其他写入被静默丢弃,即使它们在各自的领导者上成功处理。这实现了最终所有副本都处于一致状态的目标,但代价是数据丢失。
手动冲突解决
在数据库里,让冲突阻塞整个复制流程、直到人工处理,通常并不现实。更常见的是,数据库会保留某条记录的所有并发写入值。这些值有时称为 兄弟。下次查询该记录时,数据库会返回 所有 这些值,而不只是最新值。随后你可以按需要解决这些值:要么在应用代码里自动处理(例如把 B 和 C 合并成 “B/C”),要么让用户参与处理;最后再把新值写回数据库以消解冲突。
自动冲突解决
于许多应用程序,处理冲突的最佳方法是使用自动将并发写入合并为一致状态的算法。自动冲突解决确保所有副本 收敛 到相同的状态——即,处理了相同写入集的所有副本都具有相同的状态,无论写入到达的顺序如何。
CRDT 与操作变换
两个算法族通常用于实现自动冲突解决:无冲突复制数据类型(CRDT)和 操作变换(OT)。它们具有不同的设计理念和性能特征,但都能够为前面提到的所有类型的数据执行自动合并。
OT 最常用于文本的实时协作编辑, CRDT 可以在分布式数据库中,JSON 数据的同步引擎可以使用 CRDT
无主复制
一些数据存储系统采用不同的方法,放弃领导者的概念,并允许任何副本直接接受来自客户端的写入。一些最早的复制数据系统是无主的,但在关系数据库主导的时代,这个想法基本上被遗忘了。
当节点故障时写入数据库
现在想象不可用节点恢复上线,客户端开始从它读取。在节点宕机期间发生的任何写入都从该节点丢失。因此,如果你从该节点读取,你可能会得到 陈旧(过时)值作为响应。
为了解决这个问题,当客户端从数据库读取时,它不只是将其请求发送到一个副本:读取请求也并行发送到多个节点。客户端可能会从不同的节点获得不同的响应;
为了区分哪些响应是最新的,哪些是过时的,写入的每个值都需要用版本号或时间戳标记,当客户端收到对读取的多个值响应时,它使用具有最大时间戳的值(即使该值仅由一个副本返回,而其他几个副本返回较旧的值)。
追赶错过的写入
读修复当客户端并行从多个节点读取时,它可以检测任何陈旧响应。
提示移交如果一个副本不可用,另一个副本可能会以 提示 的形式代表其存储写入。当应该接收这些写入的副本恢复时,存储提示的副本将它们发送到恢复的副本,然后删除提示。这个 移交 过程有助于使副本保持最新,即使对于从未读取的值也是如此,因此不由读修复处理。
反熵此外,还有一个后台进程定期查找副本之间数据的差异,并将任何缺失的数据从一个副本复制到另一个。与基于领导者的复制中的复制日志不同,这个 反熵进程 不以任何特定顺序复制写入,并且在复制数据之前可能会有显著的延迟。
读写仲裁 & 仲裁一致性的局限
监控陈旧性
在具有无主复制的系统中,没有固定的写入应用顺序,这使得监控更加困难。副本为移交存储的提示数量可以是系统健康的一个度量,但很难有用地解释。最终一致性是一个故意模糊的保证,但为了可操作性,能够量化"最终"很重要。
单主与无主复制的性能
基于单个领导者的复制系统可以提供在无主系统中难以或不可能实现的强一致性保证。
从领导者读取确保最新响应,但它存在性能问题:
- 读取吞吐量受领导者处理请求能力的限制(
- 如果领导者失败,你必须等待检测到故障,并在继续处理请求之前完成故障转移。
- 系统对领导者上的性能问题非常敏感:如果领导者响应缓慢,例如由于过载或某些资源争用,增加的响应时间也会立即影响用户。
无主架构的一大优势是它对此类问题更有弹性。因为没有故障转移,而且请求本来就是并行发往多个副本,所以某个副本变慢或不可用对响应时间影响较小:客户端只需采用更快副本的响应即可。利用最快响应的做法称为 请求对冲,它可以显著降低尾部延迟
无主系统也可能有性能问题:
- 即使系统不需要执行故障转移,一个副本确实需要检测另一个副本何时不可用,以便它可以存储有关不可用副本错过的写入的提示。
- 你拥有的副本越多,你的仲裁就越大,在请求完成之前你必须等待的响应就越多。
- 大规模网络中断使客户端与大量副本断开连接,可能使形成仲裁变得不可能。