基于 snapshot 的 MongoDB 从库读优化

杨林 2017-09-08 325 标签: MongoDB腾讯云

导语

我们发现腾讯云上一些CMongo实例在主库写压力比较大的情况下,这时从库上会出现很多慢查询,经过调查发现,从库在回放oplog的时候加了全局锁,阻塞了所有的读直到回放结束。经过我们的优化,使得从库的读延时降低几倍到几十倍不等,qps也有一个明显的提升。

背景知识

mongodb复制集原理

mongodb复制集是由一系列mongod实例组成的,包含一个primary和若干个secondary,数据通过primary写入, primary与secondary之间通过oplog来同步数据,primary上的写操作完成后,会向local.oplog.rs特殊集合写入一条oplog,secondary负责从复制源(一般为primary,但是mongo也支持链式复制,即secondary也可以作为复制源)拉取oplog,在secondary上回放,从而保持主从之间数据的一致性。

下图是一个典型的mongo复制集,包含1个primary和2个secondary:

当一个新节点加入复制集时,首先要执行initial-sync,如果执行成功,就开始不停的从复制源拉取oplog,然后在从上面回放。

     if (memberState.primary() && !replCoord->isWaitingForApplierToDrain()) {
          sleepsecs(1);
          continue;
      }
      bool initialSyncRequested = BackgroundSync::get()->getInitialSyncRequestedFlag();
      // Check criteria for doing an initial sync:
      // 1. If the oplog is empty, do an initial sync
      // 2. If minValid has _initialSyncFlag set, do an initial sync
      // 3. If initialSyncRequested is true
      if (getGlobalReplicationCoordinator()->getMyLastAppliedOpTime().isNull() ||
          getInitialSyncFlag() || initialSyncRequested) {
          syncDoInitialSync();
          continue;  // 如果initial-sync失败,就继续重试
      }
      if (!replCoord->setFollowerMode(MemberState::RS_RECOVERING)) {
          continue;
      }
      //initial-sync结束之后,就开始进入不停的从源拉取oplog然后回放的状态
      SyncTail tail(BackgroundSync::get(), multiSyncApply);
      tail.oplogApplication();

WT snapshot

mongodb从3.2开始默认的底层存储引擎改为WiredTiger(简称WT),snapshot是WT实现事务的基础,那WT中snapshot是什么呢?其实就是事务开始或者进行操作之前对整个 WT 引擎内部正在执行或者将要执行的事务进行一次快照,保存当时整个引擎所有事务的状态,确定哪些事务是对自己见的,哪些事务都自己是不可见。说白了就是一些列事务 ID 区间。WT的事务并发区间如下图所示:

如果在T6时刻创建了一个snapshot,那么只能读到(0,T1],以及[T1,T5]之间已经提交的事务的修改即T2,其他都是不可见的。

问题分析

从库在回放oplog的过程中会加Lock::ParallelBatchWriterMode(PBWM)锁,这个锁会阻塞所有的读,直到这一批oplog回放结束。对于有读从库需求的业务来说,会导致很多慢查询,甚至会影响业务正常服务。

代码主要在sync_tail.cpp的syncTail::oplogApplication()中:

