转载自:https://cloud.tencent.com/developer/article/1525137

背景

许多用户使用 MongoDB 存储用户的评论数据,并使用 find().skip().limit() 来实现“翻页”功能。

比如每页有100条评论,如果要跳转到第 10 页,可以通过执行 find({}).skip(900).limit(100)获得结果。

然而在用户实际的使用过程中,发现性能不尽如人意。特别是skip条数比较大的时候,请求执行时间特别长。

问题分析

MongoDB分片集群的架构如下所示。mongos作为接入层,接受客户端请求并路由到1个或者多个分片去执行,然后收集分片的执行结果,并进行过滤排序等聚合操作之后返回给客户端。

MongoDB分片集群架构

通过观察机器的资源使用率,我们发现mongod->mongos的网卡流量非常高,大概比mongos返回给客户端的流量要高 1~2 个数量级。如下图所示:

mongos机器上出入流量对比

从直观上来看,mongos接收了太多的“无用”数据,然后过滤之后再返回给客户端。


mongos为什么会接收这么多“无用”数据呢?可以从mongos内核代码层面进行分析。

mongos在执行客户端的查询请求时,大致会经过下面几步:

  1. 解析请求,通过查找路由表,确定具体去哪个分片或者哪几个分片执行查询请求。
  2. 解析mongos上的查询请求,并标准化成到每个分片mongod的子请求。然后选择一个TaskExecutor给分片发查询子请求,并获得分片执行的初始结果
  3. mongos端通过RouterExecStage对请求进行 sort, skip, limit 等操作,最后将整理好的结果不断传递给客户端。

其中第 2 步 标准化子请求的流程在 transformQueryForShards 函数中实现,可以参考Github上的代码

下面对关键代码进行分析:

// 标准化到每个mongod分片去执行的 查询请求
StatusWith<std::unique_ptr<QueryRequest>> transformQueryForShards(
    const QueryRequest& qr, bool appendGeoNearDistanceProjection) {
    // If there is a limit, we forward the sum of the limit and the skip.
    // 给mongod的limit = limit+skip, 也就是说:不在mongod上执行skip
    boost::optional<long long> newLimit;
    if (qr.getLimit()) {
        long long newLimitValue;
        if (mongoSignedAddOverflow64(*qr.getLimit(), qr.getSkip().value_or(0), &newLimitValue)) {
            return Status(
                ErrorCodes::Overflow,
                str::stream()
                    << "sum of limit and skip cannot be represented as a 64-bit integer, limit: "
                    << *qr.getLimit()
                    << ", skip: "
                    << qr.getSkip().value_or(0));
        }
        newLimit = newLimitValue;
    }

    // Similarly, if nToReturn is set, we forward the sum of nToReturn and the skip.
    ...

    auto newQR = stdx::make_unique<QueryRequest>(qr);
    newQR->setProj(newProjection);
    newQR->setSkip(boost::none);    // 不在mongod上执行 skip
    newQR->setLimit(newLimit);
    newQR->setNToReturn(newNToReturn);

    ...
    return std::move(newQR);
}

也就是说mongod会将数据都传给mongos,然后在mongos层执行skip。这种策略在请求需要到多个分片去执行的情景,是完全合理的。

比如有 2 个分片,

分片 1 上的数据是: 1, 2, 3, 4,5

分片 2 上的数据是: 6, 7, 8, 9,10

如果要执行全表扫描,并过滤最小的5个数字。mongos必须要对 2 个分片上的数据归并排序之后再执行skip。此时把skip交给mongod分片层去做是不合理的,因为在请求的开始阶段,并不能确定每个分片应该skip多少数据。


上面的代码分析,解释了“无用”数据的合理性和必要性。但是对于某些业务场景,仍然存在很大的优化空间。

原因在于,查询请求只发送到了某一个特定的分片上执行。比如业务使用文章的TopicId作为shardKey,此时关于这篇文章的评论数据都存在于某一个特定的分片上。

对于定位到唯一分片的场景,可以在mongod层执行skip+limit操作,并将过滤后的结果返回给mongos;mongos对这种场景不需要执行下一步过滤,而是直接给客户端返回结果。

这种方案在理论上能够很大程度降低mongos和mongod的压力,并大大缩短请求执行时间。

解决方案

基于上面的分析,我们对内核代码进行了优化,整体框架如下所示:

mongos-skip策略优化

测试结果

在测试环境中创建一个分片表,然后准备测试数据,如下:

for (var i=0;i<10;i++) {db.testcoll.insert({a:1,b:i,c:"someBigString自定义"}); sleep(10);}

然后发起skip(5000).limit(10) 的查询请求,统计执行时间和资源消耗情况如下:

版本对比

请求总数

并发数

耗时

网卡流量

mongos-CPU(Peak)

mongod-CPU(Peak)

原有版本

200

5

6.3s

120MB/s

30%

13%

优化版本

200

5

0.6s

<1MB/s

1.7%

14%

CPU消耗的观测方式为top, 网卡消耗的观测方式为sar

从测试结果来看,优化后的版本速度提升了一个数量级,而且对网卡流量的冲击下降了2个数量级。

总结

mongos内核在skip处理流程上存在较大的优化空间,通过区分 去往单一分片 的查询请求,可以明显节省系统资源,提升请求的执行速度。

目前已经给官方提了 JIRA: SERVER-41329 Improve skip performance in mongos when request is sent to a single shard

并将代码修改 PR 给了 开源社区:GitHub Commit

腾讯云MongoDB 目前已经集成了这项优化, 欢迎体验。

 posted on 2020-07-06 14:43  xibuhaohao  阅读(678)  评论(0编辑  收藏  举报