5.3 实时复制
5.3.1 oplog复制
在副本集架构中,主节点与备节点之间是通过oplog来同步数据的,这里的oplog是一个特殊的固定集合,当主节点上的一个写操作完成后,会向oplog集合写入一条对应的日志,而备节点则通过这个oplog不断拉取到新的日志,在本地进行回放以达到数据同步的目的。
如果我们将oplog看作缓冲队列,那么整个复制过程就是一个典型的”生产者-消费者“模式的应用。如图5-11所示。
图5-11 oplog复制链路
这里的主节点就是生产者,负责向自身的oplog队列中写入增量日志,也就是产生数据变更的记录。备节点则作为消费者一方,不断通过“pull”的方式拉取到这些增量日志进行消费。由于日志会不断增加,因此oplog被设计为固定大小的集合,它本身就是一个特殊的固定集合(capped collection),当oplog的容量达到上限时,旧的日志会被滚动删除。
一个典型的oplog如下所示:
字段说明见表5-2。
表5-2 字段说明
其中,ts字段描述了oplog产生的时间戳,可称之为optime。
optime是备节点实现增量日志同步的关键,它保证了oplog是节点有序的,其由两部分组成:
● 当前的系统时间,即UNIX时间至现在的秒数,32位。
● 整数计时器,不同时间值会将计数器进行重置,32位。
optime属于BSON的Timestamp类型,这个类型一般在MongoDB内部使用。
既然oplog保证了节点级有序,那么备节点便可以通过轮询的方式进行拉取,这里会用到可持续追踪的游标(tailable cursor)技术,如图5-12所示。
图5-12 oplog pull模式
每个备节点都分别维护了自己的一个offset,也就是从主节点拉取的最后一条日志的optime,在执行同步时就通过这个optime向主节点的oplog集合发起查询。为了避免不停地发起新的查询链接,在启动第一次查询后可以将cursor挂住(通过将cursor设置为tailable)。这样只要oplog中产生了新的记录,备节点就能使用同样的请求通道获得这些数据。
tailable cursor只有在查询的集合为固定集合时才允许开启。
通过db.currentOp命令可以看到具体的实现,代码如下:
local.oplog.rs指向了oplog集合,它存在于本地的local数据库中。local数据库里面的集合不会被同步到其他节点,而且除了oplog, local库还包含一些具有特殊用途的集合,具体如下。
● local.system.replset:用来记录当前副本集的成员。
● local.startup_log:用来记录本地数据库的启动日志信息。
● local.replset.minvalid:用来记录副本集的跟踪信息,如初始化同步需要的字段。
5.3.2 幂等性
每一条oplog记录都描述了一次数据的原子性变更,对于oplog来说,必须保证是幂等性的。也就是说,对于同一个oplog,无论进行多少次回放操作,数据的最终状态都会保持不变。比如在一些原子性操作更新中,我们用$inc来使字段自增,这个操作就不是幂等的,对文档字段多次执行$inc操作,每次都会产生新的结果。这些非幂等的更新命令在oplog中通常会被转换为$set操作,这样无论执行了多少次,文档的最终状态始终与第一次执行的效果一样。
$inc操作,代码如下:
在oplog中转换为$set操作,直接写入变更后的值,代码如下:
5.3.3 复制延迟
由于oplog集合是有固定大小的,因此存放在里面的oplog随时可能会被新的记录冲掉。如果备节点的复制不够快,就无法跟上主节点的步伐,从而产生复制延迟(replication lag)问题。这是不容忽视的,一旦备节点的延迟过大,则随时会发生复制断裂的风险,这意味着备节点的optime(最新一条同步记录)已经被主节点老化掉,于是备节点将无法继续进行数据同步。
为了尽量避免复制延迟带来的风险,我们可以采取一些措施,比如:
● 增加oplog的容量大小,并保持对复制窗口的监视。
● 通过一些扩展手段降低主节点的写入速度。
● 优化主备节点之间的网络。
● 避免字段使用太大的数组(可能导致oplog膨胀)。
oplog集合的大小
oplog集合的大小可以通过参数replication.oplogSizeMB设置,对于64位系统来说,oplog的默认值为:
对于大多数业务场景来说,很难在一开始评估出一个合适的oplogSize,所幸的是MongoDB在4.0版本之后提供了replSetResizeOplog命令,可以实现动态修改oplogSize而不需要重启服务器。
5.3.4 初始化同步
在开始时,备节点仍然需要向主节点获得一份全量的数据用于建立基本快照,这个过程就称为初始化同步(initial sync)。在MongoDB 3.4版本之后对于初始化同步做了不少改进,我们来看看它是怎么完成的:
● 备节点记录当前的同步optime=t1(来自主节点的同步时间戳),进入STARTUP2状态。
● 从主节点上复制所有非local数据库的集合数据,同时创建这些集合上的索引。
在这个过程中,备节点会开启另外一个线程,将集合复制过程中的增量oplog(t1之后产生)也复制到本地。
● 将拉取到t1之后的增量oplog进行回放,在完成之前,节点一直处于RECOVERING状态,此时是不可读的。
● oplog回放结束后,恢复SECONDARY状态,进入正常的增量同步流程。
最关键的一点就是,在全量复制过程中同时拉取了增量oplog,因此我们不需要担心在复制完成之后主节点上的t1oplog记录被冲掉,而导致初始化同步失败,这大大提升了该过程的性能和可靠性。
初始化同步对主节点仍然会有一定的性能影响,因此在执行初始化同步之前需要考量当前系统的压力情况,尽量选择在业务不繁忙时进行。
同步源
在前面的描述中,笔者只提到了备节点和主节点之间的复制,但实际上MongoDB是允许通过备节点进行复制的,这会发生在以下的情况中。
(1)在settings.chainingAllowed开启的情况下,备节点自动选择一个最近的节点(ping命令时延最小)进行同步。
(2)使用replSetSyncFrom命令临时更改当前节点的同步源,比如在初始化同步时将同步源指向备节点来降低对主节点的影响。
settings.chainingAllowed选项默认是开启的,也就是说默认情况下备节点并不一定会选择主节点进行同步,这个副作用就是会带来延迟的增加,你可以通过下面的操作进行关闭:
尽管存在备节点向备节点同步数据的情况,笔者仍然选择将主节点同步作为主要的场景描述,相比之下这样更加容易理解。
5.3.5 数据回滚
由于复制延迟是不可避免的,这意味着主备节点之间的数据无法保持绝对的同步。当副本集中的主节点宕机时,备节点会重新选举成为新的主节点。那么,当旧的主节点重新加入时,必须回滚掉之前的一些“脏日志数据”,以保证数据集与新的主节点一致。主备复制集合的差距越大,发生大量数据回滚的风险就越高。
对于写入的业务数据来说,如果已经被复制到了副本集的大多数节点,则可以避免被回滚的风险。应用上可以通过设定更高的写入级别(writeConcern:majority)来保证数据的持久性。
这些由旧主节点回滚的数据会被写到单独的rollback目录下,必要的情况下仍然可以恢复这些数据。