一、操作场景
·全表扫描导致接口雪崩:核心查询未命中索引,响应时间从毫秒级上升至秒级,高峰期引发接口超时与服务不可用。
·批量操作冲击数据库:大批量写入或删除操作瞬间占满 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 查询拆分为多批次执行。
// 高并发场景需谨慎:大列表 $in db.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 time def 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 条,间隔 100ms async function batchDelete(collection, filter, batchSize = 1000) { let totalDeleted = 0; while (true) { // 先查询要删除的文档 ID const 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_64Runtime 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()
十七、规范十一:核心业务必须使用 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 } } // 危险! ) // 正确:核心业务使用 majority db.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" } } } ])
20、规范十三:复杂计算下沉至应用层
·核心动作:高并发场景建议将复杂运算交给数据库,应由应用层或离线系统处理。
MongoDB 的聚合管道适合中等复杂度的数据处理。涉及多层 $lookup 关联、大规模 $group 统计等重计算操作时,单次聚合可能耗时数十秒,高峰期少量请求即可将数据库 CPU 打满,影响核心业务。
| 场景 | 不推荐 | 推荐 |
| 复杂报表 | 实时 Aggregation | 离线计算 + 缓存 |
| 多表关联 | 多层 `$lookup` | 应用层拼接 |
| 实时统计 | MapReduce | 预聚合 + 缓存 |
·业务应用:实时报表使用 Aggregation 进行复杂统计,涉及多层 $lookup、$unwind、$group,单次聚合耗时超过 10 秒。
·问题表现:高峰期少量报表请求将数据库 CPU 打满,核心交易业务的查询响应时间恶化。
·优化操作:将复杂计算迁移至 Spark 离线处理,结果写入缓存供前端查询,数据库负载下降 70%。
22、上线前审查
| 检查项 | 验证方法 | 通过标准 |
| 所有查询都命中索引 | `explain("executionStats")` | `stage` 为 `IXSCAN`,无 `COLLSCAN` |
| 无内存排序 | `explain("executionStats")` | 无 `SORT` 阶段 |
| 扫描效率合理 | `totalDocsExamined / nReturned` | 比值接近1 |
| 使用投影 | 检查查询语句 | 仅返回必要字段 |
| Update 有条件 | 代码审查 | 禁止空条件 |
| 使用 $set 更新 | 代码审查 | 较少字段更新场景不建议全文档替换 |
| 批量操作有控制 | 代码审查 | 单批次 ≤ 1000条,同时适当控制并发数 |
| 核心业务 w:majority | 检查 Write Concern | 支付/订单使用 majority |
23、定期检查
| 检查项 | 验证方法 | 操作建议 |
| 慢查询分析 | 慢查询日志 | 定期分析100ms以上的查询 |
| 写入延迟监控 | 监控面板 | P99延迟异常时排查原因 |
| Oplog 使用率 | `rs.printReplicationInfo()` | Oplog 窗口期 > 24小时 |
25、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 阶段说明未利用索引完成排序,退化为内存排序// 常见原因:排序字段未加入索引,或索引中字段的排序方向与查询不匹配
26、Q2:如何安全执行大批量数据更新?
1. 评估影响范围:从节点执行 count() 确认匹配文档数量
2. 选择低峰期:在业务低峰期执行
3. 分批执行:每批1000 ~ 5000条,批次间休眠
4. 监控资源:观察 CPU、内存、oplog 使用率
5. 记录进度:保存每批最后一条的 _id,支持断点续跑