一个"莫名其妙"的告警

某天下午,监控群里弹出一条告警: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 的升级,功能上完全正确——但性能上引入了一个"空管道全表扫描"的退化。这种退化在数据量小的时候不可见,数据量上来后才爆发,是最容易被忽视的一类性能问题。

几个核心认知:

  1. $match 的聚合 = COLLSCAN,这是一条铁律,不管你的集合有多大
  2. estimatedDocumentCount() 是计数的首选,除非你需要精确到最近一次写入
  3. 分页查询每次都 count 是个反模式,缓存、模糊化、$facet 合并都是更好的选择
  4. 从 V1 到 V2 的升级要做性能回归测试,功能正确不等于性能不退化
  5. MongoDB Profiling 是你的第一道防线,开启它,定期看慢查询,问题就不会在深夜才被发现

一条 28 秒的全表扫描,根因只是一个 null 判断的缺失。修复也只需要一个 if 分支。但如果你不看慢查询日志,这个 if 分支可能永远也不会被加上。


标签:#MongoDB #COLLSCAN #全表扫描 #聚合管道 #estimatedDocumentCount #countDocuments #慢查询优化 #CPU飙升 #MongoDB性能优化 #聚合查询 #分页计数 #Java #SpringData #数环通 #iPaaS

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