MongoDB CPU 飙升排查实录:一条聚合语句引发的全表扫描血案
一个"莫名其妙"的告警
某天下午,监控群里弹出一条告警:MongoDB 主节点 CPU 飙到了 95%。
我们(数环通 iPaaS 工程团队)的 MongoDB 跑在 K8s 上,三副本副本集,承载的是 iPaaS 平台的"表格数据"存储——每个用户创建的表格对应一个 MongoDB Collection,表里的每一行数据就是一个 Document。单表数据量从几百条到几十万条不等。
登上 MongoDB 看了一眼 mongostat:
insert query update delete getmore command dirty used flushes vsize res qrw arw net_in net_out conn time
*0 120 *0 *0 0 45|0 0.2 78.0 0 12.3G 4.2G 0|0 1|1 5.00K 12.3M 342 Jun 1 14:23:15.012
*0 350 *0 *0 0 120|0 0.2 78.1 0 12.3G 4.2G 0|0 1|1 12.0K 28.5M 342 Jun 1 14:23:16.014
*0 480 *0 *0 0 210|0 0.3 78.3 0 12.3G 4.3G 0|0 1|1 18.0K 45.2M 345 Jun 1 14:23:17.011
query 数量在飙升,command 数量也在涨,连接数从 342 开始往上爬。但我们的业务流量并没有明显变化——不是大促,没有批量导入,也没有新上线什么功能。
打开 MongoDB 的 Profiling,捞到了这么一条慢查询:
{
"op": "command",
"ns": "mydb.biz_table_data_xxx",
"planSummary": "COLLSCAN",
"nreturned": 1,
"keysExamined": 0,
"docsExamined": 473997,
"millis": 28104,
"command": {
"pipeline": [{"$count": "count"}],
"aggregate": "biz_table_data_xxx",
"allowDiskUse": false
}
}
翻译成人话:一条只为了计数的查询,扫描了 47 万条文档,耗时 28 秒,而且是全表扫描(COLLSCAN)。
这就是 CPU 飙升的元凶。这篇文章把完整的排查和修复过程写下来——一条看起来无害的计数语句,是怎么把整个 MongoDB 拖垮的。
一、读懂慢查询日志:每个字段都在告诉你什么
在动手之前,先把这条慢查询的关键字段拆开看:
| 字段 | 值 | 含义 |
|---|---|---|
planSummary |
COLLSCAN |
全集合扫描,没走任何索引 |
pipeline |
[{"$count": "count"}] |
聚合管道只有一个 $count 阶段,没有任何 $match 过滤 |
docsExamined |
473,997 |
扫描了 47 万条文档 |
nreturned |
1 |
只返回了 1 条结果(count 值) |
keysExamined |
0 |
没有检查任何索引键 |
millis |
28,104 |
耗时 28 秒 |
numYield |
3,725 |
让出了 3725 次锁,说明这个查询长期占用资源 |
allowDiskUse |
false |
不允许使用磁盘,大数据量时可能直接报错 |
最致命的是 pipeline 字段:[{"$count": "count"}]。整个聚合管道里只有一个 $count 阶段。如果有查询条件,pipeline 应该是这样的:
{
"pipeline": [
{"$match": {"gmtModified": {"$gte": "...", "$lt": "..."}}},
{"$count": "count"}
]
}
$match 阶段会利用索引缩小范围,然后 $count 只需要统计匹配到的文档。但当 pipeline 里没有 $match 时,$count 就只能遍历所有文档来计数——这就是 COLLSCAN 的直接原因。
二、为什么 count() 和 $count 的性能差异这么大
这是理解整个问题的关键。MongoDB 有两种计数方式,它们的性能差异可以达到几个数量级。
2.1 estimatedDocumentCount():读元数据,O(1)
db.collection.estimatedDocumentCount()
这个方法直接读取集合的元数据(metadata)来获取文档数量,不扫描任何文档。对于 47 万条数据的集合,响应时间在毫秒级别。
对应 Java 代码:
mongoTemplate.getCollection(collectionName).estimatedDocumentCount();
// 耗时:< 1ms
2.2 countDocuments({}):走 count 命令,需要遍历
db.collection.countDocuments({})
空条件的 countDocuments 在 MongoDB 4.0+ 内部会转换为聚合管道 [{$match: {}}, {$count: "count"}],仍然需要扫描文档。但因为有 $match(即使是空条件),MongoDB 优化器可能选择走 _id 索引的 COUNTSCAN。
2.3 聚合管道 $count:无条件时必定 COLLSCAN
db.collection.aggregate([{"$count": "count"}])
这是最慢的方式。聚合框架中的 $count 没有查询优化器的加持,当 pipeline 里没有前置的 $match 阶段时,它会遍历集合中的每一条文档来计数。
三种计数方式的性能对比(47万文档集合):
方式 底层实现 耗时
─────────────────────────────────────────────────────────────
estimatedDocumentCount() 读集合元数据 < 1ms
countDocuments({}) count 命令 + 优化 ~50ms
aggregate([{$count:"count"}]) 聚合管道全遍历 ~28,000ms
28 秒 vs 1 毫秒,差了将近 3 万倍。
三、定位代码:谁在发这条聚合语句
慢查询日志指向了某个业务表格的集合。在代码里搜索,找到了调用链路:
用户打开表格页面(分页查询)
→ TableDataService.queryPage()
→ TableDataManagerSelector.select() // 根据表格版本选择 V1 或 V2 实现
→ TableDataManagerV2.countByParam() // 问题代码在这里
→ mongoTemplate.aggregate(pipeline, collection, Map.class)
问题出在 countByParam 方法的实现上。核心逻辑是这样的(已简化):
public Long countByParam(TableDataQuery query, TableMeta table) {
// 构建 match 阶段(搜索条件、时间范围、过滤规则等)
MatchOperation matchOperation = buildMatchOperation(query, table);
// 构建 count 阶段
CountOperation countOperation = Aggregation.count().as("count");
List<AggregationOperation> operations = new ArrayList<>();
// matchOperation 为 null 时,这个方法会跳过,不加入 pipeline
addAggregationOperation(operations, matchOperation);
addAggregationOperation(operations, countOperation);
// 发送聚合查询
Aggregation aggregation = Aggregation.newAggregation(operations);
AggregationResults<Map> results = mongoTemplate.aggregate(
aggregation, query.getTableDataId(), Map.class);
// ...
}
buildMatchOperation 内部会检查是否有搜索关键词、时间范围、过滤规则、batchId 等条件:
private MatchOperation buildMatchOperation(TableDataQuery query, TableMeta table) {
Criteria matchCriteria = buildSearchCriteria(query, table);
// 当所有条件都为空时,matchCriteria 为 null
if (matchCriteria != null) {
return Aggregation.match(matchCriteria);
}
return null; // ← 返回 null,不会加入 pipeline
}
当用户打开表格页面、不带任何筛选条件时(这是最常见的使用场景),所有条件都是空的,matchOperation 返回 null,最终发给 MongoDB 的 pipeline 就只剩 [$count]——全表扫描。
3.1 为什么这个设计一开始没有问题
我们的表格数据有两个版本的 Manager 实现(V1 和 V2),通过一个 Selector 按表格版本号动态选择:
public TableDataManager select(TableMeta table) {
if (table != null && table.getExtensions() != null
&& table.getExtensions().getVersion() != null
&& table.getExtensions().getVersion() > 1) {
return tableDataManagerV2; // 聚合管道版
}
return tableDataManager; // V1: mongoTemplate.count() 版
}
V1 的 countByParam 用的是 mongoTemplate.count(query, collection):
// V1 实现
public Long countByParam(TableDataQuery query, TableMeta table) {
Query query = new Query();
populateSearchQuery(query, query, table);
return mongoTemplate.count(query, query.getTableDataId());
}
mongoTemplate.count() 底层调用 MongoDB 的 count 命令,当 query 为空时,MongoDB 会使用 estimatedDocumentCount() 的快速路径——读元数据,毫秒级返回。
V2 是为了支持更复杂的模糊搜索(数字、日期类型的全文搜索),引入了聚合管道。但代价是:计数也改用了聚合管道,丢掉了快速路径。
3.2 什么时候开始出问题的
V2 上线初期,大部分表格数据量在几千条以内,即使全表扫描也就几十毫秒,不会引起注意。但随着业务增长,某些表格的数据量涨到了几十万条,单次 $count 扫描耗时从几十毫秒飙升到几十秒——而且每次打开表格页面都会触发一次。
更要命的是,queryByParam(分页查询)里每次请求都先调一次 countByParam 获取总数:
public PagerData<List<TableData>> queryPage(TableDataQuery query, TableMeta table) {
// ...
Long totalCount = managerSelector.select(table).countByParam(query, table); // ← 每次分页都 count
// ...
List<TableDataDO> resultList = managerSelector.select(table).selectByParam(query, table);
// ...
}
如果有 10 个用户同时打开大表格页面,就是 10 个并发的 28 秒全表扫描——MongoDB CPU 直接打满。
四、修复方案:一条 if 判断解决 28 秒到 1 毫秒
修复思路很直接:当没有任何过滤条件时,走 estimatedDocumentCount() 快速路径,不走聚合管道。
public Long countByParam(TableDataQuery query, TableMeta table) {
checkQuery(query);
long start = System.currentTimeMillis();
// 构建 match 阶段
MatchOperation matchOperation = buildMatchOperation(query, table);
// 无过滤条件时,走快速计数路径,避免全表扫描(COLLSCAN)
if (matchOperation == null) {
long count = mongoTemplate.getCollection(query.getTableDataId())
.estimatedDocumentCount();
log.info("countByParam(estimated) {} cost:{}, count:{}",
query.getTableDataId(), System.currentTimeMillis() - start, count);
return count;
}
// 有过滤条件时,走聚合管道(保持原有逻辑)
ProjectionOperation projectionOperation = buildProjectOperation(query, table);
CountOperation countOperation = buildCountOperation();
List<AggregationOperation> operations = new ArrayList<>();
if (isNeedProjectionOperationFirst(query)) {
addAggregationOperation(operations, projectionOperation);
}
addAggregationOperation(operations, matchOperation);
addAggregationOperation(operations, countOperation);
Aggregation aggregation = Aggregation.newAggregation(operations);
AggregationResults<Map> results = mongoTemplate.aggregate(
aggregation, query.getTableDataId(), Map.class);
// ...
}
核心改动就三点:
| 改动 | 说明 |
|---|---|
提前判断 matchOperation == null |
无过滤条件时直接短路 |
走 estimatedDocumentCount() |
读 MongoDB 集合元数据,O(1) 复杂度 |
ProjectionOperation 延迟构建 |
只有走到聚合路径时才构建,减少无用计算 |
4.1 修复效果
修复前(47 万文档,无过滤条件):
pipeline: [{"$count": "count"}]
planSummary: COLLSCAN
docsExamined: 473,997
millis: 28,104
修复后(同样的场景):
调用: estimatedDocumentCount()
耗时: < 1ms
文档扫描: 0
28 秒 → 1 毫秒,提速约 3 万倍。
五、更大的问题:分页查询每次都要 count 吗
修完慢查询,CPU 降下来了。但回过头想一个问题:每次分页查询都先 count 一次,这本身合理吗?
5.1 当前逻辑的问题
public PagerData<List<TableData>> queryPage(...) {
// 1. 先 count 总数(即使修复后也需要一次查询)
Long totalCount = countByParam(query, table);
// 2. 再查分页数据
List<TableDataDO> resultList = selectByParam(query, table);
}
对于大表格(10 万+ 文档),即使用了 estimatedDocumentCount() 快速路径,如果带了过滤条件,count 仍然要走聚合管道。而且用户每翻一页、每改一个筛选条件,都会触发一次 count。
5.2 几种优化策略
| 策略 | 适用场景 | 实现复杂度 |
|---|---|---|
| 精确 count | 数据量 < 1 万 | 低,保持现状 |
| 模糊 count(超过阈值显示 “10000+”) | 数据量 > 1 万 | 中,需要前端配合 |
| count 结果缓存 | 数据变更不频繁 | 中,加 Redis 缓存 + TTL |
| "下一页"替代跳页 | 无限滚动场景 | 高,需要改前端交互 |
| count 与查询并行执行 | 通用 | 低,用 CompletableFuture |
对于我们的场景,最实用的改进是count 结果缓存 + TTL:
// 伪代码:count 结果缓存 5 秒
String cacheKey = "table_count:" + query.getTableDataId() + ":" + query.toHash();
Long count = redisTemplate.opsForValue().get(cacheKey);
if (count == null) {
count = doCount(query, table); // 实际 count
redisTemplate.opsForValue().set(cacheKey, count, 5, TimeUnit.SECONDS);
}
5 秒的 TTL 意味着同一个表格在 5 秒内的多次翻页不会重复 count。对于数据变更不频繁的管理后台场景,这是性价比最高的方案。
六、MongoDB 聚合管道的常见性能陷阱
这次问题让我们系统性地审视了 MongoDB 聚合管道的性能风险。以下是我们在代码里排查出的几类问题:
6.1 无 $match 的聚合 = 全表扫描
// 危险:没有 $match,$group 会扫描全部文档
db.collection.aggregate([
{ $group: { _id: null, total: { $sum: "$amount" } } }
])
// 安全:加上 $match 利用索引
db.collection.aggregate([
{ $match: { status: "active", gmtCreate: { $gte: "2026-01-01" } } },
{ $group: { _id: null, total: { $sum: "$amount" } } }
])
6.2 $project 在 $match 之前 = 无法利用索引
// 危险:$project 在 $match 之前,MongoDB 无法用索引做 $match
db.collection.aggregate([
{ $project: { status_lower: { $toLower: "$status" } } },
{ $match: { status_lower: "active" } }
])
// 安全:$match 在 $project 之前,可以利用索引
db.collection.aggregate([
{ $match: { status: "active" } },
{ $project: { status_lower: { $toLower: "$status" } } }
])
这就是我们 V2 代码里 isNeedProjectionOperationFirst 方法存在的原因——模糊搜索不得不先投影再匹配,但这意味着模糊搜索场景一定无法走索引。
6.3 $where 和正则表达式 = 全表扫描
V1 版本的 buildCondition 方法里大量使用了 $where:
// V1 的过滤条件实现
case EQ:
return Criteria.where("$where").is(
String.format("function () { return this.%s == '%s'; }", key, rightValue));
case NOT_NULL:
return Criteria.where("$where").is(
String.format("function () { return this.%s != '' && this.%s != null; }", key, key));
$where 是 JavaScript 执行,永远无法使用索引。V2 已经修复了这个问题,改用标准的 Criteria:
// V2 的过滤条件实现
case EQ:
return Criteria.where(key).is(fieldConverter.convertToDB(rightValue));
case NOT_NULL:
return new Criteria().andOperator(
Criteria.where(key).ne(null), Criteria.where(key).ne(""));
6.4 深层嵌套字段的查询
我们的表格数据存储在 fields 这个嵌套对象里:
{
"_id": "xxx",
"gmtCreate": "2026-01-01",
"fields": {
"col_12345": "张三",
"col_67890": 100.50
}
}
查询 fields.col_12345 需要在 fields.col_12345 上建索引。但问题是每个表格的列都不同,索引需要动态创建。我们的做法是在表格创建时自动生成索引:
@Override
public void createIndex(String tableDataId, TableMeta table) {
List<IndexModel> indexModels = new ArrayList<>();
// 默认给 gmtCreate 和 gmtModified 建索引
indexModels.add(new IndexModel(new BasicDBObject("gmtCreate", -1)));
indexModels.add(new IndexModel(new BasicDBObject("gmtModified", -1)));
mongoTemplate.getCollection(tableDataId).createIndexes(indexModels);
}
但对于用户自定义的筛选列,索引覆盖率取决于业务配置——这也是后续需要优化的方向。
七、可落地清单
把这次排查的经验收敛成一份 MongoDB 查询优化 checklist:
[计数查询]
□ 无过滤条件的 count 使用 estimatedDocumentCount(),不要用聚合管道
□ 有过滤条件的 count 确保 $match 在前,且匹配字段有索引
□ 分页查询的 count 结果加缓存(TTL 5-10 秒),避免翻页重复 count
□ 大集合(>10万)的 count 考虑模糊化(超过阈值显示 "10000+")
[聚合管道]
□ 聚合管道必须有 $match 阶段,避免空管道 COLLSCAN
□ $match 放在 $project 之前,确保能利用索引
□ 避免使用 $where(JavaScript 执行,永远不走索引)
□ 模糊搜索的正则不要以 .* 开头(破坏索引前缀匹配)
□ 大数据量聚合设置 allowDiskUse: true,避免内存不足报错
[索引策略]
□ 高频查询字段建索引,用 explain() 验证是否走了索引
□ 定期用 db.collection.getIndexes() 检查冗余索引
□ 嵌套字段查询需要确认索引路径正确(fields.col_xxx)
□ TTL 索引用于自动过期数据(日志、临时记录)
[监控告警]
□ 开启 MongoDB Profiling(slowms 设为 100ms)
□ 监控 COLLSCAN 查询数量,突增时告警
□ 监控 opcounters(query/insert/update/delete)的 QPS 趋势
□ 监控连接数增长曲线(连接数突增通常伴随慢查询)
[代码审查]
□ 审查所有 aggregate() 调用,确认 pipeline 非空
□ 审查所有 countByParam/countDocuments 调用,确认有条件时走索引
□ V1 的 $where 用法必须迁移到标准 Criteria(V2 已修复)
□ 分页查询是否每次都需要 count,能否用缓存或"下一页"替代
八、容易踩的坑
坑 1:estimatedDocumentCount() 不反映未提交的写入
estimatedDocumentCount() 读的是集合元数据,不保证包含最近几毫秒的写入。对于管理后台的表格计数展示来说,差几条完全无所谓。但如果是精确的财务对账场景,需要用 countDocuments({}) 或带 $match 的聚合。
坑 2:V1 到 V2 升级时的"静默退化"
V1 的 count() 走快速路径,V2 的聚合管道走全表扫描。如果代码升级时没有做性能对比测试,这种退化很难被发现——在小数据集上两者表现一致,数据量上来后才暴露。
坑 3:allowDiskUse: false + 大集合 = 直接报错
我们的慢查询里 allowDiskUse 设的是 false。如果聚合过程中的中间结果超过 100MB 内存限制,MongoDB 会直接报错而不是降级到磁盘。对于大集合的聚合操作,建议显式设置为 true。
坑 4:count 和数据查询不在同一个事务里
queryByParam 先 count 再查数据,两次查询之间数据可能已经变了。极端情况下会出现 “count=100 但只查到 99 条” 的情况。对于要求严格一致的场景,应该用 Facet 在一次聚合里同时返回 count 和数据。
坑 5:正则搜索的 “.keyword.” 模式
V2 的模糊搜索用了 Pattern.compile("^.*" + keyword + ".*$"),前后的 .* 让正则无法利用索引前缀匹配。对于大集合的模糊搜索,正确的方案是用 MongoDB Atlas Search 或者 Elasticsearch 做全文检索,而不是在聚合管道里做正则。
坑 6:忽略 numYield 字段
慢查询日志里的 numYield: 3725 意味着这个查询在执行期间让出了 3725 次锁。每次让出都是因为其他查询在等,这说明这个慢查询不仅自己慢,还在阻塞其他查询。CPU 飙升 + 其他接口变慢,往往是同一个慢查询的连锁反应。
九、常见问题(FAQ)
Q:estimatedDocumentCount() 和 countDocuments({}) 该用哪个?
A:取决于场景。estimatedDocumentCount() 读元数据,毫秒级返回,但不保证包含最近写入;countDocuments({}) 精确但更慢。对于展示型的分页计数(“共 47,399 条”),estimatedDocumentCount() 完全够用。对于财务、审计等精确计数场景,用 countDocuments({}) 并确保走了索引。
Q:MongoDB 的 $count 聚合和 count() 命令有什么区别?
A:count() 命令是独立的数据库命令,MongoDB 可以对其做优化(空条件走元数据快速路径)。聚合管道的 $count 是聚合框架的一个阶段,走的是聚合执行引擎,没有快速路径。如果 pipeline 里 $count 前面没有 $match,就是全表扫描。
Q:为什么不在 MongoDB 端设置 maxTimeMS 来限制慢查询?
A:maxTimeMS 能防止单个查询无限期执行,但被超时的查询会抛异常,业务侧需要处理。建议同时设置(比如 maxTimeMS: 5000)作为兜底,但根本解法还是优化查询本身。
Q:分页查询的 count 能和数据查询合并成一次聚合吗?
A:可以。用 $facet 在一次聚合里同时返回分页数据和总数:
db.collection.aggregate([
{ $match: { ... } },
{ $facet: {
data: [{ $sort: { _id: -1 } }, { $skip: 0 }, { $limit: 20 }],
count: [{ $count: "total" }]
}}
])
这样只需一次聚合,减少了 count 的独立开销。但注意 $facet 的各个分支是独立执行的,不会共享中间结果。
Q:我们的表格数据量还会继续增长,有什么长期方案?
A:三个方向:① 冷热分离——把不活跃表格的数据归档到冷存储;② 分表——数据量超过阈值的表格做 Collection 拆分;③ 搜索引擎——模糊搜索和聚合统计走 Elasticsearch,MongoDB 只负责 CRUD。我们在 iPaaS 平台的长期规划中已经在评估第三种方案。
Q:为什么 V2 要用聚合管道替代原来的 Query?
A:因为需要支持数字和日期类型的模糊搜索。比如用户搜 “2024”,需要在数字字段 100.50 和日期字段 2024-01-01 里做文本匹配。这需要先通过 $project 把数字/日期转成字符串,再做正则匹配。标准的 Query API 做不到这种"先投影再匹配"的操作,只有聚合管道能实现。
十、写在最后
这次排查最大的教训是:MongoDB 聚合管道不是万能的,它有自己独特的性能特征。
mongoTemplate.count(query, collection) 和 mongoTemplate.aggregate(pipeline, collection) 看起来都是"查个数据",但底层走的是完全不同的执行路径。前者有空条件的快速路径优化,后者没有。
从 V1 到 V2 的升级,功能上完全正确——但性能上引入了一个"空管道全表扫描"的退化。这种退化在数据量小的时候不可见,数据量上来后才爆发,是最容易被忽视的一类性能问题。
几个核心认知:
- 无
$match的聚合 = COLLSCAN,这是一条铁律,不管你的集合有多大 estimatedDocumentCount()是计数的首选,除非你需要精确到最近一次写入- 分页查询每次都 count 是个反模式,缓存、模糊化、
$facet合并都是更好的选择 - 从 V1 到 V2 的升级要做性能回归测试,功能正确不等于性能不退化
- MongoDB Profiling 是你的第一道防线,开启它,定期看慢查询,问题就不会在深夜才被发现
一条 28 秒的全表扫描,根因只是一个 null 判断的缺失。修复也只需要一个 if 分支。但如果你不看慢查询日志,这个 if 分支可能永远也不会被加上。
标签:#MongoDB #COLLSCAN #全表扫描 #聚合管道 #estimatedDocumentCount #countDocuments #慢查询优化 #CPU飙升 #MongoDB性能优化 #聚合查询 #分页计数 #Java #SpringData #数环通 #iPaaS
更多推荐




所有评论(0)