操作场景
查询和写入是应用程序与 MongoDB 交互的核心环节。在实际生产环境中,由于查询写入操作不当,往往会引发严重的可用性问题,例如:
全表扫描导致接口雪崩:核心查询未命中索引,响应时间从毫秒级上升至秒级,高峰期引发接口超时与服务不可用。
批量操作冲击数据库:大批量写入或删除操作瞬间占满 CPU 和磁盘 I/O,在线业务查询响应恶化至秒级。
主从切换时写入丢失:核心业务使用默认的 Write Concern=1,Primary 节点故障后已确认成功的写入丢失。
本文档提供数据库查询与写入的操作实践和建议,旨在规范开发行为,降低系统性能风险与业务安全隐患。
查询规范
规范一:使用 explain() 验证执行计划
核心动作:所有上线的查询语句必须经过
explain("executionStats") 验证,确认命中索引且扫描效率合理。生产环境中,开发者默认认为查询"应该"走索引,但实际执行路径可能因索引缺失、字段顺序不匹配等原因退化为全表扫描(COLLSCAN)。
explain() 是唯一可靠的执行计划验证手段,可暴露扫描效率、排序方式、索引命中等关键信息。通过关键指标的对照分析,可快速定位查询性能瓶颈:指标 | 理想值 | 需要优化 |
`winningPlan.stage` | `IXSCAN` | `COLLSCAN`(全表扫描,缺少索引) |
`totalKeysExamined / nReturned` | ≈ 1 | >> 10(索引效率低) |
是否有 `SORT` 阶段 | 无 | 有(内存排序,需为排序字段建索引) |
执行阶段类型说明:
Stage | 说明 | 是否理想 |
`COLLSCAN` | 全表扫描 | 建议优化 |
`IXSCAN` | 索引扫描 | 理想 |
`FETCH` | 回表获取文档 | 建议优化 |
`SORT` | 内存排序 | 需要为排序字段建索引 |
业务应用:某电商订单查询接口上线后,未经过执行计划验证。一个本应命中
userId_createTime 复合索引的查询实际触发了全表扫描。问题表现:双11期间该接口响应时间从10ms飙升至5s,订单列表页白屏,引发大量用户投诉。
优化操作:上线前使用
explain("executionStats") 验证执行计划,确认 winningPlan.stage 为 IXSCAN、扫描效率比接近1后方可发布。explain() 使用示例:以下示例演示如何通过执行计划验证查询是否命中索引。
// 获取执行统计信息(推荐)db.t_orders.find({status: "paid",createTime: { $gte: ISODate("2024-01-01") }}).explain("executionStats")
规范二:查询建议使用投影
核心动作:查询时使用投影(Projection)只返回需要的字段,禁止返回完整文档。
MongoDB 默认返回文档的全部字段。当文档包含大段 HTML 描述、二进制附件等大字段时,无差别地返回完整文档会显著增加网络传输量和内存占用,在高并发场景下造成带宽瓶颈和响应延迟。
业务应用:电商平台商品详情页有数十个字段(包括大段 HTML 描述),但移动端仅需展示价格和库存。
问题表现:每次查询返回完整文档,大促期间百万级请求下带宽暴涨,响应延迟从5ms飙升至200ms。
优化操作:通过投影仅返回必要字段,单次查询数据量降低80%,接口吞吐量提升5倍。
投影查询示例:以下示例演示如何通过投影减少返回数据量。
// 错误:返回完整文档(包含不需要的大字段)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页。问题表现:MongoDB 需要先扫描并跳过前10万条记录,单次查询耗时超过10秒。
优化操作:改用基于
_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 的列表大小
核心动作:
$in 和 $or 的列表大小控制在50个以内,超过时分批查询。MongoDB 在执行
$in 查询为多区间索引扫描。$or 在 Plan Stage 拆为多个子计划,各分支可能走不同索引,需要做去重合并。列表过大时,索引查找次数和结果合并开销呈线性增长,单次查询延迟可达数百毫秒,并对数据库造成瞬时压力。业务应用:推荐系统一次请求需查询用户可能感兴趣的100个商品,使用
{productId: {$in: [100 个 ID]}}。问题表现:MongoDB 将其拆解为100次索引查找再合并结果,延迟可达数百毫秒,高并发时数据库连接池饱和。
优化操作:改为每批20个 ID 分5次查询,总延迟反而更低,且不会对数据库造成瞬时压力。
分批查询示例:以下示例演示如何将大列表
$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;}
写入规范
规范五:使用 $set 进行局部更新
核心动作:更新操作使用 $set 等更新操作符,文档过大场景,不建议全文档替换。
全文档替换(
replaceOne)会将整个文档覆写,未指定的字段会丢失。$set 等操作符仅修改指定字段,写入数据量小、无并发覆盖风险。业务应用:某系统更新用户昵称时,将整个用户文档取出、修改昵称字段、再整个写回。
问题表现:写入数据量为整个文档大小(约10KB)。
优化操作:改用
$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 }});
规范六:Update 必须带查询条件
核心动作:Update 语句必须带明确且非空的查询条件(Query)。
空条件更新({})会匹配集合中的所有文档,存在误更新非目标数据的风险。
业务应用:某金融系统在执行账户余额调整时,因查询条件的变量未正确赋值,Update 语句的 query 参数退化为空对象 {}。
问题表现:一次更新操作将非目标用户的账户余额被错误覆盖为测试值
0,引发重大资损事故。优化操作:强制代码审查,所有 Update 必须有非空 query 条件检查,应用层增加空条件拦截防护。
安全更新示例:以下示例演示空条件更新的风险及应用层防护方法。
// 危险:空条件会匹配所有文档,极易误更新非目标数据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)
规范七:数组更新使用操作符
核心动作:更新数组元素时,禁止全量取出修改后写回,必须使用数组更新操作符。
全量替换数组会将整个数组字段作为一次写入记录到 oplog。当数组较大时(如用户关注列表数千人),每次修改单个元素都产生数 KB 的 oplog 记录,造成严重的"写放大",加速 oplog 填满并加剧主从延迟。
业务应用:社交平台的用户关注列表动辄数千人,某功能需要修改关注列表中某人的备注。
问题表现:将整个数组取出、修改、写回,产生数 KB 的写入量和 oplog 记录。高活跃场景下 oplog 快速填满,主从延迟加剧。
优化操作:使用
$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" } } });
规范八:批量写入控制速率
核心动作:批量写入需控制单批次数量(建议1000 ~ 5000条/批),批次间加休眠,避免瞬时压力冲击数据库。
大批量写入会瞬间占满 MongoDB 的 CPU 和磁盘 I/O 资源,影响在线业务的查询响应。通过控制单批次数量和批次间休眠,将写入压力平滑分摊到更长的时间窗口内。
业务应用:数据迁移脚本一次性导入100万条数据,单批次10万条。
问题表现:瞬间写入压力导致 MongoDB CPU 飙升至100%,在线业务查询响应时间从10ms恶化到2s。
优化操作:改为每批1000条、批次间休眠100ms,迁移耗时虽增加,但在线业务完全无感知。
分批写入示例:以下示例演示如何将大批量数据拆分为多批次平滑写入。
# 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
核心动作:禁止直接执行大量数据的
remove 操作,改用 TTL 索引、分批删除或 drop 集合。直接对大量数据执行
remove 操作,会在短时间内产生大量的删除 oplog 日志,占满 CPU 和磁盘 I/O,并导致主从延迟飙升。对于基于时间的过期清理场景,TTL 索引由 MongoDB 后台任务平滑执行,对业务影响小。删除方案选择:方案 | 适用场景 | 性能影响 |
TTL 索引 | 基于时间自动过期 | 后台平滑执行,无感知 |
drop 集合 | 清空整个集合 | 瞬间完成 |
分批删除 | 部分数据删除 | 可控,需控制速率 |
重命名 + 新建 | 保留表结构清空数据 | 快速 |
业务应用:日志系统定期清理30天前的历史数据,脚本执行
db.logs.remove({createTime: {$lt: 某时间戳}})。问题表现:删除数亿条记录持续数小时,期间数据库 CPU 占用100%、主从延迟飙升至分钟级,在线业务读取到脏数据。
优化操作:改用 TTL 索引自动清理,MongoDB 后台任务平滑淘汰过期数据,业务感知较小。
分批删除示例:以下示例演示如何将大量删除操作拆分为多批次平滑执行。
// 分批删除,每批 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;}
Running Environment
Operating System: Ubuntu 24.04.3 LTS / x86_64
Runtime Version: Node.js v22.13.1
规范十:高写入过期场景使用按时间分表替代逐条删除
核心动作:对于写入量大且数据有固定保留周期的业务,按时间维度将数据写入独立集合(如按月分表),过期时直接 drop 整个集合,避免逐条删除带来的性能冲击。
业务应用:某行为埋点系统每日写入千万级记录,仅需保留最近一个月的数据。初期使用 TTL 索引自动过期,随着数据量增长,TTL 后台线程持续删除大量数据,引起业务性能抖动,同时产生大量 oplog,导致主从延迟不断攀升。
问题表现:TTL 每日需清理数千万条过期文档,后台删除任务长时间占用磁盘 I/O,业务长时间抖动,同时主从延迟从秒级恶化至分钟级,从节点读取频繁超时。
优化操作:改为按月分表,每月数据写入独立集合(如 t_events_202601、t_events_202602),业务按时间路由读写。过期清理时直接 drop 早期集合,瞬间完成且不产生删除 oplog,主从延迟恢复正常。
按月分表示例:以下示例演示按月分表的写入路由与过期清理方法。
// 写入时按月路由到对应集合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()
Write Concern 配置
规范十:核心业务必须使用 w:majority
核心动作:支付、订单等核心业务必须配置
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 节点后即返回成功。问题表现:Primary 节点在写入确认后、数据同步到 Secondary 之前发生硬件故障。主从切换后,已确认成功的支付记录丢失,用户付款但订单状态未更新。
优化操作:改用
w:"majority",数据同步到多数节点才返回成功。写入延迟略有增加,但主从切换时数据零丢失。Write Concern 配置示例:以下示例演示不同 Write Concern 级别的配置方法。
// 禁止: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 统计。问题表现:中间数据量膨胀100倍,内存占用超限报错,执行时间超时。
优化操作:调整为先
$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" } } }])
规范十二:复杂计算下沉至应用层
核心动作:高并发场景建议将复杂运算交给数据库,应由应用层或离线系统处理。
MongoDB 的聚合管道适合中等复杂度的数据处理。涉及多层
$lookup 关联、大规模 $group 统计等重计算操作时,单次聚合可能耗时数十秒,高峰期少量请求即可将数据库 CPU 打满,影响核心业务。场景 | 不推荐 | 推荐 |
复杂报表 | 实时 Aggregation | 离线计算 + 缓存 |
多表关联 | 多层 `$lookup` | 应用层拼接 |
实时统计 | MapReduce | 预聚合 + 缓存 |
业务应用:实时报表使用 Aggregation 进行复杂统计,涉及多层
$lookup、$unwind、$group,单次聚合耗时超过 10 秒。问题表现:高峰期少量报表请求将数据库 CPU 打满,核心交易业务的查询响应时间恶化。
优化操作:将复杂计算迁移至 Spark 离线处理,结果写入缓存供前端查询,数据库负载下降 70%。
查询写入检查清单
上线前审查
检查项 | 验证方法 | 通过标准 |
所有查询都命中索引 | `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小时 |
常见问题
Q1:如何判断查询是否需要优化?
// 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 阶段说明未利用索引完成排序,退化为内存排序// 常见原因:排序字段未加入索引,或索引中字段的排序方向与查询不匹配
Q2:如何安全执行大批量数据更新?
1. 评估影响范围:从节点执行
count() 确认匹配文档数量2. 选择低峰期:在业务低峰期执行
3. 分批执行:每批1000 ~ 5000条,批次间休眠
4. 监控资源:观察 CPU、内存、oplog 使用率
5. 记录进度:保存每批最后一条的
_id,支持断点续跑