“这台机器能扛多少 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 configured | WiredTiger 缓存上限 | 默认约为 (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 扩容的时机与顺序
扩容应提前于阈值触发,而不是等到打满。推荐顺序:
- 先看能否用索引优化降低负载(成本最低)
- 再看能否垂直扩容(简单直接)
- 最后才水平分片(复杂度最高,且分片键设计不可逆)
注意:水平扩容不是万能药。如果分片键设计有热点,加分片也无法分摊写入。扩容前先确认
getShardDistribution()的分布是否均匀,否则加的分片只会闲置。
7. 总结与最佳实践
- 方法论:基准测上限、负载测达标、稳定性测长跑,三者缺一不可
- 数据真实:数据集规模与分布、读写比例、写关注必须与生产一致
- 工作集:内存规划的核心是让热点数据加索引常驻,索引往往比文档更吃内存
- 连接数:池化复用优先,上限等于实例数乘池大小乘安全系数
- 存储账本:集合体积加索引体积,乘副本数,再留 20% 余量
- 扩容阈值:磁盘 80%、缓存 90%、连接 70%、复制延迟 10 秒,提前动作
- 报告指标:吞吐、P50/P95/P99 延迟、错误率、资源占用、复制延迟,缺一不可
决策铁律:容量规划不是一次性的数学题,而是持续校准的过程。上线前用压测给出基线,上线后用监控对比基线,一旦偏离就复盘。最贵的不是加机器,而是没有基线导致每次扩容都在猜。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。