OpTime SyncTail::multiApply(OperationContext* txn,
                            const OpQueue& ops,
                            boost::optional<BatchBoundaries> boundaries) {
    invariant(_applyFunc);
    if (getGlobalServiceContext()->getGlobalStorageEngine()->isMmapV1()) {
        // Use a ThreadPool to prefetch all the operations in a batch.
        prefetchOps(ops.getDeque(), &_prefetcherPool);
    }
    std::vector<std::vector<BSONObj>> writerVectors(replWriterThreadCount);
    //根据objId hash分组,保证相同_id的写入顺序即可
    fillWriterVectors(txn, ops.getDeque(), &writerVectors);
    stdx::lock_guard<SimpleMutex> fsynclk(filesLockedFsync);
    // Lock::ParallelBatchWriteMode是一个RAII类,持有全局锁直到回放结束,在这期间阻塞所有的读写
    Lock::ParallelBatchWriterMode pbwm(txn->lockState());
    /*......*/
    //多线程回放已经分组的oplog
    applyOps(writerVectors, &_writerPool, _applyFunc, this);
    OpTime lastOpTime;
    {
        ON_BLOCK_EXIT([&] { _writerPool.join(); });
        std::vector<BSONObj> raws;
        raws.reserve(ops.getDeque().size());
        for (auto&& op : ops.getDeque()) {
            raws.emplace_back(op.raw);
        }
        //将oplog写入从库的local.oplog.rs集合中,保证和复制源上的ts一致
        lastOpTime = writeOpsToOplog(txn, raws);
    }

通过分析代码,可以得出从库加全局锁PBWM的目的:

1.避免脏读

这里的脏读可以分为两块,一是用户数据的脏读,这个很好理解。二是由于mongodb支持链式复制,如果从库作为复制源,其他的从库会来读源的oplog,所以要保证其他从库读取的oplog是完整的。对于mmap从库写入oplog是顺序写,而WT的话不一定要保证顺序,如果不加锁的话,其他从库读的话可能会漏掉部分oplog。

如上图所示,secondary插入oplog是乱序的,如果10这个点去读oplog,由于是B树遍历,会漏掉6,9两条记录。

2.资源争抢,影响同步性能

这点我们在测试的时候也遇到,当主库的写入压力很大时,从库的同步写入也很高,这时候如果从库上面又有大量的读,会出现资源争抢的情况,影响同步的性能,造成主从同步的延时。

我们的优化

基于WT的snapshot我们知道,一个snapshot可以理解为是对数据库某个点的状态。所以我们的优化就是去掉全局锁,所有的从库读都改为读snapshot,这样就可以解决上面说到的脏读和oplog丢失的情况,具体的做法如下:

  • 创建snapshot
    对于支持snapshot的引擎,从库在每次applyOplog结束的时候我们会去创建一个snapshot,在创建的过程中要保证不会有新的写入,创建的snapshot由snapshotManager管理,如果已经存在snapshot的话就更新,然后删除旧的snapshot。
    注:由于写入很多的时候,applyOplog会非常频繁,所以要控制snapshot的创建频率。

  • 修改所有外部读为snapshot读
    所谓的外部读就是通过OP_QUERY和OP_GETMORE的方式来查询的请求,由于mongo协议的特殊性,OP_QUERY中根据ns又分为Command和Query两种,对于这些读请求入口,如果是从库读都需要改成读snapshot。

  • 修改内部读为snapshot读
    如果设置了readConcern为readMajority的话,mongo会开启一个后台线程,对已经同步到大多数节点的oplog做一个snapshot,来实现readMajority。代码:

         SnapshotName name(0);  // assigned real value in block.
          {
              // Make sure there are no in-flight capped inserts while we create our snapshot.
              Lock::ResourceLock cappedInsertLockForOtherDb(
                  txn->lockState(), resourceCappedInFlightForOtherDb, MODE_X);
              Lock::ResourceLock cappedInsertLockForLocalDb(
                  txn->lockState(), resourceCappedInFlightForLocalDb, MODE_X);

              name = replCoord->reserveSnapshotName(nullptr);
              // This establishes the view that we will name.
              _manager->prepareForCreateSnapshot(txn.get());
          }
          auto opTimeOfSnapshot = OpTime();
          {
              //RAII类,获取对一个collection的引用,并且加锁
              AutoGetCollectionForRead oplog(txn.get(), rsOplogName);
              invariant(oplog.getCollection());
              // 读取最新的oplog,这里就是我们说的内部读
              auto cursor = oplog.getCollection()->getCursor(txn.get(), /*forward*/ false);
              auto record = cursor->next();
              if (!record)
                  continue;  // oplog is completely empty.
              const auto op = record->data.releaseToBson();
              opTimeOfSnapshot = fassertStatusOK(28780, OpTime::parseFromOplogEntry(op));
              invariant(!opTimeOfSnapshot.isNull());
          }
          //对同步到大多数节点的oplog,创建snapshot
          _manager->createSnapshot(txn.get(), name);
          _manager->setOplogTimeOfSnapshot(name, opTimeOfSnapshot.getTimestamp());
          replCoord->onSnapshotCreate(opTimeOfSnapshot, name);

从上面的代码可以看出,从库去读自己的oplog并不是通过命令的形式,而是调用内部的接口,所以为了保证从库在读取oplog时数据的一致性,也要改成从snapshot中读。

测试

WT cacheSize:10G
测试工具:ycsb
测试指标:从库读延时(这里的延时取的是ycsb 99.99%操作的平均延时)和qps

为了测试的准确性,要保证两次测试下面的条件相同:

  1. 主库的写压力相同,并且压力足够大,模拟线上主库写入压力大,这样从库回放的写入也很高。每次测试的写入数据为5千万条,数据量大于WT cacheSize。
  2. 由于mongo server端是多线程处理的请求,所以要限制cpu,保证从库cpu使用不能超过我们设定的值

但是在测试的过程中我们发现在限制cpu的情况下,snapshot版本的从库上面会出现资源争抢的情况,导致从库的写降读升,使得写入压力不同,导致测试结果不准。所以我们在后面的测试中不限制cpu,通过其他方法来分析从库qps的变化。对测试结果进行统计分析之后得出下图:

通过测试发现,snapshot版本在在4种不同单条数据大小的情况下,从库读的延时都有明显的减小,当条数据越大时延时降低月多。延时的减小带来的是qps的提高。从延时数据可以看出,假设在cpu使用相同并且写入压力相同的情况下,qps也是有一个很大的提升,下图以4k大小的单条数据为例,用mongostat观察主从的读写情况:

左边是snapshot版本,右边是原生版本,对比发现snapshot版本的读qps有明显的提升,且qps稳定,原生版本qps较低且波动幅度较大,而且还是在原生版本cpu使用高于snapshot版本的情况下。

小结

通过去掉从库的全局锁PBWM,使得从库在主库写入压力很大的情况下,从库的读性能有一个很大的提升,但是并不是所有的场景都适用,下面两点需要注意。

  • 前面说到的资源争抢的情况,在去掉全局锁PBWM之后,这个问题在读写压力都很大的情况下可能更加显著,这个需要看具体业务了,决定是否开启从库snapshot读。
  • 在小内存(WT默认最小内存1G)的情况下,如果创建了老的snapshot没有删除,并且写入非常大的情况下,WT的dirty占比会很高,这时候用户线程也会参与eviction,造成写入性能的骤降。这个问题在原生的mongo中如果设置了readMajority的话也会出现,后面的话会去深入WT内部去研究这个问题。