Mongo 高可用

开启 MongoDB 学习新篇章

Posted by lichao modified on November 1, 2021

高可用性 HA(High Availability)指的是缩短因正常运维或者非预期故障而导致的停机时间,提高系统可用性。大白话就是,无论出啥事都不能让承载的业务受影响,这就是高可用。无论是数据的高可靠,还是组件的高可用全都是一个解决方案:冗余。通过多个组件和备份导致对外提供一致性和不中断的服务。冗余是根本,但是怎么使用冗余则各有不同。

MongoDB 高可用方案分两种:

  • Master-Slave 主从复制
  • ReplicaSets 复制集

Master-Slave 模式

Mongodb 提供的第一种冗余策略就是 Master-Slave 策略,也是分布式系统最开始的冗余策略,这种是一种热备策略。Master-Slave 由主从角色构成:

  • Master(主): 可读可写,当数据有修改的时候,会将 Oplog 同步到所有连接的 Slave 上去。
  • Slave(从): 只读,所有的 Slave 从 Master 同步数据,从节点与从节点之间无感知。 Master-Slave 架构一般用于备份或者做读写分离,一般是一主一从设计和一主多从设计。

主从模式存在的问题:

  • 数据不一致: Master 节点可以写,Slave 节点只能同步 Master 数据并对外提供读服务,这是异步的过程。
  • 可用性差:主节点挂掉后,需要人为操作处理把 slave 节点切换成 master 节点。是一个巨大的停服窗口。

MongoDB 3.6 起已不推荐使用主从模式,自 MongoDB 3.2 起,分片群集组件已弃用主从复制。因为 Master-Slave 其中 Master 宕机后不能自动恢复,只能靠人为操作,可靠性也差,操作不当就存在丢数据的风险。

ReplicaSet 副本集模式

ReplicaSets 的结构非常类似一个集群 ,其中一个节点如果出现故障, 其它节点马上会将业务接过来而无须停机操作。

Mongodb 副本集是一种典型的高可用部署模式,副本集是一组服务器,一般是一个主节点(Primary)用来处理客户端的读写请求,多个副本节点(Secondary)节点对主节点的数据进行备份,以防止主节点宕机导致的单点故障。一旦主节点宕机后,那么整个副本集会进行一次新的选举,选举出一个新的节点成为主服务器。这种副本集部署模式,在主节点挂掉后,自动进行故障转移,保证业务的可用性。

  • 在副本集中,写操作必须在主节点执行,不能对备份节点执行写操作。备份节点只能通过复制功能写入数据,不接受客户端的写入请求。
  • 默认情况下,客户端不能从备份节点中读取数据。在备份节点上显式地执行 setSlaveOk 之后,客户端就可以从备份节点中读取数据了。

备份节点可能会落后于主机点,可能没有最新写入的数据,所有备份节点在默认情况下会拒绝读取请求,以防应用程序意外拿到过期的数据。

操作配置

下面依次执行创建副本集、使用副本集和关闭副本集的操作。

  1. 使用 --nodb 选项启动一个 mongo shell,这样可以启动shell但是不连接到任何的mongod。

    1
    
    $mongo --nodb
    
  2. 创建一个副本集,其中包含三个服务器的副本集:一个主服务器和两个备份服务器。

    1
    
    > replSet = new ReplSetTest({"nodes":3}) 
    
  3. 启动三个 mongod 进程

    1
    2
    3
    4
    
    // 启动 3 个 mongod 进程
    > replSet.startSet()
    // 配置复制功能
    > replSet.initiate()
    
  4. 在第二个 shell 中,连接到运行在第一个端口的 mongod,即连接到一个副本集成员。

    1
    
    $mongo --port 20000
    
  5. 执行 isMaster命令,查看副本集状态。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    > db.isMaster()
    {
      "isMaster": true,
      "hosts": [
        ...:20000,
        ...:20001,
        ...:20002
      ]
    }
    
  6. 主节点上执行写入操作:

    1
    2
    
    > db.blog.insert({"title":"hello, world!!"})
      
    
  7. 在另一个 shell 中,连接一个备份节点

    1
    
    $mongo --port 20001
    
  8. 在备份节点上执行查找操作

    1
    2
    3
    
    > db.blog.find()
    
    Error: error: { "ok" : 0, "errmsg" : "not master and slaveOk=false", "code" : 13435 }
    
  9. 设置允许从备份节点读取数据,即使是过期数据,注意setSlaveOk是对连接设置的,而不是对数据库设置的

    1
    2
    3
    4
    
    > db.setSlaveOk()
    
    > db.blog.find()
    {"title":"hello, world!!"}
    
  10. 不能对备份节点执行写操作

    1
    2
    3
    
    > db.bolg.insert({"title":"hello, world"})
    
    WriteResult({ "writeError" : { "code" : 10107, "errmsg" : "not master" } })
    
  11. 在第一个 shell 中执行副本集关闭

    1
    2
    3
    
    > replSet.stopSet()
    
    ReplSetTest stopSet Shut down repl set - test worked 
    

