复制是指在多台服务器上保持相同的数据副本。MongoDB 实现此功能的方式是保存操作日志(oplog),其中包含了主节点执行的每一次写操作。oplog 是存在于主节点 local 数据库中的一个固定集合。从节点通过查询此集合以获取需要复制的操作。
每个从节点都维护着自己的 oplog,用来记录它从主节点复制的每个操作。这使得每个成员都可以被用作其他成员的同步源。从节点从同步源中获取操作,将其应用到自己的数据集上,然后再写入 oplog 中。如果应用某个操作失败(只有在基础数据已损坏或数据与主节点不一致时才会发生这种情况),则从节点会停止从当前数据源复制数据。
oplog 中按顺序保存着所有执行过的写操作。每个成员都维护了一份自己的 oplog,它们应该和主节点的oplog 完全一致(可能会有一些延迟)
如果一个从节点由于某种原因而停止运行,那么当它重新启动后,就会从 oplog 中的最后一个操作开始同步。由于这些操作是先应用到数据上然后再写入 oplog,因此从节点可能会重复已经应用到其数据上的操作。MongoDB 在设计时就考虑到了这种情况:将 oplog 中的同一个操作执行多次与只执行一次效果是一样的。oplog 中的每个操作都是幂等的。也就是说,无论对目标数据集应用一次还是多次,oplog 操作都会产生相同的结果。
由于 oplog 的大小是固定的,因此它只能容纳一定数量的操作。通常来说,oplog 使用空间的速度与系统写入的速度差不多:如果在主节点上每分钟写入 1KB 的数据,那么 oplog 就会以每分钟 1KB 的速度被填满。不过,也有一些例外:如果一个操作会影响多个文档,比如删除多个文档或导致多文档更新,那么这个操作将被分解为许多 oplog 条目。主节点上的单个操作将为每个受影响的文档分解一个 oplog 操作。因此,如果使用 db.coll. remove() 从集合中删除 1 000 000 个文档,那么 oplog 中就会有 1 000 000 条操作日志,每条日志对应一个被删除的文档。如果进行大量的批量操作,那么oplog 可能会比你预期的更快被填满。
在大多数情况下,默认的 oplog 大小就足够了。如果预测副本集的工作负载属于以下模式之一,那么你可能会希望创建一个大于默认值的 oplog。相反,如果应用程序主要执行读操作而执行很少的写操作,那么一个较小的 oplog 就足够了。以下这些工作负载可能会需要更大的 oplog。
-
一次更新多个文档
为了保持幂等性,oplog 必须将一个多文档更新转换为多个单独的操作。这可能会占用大量的 oplog 空间,但相应的数据大小和数据的磁盘使用量不会增加。
-
删除的数据量与插入的数据量相同
如果删除的数据量与插入的数据量大致相同,那么数据库的磁盘使用量不会显著增加,但是操作日志的大小可能会非常大。
-
大量的就地(in-place)更新
如果很大一部分的工作负载是不增加文档大小的更新,那么数据库会记录大量操作,但磁盘上的数据量不会改变。
在 mongod 进程创建 oplog 之前,可以使用 oplogSizeMB 选项指定其大小。然而,在第一次启动副本集成员后,只能使用“更改 oplog 大小”这个流程来更改 oplog 的大小。
MongoDB 中存在两种形式的数据同步:初始化同步用于向新成员中添加完整的数据集,复制用于将正在发生的变更应用到整个数据集。下面将逐一进行讲解。
初始化同步
MongoDB 在执行初始化同步时,会将所有数据从副本集中的一个成员复制到另一个成员中。当一个副本集成员启动时,它会检查自身的有效状态,以确定是否可以开始从其他成员中同步数据。如果状态有效,它就会尝试从该副本集的另一个成员中复制数据的完整副本。这一过程有几个步骤,可以从mongod 的日志中看到。
首先,MongoDB 会克隆除 local 数据库之外的所有数据库。mongod 会扫描源数据库中的每个集合,并将所有数据插入目标成员上这些集合的对应副本中。在开始克隆操作之前,目标成员上的任何现有数据都将被删除。
只有当你不再需要数据目录中的数据或者已经将数据移到其他地方时,才对一个成员进行初始化同步,因为在初始化同步时 mongod 首先会将其全部删除。
在 MongoDB 3.4 及之后的版本中,初始化同步在为每个集合复制文档时会创建集合中的所有索引(在早期版本中,只有"_id" 索引会在此阶段创建)。此过程还会在数据复制期间提取新添加的 oplog 记录,因此为了在这一阶段存储这些记录,应该确保目标成员在 local 数据库中有足够的磁盘空间。
一旦所有的数据库都被克隆,mongod 就会使用这些来自同步源的 oplog 记录来更新它的数据集以反映副本集的当前状态,并将复制过程中发生的所有变更应用到数据集上。这些变更可能包括任何类型的写入(插入、更新和删除),而此过程可能意味着 mongod 必须重新克隆某些被克隆程序移动并因此丢失的文档。
这时,数据应该与主节点上的数据集完全匹配。成员在完成初始化同步后会过渡到正常同步流程,这使其成了从节点。
从操作者的角度来看,进行初始化同步的过程非常容易:只需用一个干净的数据目录启动 mongod。然而,更推荐从备份中进行恢复。从备份中恢复通常比通过mongod 复制所有的数据要快。
还有一点,克隆可能会破坏同步源的工作集。在许多情况下,某些数据的子集经常会被访问,因而这部分数据总是存在于内存中(因为操作系统经常对其进行访问)。执行初始化同步会强制此成员将其所有数据分页加载到内存中,从而“驱逐”那些经常使用的数据。当那些通常由 RAM 中的数据进行处理的请求突然被迫转到磁盘时,可能此成员的速度会显著降低。不过,对于那些小型数据集和性能较好的服务器,初始化同步是一个简单易用的选择。
进行初始同步时一个最常见的问题就是时间过长。在这种情况下,新成员可能会从同步源的 oplog 末尾“脱离”:由于同步源的 oplog 已经覆盖了成员继续复制所需的数据,因此新成员会远远落后于同步源并且无法再跟上。
除了在不太忙的时候尝试初始化同步或从备份进行恢复之外,没有其他方法可以解决这个问题。如果成员已经脱离了同步源的 oplog,那么初始化同步将无法进行。
复制
MongoDB 执行的第二种同步是复制。从节点成员在初始化同步之后会持续复制数据。它们从同步源复制 oplog,并在一个异步进程中应用这些操作。从节点可以根据需要自动更改同步源,以应对 ping 时间及其他成员复制状态的变化。有一些规则可以控制给定节点从哪些成员进行同步。例如,拥有投票权的副本集成员不能从没有投票权的成员那里同步数据,从节点不能从延迟成员和隐藏成员那里同步数据。
处理过时数据
如果某个从节点远远落后于同步源当前的操作,那么这个从节点就是过时的。过时的从节点无法赶上同步源,因为同步源上的操作过于领先了:如果继续同步,从节点就需要跳过一些操作。这种情况可能发生在以下场景中:从节点服务器停止运行,写操作超过了自身处理能力,或者忙于处理过多的读请求。
当一个从节点过期时,它将依次尝试从副本集中的每个成员进行复制,看看是否有成员拥有更长的 oplog 以继续进行同步。如果没有一个成员拥有足够长的 oplog,那么该成员上的复制将停止,并且需要重新进行完全同步或从最近的备份中恢复。
为了避免出现不同步的从节点,让主节点拥有一个比较大的oplog 以保存足够多的操作日志是很重要的。一个更大的oplog 会占用更多的磁盘空间,但通常这是一个很好的折中,因为磁盘空间一般来说比较便宜,而且实际中使用的oplog 只有一小部分,所以不会占用太多的 RAM。根据经验,oplog 应该可以覆盖两到三天的正常操作(复制窗口)。