10. MongoDB 性能调优与运维监控

MongoDB Profiler、Explain 分析、WiredTiger 缓存、慢查询优化与生产监控指标

MongoDB 的性能表现是一个多维度系统工程,涉及存储引擎内部实现、索引设计、查询模式、网络连接、并发控制以及操作系统层面的资源管理等多个层面。本文将从底层原理出发,结合生产环境中的实际案例,系统地讲解 MongoDB 的性能调优与运维监控方法。

1. WiredTiger 存储引擎

WiredTiger 是 MongoDB 3.2 版本之后的默认存储引擎,其设计目标是在保证数据持久性的前提下,实现高并发的读写性能。理解 WiredTiger 的内部工作机制,是进行 MongoDB 性能调优的基础。

1.1 缓存配置

WiredTiger 采用 B-tree 作为底层数据结构,所有数据的读写操作都经过内存缓存层。缓存的大小直接决定了热数据的命中率以及写操作的缓冲能力。

# mongod.conf
storage:
  wiredTiger:
    engineConfig:
      cacheSizeGB: 4  # 默认 = (RAM - 1GB) / 2

在生产环境中,缓存配置需要综合考虑多个因素。默认的 (RAM - 1GB) / 2 公式适用于大多数场景,但如果系统同时运行着多个 MongoDB 实例或其他服务,则需要手动调整以避免内存争抢。例如,在一台 64GB 内存的专门部署 MongoDB 的服务器上,可以将缓存设置为 28GB 左右,为操作系统文件系统缓存预留充足空间。

// 运行时查看缓存状态
db.serverStatus().wiredTiger.cache
// {
//   "bytes currently in the cache": 2147483648,
//   "maximum bytes configured": 4294967296,
//   "tracked dirty bytes in the cache": 10485760,
//   "pages read into cache": 524288,
//   "pages written from cache": 314572
// }

内存使用分配:

总 RAM 32GB
  ├── WiredTiger Cache:     ~15GB  ((32-1)/2)
  ├── 索引和其他:            ~5GB
  ├── 连接/线程栈:           ~2GB
  └── OS 文件系统缓存(剩余)  ~10GB  ← 可加速冷数据读取

常见错误:将 cacheSizeGB 设置得过大(如接近总内存),会导致操作系统频繁进行内存回收(OOM killer 风险)或大量页换出(swap thrashing),反而降低整体性能。

1.2 页驱逐策略

WiredTiger 缓存中的数据以页(page)为单位管理。当缓存接近上限时,会通过页驱逐(page eviction)机制将不常用的页移出缓存,为新的数据腾出空间。

// 脏页比例高时触发写入,避免突发的 write burst
db.serverStatus().wiredTiger.cache['dirty percentage in the cache']
// 应保持在 20% 以下

页驱逐策略涉及几个关键阈值:

  • 当脏页比例超过 5% 时,WiredTiger 开始在后台进行页面刷新
  • 当脏页比例超过 20% 时,应用程序的读写操作可能会被阻塞
  • 当触发驱逐但无可用页时,会出现 page eviction server 压力增大

检查点机制:WiredTiger 定期将内存中的脏页刷入磁盘(默认每 60 秒一次),同时还会在两分钟间隔内创建一致性快照。检查点期间会有短暂的写延迟增加,因此在高负载场景下可以考虑将检查点间隔适当调大。

// 查看 checkpoint 统计
> db.serverStatus().wiredTiger.transaction
{
  "transaction checkpoint max time (msecs)": 2450,
  "transaction checkpoint min time (msecs)": 120,
  "transaction checkpoint most recent time (msecs)": 380
}

B-tree 页分裂与合并:随着数据写入,WiredTiger 的 B-tree 节点会定期更新并分裂。频繁的页分裂会导致数据在磁盘上不连续,影响顺序读取性能。定期使用 compact 命令可以整理磁盘碎片,但会在执行期间对集合加锁。

// 对集合执行压缩(生产环境应在低峰期运行)
db.runCommand({ compact: "orders", force: true })

2. 慢查询分析

慢查询是影响 MongoDB 性能最常见的问题根源。MongoDB 提供的 Profiler 和分析工具可以帮助定位性能瓶颈。

2.1 启用 Profiler

// 开启慢查询收集(>100ms)
db.setProfilingLevel(1, { slowms: 100 });
// 0 = 关闭, 1 = 慢查询, 2 = 所有查询

// 查看慢查询
db.system.profile.find().sort({ ts: -1 }).limit(10);

