38. MongoDB 压测与容量规划

压测与容量规划方法:基准与负载与稳定性三层测试、YCSB 与 mongosh 自建压测脚本、工作集估算与内存规划、maxIncomingConnections 连接模型、存储与索引体积估算,以及扩容触发阈值与压测报告指标。

“这台机器能扛多少 QPS"是容量规划里最容易问错的问题。真实答案取决于工作集是否装进内存、索引是否命中、写关注等级、连接数与并发模型,而不是一个孤立的数字。本文把压测拆成基准、负载、稳定性三层,给出用 mongosh 自建压测脚本的方法,并逐一拆解内存、连接、存储三类资源的估算模型与扩容触发阈值。

1. 压测方法论

压测不是"跑一个工具看数字”,而是分三层逐步逼近生产真相。

1.1 三层压测

层次目标变量控制产出
基准测试(Benchmark)摸清单点能力上限固定数据集、固定负载单机吞吐与延迟基线
负载测试(Load)验证目标负载下的表现模拟真实读写比例达标所需资源量
稳定性测试(Soak)发现长期运行的问题持续加压数小时到数天内存泄漏、碎片、延迟漂移

基准测试回答"极限在哪",负载测试回答"够不够用",稳定性测试回答"能不能一直用"。三者缺一不可。

1.2 压测的常见误区

  • 用空集合压测:不命中磁盘、不触发淘汰,数字虚高
  • 用均匀随机键压测:真实业务多为热点访问,均匀分布过于乐观
  • 忽略写关注:w:1 与 w:majority 的吞吐能差一倍以上
  • 单线程测延迟、多线程测吞吐混为一谈
  • 压测数据量远小于生产,工作集估算失真

决策铁律:压测数据集必须按生产规模等比构造,且写入时要让数据"落盘"再开测。空集合的压测结果没有参考价值,只适合验证脚本正确性。

1.3 压测前必须固定的变量

数据集大小与分布(键的基数、热点比例)
读写比例(如 80:20)
写关注与读关注等级
并发连接数与线程模型
索引集合(与生产完全一致)
副本集拓扑(几节点、是否跨机房)

任何一项与生产不符,结论就要打折扣。

2. 压测工具与自建脚本

2.1 常用工具概览

工具类型特点适用
YCSB通用 KV 压测支持 MongoDB binding,可配置读写比基准对比
mongo-perf官方微基准覆盖大量操作类型版本对比、回归
mongos-bench分片压测面向分片集群分片吞吐
mongosh 脚本自建完全可控,贴近业务负载测试
应用层压测端到端最真实上线前验收

2.2 YCSB 快速上手

# 安装并加载数据(示意)
./bin/ycsb load mongodb -s -P workloads/workloada \
  -p mongodb.url="mongodb://rs-shard01:27018/shop?w=majority" \
  -p recordcount=10000000

# 运行负载:50% 读 50% 更新
./bin/ycsb run mongodb -s -P workloads/workloada \
  -p mongodb.url="mongodb://rs-shard01:27018/shop?w=majority" \
  -p operationcount=5000000 \
  -threads 64

YCSB 的 workloada 到 workloadf 覆盖不同读写比例,适合做基准对比,但它的数据模型过于简单(单表单键),不能替代贴近业务的负载测试。

2.3 用 mongosh 自建读写压测

自建脚本能精确控制文档结构、读写比例与热点分布,是负载测试的主力:

// insert_bench.js:批量插入压测
const TOTAL = 1000000
const BATCH = 1000
const coll = db.getSiblingDB("bench").events

coll.drop()
coll.createIndex({ userId: 1, createdAt: -1 })

const start = Date.now()
for (let i = 0; i < TOTAL; i += BATCH) {
  const docs = []
  for (let j = 0; j < BATCH; j++) {
    docs.push({
      userId: "u" + ((i + j) % 100000),      // 高基数,打散
      type: ["click", "view", "buy"][(i + j) % 3],
      amount: (i + j) % 500,
      createdAt: new Date()
    })
  }
  coll.insertMany(docs, { ordered: false, writeConcern: { w: "majority" } })
}
const elapsed = (Date.now() - start) / 1000
print(`inserted ${TOTAL} in ${elapsed.toFixed(1)}s, ${(TOTAL / elapsed).toFixed(0)} ops/s`)
// read_bench.js:混合读写压测
const coll = db.getSiblingDB("bench").events
const CONCURRENCY = 32
const DURATION_MS = 60000
const deadline = Date.now() + DURATION_MS
let reads = 0, writes = 0, errors = 0

