转载自:https://cloud.tencent.com/developer/article/1525137
背景
许多用户使用 MongoDB 存储用户的评论数据,并使用 find().skip().limit()
来实现“翻页”功能。
比如每页有100条评论,如果要跳转到第 10 页,可以通过执行 find({}).skip(900).limit(100)
获得结果。
然而在用户实际的使用过程中,发现性能不尽如人意。特别是skip条数比较大的时候,请求执行时间特别长。
问题分析
MongoDB分片集群的架构如下所示。mongos作为接入层,接受客户端请求并路由到1个或者多个分片去执行,然后收集分片的执行结果,并进行过滤排序等聚合操作之后返回给客户端。
![](https://ask.qcloudimg.com/draft/2518717/wpfj1jzd9d.png?imageView2/2/w/1620)
通过观察机器的资源使用率,我们发现mongod->mongos的网卡流量非常高,大概比mongos返回给客户端的流量要高 1~2 个数量级。如下图所示:
![](https://ask.qcloudimg.com/draft/2518717/10m9jcacof.png?imageView2/2/w/1620)
从直观上来看,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的压力,并大大缩短请求执行时间。
解决方案
基于上面的分析,我们对内核代码进行了优化,整体框架如下所示:
![](https://ask.qcloudimg.com/draft/2518717/i3kf3evhl8.png?imageView2/2/w/1620)
测试结果
在测试环境中创建一个分片表,然后准备测试数据,如下:
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 目前已经集成了这项优化, 欢迎体验。