32. MongoDB 批量写入与吞吐优化

批量写入与写吞吐优化:bulkWrite/insertMany 使用、有序与无序批量语义、批大小与 16MB 消息限制调优、写密集场景的索引代价、副本集与分片集群的写路径权衡

写入吞吐是很多 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 与内存
数 KB200~1000常规推荐起点
数十 KB50~200接近 16MB 上限
接近 16MB1单条批量

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(主键)11x
1 主键 + 2 二级33x
1 主键 + 5 二级66x
1 主键 + 10 二级1111x

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 写入瓶颈的定位顺序

  1. 先看网络与批大小:逐条插入 → 批量插入,通常先翻数倍
  2. 再看索引:db.serverStatus().metrics 确认索引写入占比,减少冗余索引
  3. 再看缓存:WiredTiger cacheSizeGB 是否匹配数据活跃集
  4. 最后看集群:分片分布是否均匀、副本确认延迟

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
  • 语义选择:日志型无序 + 弱确认,业务型有序 + 适当确认
  • 批大小:按文档体积估算,实测吞吐曲线定最优值
  • 索引瘦身:写密集集合的索引越少越好,复合优先
  • 集群权衡:副本确认分级,分片键保证写分布均匀

决策铁律:没有永远最优的批大小,只有"当前负载与硬件下的最优值"。把批量写入参数化(批大小、有序、写关注),配合压测脚本定期校准,才能让写吞吐始终贴近硬件与集群的极限。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 33. MongoDB 地理空间与全文检索
  2. 31. MongoDB 读写关注与一致性
  3. 30. MongoDB 静态加密与字段级加密