// 用 startParallel 模拟并发
const futures = []
for (let c = 0; c < CONCURRENCY; c++) {
  futures.push(() => {
    while (Date.now() < deadline) {
      try {
        if (Math.random() < 0.8) {
          coll.find({ userId: "u" + Math.floor(Math.random() * 100000) })
              .sort({ createdAt: -1 }).limit(10).toArray()
          reads++
        } else {
          coll.insertOne({ userId: "u" + Math.floor(Math.random() * 100000),
                           type: "buy", amount: 42, createdAt: new Date() })
          writes++
        }
      } catch (e) { errors++ }
    }
  })
}
startParallel(...futures)
const total = reads + writes
print(`reads=${reads} writes=${writes} errors=${errors} total=${total} in ${DURATION_MS / 1000}s`)
print(`throughput=${(total / (DURATION_MS / 1000)).toFixed(0)} ops/s`)

注意:mongosh 的单进程受 JS 引擎与网络限制,适合验证查询性能与资源消耗,不适合压出极限吞吐。要测极限需用多进程或专门的压测工具。

2.4 压测脚本的必备要素

  • 预热阶段:先跑 30 秒让缓存与连接池进入稳态
  • 统计阶段:单独计时,排除预热
  • 分位数统计:记录 P50、P95、P99,而不只是平均值
  • 错误计数:超时与写冲突必须单独统计

3. 工作集估算与内存规划

工作集(working set)是"活跃数据与索引"的集合,MongoDB 的性能分水岭就是工作集能否装进内存。

3.1 工作集的定义

工作集不等于数据集总量,而是最近被频繁访问的那部分数据加索引。经验上,80% 的请求落在 20% 的数据上,工作集往往远小于总数据量。

// 估算集合与索引体积
db.events.stats()
{
  ns: "bench.events",
  count: 10000000,
  size: 2048576000,          // 文档总字节数
  avgObjSize: 205,
  storageSize: 2345678901,   // 磁盘占用(含压缩与填充)
  totalIndexSize: 850000000,
  indexSizes: {
    "_id_": 320000000,
    "userId_1_createdAt_-1": 530000000
  }
}

3.2 内存规划模型

所需内存 ≈ 工作集文档大小 + 工作集索引大小 + 连接开销 + 预留余量
// 从 WiredTiger 缓存看实际内存占用
db.serverStatus().wiredTiger.cache
{
  "bytes currently in the cache": 5368709120,
  "maximum bytes configured": 7516192768,
  "bytes read into cache": 12345678901,
  "tracked dirty bytes in the cache": 104857600,
  "pages read into cache": 234567,
  "pages written from cache": 12345
}
指标含义关注点
maximum bytes configuredWiredTiger 缓存上限默认约为 (RAM 减 1GB) 的一半
bytes currently in the cache当前缓存占用持续接近上限说明内存吃紧
tracked dirty bytes脏页字节数过高会触发强制刷盘,拖慢写
bytes read into cache从磁盘读入字节持续增长说明工作集未命中

3.3 判断工作集是否装得下

// 用 db.serverStatus 观察页读入速率
db.serverStatus().wiredTiger.cache["pages read into cache"]
// 间隔采样两次,若稳定期仍在增长,说明工作集超出内存
// 用索引与文档大小估算
const stats = db.events.stats()
const workingSet = stats.size * 0.2 + stats.totalIndexSize   // 20% 热点数据 + 全部索引
print(`estimated working set: ${(workingSet / 1024 / 1024 / 1024).toFixed(2)} GB`)

决策铁律:内存规划的目标是让"热点数据加索引"常驻内存。若索引总量已接近内存上限,先精简索引(删未使用索引),再考虑加内存或分片。索引往往比文档更吃内存。

4. 连接数与线程模型

4.1 maxIncomingConnections

maxIncomingConnections 限制单实例可接受的连接数,默认 65536(受 ulimit 影响)。

// 查看当前连接状态
db.serverStatus().connections
{
  current: 142,
  available: 837930,
  totalCreated: 10432,
  active: 12,
  threaded: 142,
  exhaustIsMaster: 0,
  exhaustHello: 0,
  awaitingTopologyChanges: 0
}
字段含义关注点
current当前连接数接近上限需告警
available剩余可用连接为负说明已超限
active正在执行的连接与 current 比值反映空闲率
totalCreated累计创建连接数持续高速增长说明连接未复用

4.2 调整连接上限

# 启动参数(mongod.conf)
net:
  maxIncomingConnections: 20000
  port: 27018
// 运行时查看与估算
const conns = db.serverStatus().connections
print(`used ${conns.current}, available ${conns.available}`)

4.3 连接池规划

应用侧连接池总大小乘以应用实例数,不应超过实例的连接上限:

所需连接上限 ≈ 应用实例数 × 每实例连接池大小 × 安全系数(1.5)

例如 20 个应用实例,每实例池大小 100,则需 20 × 100 × 1.5 = 3000 连接上限。驱动连接池配置示例(Node.js):