// 常用分析查询
db.system.profile.aggregate([
    { $match: { op: "query" } },
    { $group: {
        _id: "$ns",
        avgTime: { $avg: "$millis" },
        maxTime: { $max: "$millis" },
        count: { $sum: 1 }
    }},
    { $sort: { avgTime: -1 } }
]);

Profiler 数据存储在 db.system.profile 的固定大小集合中(默认 1MB)。在生产环境中,应当注意 Profiler 本身也会消耗写入性能。建议在排查问题时临时开启,完成后及时关闭。

// 查询返回超过 1000 条文档的慢查询
db.system.profile.find({ 
  "op": "query",
  "responseLength": { $gt: 100000 }
}).sort({ ts: -1 }).limit(5)

// 分析特定集合的写入延迟
db.system.profile.aggregate([
  { $match: { op: "insert", ns: "shop.orders" } },
  { $group: { _id: null, avgTime: { $avg: "$millis" }, p95: { $percentile: { p: [0.95], input: "$millis", method: "approximate" } } } }
])

2.2 查询优化检查清单

问题诊断解决
COLLSCANexplain 显示无索引创建合适的索引
内存排序executionStats.totalDocsExamined >> nReturned复合索引包含排序字段
大量 docsExamined查询未有效利用索引$match 添加更多过滤条件
写入延迟writeConcern: majority降低 w (权衡一致性)
锁争用db.serverStatus().locks分片/优化查询

实际案例:某电商平台在大促期间出现大量慢查询,经 Profiler 分析发现订单查询集合的 executionStats.totalKeysExamined 远大于 nReturned。根本原因是索引 { userId: 1, createdAt: -1 } 被查询条件 { createdAt: { $gte: ISODate(...) }, userId: "xxx" } 使用,由于查询顺序与索引前缀不匹配,MongoDB 退化为扫描整个索引后过滤。调整索引为 { createdAt: -1, userId: 1 } 后,查询性能提升 40 倍。

3. 查询计划与执行分析

MongoDB 的查询优化器会为每个查询生成并评估多个候选执行计划,选择成本最低的方案。深入理解查询计划的生成逻辑,有助于主动优化查询性能。

3.1 explain 深度解读

// allPlansExecution 模式:收集所有候选计划的执行统计
> db.orders.find({ status: "shipped", amount: { $gt: 100 } }).sort({ createdAt: -1 }).explain("allPlansExecution")

关键字段说明:

  • executionStats.executionStages.stage:当前阶段的操作类型(COLLSCAN、IXSCAN、FETCH、SORT 等)
  • executionStats.totalDocsExamined:扫描的文档总数
  • executionStats.totalKeysExamined:扫描的索引键数量
  • executionStats.nReturned:最终返回的文档数
  • executionStats.executionTimeMillis:实际执行耗时

效率评估指标:

  • totalDocsExamined / nReturned 越接近 1 越好
  • 若 totalDocsExamined >> nReturned,说明索引过滤效果差
  • rejectedPlans 中可以看到优化器放弃的其他候选方案

3.2 Plan Cache 管理

MongoDB 会缓存查询计划的评估结果,避免重复计算。但当数据分布发生变化时,缓存的计划可能不再最优。

// 查看指定查询形状的缓存计划
db.orders.explain("executionStats").find({ status: "pending" })

// 清除特定集合的 plan cache
db.orders.getPlanCache().clear()

// 查看缓存条目
db.orders.getPlanCache().list()

查询形状(Query Shape):指去除具体参数值后的查询结构。例如 { status: "A" } 和 { status: "B" } 属于同一查询形状,共享同一个缓存计划。

// 强制使用 hint 覆盖优化器选择
db.orders.find({ status: "shipped" }).hint({ status: 1, createdAt: -1 })

// 在驱动中使用 hint(Node.js 示例)
const orders = await db.collection('orders')
  .find({ status: "shipped" })
  .hint({ status: 1, createdAt: -1 })
  .toArray();

4. 索引优化策略

索引是 MongoDB 查询性能优化的核心手段。合理的索引设计可以将查询效率提升几个数量级。

4.1 复合索引 ESR 规则

在构建复合索引时,字段的排列顺序至关重要。ESR 原则提供了一个经过实践验证的顺序规则:

  • E(Equality):等值查询字段排在最前
  • S(Sort):排序字段排在中间
  • R(Range):范围查询字段排在最后
// 查询模式:查找某用户某时间段内金额大于 100 的订单,按时间倒序排列
// { userId: "xxx", createdAt: { $gte: ..., $lte: ... }, amount: { $gt: 100 } }
// 排序:{ createdAt: -1 }

