查询与写入规范

最近更新时间:2026-05-12 18:07:11

我的收藏

操作场景

查询和写入是应用程序与 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.stageIXSCAN、扫描效率比接近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_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 } } // 危险!
)
// 正确:核心业务使用 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" } } }
])

规范十二:复杂计算下沉至应用层

核心动作:高并发场景建议将复杂运算交给数据库,应由应用层或离线系统处理。
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.executionStats
if (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,支持断点续跑