背景
许多用户使用 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 在执行客户端的查询请求时, 大致会经过下面几步:
解析请求, 通过查找路由表, 确定具体去哪个分片或者哪几个分片执行查询请求.
解析 mongos 上的查询请求, 并标准化成到每个分片 mongod 的子请求. 然后选择一个 TaskExecutor 给分片发查询子请求, 并获得分片执行的初始结果
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 https://jira.mongodb.org/browse/SERVER-41329
并将代码修改 PR 给了 开源社区: GitHub Commit https://github.com/mongodb/mongo/pull/1326/files
腾讯云 MongoDB 目前已经集成了这项优化, 欢迎体验.
来源: https://www.qcloud.com/developer/article/1525137