// 遵循 ESR 的索引设计
db.orders.createIndex({ userId: 1, createdAt: -1, amount: 1 })
// E: userId(等值)
// S: createdAt(排序,同时也包含在查询条件中)
// R: amount(范围查询)

反例:若将 amount 放在 createdAt 之前,索引会退化为先按 amount 过滤,再在子集中按 createdAt 排序,排序操作需要额外的内存排序阶段。

4.2 覆盖查询(Covered Query)

当查询所需的所有字段(包括查询条件、投影返回字段、排序字段)都在索引中时,MongoDB 可以直接从索引返回结果,无需回表读取文档。

// 只返回 userId 和 createdAt,两个字段都在索引中
db.orders.find(
  { userId: "user123", status: "completed" },
  { _id: 0, userId: 1, createdAt: 1 }
).hint({ userId: 1, status: 1, createdAt: -1 })

在 explain 输出中,Covered Query 的特征是 executionStages.stage: "IXSCAN" 且不存在 FETCH 阶段。

4.3 索引交集与隐藏索引

// 查看索引使用情况(MongoDB 4.4+)
db.orders.aggregate([{ $indexStats: {} }])

// 创建隐藏索引(不影响查询计划,可安全测试)
db.orders.createIndex(
  { productId: 1, warehouseId: 1 }, 
  { hidden: true }
);

// 隐藏索引生效测试
db.orders.find({ productId: "P001", warehouseId: "W01" }).hint("productId_1_warehouseId_1")

索引使用最佳实践:

  • 每个集合索引总数不宜超过 5-7 个,过多索引会影响写入性能
  • 定期分析 $indexStats,移除未使用的索引
  • 对于 TTL 集合,TTL 字段应作为索引的一部分或单独索引
  • 文本索引和地理空间索引有特殊的构建考量,需提前评估内存开销

5. 聚合管道优化

聚合管道(Aggregation Pipeline)是 MongoDB 进行复杂数据处理的核心功能。管道阶段的顺序和执行方式直接影响性能。

5.1 Pipeline 阶段顺序优化

MongoDB 聚合引擎会尝试进行部分优化(如将 $match 下推到索引扫描之前),但开发者应当主动遵循最优管道顺序。

// 反例:先展开数组再过滤,效率极低
db.orders.aggregate([
  { $unwind: "$items" },
  { $match: { "items.price": { $gt: 100 }, status: "completed" } },
  { $group: { _id: "$items.category", total: { $sum: "$items.price" } } }
])

// 正例:先过滤再展开,聚合也能享受索引加速
db.orders.aggregate([
  { $match: { status: "completed" } },  // 使用 { status: 1 } 索引
t  { $unwind: "$items" },
  { $match: { "items.price": { $gt: 100 } } },
  { $group: { _id: "$items.category", total: { $sum: "$items.price" } } }
])

5.2 $lookup 与外连接优化

$lookup 相当于 SQL 的 LEFT OUTER JOIN,是实现多表关联查询的重要工具。但其性能开销较大,应谨慎使用。

// 推荐:在关联字段上建立索引,确保 $lookup 的局部字段有索引支撑
db.orders.createIndex({ customerId: 1 });
db.customers.createIndex({ _id: 1 });  // _id 默认已有索引

// $lookup 聚合示例
db.orders.aggregate([
  { $match: { status: "shipped", createdAt: { $gte: new Date(Date.now() - 86400000) } } },
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" },
  { $project: { orderId: 1, "customer.name": 1, "customer.email": 1 } }
])

$lookup 优化要点:

  • 优先过滤 $match 减少进入 $lookup 的文档数
  • 避免多层嵌套的 $lookup,可考虑在应用层分步查询
  • 确保 localField 和 foreignField 都有索引
  • 若关联结果不需要全部字段,使用 $project 提前裁剪

5.3 内存限制与磁盘使用

默认情况下,聚合管道的每个阶段最多使用 100MB 内存。超出限制会报错。

// 允许管道阶段使用磁盘临时文件(适用于大数据量聚合)
db.orders.aggregate([
  { $group: { _id: "$category", count: { $sum: 1 }, avgPrice: { $avg: "$price" } } }
], { allowDiskUse: true })

// 在驱动中设置内存限制(Java 示例)
AggregateIterable<Document> result = collection.aggregate(Arrays.asList(
  Aggregates.group("$category", Accumulators.sum("count", 1))
)).allowDiskUse(true);