同步

复制用于在多台服务器之间备份数据

MongoDB 复制功能是使用操作日志 oplog 实现的,操作日志包含了主节点的每一次写操作。oplog 是主节点的 local 数据库中的一个固定集合。备份节点通过查询这个集合就可以知道需要进行复制的操作。

每个备份节点都维护着自己的 oplog,记录着每一次从主节点复制数据的操作。这样,每个成员都可以作为同步源提供给其他成员使用。备份节点从当前使用的同步源中获取需要执行的操作,然后在自己的数据集上执行这些操作,最后再将这些操作写入自己的 oplog。

由于复制操作的过程是先复制数据再写入 oplog,所以,备份节点可能会在已经同步过的数据上再次执行复制操作。MongoDB 保证将 oplog 中的同一个操作执行多次,与只执行一次的效果是一样的。

oplog 大小是固定的,只能保存特定数量的操作日志,oplog 操作的维度是文档,即如果单个操作影响多个文档,那么每个受影响的文档都会对应 oplog 中的一条日志。如果执行大量的批量操作 oplog 很快就会被填满。

选择

由于 oplog 大小是固定的,当从库加入到副本集的时候,就会检查自身状态,确定是否可以从某个成员那里进行增量同步。如果不行就需要从某个成员那里进行完整的数据复制,即 Initial Syc(全量同步)。

  1. 如果 local 数据库中的 oplog.rs 集合是空的,则做全量同步。
  2. 如果 minValid 集合里面存储的是 _initialSyncFlag(表示正在做全量同步,但全量同步失败),则做全量同步。
  3. 如果 initialSyncRequested 是 true,则做全量同步(用于 resync 命令,resync 命令只用于 master/slave 架构,副本集无法使用)

以上三个条件有一个条件满足就需要做全量同步。

全量同步

MongoDB 默认是采取级联复制的架构,就是默认不一定选择主库作为自己的同步源,如果不想让其进行级联复制,可以通过 chainingAllowed 参数来进行控制。在级联复制的情况下,也可以通过 replSetSyncFrom 命令来指定想复制的同步源。所以这里说的同步源其实相对于从库来说就是它的主库。

  1. 从库会在副本集其他节点通过以下条件筛选符合自己的同步源。通过下述筛选最后过滤出来的节点作为新的同步源。
    • 如果设置了 chainingAllowed 为 false,那么只能选取主库为同步源
    • 找到与自己 ping 时间最小的并且数据比自己新的节点(在副本集初始化的时候,或者新节点加入副本集的时候,新节点对副本集的其他节点至少 ping 两次)
    • 该同步源与主库最新 optime 做对比,如果延迟主库超过 30s,则不选择该同步源。
    • 在第一次的过滤中,首先会淘汰比自己数据还旧的节点。如果第一次没有,那么第二次需要算上这些节点,防止最后没有节点可以做为同步源了。
    • 最后确认该节点是否被禁止参与选举,如果是则跳过该节点。
  2. 删除 MongoDB 中除 local 以外的所有数据库
  3. 拉取主库存量数据,即将同步源的所有记录全部复制到本地 增量复制
    1. Add _initialSyncFlag to minValid collection to tell us to restart initial sync if we crash in the middle of this procedure
    2. Record start time.(记录当前主库最近一次 oplog time)
    3. Clone.
    4. Set minValid1 to sync target’s latest op time.
    5. Apply ops from start to minValid1, fetching missing docs as needed.(Apply Oplog 1)
    6. Set minValid2 to sync target’s latest op time.
    7. Apply ops from minValid1 to minValid2.(Apply Oplog 2)
    8. Build indexes.
    9. Set minValid3 to sync target’s latest op time.
    10. Apply ops from minValid2 to minValid3.(Apply Oplog 3)
    11. Cleanup minValid collection: remove _initialSyncFlag field, set ts to minValid3 OpTime