const client = new MongoClient(uri, {
  maxPoolSize: 100,        // 每实例最大连接数
  minPoolSize: 10,         // 保持的最小连接
  maxIdleTimeMS: 60000,    // 空闲回收
  waitQueueTimeoutMS: 5000 // 获取连接超时
})

重要:连接数过多会消耗大量内存(每个连接约 1MB 栈与缓冲),并非越多越好。current 远大于 active 说明连接池配置过大,应调小 maxPoolSize。连接是稀缺资源,池化复用比扩容更有效。

5. 存储容量与索引体积估算

5.1 文档体积估算

单文档字节 ≈ 字段名长度 + 值大小 + BSON 开销(每字段约 2 到 5 字节 + 类型开销)
集合体积 ≈ 文档数 × 平均文档大小
磁盘占用 ≈ 集合体积 / 压缩比(WiredTiger 默认约 2 到 5 倍压缩)
// 用 collStats 精确获取
db.events.stats(1024 * 1024)   // 以 MB 为单位返回

5.2 索引体积估算

索引体积取决于键长度、文档数与索引数量。经验值:每个索引约为集合体积的 30% 到 60%。

// 查看各索引的实际大小
db.events.stats().indexSizes
// { "_id_": 320000000, "userId_1_createdAt_-1": 530000000 }
资源估算公式备注
文档存储文档数 × 平均大小 / 压缩比压缩比依数据而定
索引存储索引数 × 键长 × 文档数 × 系数键越长索引越大
oplog写入速率 × 覆盖小时数至少 990MB
预留空间总量 × 20%应对碎片与峰值

5.3 容量规划的完整账本

总磁盘 ≈ (集合体积 + 索引体积) × 副本数 × (1 + 预留比例)

副本集 3 节点意味着每个分片的数据要存 3 份,容量规划必须乘以副本数。分片集群则按分片数分摊。

决策铁律:容量规划要按"最坏情况"算:压缩比取保守值、预留 20% 应对碎片、乘上副本数与分片冗余。磁盘打满导致的宕机比内存不足更致命,因为它会让整个节点不可写。

6. 副本集与分片集群的扩容阈值

6.1 需要扩容的信号

资源阈值扩容动作
磁盘使用率持续超过 80%扩容磁盘或分片
内存缓存占用持续超过上限 90%加内存或精简索引
连接数持续超过上限 70%调大连接上限或加实例
CPU 使用率峰值持续超过 70%垂直扩容或分片
复制延迟持续超过 10 秒查网络与磁盘,或加从节点

6.2 垂直扩容与水平扩容的取舍

// 水平扩容:新增分片
sh.addShard("shard04/rs-shard04:27018")

// 查看分片状态
sh.status()
shards:
  {  "_id": "shard01",  "host": "shard01/rs-shard01:27018",  "state": 1 }
  {  "_id": "shard02",  "host": "shard02/rs-shard02:27018",  "state": 1 }
  {  "_id": "shard03",  "host": "shard03/rs-shard03:27018",  "state": 1 }
  {  "_id": "shard04",  "host": "shard04/rs-shard04:27018",  "state": 1 }
方式手段上限适用
垂直扩容加 CPU、内存、磁盘单机物理上限工作集未超内存
水平扩容加分片近乎无限数据量或写入超出单机
读写分离加从节点、读偏好受复制延迟限制读多写少

6.3 扩容的时机与顺序

扩容应提前于阈值触发,而不是等到打满。推荐顺序:

  1. 先看能否用索引优化降低负载(成本最低)
  2. 再看能否垂直扩容(简单直接)
  3. 最后才水平分片(复杂度最高,且分片键设计不可逆)

注意:水平扩容不是万能药。如果分片键设计有热点,加分片也无法分摊写入。扩容前先确认 getShardDistribution() 的分布是否均匀,否则加的分片只会闲置。

7. 总结与最佳实践

  • 方法论:基准测上限、负载测达标、稳定性测长跑,三者缺一不可
  • 数据真实:数据集规模与分布、读写比例、写关注必须与生产一致
  • 工作集:内存规划的核心是让热点数据加索引常驻,索引往往比文档更吃内存
  • 连接数:池化复用优先,上限等于实例数乘池大小乘安全系数
  • 存储账本:集合体积加索引体积,乘副本数,再留 20% 余量
  • 扩容阈值:磁盘 80%、缓存 90%、连接 70%、复制延迟 10 秒,提前动作
  • 报告指标:吞吐、P50/P95/P99 延迟、错误率、资源占用、复制延迟,缺一不可

决策铁律:容量规划不是一次性的数学题,而是持续校准的过程。上线前用压测给出基线,上线后用监控对比基线,一旦偏离就复盘。最贵的不是加机器,而是没有基线导致每次扩容都在猜。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践