注意:allowDiskUse: true 虽然避免了内存超限错误,但磁盘 I/O 速度远低于内存,应作为兜底方案。根本解决之道是通过 $match、$project 以及优化分组键来减少每个阶段的中间数据量。

6. 连接与并发

MongoDB 使用检查点锁和文档级并发控制(WiredTiger 提供的多版本并发控制 MVCC)来保证并发安全。在高并发场景下,连接管理和线程模型同样关键。

6.1 服务端连接限制

# mongod.conf
net:
  maxIncomingConnections: 1000      # 默认 65536,根据 ulimit 调整

MongoDB 默认允许 65536 个传入连接,但这并不意味着应当接受如此多的连接。每个连接至少消耗约 1MB 内存(连接上下文 + 线程栈),过多的空闲连接会挤占 WiredTiger Cache 的可用内存。

生产环境中建议根据实际并发量设置合理的上限:

maxIncomingConnections = 预期峰值并发查询数 × 冗余系数(1.3~1.5)

同时需要配合操作系统层面的 ulimit 调整:

# /etc/security/limits.conf
mongod   soft   nofile    64000
mongod   hard   nofile    64000
mongod   soft   nproc     64000
mongod   hard   nproc     64000

6.2 驱动连接池配置

客户端驱动程序通过连接池与服务端保持长连接,避免频繁创建 TCP 连接的昂贵开销。

Node.js 驱动(mongodb 6.x):

const { MongoClient } = require('mongodb');

const client = new MongoClient(uri, {
  maxPoolSize: 50,           // 连接池最大连接数,默认 100
  minPoolSize: 10,           // 最小保留连接数,应对突发流量
  maxIdleTimeMS: 60000,      // 空闲连接最大存活时间
  waitQueueTimeoutMS: 5000,  // 获取连接等待超时
  serverSelectionTimeoutMS: 30000,
  socketTimeoutMS: 0,        // 0 = 永不超时(推荐配合应用层超时)
  heartbeatFrequencyMS: 10000
});

Java 驱动(MongoDB Java Driver 4.x):

MongoClientSettings settings = MongoClientSettings.builder()
    .applyConnectionString(new ConnectionString("mongodb://localhost:27017"))
    .applyToConnectionPoolSettings(builder -> builder
        .maxSize(100)
        .minSize(20)
        .maxWaitTime(5000, TimeUnit.MILLISECONDS)
        .maxConnectionIdleTime(60000, TimeUnit.MILLISECONDS)
    )
    .applyToSocketSettings(builder -> builder
        .connectTimeout(10000, TimeUnit.MILLISECONDS)
        .readTimeout(0, TimeUnit.MILLISECONDS)
    )
    .build();

MongoClient client = MongoClients.create(settings);

Python 驱动(pymongo 4.x):

from pymongo import MongoClient

client = MongoClient(
    "mongodb://localhost:27017",
    maxPoolSize=100,
    minPoolSize=10,
    maxIdleTimeMS=60000,
    waitQueueTimeoutMS=5000,
    serverSelectionTimeoutMS=30000
)

连接池调优要点:

  • maxPoolSize 不宜超过服务端 maxIncomingConnections 的 80%
  • 设置合理的 waitQueueTimeoutMS,防止请求无限堆积
  • 长连接应用应配置 maxIdleTimeMS 避免防火墙切断静默连接
  • 副本集环境下,驱动会自动维护到各节点的连接池

7. 监控告警体系搭建

完善的监控告警体系是 MongoDB 稳定运行的保障。生产环境建议结合多种监控手段形成互补。

7.1 Prometheus + Grafana 监控方案

使用 mongodb_exporter 将 MongoDB 指标暴露为 Prometheus 格式,再通过 Grafana 可视化。

# docker-compose.yml
version: '3'
services:
  mongodb_exporter:
    image: percona/mongodb_exporter:0.40
    command:
      - "--mongodb.uri=mongodb://monitor:pass@mongodb:27017"
      - "--discovering-mode"
      - "--compatible-mode"
    ports:
      - "9216:9216"

关键 Grafana 面板指标:

  • MongoDB Connections:当前连接 / 可用连接比率
  • Opcounters:各类型操作(insert/query/update/delete)的 QPS 趋势
  • WiredTiger Cache:命中率、脏页比例、驱逐页数量
  • Replication Lag:副本集同步延迟
  • Oplog Window:oplog 可覆盖的时间窗口

7.2 关键告警规则

