【腾讯云 MongoDB】 基于snapshot的从库读优化

导语

我们发现腾讯云上一些腾讯云MongoDB实例在主库写压力比较大的情况下,这时从库上会出现很多慢查询,经过调查发现,从库在回放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两条记录。

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

我们的优化

基于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大小的单条数据为例:

左边是snapshot版本读的qps数据,右边是原生版本的,对比发现snapshot版本的读qps也有明显的提升,而且还是在原生版本cpu使用高于snapshot版本的情况下。

小结

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

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

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏技术博客

一步一步学Linq to sql(七):并发与事务

为了看起来清晰,我已经事先把所有分类为1产品库存修改为相同值了。然后执行下面的程序:

763
来自专栏我是攻城师

ElasticSearch2.1的java api批量建索引方法

2707
来自专栏晨星先生的自留地

面试中遇到的坑之mysql注入入门

1904
来自专栏杨建荣的学习笔记

浅谈Orabbix监控指标(r6笔记第27天)

对于Orabbix监控Oracle来说,它是提供了一个相对轻量级的客户端来综合监控多个数据库实例。从这一点来看,它的角色有点类似于工作中使用的SQLDevelo...

3909
来自专栏Golang语言社区

[Go 语言社区] Golang架构底层---日志函数

服务器后台架构,日志是必不可少的一个功能模块,日志可以分为很多中:统计日志,访问日志,错误日志等 今天大家发是运行中的日志函数 // 日志函数,传入数据为字...

3156
来自专栏草根专栏

Entity Framework Core 2.0 入门

LearnEf.Console依赖LearnEf.Domains和LearnEf.Data:

73214
来自专栏大数据

批处理实现最简化数据自动备份

最近一直在考虑oracle数据自动备份到本地的问题,也找机会当面向大牛请教过,得到了一堆关于DG、GG、RAC、DBLINK、ARCHLOG等方面的建议,还有个...

1917
来自专栏解Bug之路

MySQL多版本并发控制机制(MVCC)-源码浅析 顶

作为一个数据库爱好者,自己动手写过简单的SQL解析器以及存储引擎,但感觉还是不够过瘾。<<事务处理-概念与技术>>诚然讲的非常透彻,但只能提纲挈领,不能让你玩转...

982
来自专栏源码之家

批量删除WP的垃圾评论

1664
来自专栏Linyb极客之路

MySQL 之 SQL 优化实战记录

N个机台将业务数据发送至服务器,服务器程序将数据入库至MySQL数据库。服务器中的javaweb程序将数据展示到网页上供用户查看。

511

扫码关注云+社区