Mongo 3.4 Initial Sync 在创建的集合的时候同时创建了索引(与主库一样),在 MongoDB 3.4 版本之前只创建 _id 索引,其他索引等待数据 copy 完成之后进行创建。

增量同步

在完成 Initial Sync 后,就是已经把同步源的存量数据拿过来了,主库后续写入的数据通过增量同步的方式进行同步。 增量复制 注:这里不一定是 Primary,同步源也可能是 Secondary。

可以看到上述有 6 个步骤,那每个步骤具体做的事情如下:

  1. Sencondary 初始化同步完成之后,开始增量复制,通过 produce 线程在 Primary oplog.rs 集合上建立 cursor,并且实时请求获取数据。
  2. Primary 返回 oplog 数据给 Secondary。
  3. Sencondary 读取到 Primary 发送过来的 oplog,将其写入到队列中。
  4. Sencondary 的同步线程会通过 tryPopAndWaitForMore 方法一直消费队列,当每次达到一定的条件之后,就会将数据给 prefetchOps 方法处理,prefetchOps 方法主要将数据以 database 级别切分,便于后面多线程写入到数据库中。如果采用的 WiredTiger 引擎,那这里是以 Docment ID 进行切分。满足以下条件之一即可:
    • 总数据大于 100MB
    • 已经取到部分数据但没到 100MB,但是目前队列没数据了,这个时候会阻塞等待一秒,如果还没有数据则本次取数据完成。
  5. 最终将划分好的数据以多线程的方式批量写入到数据库中(在从库批量写入数据的时候 MongoDB 会阻塞所有的读)。
  6. 然后再将 Queue 中的 Oplog 数据写入到 Sencondary 中的 oplog.rs 集合中。

心跳

一旦一个副本集创建成功,那么每一个节点之间都保持着通信,每 2s 会向整个副本集的其他节点发一次心跳通知,也就是一个 ping 包。在任意一个节点的数据库内部,维护着整个副本集节点的状态信息,一旦某一个节点超过 10s 不能收到回应,就认为这个节点不能访问。另外,对于主节点而言,除了维护这个状态信息外,还要判断是否和大多数节点可以正常通信,如果不能,则要主动降级。

成员状态

各个成员会通过心跳将自己的当前状态告诉其他成员。常见状态:

  • startup: 成员刚启动时处于这个状态。在这个状态下,MongoDB 会尝试加载成员的副本集配置,配置加载成功之后,就进入 startup2 状态。
  • startup2: 整个初始化同步过程都会处于整个状态。
  • recovering: 这个状态表明成员运转正常,但还不能处理读请求。如果成员处于这个状态,可能会造成轻微的系统过载。
    • 启动时,成员需要做一些检查以确保自己处于有效状态,之后才可以处理读取请求。
    • 启动时,成为备份节点之前。
    • 在处理非常耗时的操作时,成员也可能进入recovering状态。
    • 当一个成员与其他成员脱节时(too stale to catch up)。
  • arbiter: 不存数据,不会被选为主,只进行选主投票。使用 Arbiter 可以减轻在减少数据的冗余备份,又能提供高可用的能力。在正常操作下,仲裁者应该始终处于 arbiter 状态。
  • primary: 主节点
  • Sencondary: 备份节点

MongoDB 复制集成员状态机: 复制集状态机

选举

MongoDB 3.2 版本之前选举协议是基于 Bully 算法,从 3.2 版本开始 MongoDB 的副本集协议是一种 raft-like 协议(内部叫pv1),即基于 raft 协议的理论思想实现,并且对之进行了一些扩展。

预选举(pre-vote)

先来看没有预选举阶段的 raft 协议在下面这种场景下有什么问题。

89eduo81og.png

如图所示是一个有 3 个节点的集群,其中 S2 暂时与 S1 和 S3 不互通。

  1. 在 raft 协议中,对于 S2 这一节点而言,每次达到选举超时的时候它都会发起一次选举并自增 term。由于并不能连接到 S1 和 S3,选举会失败,如此反复,term 会增加到一个相对比较大的值(图中为57)。
  2. 由于 S1 和 S3 满足大多数条件,不妨假设选择 S1 成为集群新的主节点,term 变为 2。
  3. 当网络连接恢复,S2 又可以重新连接到 S1 和 S3 之后,其 term 会通过心跳传递给 S1 和 S3,而这会导致 S1 step down 成为从节点。
  4. 选举超时时间过后,集群会重新触发一次选举,无论是 S1 还是 S3 成为新的主(S2 由于落后所以不可能),其 term 值会变成 58;