# Prometheus 告警规则(prometheus-rules.yml)
groups:
  - name: mongodb_alerts
    rules:
      - alert: MongoDBHighConnections
        expr: mongodb_connections{state="current"} / mongodb_connections{state="available"} > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "MongoDB 连接数过高"
          
      - alert: MongoDBReplicationLag
        expr: mongodb_mongod_replset_member_replication_lag > 10
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "副本集复制延迟超过 10 秒"
          
      - alert: MongoDBSlowQueries
        expr: rate(mongodb_mongod_op_latencies_ops_total[5m]) > 0.1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "慢查询比例异常升高"
          
      - alert: MongoDBOplogWindowSmall
        expr: mongodb_mongod_replset_oplog_tail_timestamp - mongodb_mongod_replset_oplog_head_timestamp < 86400
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Oplog 窗口小于 24 小时"

7.3 Atlas 监控对比

MongoDB Atlas 云托管服务内置了完善的监控告警体系:

  • 自动配置的慢查询 Profiler 和性能建议器(Performance Advisor)
  • 自动索引推荐(基于实际工作负载分析)
  • 实时性能图表(延迟热力图、连接监控、操作统计)
  • 内置告警通道(Email、Slack、PagerDuty)

自托管集群可以通过集成 mongodb_exporter + Prometheus 来模拟 Atlas 的大部分监控能力,但需要额外投入运维成本。对于缺乏专业 DBA 的团队,Atlas 的自动化调优建议可以显著降低性能问题排查难度。

8. 分片性能调优

当单节点无法满足数据量或 QPS 需求时,分片(Sharding)是 MongoDB 水平扩展的核心方案。分片的性能主要由片键(Shard Key)的选择决定。

8.1 片键选择策略

理想的片键应同时满足三个特性:

  • 高基数(High Cardinality):片键值应足够分散,避免所有文档落入少量分片
  • 均匀分布(Even Distribution):写入负载在各个分片间均衡
  • 查询隔离(Query Isolation):目标查询尽量路由到单个分片,减少 scatter-gather 开销
// 反例:单调递增字段(如自增 ID、时间戳)作为片键
// 所有新写入都集中到最后一个分片,形成热点
sh.shardCollection("logs.events", { timestamp: 1 })

// 正例:使用复合片键,加入具有高基数的字段打散写入
sh.shardCollection("logs.events", { accountId: "hashed" })
sh.shardCollection("logs.events", { accountId: 1, timestamp: 1 })

Hashed Sharding:对片键值做哈希后再分布,天然打散单调递增的数据,保证写入均匀。代价是范围查询必须路由到所有分片。适合写入密集型场景。

Ranged Sharding:按片键值范围分片,天然支持范围查询路由到特定分片。但需要片键本身分布均匀。适合读密集型且范围查询频繁的场景。

8.2 Jumbo Chunk 处理

当某个 chunk 中的文档数量或大小超过上限(默认单个 chunk 文档数上限约 25 万或数据大小 64MB),该 chunk 会被标记为 jumbo(超大),Balacer 无法迁移它。

// 查看 jumbo chunk 情况
sh.status(true)

// 手动触发数据分裂
db.adminCommand({ split: "shop.orders", middle: { _id: ObjectId(...) } })

// 尝试手动迁移非 jumbo 的 chunk
sh.moveChunk("shop.orders", { _id: ObjectId(...) }, "shard02")

处理 jumbo chunk 的方法:

  1. 如果是因为片键值分布不均,考虑重新选择片键或使用复合片键
  2. 临时提升 chunk 大小阈值(谨慎操作)
  3. 对超大文档做业务拆分,降低单文档体积

8.3 Balancer 窗口配置

Balancer 负责在各个分片之间迁移 chunk,以维持均衡。迁移操作会消耗网络和磁盘 I/O,建议在业务低谷期运行。

// 设置 balancer 窗口(仅在当地时间凌晨 2-6 点运行)
use config
db.settings.update(
  { _id: "balancer" },
  { 
    $set: { 
      activeWindow: { start: "02:00", stop: "06:00" }
    } 
  },
  { upsert: true }
)

// 临时关闭 balancer(执行维护操作时)
sh.stopBalancer()

// 重新启用
sh.startBalancer()

Zone Sharding(区域分片):可以将数据按地理位置或业务属性绑定到特定分片,实现数据就近存储和多数据中心部署。

// 为 shard 标记区域
sh.addShardToZone("shard0000", "Asia")
sh.addShardToZone("shard0001", "Europe")

// 定义区域范围
sh.updateZoneKeyRange(
  "users.accounts",
  { region: "CN", _id: MinKey },
  { region: "CN", _id: MaxKey },
  "Asia"
)

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践