explain("executionStats") 验证,确认命中索引且扫描效率合理。explain() 是唯一可靠的执行计划验证手段,可暴露扫描效率、排序方式、索引命中等关键信息。通过关键指标的对照分析,可快速定位查询性能瓶颈:指标 | 理想值 | 需要优化 |
`winningPlan.stage` | `IXSCAN` | `COLLSCAN`(全表扫描,缺少索引) |
`totalKeysExamined / nReturned` | ≈ 1 | >> 10(索引效率低) |
是否有 `SORT` 阶段 | 无 | 有(内存排序,需为排序字段建索引) |
Stage | 说明 | 是否理想 |
`COLLSCAN` | 全表扫描 | 建议优化 |
`IXSCAN` | 索引扫描 | 理想 |
`FETCH` | 回表获取文档 | 建议优化 |
`SORT` | 内存排序 | 需要为排序字段建索引 |
userId_createTime 复合索引的查询实际触发了全表扫描。explain("executionStats") 验证执行计划,确认 winningPlan.stage 为 IXSCAN、扫描效率比接近1后方可发布。// 获取执行统计信息(推荐)db.t_orders.find({status: "paid",createTime: { $gte: ISODate("2024-01-01") }}).explain("executionStats")
// 错误:返回完整文档(包含不需要的大字段)db.t_products.find({ productId: "P001" })// 正确:仅返回需要的字段db.t_products.find({ productId: "P001" },{ _id: 1, name: 1, price: 1, stock: 1 })// 正确:排除大字段db.t_products.find({ productId: "P001" },{ description: 0, htmlContent: 0 })
skip + limit 进行深度分页,改用基于排序字段的游标分页。skip(N) 的底层实现是先扫描并丢弃前 N 条记录,再返回后续结果。当 N 达到数万甚至数十万时,查询耗时随页码线性增长,深度分页的响应时间可达数秒甚至超时。游标分页通过记录上一页最后一条数据的排序字段值,利用索引直接定位下一页起点,性能与页码无关。skip(100000).limit(20) 翻到第5000页。_id 和排序字段的游标分页,无论翻到第几页,查询时间均稳定在 10ms 以内。// 错误:skip 分页,深度分页时性能急剧下降db.t_orders.find().sort({ createTime: -1 }).skip(100000).limit(20)// 正确:游标分页,性能稳定// 第一页db.t_orders.find().sort({ createTime: -1, _id: -1 }).limit(20)// 后续页(使用上一页最后一条的 createTime 和 _id)db.t_orders.find({$or: [{ createTime: { $lt: lastTime } },{ createTime: lastTime, _id: { $lt: lastId } }]}).sort({ createTime: -1, _id: -1 }).limit(20)
$in 和 $or 的列表大小控制在50个以内,超过时分批查询。$in 查询为多区间索引扫描。$or 在 Plan Stage 拆为多个子计划,各分支可能走不同索引,需要做去重合并。列表过大时,索引查找次数和结果合并开销呈线性增长,单次查询延迟可达数百毫秒,并对数据库造成瞬时压力。{productId: {$in: [100 个 ID]}}。$in 查询拆分为多批次执行。// 高并发场景需谨慎:大列表 $indb.t_orders.find({ productId: { $in: [/* 100 个 ID */] } })// 正确:分批查询async function batchQuery(productIds, batchSize = 50) {const results = [];for (let i = 0; i < productIds.length; i += batchSize) {const batch = productIds.slice(i, i + batchSize);const docs = await db.t_orders.find({productId: { $in: batch }}).toArray();results.push(...docs);}return results;}
replaceOne)会将整个文档覆写,未指定的字段会丢失。$set 等操作符仅修改指定字段,写入数据量小、无并发覆盖风险。$set 局部更新,并发问题彻底解决,写入数据量从10KB降至100B。$set 局部更新的正确用法。// 错误:全文档替换(会丢失未指定的字段,且有并发问题)const user = await db.t_users.findOne({ _id: userId });user.nickName = "新昵称";await db.t_users.replaceOne({ _id: userId }, user); // 危险!// 正确:$set 局部更新await db.t_users.updateOne({ _id: userId },{ $set: { nickName: "新昵称" } });// 正确:组合多个更新操作符await db.t_users.updateOne({ _id: userId },{$set: { nickName: "新昵称" },$inc: { loginCount: 1 },$currentDate: { lastLoginTime: true }});
0,引发重大资损事故。// 危险:空条件会匹配所有文档,极易误更新非目标数据db.t_users.updateOne({}, { $set: { status: "inactive" } })// 正确:带明确查询条件,精准定位目标文档db.t_users.updateOne({ userId: "U001" },{ $set: { status: "inactive" } })// 正确:批量更新必须带明确条件,且更新前应评估影响范围db.t_users.updateMany({ status: "pending", createTime: { $lt: ISODate("2024-01-01") } },{ $set: { status: "expired" } })
# Python 示例:防止空条件更新def safe_update(collection, filter_query, update_doc, **kwargs):if not filter_query or filter_query == {}:raise ValueError("Update filter cannot be empty!")return collection.update_one(filter_query, update_doc, **kwargs)
$set + 位置操作符精准更新单个元素,写入量降低99%。// 错误:全量替换数组const order = await db.t_orders.findOne({ orderId: "ORD001" });order.items[0].quantity = 5;await db.t_orders.updateOne({ orderId: "ORD001" },{ $set: { items: order.items } } // 全量写入);// 正确:使用 $ 位置操作符更新匹配的元素await db.t_orders.updateOne({ orderId: "ORD001", "items.productId": "P001" },{ $set: { "items.$.quantity": 5 } } // 精准更新);// 正确:使用 arrayFilters 更新多个元素(MongoDB 3.6+)await db.t_orders.updateOne({ orderId: "ORD001" },{ $set: { "items.$[elem].quantity": 5 } },{ arrayFilters: [{ "elem.productId": { $in: ["P001", "P002"] } }] });// 正确:向数组追加元素await db.t_orders.updateOne({ orderId: "ORD001" },{ $push: { items: { productId: "P003", quantity: 1 } } });// 正确:从数组删除元素await db.t_orders.updateOne({ orderId: "ORD001" },{ $pull: { items: { productId: "P003" } } });
# Python 示例:分批插入import timedef batch_insert(collection, documents, batch_size=1000, interval=0.1):for i in range(0, len(documents), batch_size):batch = documents[i:i + batch_size]collection.insert_many(batch, ordered=False)# 批次间休眠,降低数据库压力if i + batch_size < len(documents):time.sleep(interval)
remove 操作,改用 TTL 索引、分批删除或 drop 集合。remove 操作,会在短时间内产生大量的删除 oplog 日志,占满 CPU 和磁盘 I/O,并导致主从延迟飙升。对于基于时间的过期清理场景,TTL 索引由 MongoDB 后台任务平滑执行,对业务影响小。删除方案选择:方案 | 适用场景 | 性能影响 |
TTL 索引 | 基于时间自动过期 | 后台平滑执行,无感知 |
drop 集合 | 清空整个集合 | 瞬间完成 |
分批删除 | 部分数据删除 | 可控,需控制速率 |
重命名 + 新建 | 保留表结构清空数据 | 快速 |
db.logs.remove({createTime: {$lt: 某时间戳}})。// 分批删除,每批 1000 条,间隔 100msasync function batchDelete(collection, filter, batchSize = 1000) {let totalDeleted = 0;while (true) {// 先查询要删除的文档 IDconst docs = await collection.find(filter, { _id: 1 }).limit(batchSize).toArray();if (docs.length === 0) break;const ids = docs.map(d => d._id);const result = await collection.deleteMany({ _id: { $in: ids } });totalDeleted += result.deletedCount;console.log(`已删除 ${totalDeleted} 条...`);// 间隔休眠await new Promise(resolve => setTimeout(resolve, 100));}return totalDeleted;}
Operating System: Ubuntu 24.04.3 LTS / x86_64
Runtime Version: Node.js v22.13.1
// 写入时按月路由到对应集合const collName = "t_events_" + new Date().toISOString().slice(0, 7).replace("-", "")db.getCollection(collName).insertOne({userId: "U001",action: "click_buy",createTime: new Date()})// 清理过期数据:直接 drop 两个月前的集合,瞬间完成db.t_events_202601.drop()
writeConcern: { w: "majority", j: true },确保数据写入多数节点后才返回成功。w:1 仅确认数据写入 Primary 节点即返回成功。若 Primary 在数据同步到 Secondary 之前发生故障,主从切换后已确认成功的写入将丢失。w:"majority" 要求数据写入多数节点才确认,即使 Primary 故障也不会丢失数据。配置 | 数据安全性 | 写入延迟 | 适用场景 |
`{ w: 1 }` | 中等 | 最低 | 日志等数据安全性不高的场景 |
`{ w: "majority" }` | 高 | 较高 | 核心业务、交易、订单 |
`{ w: 1, j: false }` | 极低 | 最低 | 禁止使用,数据可能丢失 |
`{ w: "majority", j: true }` | 极高 | 较高 | 金融、支付等资金相关 |
w:1,数据写入 Primary 节点后即返回成功。w:"majority",数据同步到多数节点才返回成功。写入延迟略有增加,但主从切换时数据零丢失。// 禁止:j:false 可能导致数据丢失db.t_orders.insertOne({ orderId: "ORD001" },{ writeConcern: { w: 1, j: false } } // 危险!)// 正确:核心业务使用 majoritydb.t_orders.insertOne({ orderId: "ORD001", amount: 199.00 },{ writeConcern: { w: "majority", j: true, wtimeout: 5000 } })// 连接串配置方式(全局生效)// mongodb://user:pwd@host1,host2,host3/db?w=majority&j=true&wtimeout=5000
$match 放最前面、$project 紧随其后减少字段,避免滥用 $unwind。$unwind 展开数组再 $match 过滤,中间数据量可能膨胀数十倍;先 $match 过滤再 $unwind,可在源头大幅缩减数据量。$unwind 展开数组(100万文档展开为1亿条),再 $match 过滤(剩10万条),最后 $group 统计。$match 过滤(100万降至1万),再 $unwind(展开为100万条),内存占用降至可控范围,执行时间从超时降至5秒。// 错误:先展开再过滤,中间数据量膨胀db.t_orders.aggregate([{ $unwind: "$items" }, // 先展开,数据量膨胀{ $match: { status: "paid" } }, // 再过滤{ $group: { _id: "$items.category", total: { $sum: "$items.amount" } } }])// 正确:先过滤再展开,源头缩减数据量db.t_orders.aggregate([{ $match: { status: "paid" } }, // 先过滤,减少数据量{ $project: { items: 1 } }, // 只保留需要的字段{ $unwind: "$items" }, // 再展开{ $group: { _id: "$items.category", total: { $sum: "$items.amount" } } }])
$lookup 关联、大规模 $group 统计等重计算操作时,单次聚合可能耗时数十秒,高峰期少量请求即可将数据库 CPU 打满,影响核心业务。场景 | 不推荐 | 推荐 |
复杂报表 | 实时 Aggregation | 离线计算 + 缓存 |
多表关联 | 多层 `$lookup` | 应用层拼接 |
实时统计 | MapReduce | 预聚合 + 缓存 |
$lookup、$unwind、$group,单次聚合耗时超过 10 秒。检查项 | 验证方法 | 通过标准 |
所有查询都命中索引 | `explain("executionStats")` | `stage` 为 `IXSCAN`,无 `COLLSCAN` |
无内存排序 | `explain("executionStats")` | 无 `SORT` 阶段 |
扫描效率合理 | `totalDocsExamined / nReturned` | 比值接近1 |
使用投影 | 检查查询语句 | 仅返回必要字段 |
Update 有条件 | 代码审查 | 禁止空条件 |
使用 $set 更新 | 代码审查 | 较少字段更新场景不建议全文档替换 |
批量操作有控制 | 代码审查 | 单批次 ≤ 1000条,同时适当控制并发数 |
核心业务 w:majority | 检查 Write Concern | 支付/订单使用 majority |
检查项 | 验证方法 | 操作建议 |
慢查询分析 | 慢查询日志 | 定期分析100ms以上的查询 |
写入延迟监控 | 监控面板 | P99延迟异常时排查原因 |
Oplog 使用率 | `rs.printReplicationInfo()` | Oplog 窗口期 > 24小时 |
// 1. 使用 explain 查看执行计划var result = db.t_orders.find({ status: "paid" }).explain("executionStats")// 2. 检查是否使用索引// winningPlan.stage 为 IXSCAN 表示命中索引// winningPlan.stage 为 COLLSCAN 表示全表扫描,需立即优化// 3. 检查扫描效率var stats = result.executionStatsif (stats.nReturned === 0) {print("返回结果为空,请确认查询条件是否正确")} else if (stats.totalKeysExamined === 0) {print("未使用索引(全表扫描),请结合步骤 2 确认是否需要创建索引")} else {print("索引扫描比:", (stats.totalKeysExamined / stats.nReturned).toFixed(2))print("文档扫描比:", (stats.totalDocsExamined / stats.nReturned).toFixed(2))// 两个比值均接近 1 为理想// 索引扫描比远大于 1,说明索引选择性不足或存在冗余扫描// 文档扫描比远大于 1,说明索引筛选后仍需回表过滤大量不符合条件的文档}// 4. 检查是否有内存排序// executionStages 中出现 SORT 阶段说明未利用索引完成排序,退化为内存排序// 常见原因:排序字段未加入索引,或索引中字段的排序方向与查询不匹配
count() 确认匹配文档数量_id,支持断点续跑文档反馈