写入吞吐是很多 MongoDB 系统的生命线:日志采集、IoT 上报、订单落库,每分钟都可能涌入百万级写入。逐条写入不仅网络往返开销巨大,还会让每次操作的确认等待拖垮整体吞吐。批量写入把多条操作打包进一次请求,配合合适的批大小、有序或无序的语义选择,以及审慎的索引设计,能把写吞吐提升一个数量级。但批量写入不是简单的"把插入拼一起",索引代价、副本同步、分片分布都会在批量场景被放大。本文从 API 使用讲起,覆盖有序无序语义、批大小调优、索引代价与副本集/分片权衡,并给出可复现的压测方法。
1. 批量写入 API 基础
MongoDB 提供 insertMany 与 bulkWrite 两类批量接口。insertMany 是纯插入的快捷方式;bulkWrite 支持在单次请求内混合插入、更新、删除、替换。
// insertMany:批量插入
db.orders.insertMany([
{ orderNo: "B-1", amount: 10 },
{ orderNo: "B-2", amount: 20 },
{ orderNo: "B-3", amount: 30 }
])
// bulkWrite:混合操作
db.orders.bulkWrite([
{ insertOne: { document: { orderNo: "B-4", amount: 40 } } },
{ updateOne: { filter: { orderNo: "B-1" }, update: { $inc: { amount: 1 } } } },
{ updateMany: { filter: { status: "stale" }, update: { $set: { status: "archived" } } } },
{ deleteOne: { filter: { orderNo: "B-2" } } }
])
| API | 适用场景 | 特性 |
|---|---|---|
| insertOne | 单条插入 | 最简单 |
| insertMany | 纯批量插入 | 一次请求多条 |
| bulkWrite | 混合写操作 | 原子性按操作不按批次 |
| updateMany | 条件批量更新 | 单个操作 |
1.1 批量请求的内部处理
一条批量请求到达 mongod 后,会被拆分为单个操作顺序执行。插入顺序、索引更新、日志写入都在服务端完成。对副本集而言,整个批次的 oplog 会作为一个事务写入(批次的原子性窗口),但不同操作之间的失败处理取决于有序还是无序。
// 批量请求的返回结果(示意)
db.orders.insertMany([{ orderNo: "B-5" }, { orderNo: "B-6" }])
// {
// "acknowledged": true,
// "insertedIds": { "0": ObjectId("..."), "1": ObjectId("...") }
// }
2. 有序与无序批量
批量写入可以配置为有序(ordered,默认)或无序(unordered)。两者的差别在遇到失败时体现。
- 有序:按顺序执行,遇到第一个错误就停止,返回已执行与未执行的索引
- 无序:不保证顺序,遇错跳过继续执行其余操作,返回全部错误列表
// 有序:遇到错误立即停止
db.orders.insertMany(
[
{ orderNo: "O-1" },
{ orderNo: "O-1" }, // 违反唯一索引,触发错误
{ orderNo: "O-2" } // 不会执行
],
{ ordered: true }
)
// 无序:跳过错误继续
db.orders.insertMany(
[
{ orderNo: "U-1" },
{ orderNo: "U-1" }, // 报错但继续
{ orderNo: "U-2" } // 会执行
],
{ ordered: false }
)
| 参数 | 执行顺序 | 遇错行为 | 吞吐特征 |
|---|---|---|---|
| ordered: true | 严格顺序 | 立即停止 | 串行,慢 |
| ordered: false | 无序 | 跳过继续 | 可并行,快 |
无序批量对分片集群还有一个额外收益:mongos 可以把无序批次中的操作并行分发到不同分片,显著提升跨分片的写入吞吐。纯插入的日志型负载,几乎都应该使用 ordered: false。
// 日志批量写入:无序 + 弱写关注,追求最大吞吐
db.app_events.insertMany(batchEvents, {
ordered: false,
writeConcern: { w: 1 }
})
重要:
ordered: false牺牲的是"遇错即停"的确定性,换的是吞吐。对于数据本身互相独立、允许部分失败后重试的负载(日志、埋点、队列消息),无序是正确选择;对于存在依赖关系的业务数据,保持有序并处理部分失败。
3. 批大小与消息限制
批量请求的吞吐与批大小强相关,但并非越大越好。决定批大小的两个硬约束:单条 BSON 文档最大 16MB,单条批量消息也受 16MB 限制(mongod 的 maxBsonObjectSize)。
// 批大小过大的后果:单条消息超过限制报错
db.orders.insertMany(bigBatch)
// 错误:BSON size limit exceeded 或 message exceeds 16MB
3.1 批大小的经验法则
- 批大小不是"固定 1000",而是"总体积接近但不超过 16MB 的文档数"
- 推荐从 100~1000 条开始,配合文档平均大小计算总体积
- 小文档(几百字节)可以到几千条;大文档(几十 KB)则只能几十条
- 批大小过大时,单条失败会拖累整批重试
// 按体积估算批大小(示意)
const doc = { orderNo: "S-1", amount: 100, items: [] }
const docSize = Object.bsonsize(doc) // 单条体积
const batchSize = Math.floor((8 * 1024 * 1024) / docSize) // 留一半余量
| 文档平均大小 | 推荐批大小 | 注意 |
|---|---|---|
| 数百字节 | 1000~5000 | 注意 CPU 与内存 |
| 数 KB | 200~1000 | 常规推荐起点 |
| 数十 KB | 50~200 | 接近 16MB 上限 |
| 接近 16MB | 1 | 单条批量 |
3.2 部分失败的重试策略
无序批量允许部分操作失败并继续。批量返回的 writeErrors 与 writeConcernErrors 需要被应用正确解读,失败的索引对应的操作才需要重试。
// Node.js 驱动:批量插入并处理部分失败
const result = await orders.insertMany(batch, { ordered: false })
if (result.writeErrors && result.writeErrors.length > 0) {
const failedIndexes = result.writeErrors.map(e => e.index)
const retryBatch = failedIndexes.map(i => batch[i])
// 过滤掉不可重试的错误(如违反唯一约束)后,再次批量提交
await orders.insertMany(retryBatch, { ordered: false })
}
重试时务必区分错误类型:DuplicateKeyError 重试无意义(数据已存在或冲突),应走去重逻辑;NetworkError 与超时则应在短暂退避后重试。批量越大,部分失败越常见,重试逻辑是批量的标配,而不是异常路径。
3.3 吞吐曲线的度量
批大小的最优值要靠实测:固定负载下逐档递增批大小(100 → 500 → 1000 → 5000),记录每秒写入条数与 P99 延迟,取吞吐开始掉头或延迟开始飙升前的那一档。批量太大时,WiredTiger 缓存压力、日志写入与网络带宽会同步上升,吞吐反而下降。
// 简单压测循环(mongosh 示意)
const docs = Array.from({ length: 10000 }, (_, i) => ({ seq: i, ts: new Date() }))
for (let size of [100, 500, 1000, 5000]) {
const t0 = Date.now()
let inserted = 0
for (let i = 0; i < docs.length; i += size) {
db.bench.insertMany(docs.slice(i, i + size), { ordered: false })
inserted += Math.min(size, docs.length - i)
}
const ms = Date.now() - t0
print(`batch=${size} total=${inserted} ops/s=${Math.round(inserted / (ms / 1000))}`)
}
4. 写密集场景的索引代价
写入的隐形杀手是索引。每插入一条文档,集合上的每个索引都要插入一个索引条目;批量插入 N 条文档、集合有 M 个索引,就需要 N × M 次索引写入。
4.1 索引写放大
// 同样的插入,索引越多越慢
db.orders.createIndex({ customerId: 1 })
db.orders.createIndex({ status: 1, createdAt: -1 })
db.orders.createIndex({ sku: 1 })
db.orders.createIndex({ warehouse: 1, zone: 1 })
// 每插入一条,要维护 1 个主键索引 + 4 个二级索引 = 5 次索引写入
| 索引数量 | 每次插入的索引写入 | 写放大倍数 |
|---|---|---|
| 1(主键) | 1 | 1x |
| 1 主键 + 2 二级 | 3 | 3x |
| 1 主键 + 5 二级 | 6 | 6x |
| 1 主键 + 10 二级 | 11 | 11x |
4.2 写密集索引策略
写密集场景的索引设计原则是"能少就少、能复合就复合、能覆盖查询就覆盖":
- 删除对写路径无收益的索引(尤其低选择性单字段索引)
- 用复合索引替代多个单字段索引,减少索引总个数
- 必要时采用后台构建或滚动重建,避免建索引阻塞写入
- 对纯追加日志型集合,考虑不建或只建极少数索引
// 写密集集合的最小索引集(示意)
db.app_events.createIndex({ deviceId: 1, ts: -1 }) // 查询所需
// 避免:为每个字段单独建索引
决策铁律:索引是为读建的,写密集系统要问的不是"能建什么索引",而是"不建这个索引,查询能不能接受变慢"。每多一个索引,写入就多一份持续成本。写吞吐瓶颈时,先数数这个集合背了多少索引。
5. 副本集与分片的写路径权衡
批量写入的吞吐上限不只在单节点,还在整个集群的写路径。副本集的确认机制与分片的数据分布,共同决定集群级写吞吐。
5.1 副本集写路径
每次批量写入在副本集上要写入 oplog 并(可配置)等待确认。w: "majority" 的批量写入比 w: 1 慢数倍,因为每条(每批)要等多数派确认。批量场景的建议:默认用 w: 1,核心批处理用 w: majority 分级处理。
// 批量 + 弱确认:吞吐优先
db.logs.insertMany(batch, { ordered: false, writeConcern: { w: 1 } })
// 批量 + 强确认:一致性优先
db.orders.insertMany(batch, { ordered: false, writeConcern: { w: "majority" } })
| 写关注 | 吞吐损失 | 数据安全 | 适用 |
|---|---|---|---|
| w: 1 | 基准 | 主节点确认 | 批量默认 |
| w: majority | 明显 | 防回滚 | 关键批量 |
| w: 0 | 最快 | 可能丢 | 非关键日志 |
5.2 oplog 与日志开销
副本集每条写入都要落 oplog,oplog 大小决定可回放窗口。批量写入量大时,若 oplog 太小且副节点滞后,副节点会跟不上并进入 RECOVERING,导致副本集读写能力下降。运维上要监控 replSetGetStatus 的 secondary lag 与 oplog 窗口剩余量。
5.3 分片集群写路径
分片集群上,mongos 按分片键把写入路由到对应分片。批量写入的分片分布决定了吞吐上限:
// hashed 分片键:写入均匀打散到全部分片
sh.shardCollection("shop.orders", { orderId: "hashed" })
// ranged 分片键:写入可能集中于一个分片(热点)
sh.shardCollection("shop.orders", { createdAt: 1 })
| 分片键形态 | 写入分布 | 批量吞吐 | 查询路由 |
|---|---|---|---|
| hashed | 均匀 | 高(并行到全分片) | 广播 |
| ranged 单调递增 | 集中热点 | 低(单分片饱和) | 范围查询收敛 |
| ranged 复合 | 中等 | 中 | 可控 |
无序批量在分片集群上可被 mongos 并行分发到不同分片,这是提升批量吞吐最直接的手段之一。反过来,若分片键设计不当导致写入集中到单分片,批量再大也无法突破单分片上限。
6. 吞吐度量与瓶颈定位
优化写入吞吐离不开度量。MongoDB 的 serverStatus 与监控指标能直接暴露写入瓶颈所在。
// 查看写入相关指标
db.serverStatus().metrics
// {
// "insert": { "total": 123456, "ops": 1234 },
// "update": { "total": ..., "ops": ... },
// "commands": { ... }
// }
// 查看当前正在执行的写入与阻塞
db.currentOp({ "command.insert": { $exists: true } })
| 指标 | 含义 | 瓶颈信号 |
|---|---|---|
| metrics.insert.ops | 每秒插入数 | 增长趋平即到瓶颈 |
| wiredTiger.cache | 缓存命中与压力 | page read/eviction 高 |
| 连接数与排队 | 请求并发 | 大量排队等待 |
| repl oplog lag | 副节点滞后 | 持续增长预警 |
6.1 写入瓶颈的定位顺序
- 先看网络与批大小:逐条插入 → 批量插入,通常先翻数倍
- 再看索引:
db.serverStatus().metrics确认索引写入占比,减少冗余索引 - 再看缓存:WiredTiger cacheSizeGB 是否匹配数据活跃集
- 最后看集群:分片分布是否均匀、副本确认延迟
6.2 批量压测的对照方法
写入优化要避免"凭感觉调参",用对照压测说话。核心是保持负载不变,一次只改一个变量,记录 ops/s 与 P99 延迟。
// 对照压测:固定 5 万条数据,对比不同写关注
function benchWriteConcern(wc) {
const docs = Array.from({ length: 50000 }, (_, i) => ({ seq: i }))
const t0 = Date.now()
for (let i = 0; i < docs.length; i += 1000) {
db.bench.insertMany(docs.slice(i, i + 1000), { ordered: false, writeConcern: wc })
}
const ops = 50000 / ((Date.now() - t0) / 1000)
print(`wc=${wc.w} ops/s=${Math.round(ops)}`)
return Math.round(ops)
}
benchWriteConcern({ w: 1 })
benchWriteConcern({ w: "majority" })
| 对照项 | 固定不变 | 唯一变量 |
|---|---|---|
| 批大小对比 | 文档内容、写关注 | 批大小 |
| 写关注对比 | 批大小、文档内容 | w 值 |
| 索引数量对比 | 数据与负载 | 集合索引集 |
| 分片键对比 | 负载 | 分片键形态 |
压测时要同时记录服务端指标:db.serverStatus().metrics.insert、WiredTiger cache 命中、mongod CPU。单看应用侧 ops/s 会忽略服务端瓶颈;两者对照才能定位是"应用不够快"还是"服务端已饱和"。
决策铁律:批量写入吞吐优化的标准路径是"先合并请求,再减索引,再调批大小,再查分片分布"。逐条插入永远是最贵的用法;索引膨胀是写入吞吐的慢性病;批大小是最后需要精细调节的旋钮。
7. 批量写入优化总结
批量写入吞吐优化的核心矛盾是"单次请求的处理量"与"请求之间的开销"之间的平衡。把散落的单条插入合并成批量,网络与确认开销急剧下降;但批大小、索引数量、副本确认与分片分布又会成为新的上限。
- API 选择:纯插入用 insertMany,混合写用 bulkWrite
- 语义选择:日志型无序 + 弱确认,业务型有序 + 适当确认
- 批大小:按文档体积估算,实测吞吐曲线定最优值
- 索引瘦身:写密集集合的索引越少越好,复合优先
- 集群权衡:副本确认分级,分片键保证写分布均匀
决策铁律:没有永远最优的批大小,只有"当前负载与硬件下的最优值"。把批量写入参数化(批大小、有序、写关注),配合压测脚本定期校准,才能让写吞吐始终贴近硬件与集群的极限。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。