个人介绍
赵景波,3 年专职 DBA 经验,2017 DTCC 讲师,目前主要负责新浪 NoSQL 服务的运维及研发工作。热衷于开源 DB 内部原理探究。
综述
笔者最近在生产环境中遇到许多复制相关问题,查阅网上资料发现官方文档虽然系统但是不够有深度,网上部分深度文章则直接以源码展示,不利于大家了解。所以本文则是结合前两者最终给读者以简单的方式展现 MongoDB 复制的整个架构。本文分为以下 5 个步骤:
- MongoDB 复制简介
- MongoDB 添加从库
- MongoDB 复制流程详解
- MongoDB 高可用
- MongoDB 复制总结
1、MongoDB 复制简介
本章节首先会给大家简单介绍一些 MongoDB 复制的一些基本概念,便于大家对后面内容的理解。
1.1、基本介绍
MongoDB 有副本集及主从复制两种模式,今天给大家介绍的是副本集模式,因为主从模式在 MongoDB 3.6 也彻底废弃不使用了。MongoDB 副本集有 Primary、Secondary、Arbitrar 三种角色。今天给大家介绍的是 Primary 与 Secondary 数据同步的内部原理。MongoDB 副本集架构如下所示:
1.2、MongoDB Oplog
MongoDB Oplog 是 MongoDB Primary 和 Secondary 在复制建立期间和建立完成之后的复制介质,就是 Primary 中所有的写入操作都会记录到 MongoDB Oplog 中,然后从库会来主库一直拉取 Oplog 并应用到自己的数据库中。这里的 Oplog 是 MongoDB local 数据库的一个集合,它是 Capped collection,通俗意思就是它是固定大小,循环使用的。如下图:
MongoDB Oplog 中的内容及字段介绍:
{
“ts” : Timestamp(1446011584, 2),
“h” : NumberLong(“1687359108795812092”),
“v” : 2,
“op” : “i”,
“ns” : “test.nosql”,
“o” : { “_id” : ObjectId(“563062c0b085733f34ab4129”), “name” : “mongodb”, “score” : “100” }
}
ts: 操作时间,当前 timestamp + 计数器,计数器每秒都被重置
h:操作的全局唯一标识
v:oplog 版本信息
op:操作类型
i:插入操作
u:更新操作
d:删除操作
c:执行命令(如 createDatabase,dropDatabase)
n:空操作,特殊用途
ns:操作针对的集合
o:操作内容,如果是更新操作
o2:操作查询条件,仅 update 操作包含该字段
1.3、MongoDB 复制发展
MongoDB 目前已经迭代了很多个版本,下图我汇总了目前市面上常用版本中 MongoDB 在复制的一些重要改进。
具体细节大家可以参考MongoDB 官方Release Note: https://docs.mongodb.com/manual/release-notes/3.6/
2、MongoDB 添加从库
2.1、添加从库命令
MongoDB 添加从库比较简单,在安装后从库之后,直接在主库执行 rs.add() 或者 replSetReconfig 命令即可添加,这两个命令其实在最终都调用 replSetReconfig 命令执行。大家有兴趣可以去翻阅 MongoDB 客户端 JS 代码。
2.2、具体步骤
然后我们来看副本集加一个新从库的大致步骤,如下图,右边的 Secondary 是我新加的从库。
通过上图我们可以看到一共有 7 个步骤,下面我们看看每一个步骤 MongoDB 都做了什么:
- 主库收到添加从库命令
- 主库更新副本集配置并与新从库建立心跳机制
- 从库收到主库发送过来的心跳消息与主库建立心跳
- 其他从库收到主库发来的新版本副本集配置信息并更新自己的配置
- 其他从库与新从库建立心跳机制
- 新从库收到其他从库心跳信息并跟其他从库建立心跳机制
- 新加的节点将副本集配置信息更新到 local.system.replset 集合中,MongoDB 会在一个循环中查询 local.system.replset 是否配置了 replset 信息,一旦查到相关信息触发开启复制线程,然后判断是否需要全量复制,需要的话走全量复制,不需要走增量复制。
- 最终同步建立完成
注意:
- 副本集所有节点之前都有相互的心跳机制,每 2 秒一次,在 MongoDB 3.2 版本以后我们可以通过 heartbeatIntervalMillis 参数来控制心跳频率。
3、 MongoDB 复制流程详解
上面我们知道添加一个从库的大致流程,那我们现在来看主从数据同步的具体细节。当从库加入到副本集的时候,会判断自己是需要 Initial Syc(全量同步)还是增量同步。那是通过什么条件判断的呢?
3.1、判断全量同步及增量同步
- 如果 local 数据库中的 oplog.rs 集合是空的,则做全量同步。
- 如果 minValid 集合里面存储的是 _initialSyncFlag,则做全量同步(用于 init sync 失败处理)
- 如果 initialSyncRequested 是 true,则做全量同步(用于 resync 命令,resync 命令只用于 master/slave 架构,副本集无法使用)
以上三个条件有一个条件满足就需要做全量同步。
我们可以得出在从库最开始加入到副本集的时候,只能先进行 Initial Sync,下面我们来看看 Initial Sync 的具体流程
3.2、全量同步流程 (Init sync)
1、 寻找同步源
这里先说明一点,MongoDB 默认是采取级联复制的架构,就是默认不一定选择主库作为自己的同步源,如果不想让其进行级联复制,可以通过 chainingAllowed 参数来进行控制。在级联复制的情况下,你也可以通过 replSetSyncFrom 命令来指定你想复制的同步源。所以这里说的同步源其实相对于从库来说就是它的主库。那么同步源的选取流程是怎样的呢?
MongoDB 从库会在副本集其他节点通过以下条件筛选符合自己的同步源。
- 如果设置了 chainingAllowed 为 false,那么只能选取主库为同步源
- 找到与自己 ping 时间最小的并且数据比自己新的节点(在副本集初始化的时候,或者新节点加入副本集的时候,新节点对副本集的其他节点至少 ping 两次)
- 该同步源与主库最新 optime 做对比,如果延迟主库超过 30s,则不选择该同步源。
- 在第一次的过滤中,首先会淘汰比自己数据还旧的节点。如果第一次没有,那么第二次需要算上这些节点,防止最后没有节点可以做为同步源了。
- 最后确认该节点是否被禁止参与选举,如果是则跳过该节点。
通过上述筛选最后过滤出来的节点作为新的同步源。
其实 MongoDB 同步源在除了在 Initial Sync 和增量复制 的时候选定之后呢,并不是一直是稳定的,它可能在以下情况下进行变更同步源:
- ping 不通自己的同步源
- 自己的同步源角色发生变化
- 自己的同步源与副本集任意一个节点延迟超过 30s
2、 删除 MongoDB 中除 local 以外的所有数据库
3、 拉取主库存量数据
这里就到了 Initial Sync 的核心逻辑了,我下面以图和步骤的方式给大家展现 MongoDB 在做 Initial Sync 的具体流程。
同步流程如下:
- Add _initialSyncFlag to minValid collection to tell us to restart initial sync if we crash in the middle of this procedure
- Record start time.(记录当前主库最近一次 oplog time)
- Clone.
- Set minValid1 to sync target’s latest op time.
- Apply ops from start to minValid1, fetching missing docs as needed.(Apply Oplog 1)
- Set minValid2 to sync target’s latest op time.
- Apply ops from minValid1 to minValid2.(Apply Oplog 2)
- Build indexes.
- Set minValid3 to sync target’s latest op time.
- Apply ops from minValid2 to minValid3.(Apply Oplog 3)
- Cleanup minValid collection: remove _initialSyncFlag field, set ts to minValid3 OpTime
注:以上步骤直接 copy 的 MongoDB 源码中的注释。
以上步骤在 Mongo 3.4 Initial Sync 有如下改进:
- 在创建的集合的时候同时创建了索引(与主库一样),在 MongoDB 3.4 版本之前只创建 _id 索引,其他索引等待数据 copy 完成之后进行创建。
- 在创建集合和拷贝数据的同时,也将 oplog 拷贝到本地 local 数据库中,等到数据拷贝完成之后,开始应用本地 oplog 数据。
- 新增由于网络问题导致 Initial Sync 失败重试机制。
- 在 Initial Sync 期间发现 collection 重命名了会重新开始 Initial Sync。
上述 4 个新增特性提升了 Initial Sync 的效率并且提高了 Initial Sync 的可靠性,所以大家使用 MongoDB 最好使用最新版本 MongoDB 3.4 或者 3.6,MongoDB 3.6 更是有一些令人兴奋的特性,这里就不在此叙述了。
全量同步完成之后,然后 MongoDB 会进入到增量同步的流程。
3.3、增量同步流程
上面我们介绍了 Initial Sync,就是已经把同步源的存量数据拿过来了,那主库后续写入的数据怎么同步过来呢?下面还是以图跟具体的步骤来给大家介绍:
注:这里不一定是 Primary,刚刚提到了同步源也可能是 Secondary,这里采用 Primary 主要方便大家理解。
我们可以看到上述有 6 个步骤,那每个步骤具体做的事情如下:
- Sencondary 初始化同步完成之后,开始增量复制,通过 produce 线程在 Primary oplog.rs 集合上建立 cursor,并且实时请求获取数据。
- Primary 返回 oplog 数据给 Secondary。
- Sencondary 读取到 Primary 发送过来的 oplog,将其写入到队列中。
- Sencondary 的同步线程会通过 tryPopAndWaitForMore 方法一直消费队列,当每次达到一定的条件之后,条件如下:
- 总数据大于 100MB
- 已经取到部分数据但没到 100MB,但是目前队列没数据了,这个时候会阻塞等待一秒,如果还没有数据则本次取数据完成。
上述两个条件满足一个之后,就会将数据给 prefetchOps 方法处理,prefetchOps 方法主要将数据以 database 级别切分,便于后面多线程写入到数据库中。如果采用的 WiredTiger 引擎,那这里是以 Docment ID 进行切分。
5. 最终将划分好的数据以多线程的方式批量写入到数据库中(在从库批量写入数据的时候 MongoDB 会阻塞所有的读)。
6. 然后再将 Queue 中的 Oplog 数据写入到 Sencondary 中的 oplog.rs 集合中。
4、 MongoDB 高可用
上面我们介绍 MongoDB 复制的数据同步,我们知道除了数据同步,复制还有一个重要的地方就是高可用,一般的数据库是需要我们自己去定制方案或者采用第三方的开源方案。MongoDB 则是自己在内部已经实现了高可用方案。下面我就给大家详细介绍一下 MongoDB 的高可用。
4.1、触发切换场景
首先我们看那些情况会触发 MongoDB 执行主从切换。
- 新初始化一套副本集
- 从库不能连接到主库(默认超过 10s,可通过 heartbeatTimeoutSecs 参数控制),从库发起选举
- 主库主动放弃 primary 角色
-
主动执行 rs.stepdown 命令
-
主库与大部分节点都无法通信的情况下
-
修改副本集配置的时候(在 Mongo 2.6 版本会触发,其他版本待确定) 修改以下配置的时候:
- _id
- votes
- priotity
- arbiterOnly
- slaveDelay
- hidden
- buildIndexes
- 移除从库的时候(在 MongoDB 2.6 会触发,MongoDB 3.4 不会,其他版本待确定)
4.2、心跳机制
通过上面触发切换的场景,我们了解到 MongoDB 的心跳信息是 MongoDB 判断对方是否存活的重要条件,当达到一定的条件时,MongoDB 主库或者从库就会触发切换。下面我给大家详细介绍一下心跳机制
我们知道 MongoDB 副本集所有节点都是相互保持心跳的,然后心跳频率默认是 2 秒一次,也可以通过 heartbeatIntervalMillis 来进行控制。在新节点加入进来的时候,副本集中所有的节点需要与新节点建立心跳,那心跳信息具体是什么内容呢?
心跳信息内容:
BSONObjBuilder cmdBuilder; cmdBuilder.append("replSetHeartbeat", setName); cmdBuilder.append("v", myCfgVersion); cmdBuilder.append("pv", 1); cmdBuilder.append("checkEmpty", checkEmpty); cmdBuilder.append("from", from); if (me > -1) { cmdBuilder.append("fromId", me); }
注:上述代码摘抄 MongoDB 源码中构建心跳信息片段。
具体在 MongoDB 日志中表现如下:
command admin.$cmd command: replSetHeartbeat { replSetHeartbeat: “shard1”, v: 21, pv: 1, checkEmpty: false, from: “10.13.32.244:40011”, fromId: 3 } ntoreturn:1 keyUpdates:0
那副本集所有节点默认都是每 2 秒给其他剩余的节点发送上述信息,在其他节点收到信息后会调用ReplSetCommand命令来处理心跳信息,处理完成会返回如下信息:
result.append("set", theReplSet->name()); MemberState currentState = theReplSet->state(); result.append("state", currentState.s); // 当前节点状态 if (currentState == MemberState::RS_PRIMARY) { result.appendDate("electionTime", theReplSet->getElectionTime().asDate()); } result.append("e", theReplSet->iAmElectable()); // 是否可以参与选举 result.append("hbmsg", theReplSet->hbmsg()); result.append("time", (long long) time(0)); result.appendDate("opTime", theReplSet->lastOpTimeWritten.asDate()); const Member *syncTarget = replset::BackgroundSync::get()->getSyncTarget(); if (syncTarget) { result.append("syncingTo", syncTarget->fullName()); } int v = theReplSet->config().version; result.append("v", v); if( v > cmdObj["v"].Int() ) result << "config" << theReplSet->config().asBson();
注:以上信息是正常情况下返回的,还有一些不正常的处理场景,这里就不一一细说了。
4.3、切换流程
前面我们了解了触发切换的场景以及 MongoDB 副本集节点之前的心跳机制。下面我们来看切换的具体流程:
- 从库无法连接到主库,或者主库放弃 Primary 角色。
- 从库会根据心跳消息获取当前该节点的角色并与之前进行对比
- 如果角色发生改变就开始执行 msgCheckNewState 方法
- 在 msgCheckNewState 方法中最终调用 electSelf 方法(会有一些判断来决定是否最终调用 electSelf 方法)
- electSelf 方法最终向副本集其他节点发送 replSetElect 命令来请求投票。 命令如下:
BSONObj electCmd = BSON( "replSetElect" << 1 << "set" << rs.name() << "who" << me.fullName() << "whoid" << me.hbinfo().id() << "cfgver" << rs._cfg->version << "round" << OID::gen() /* this is just for diagnostics */ );
具体日志表现如下:
2017-12-14T10:13:26.917+0800 [conn27669] run command admin.$cmd { replSetElect: 1, set: “shard1”, who: “10.13.32.244:40015”, whoid: 4, cfgver: 27, round: ObjectId(‘5a31de4601fbde95ae38b4d2’) }
6. 其他副本集收到 replSetElect 会对比 cfgver 信息,会确认发送该命令的节点是否在副本集中,确认该节点的优先级是否是该副本集所有节点中优先级最大的。最后满足条件才会给该节点发送投票信息。
7. 发起投票的节点最后会统计所得票数大于副本集可参与投票数量的一半,则抢占成功,成为新的 Primary。
8. 其他从库如果发现自己的同步源角色发生变化,则会触发重新选取同步源。
4.4、Rollback
我们知道在发生切换的时候是有可能造成数据丢失的,主要是因为主库宕机,但是新写入的数据还没有来得及同步到从库中,这个时候就会发生数据丢失的情况。
那针对这种情况,MongoDB 增加了回滚的机制。在主库恢复后重新加入到复制集中,这个时候老主库会与同步源对比 oplog 信息,这时候分为以下两种情况:
- 在同步源中没有找到比老主库新的 oplog 信息。
- 同步源最新一条 oplog 信息跟老主库的 optime 和 oplog 的 hash 内容不同。
针对上述两种情况 MongoDB 会进行回滚,回滚的过程就是逆向对比 oplog 的信息,直到在老主库和同步源中找到对应的 oplog,然后将这期间的 oplog 全部记录到 rollback 目录里的文件中,如果但是出现以下情况会终止回滚:
- 对比老主库的 optime 和同步源的 optime,如果超过了 30 分钟,那么放弃回滚。
- 在回滚的过程中,如果发现单条 oplog 超过 512M,则放弃回滚。
- 如果有 dropDatabase 操作,则放弃回滚。
- 最终生成的回滚记录超过 300M,也会放弃回滚。
上述我们已经知道了 MongoDB 的回滚原理,但是我们在生产环境中怎么避免回滚操作呢,因为毕竟回滚操作很麻烦,而且针对有时序性的业务逻辑也是不可接受的。那 MongoDB 也提供了对应的方案,就是 WriteConcern,这里就不细说了,有兴趣的朋友可以仔细了解。其实这也是在 CAP 中做出一个选择。
5、 MongoDB 复制总结
MongoDB 复制内部原理已经给大家介绍完毕,以上其实还涉及很多细节没能一一列出。大家有兴趣可以自己去整理。这里还需要说明一点就是 MongoDB 版本迭代速度比较快,所以本文只针对于 MongoDB 2.6 到 MongoDB 3.4 版本,不过在某些版本可能会存在一些细节的变动,但是大体上的逻辑还是没有改变。最后大家如果有什么问题,也可以与我联系。
评论