【腾讯云 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 条评论
登录 后参与评论

相关文章

来自专栏C/C++基础

操作系统简介

操作系统(Operating System,OS)是计算机系统组成要素,是管理和控制计算机硬件与软件资源的基本软件。操作系统是用户和计算机交互的接口,也是计算机...

833
来自专栏「3306 Pai」社区

海豚 VS 大象 功能对比

MySQL为多线程架构后台有多个线程处理内部操作例如:刷脏、Undo purge、checkpoint等,整体上MySQL分为两层Server/存储引擎。存储引...

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

一则报警信息所折射出来的诸多问题(r9笔记第14天)

在主备库环境中,如果出现数据文件级的一些不一致,后期修复会很麻烦,所以这种情况可以提前规避,减少后期的隐患,我定制了一个数据库监控选项,即数据文件状态的检查。 ...

3398
来自专栏数据和云

深入剖析 Group Replication内核的引擎特性

小编寄语 主库master与从库slave的切换不管是主动的还是被动的都需要外部干预才能进行,这与数据库内核本身是按照单机来设计的理念悉悉相关,并且数据库系统本...

3678
来自专栏北京马哥教育

Linux操作系统基础知识学习

Linux操作系统概述 Q1.什么是GNU?Linux与GNU有什么关系? A: 1)GNU是GNU is Not Unix的递归缩写,是自由软件基金会...

28110
来自专栏Vamei实验室

Linux进程间通信

我们在Linux信号基础中已经说明,信号可以看作一种粗糙的进程间通信(IPC, interprocess communication)的方式,用以向进程封闭的内...

17810
来自专栏北京马哥教育

为 Zabbix 优化 MySQL

Zabbix 和 MySQL 在大型的 Zabbix 环境中,遇到的挑战大部分是 MySQL 以及更具体的说是 MySQL 磁盘 IO。 考虑到这一点,我将提...

2423
来自专栏Kevin-ZhangCG

什么是死锁?死锁发生的四个必要条件是什么?如何避免和预防死锁产生?

1495
来自专栏文渊之博

SQL Server中的锁的简单学习

简介     在SQL Server中,每一个查询都会找到最短路径实现自己的目标。如果数据库只接受一个连接一次只执行一个查询。那么查询当然是要多快好省的完成工作...

1735
来自专栏架构师之路

数据库主从不一致,怎么解?

任何脱离业务的架构设计都是耍流氓,绝大部分业务,例如:百度搜索,淘宝订单,QQ消息,58帖子都允许短时间不一致。

1283

扫码关注云+社区