线上某 MongoDB 复制集实例(包含 Primary、Secondary、Hidden 3个节点 ),Primary 节点突然 IOPS 很高,调查后发现,其中 Hidden 处于 RECOVERING 状态,同时 Priamry 上持续有一全表扫描 oplog 的操作,正是这个 oplog 的 COLLSCAN 导致IO很高。
2017-10-23T17:48:01.845+0800 I COMMAND [conn8766752] query local.oplog.rs query: { ts: { $gte: Timestamp 1505624058000|95, $lte: Timestamp 1505624058000|95 } } planSummary: COLLSCAN cursorid:20808023597 ntoreturn:0 ntoskip:0 keysExamined:0 docsExamined:44669401 keyUpdates:0 writeConflicts:0 numYields:353599 nreturned:0 reslen:20 locks:{ Global: { acquireCount: { r: 707200 } }, Database: { acquireount: { r: 353600 }, acquireWaitCount: { r: 15 }, timeAcquiringMicros: { r: 3667 } }, oplog: { acquireCount: { r: 353600 } } } 935646ms
上述问题,初步一看有2个疑问
- Hidden 上最新的 oplog 在 Primary 节点上是存在的,为什么 Hidden 会一直处于 RECOVERING 状态无法恢复?
- 同步拉取 oplog 时,会走 oplogHack 的路径,即快速根据oplog上次同步的位点定位到指点位置,这里会走一个二分查找,而不是COLLSCAN,然后从这个位点不断的tail oplog。既然有了这个优化,为什么会出现扫描所有的记录?
接下里将结合 MongoDB 同步的细节实现来分析下上述问题产生的原因。
备如何选择同步源?
MongoDB 复制集使用 oplog 来做主备同步,主将操作日志写入 oplog 集合,备从 oplog 集合不断拉取并重放,来保持主备间数据一致。MongoDB 里的 oplog 特殊集合拥有如下特性:
- 每条 oplog 都包含时间戳,按插入顺序递增,如果底层使用的KV存储引擎,这个时间戳将作为 oplog 在KV引擎里存储的key,可以理解为 oplog 在底层存储就是按时间戳顺序存储的,在底层能快速根据ts找位置。
- oplog 集合没有索引,它一般的使用模式是,备根据自己已经同步的时间戳,来定位到一个位置,然后从这个位置不断 tail query oplog。针对这种应用模式,对于
local.oplog.rs.find({ts: {$gte: lastFetechOplogTs}})
这样的请求,会有特殊的oplogStartHack 的优化,先根据gte的查询条件在底层引擎快速找到起始位置,然后从该位置继续 COLLSCAN。 - oplog 是一个
capped collection
,即固定大小集合(默认为磁盘大小5%),当集合满了时,会将最老插入的数据删除。
选择同步源,条件1:备上最新的oplog时间戳 >= 同步源上最旧的oplog时间戳
备在选择同步源时,会根据 oplog 作为依据,如果自己最新的oplog,比同步源上最老的 oplog 还有旧,比如 secondaryNewest < PrimaryOldest
,则不能选择 Primary 作为同步源,因为oplog不能衔接上。如上图,Secondary1 可以选择 Primary 作为同步源,Secondary2 不能选择 Primary作为同步源,但可以选择 Secondary1 作为同步源。
如果所有节点都不满足上述条件,即认为找不到同步源,则节点会一直处于 RECOVERING 状态,并会打印 too stale to catch up -- entering maintenance mode
之类的日志,此时这个节点就只能重新全量同步了(向该节点发送 resync 命令即可)。
选择同步源,条件2:如果minvalid处于不一致状态,则minvalid里的时间戳在同步源上必须存在
local.replset.minvalid
(后简称minvalid)是 MongoDB 里的一个特殊集合,用于存储节点同步的一致时间点,在备重放oplog、回滚数据的时候都会用到,正常情况下,这个集合里包含一个ts字段,跟最新的oplog时间戳一致,即 { ts: lastOplogTimestamp }
。
- 当备拉取到一批 oplog 后,假设第一条和最后一条 oplog 的时间戳分别为 firstOplogTimestamp、lastOplogTimestamp,则备在重放之前,会先把 minvalid 更新为
{ ts: lastOplogTimestamp, begin: firstOplogTimestamp}
,加了begin字段后就说明,当前处于一个不一致的状态,等一批 oplog 全部重放完,备将 oplog 写到本地,然后更新 minvalid 为{ ts: lastOplogTimestamp}
,此时又达到一致的状态。 -
节点在ROLLBACK时,会将 minvalid 先更新为
{ ts: lastOplogTimestampInSyncSource, begin: rollbackCommonPoint}
,标记为不一致的状态,直到继续同步后才会恢复为一致的状态。比如主节点 A B C F G H 备节点1 A B C F G 备节点2 A B C D E 备节点就需要回滚到 CommonPoint C,如果根据主来回滚,则minvalid会被更新为 { ts: H, begin:C}`
在选择同步源时,如果 minvalid 里包含 begin 字段,则说明它上次处于一个不一致的状态,它必须先确认 ts 字段对应的时间戳(命名为
requiredOptime
)在同步源上是否存在,主要目的是: - 重放时,如果重放过程异常结束,重新去同步时,必须要找包含上次异常退出时oplog范围的节点来同步
- ROLLBACK后选择同步源,必须选择包含ROLLBACK时参考节点对应的oplog范围的节点来同步;如上例,备节点2回滚时,它的参考节点包含了H,则在接下来选择同步源上,同步源一定要包含H才行。
为了确认 requireOptime
是否存在,备会发一个 ts: {$gte: requiredOptime, $lte: requiredOptime}
的请求来确认,这个请求会走到 oplogStartHack的路径,先走一次二分查找,如果能找到(绝大部分情况),皆大欢喜,如果找不到,就会引发一次 oplog 集合的全表扫描,如果oplog集合很大,这个开销非常大,而且会冲掉内存中的cache数据。
oplogStartHack 的本质
通过上面的分析发现,如果 requiredOptime
在同步源上不存在,会引发同步源上的一次oplog全表扫描,这个主要跟oplog hack的实现机制相关。
对于oplog的查找操作,如果其包含一个 ts: {$gte: beginTimestamp}
的条件,则 MongoDB 会走 oplogStartHack 的优化,先从引擎层获取到第一个满足查询条件的RecordId,然后把RecordId作为表扫描的参数。
- 如果底层引擎查找到了对应的点,oplogStartHack优化有效
- 如果底层引擎没有没有找到对应的点,RecordId会被设置为空值,对接下来的全表扫描不会有任何帮助。(注:个人认为,这里作为一个优化,应该将RecordId设置为Max,让接下里的全表扫描不发生。)
if (查询满足oplogStartHack的条件) {
startLoc = collection->getRecordStore()->oplogStartHack(txn, goal.getValue()); // 1. 将起始值传到底层引擎,通过二分查找找到起始值对应的RecordId
}
// Build our collection scan...
CollectionScanParams params;
params.collection = collection;
params.start = *startLoc; // 2. 将起始RecordId作为表扫描的参数
params.direction = CollectionScanParams::FORWARD;
params.tailable = cq->getParsed().isTailable();
总结
结合上述分析,当一致时间点对应的oplog在同步源上找不到时,会在同步源上触发一次oplog的全表扫描。当主备之间频繁的切换(比如线上的这个实例因为写入负载调大,主备角色切换过很多次),会导致多次ROLLBACK发生,最后出现备上minvalid里的一致时间点在同步源上找不到,引发了oplog的全表扫描;即使发生全表扫描,因为不包含minvalid的oplog,备也不能选择这个节点当同步源,最后就是一直找不到同步源,处于RECOVERING状态无法恢复,然后不断重试,不断触发主上的oplog全表扫描,恶性循环。
如何避免上述问题?
- 上述问题一般很难遇到,而且只有oplog集合大的时候影响才会很恶劣。
- 终极方法还是从代码上修复,我们已经在阿里云MongoDB云数据库里修复这个问题,并会向官方提一个PR,在上述的场景不产生全表扫描,而是返回找不到记录。