上面描述的场景有什么问题呢?

  1. term 跳变
  2. 网络恢复后多了一次无意义的选举。而从 step down 到新一轮选举完成的过程中集群是无主的(不可写状态)

预选举就是为了解决上述问题的。在尝试自增 term 并发起选举之前,S2 会看看自己有没有可能获得来自 S1 和 S3 的选票。如若不满足条件则不会发起真正的选举。

选举条件: 参与选举的节点必须大于等于 N/2 + 1 个节点,如果正常节点数已经小于一半,则整个副本集的节点都只能为只读状态,整个副本集将不能支持写入。也不能够进行选举。

追赶阶段(catchup)

新选出来的 primary 为何要进入这个阶段?同样的,可以分析一下没有这个阶段的话在下面这个场景有什么问题。 54jeiglpb3.png

如图所示,是一个有 3 个节点的集群,分别为 S1, S2, S3

  • (a) 时刻,S1 为 primary,序号为 2 的日志还没来得及复制到 S2 和 S3 上
  • (b) 时刻,S1 挂掉,触发集群重新选举
  • (c) 时刻,S3 成为新的 primary,term 变为 2
  • (d) 时刻,S1 恢复,但是发现自己比 primary 节点有更新的日志,触发回滚(rollback)操作
  • (e) 时刻,集群恢复正常,新的写入成功

上面的场景有什么问题?准确来说没什么问题,符合 raft 协议的强一致性原则,但是存在一次回滚过程。catchup 就是为了尽量避免回滚的出现而诞生的。

6fcgahgyn5.png

如图所示,如果 S1 在 (c) 时刻就恢复,这里的时刻应该再细化一下,是在 S3 获得了 S2 的投票成为 primary 状态之后,而不是在获得投票结果之前(否则的话 S1 不会投票给S3,本轮选举失败,等待下一轮选举,S1 会重新成为主)。S3 进入 catchup 状态,看看有没有哪个从节点存在比自己更新的日志,发现 S1 有,然后就同步到自己这边并提交,再“真正”成为主节点,支持外部的写入;然后整个副本集一切恢复正常。

注意到这一过程中是没有回滚操作的。集群通过副本集协议保留了序号为 2 的写入日志并且自愈。

catchup 是利用节点间的心跳和 oplog 来实现的。这一时间段的长短取决于旧 primary 挂之前超前的数据量。对于新的 primary 而言,“追赶”可以说是很形象了。总而言之,catchup 阶段可以避免部分场景下回滚的出现。 当然,官方贴心地提供了两个可调整的设置参数(在MnogoDB3.6以上的版本生效)用来控制 catchup 阶段,分别是:

  1. settings.catchUpTimeoutMillis(单位为ms)
    • 默认为 -1,表示无限的追赶等待时间。即新选举出的 primary 节点,尝试去副本集内其他节点获取(追赶catchup)更新的写入并同步到自身,直到完全同步为止。
    • 设置为 0 表示关闭 catchup 功能。
  2. settings.catchUpTakeoverDelayMillis(单位为ms)
    • 默认为 30000,即 30s。表示允许新选出来的 primary 在被接管(takeover)前处于追赶(catchup)阶段的时间。
    • 设置为 -1 表示关闭追赶抢占 (catchup takeover) 功能。 当追赶时间为无限,且关闭了追赶抢占功能时,也可通过 replSetAbortPrimaryCatchUp 命令来手动强制终止 catchup 阶段,完成向 primary 的过渡。

在 catchup 阶段发起写入操作,会发生什么?客户端会收到 MongoDB 返回的错误(NotMaster) not master,该错误一般出现在尝试对 seconday 节点进行写操作时。

对于 MongoDB 而言,还有其他手段可以用来尽量避免回滚操作的出现,比如设置 writeConcern 为 majority(对于3节点集群而言,也可直接设置为2)来避免图中(a)情况的出现。确保新选出来的 primary 包含旧 primary 挂掉前的最新数据。 89eduo81og.png

总结

MongoDB 副本集协议与 raft 协议是有区别的: iap1iku1vd

参考文献

【1】深入浅出 MongoDB 复制

【2】MongoDB内核:副本集选举过程分析

【3】Consensus: Bridging Theory and Practice

【4】4-modifications-for-Raft-consensus

【5】MongoDB doc replication

【6】MongoDB doc replica-configuration

【7】一文搞懂raft算法

【8】MongoDB高可用复制集内部机制:Raft协议