导语
我们发现腾讯云上一些腾讯云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 区间。搜索关注“腾讯云数据库”官方微信立得10元腾讯云无门槛代金券,体验移动端一键管理数据库,学习更多数据库技术实战教程。
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的目的:
- 避免脏读
这里的脏读可以分为两块,一是用户数据的脏读,这个很好理解。二是由于mongodb支持链式复制,如果从库作为复制源,其他的从库会来读源的oplog,所以要保证其他从库读取的oplog是完整的。对于mmap从库写入oplog是顺序写,而WT的话不一定要保证顺序,如果不加锁的话,其他从库读的话可能会漏掉部分oplog。
如上图所示,secondary插入oplog是乱序的,如果10这个点去读oplog,由于是B树遍历,会漏掉6,9两条记录。
- 资源争抢,影响同步性能
这点我们在测试的时候也遇到,当主库的写入压力很大时,从库的同步写入也很高,这时候如果从库上面又有大量的写入,会出现资源争抢的情况,影响同步的性能,造成主从同步的延时。搜索关注“腾讯云数据库”官方微信立得10元腾讯云无门槛代金券,体验移动端一键管理数据库,学习更多数据库技术实战教程。
我们的优化
基于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
为了测试的准确性,要保证两次测试下面的条件相同:
- 主库的写压力相同,并且压力足够大,模拟线上主库写入压力大,这样从库回放的写入也很高。每次测试的写入数据为5千万条,数据量大于WT cacheSize。
- 由于mongo server端是多线程处理的请求,所以要限制cpu,保证从库cpu使用不能超过我们设定的值
但是在测试的过程中我们发现在限制cpu的情况下,snapshot版本的从库上面会出现资源争抢的情况,导致从库的写降读升,使得写入压力不同,导致测试结果不准。所以我们在后面的测试中不限制cpu,通过其他方法来分析从库qps的变化。搜索关注“腾讯云数据库”官方微信立得10元腾讯云无门槛代金券,体验移动端一键管理数据库,学习更多数据库技术实战教程。对测试结果进行统计分析之后得出下图:
通过测试发现,snapshot版本在在4种不同单条数据大小的情况下,从库读的延时都有明显的减小,延时的减小带来的是qps的提高。从延时数据可以看出,假设在cpu使用相同并且写入压力相同的情况下,qps也是有一个很大的提升,下图以4k大小的单条数据为例:
左边是snapshot版本读的qps数据,右边是原生版本的,对比发现snapshot版本的读qps也有明显的提升,而且还是在原生版本cpu使用高于snapshot版本的情况下。
小结
通过去掉从库的全局锁PBWM,使得从库在主库写入压力很大的情况下,从库的读性能有一个很大的提升,但是并不是所有的场景都适用,下面两点需要注意。
- 前面说到的资源争抢的情况,在去掉全局锁PBWM之后,这个问题在读写压力都很大的情况下可能更加显著,这个需要看具体业务了,决定是否开启从库snapshot读。
- 在小内存(WT默认最小内存1G)的情况下,如果创建了老的snapshot没有删除,并且写入非常大的情况下,WT的dirty占比会很高,这时候用户线程也会参与eviction,造成写入性能的骤降。这个问题在原生的mongo中如果设置了readMajority的话也会出现,后面的话会去深入WT内部去研究这个问题。