第39章:MongoDB 极端性能调优与容量规划

1. 项目背景

业务场景:本地生活电商准备迎接年度最大促销------"618 年中大促"。运维团队接到死命令:系统必须扛住 10 万 QPS 的读写混合负载,P99 延迟 < 100ms。目前的架构------3 分片 × 3 节点复制集,8 核 32GB × 9 台服务器。压测结果显示------当前配置在 3 万 QPS 时 P99 延迟已经到 300ms,距离目标还有 3 倍差距。

容量规划上也有盲区------运维不知道现有的磁盘能支撑多久(日均增量 50GB,磁盘剩余 1TB------大约 20 天就会满),但还没有扩容预算;内存的工作集到底多大也不清楚(现在是 32GB,但 WiredTiger 缓存配置了 16GB------够不够?);CPU 瓶颈到底是在 MongoDB Server 层还是 WiredTiger 层还是网络层。

痛点 :性能调优没有银弹。NUMA 架构可能导致 MongoDB 只用到一半内存;透明大页(Transparent Huge Pages)可能让 WiredTiger 的 4KB 页碎成渣;文件系统选择(ext4 vs xfs)影响 fsync 性能;磁盘 IOPS 不够导致 Checkpoint 期间的写入毛刺;连接池、网络、内核参数......每一个环节都可能成为瓶颈。

2. 项目设计

小胖(盯着压测报告上 P99 300ms 的红线):大师!我们 9 台服务器配了最强的 SSD、64 核 CPU、256GB 内存,结果 3 万 QPS 就 P99 300ms?这不科学!

大师 :性能调优第一步------不猜,用数据说话。把压测期间的 MongoDB 指标拉出来,我帮你定位瓶颈在哪一层。

小胖:我看过了,CPU 85%,IOPS 15000,内存用了 90%,连接池也没满......感觉全满了?

大师 :全满等于没有重点。我们按 USE 方法论------逐一检查 Utilization(利用率)、Saturation(饱和度)、Errors(错误数),定位瓶颈的精确位置。

  1. CPU :85% 是整体利用率,但 MongoDB 是单进程的------如果是在 64 核机上只用了 8 核(其他核空闲),那整体 85% 其实意味着那 8 核已经满载了。用 mpstat 看每核利用率。
  2. IOPS:15000 IOPS 看起来高------但对比你的 SSD 的标称能力(假设 50000 IOPS)还有余地。问题可能是 Checkpoint 期间的 IO 尖峰导致的抖动,而不是平均 IOPS。
  3. 内存:90% 用在哪?如果 WiredTiger 缓存只有 16GB,而工作集是 50GB,缓存淘汰频繁导致额外的磁盘 IO------这才是真正的瓶颈。

技术映射:USE 方法论------CPU 看每核利用率,内存看缓存命中率和 eviction,磁盘看 IOPS 的分布(平均 vs P99),网络看带宽和重传率。

小胖:那怎么找到工作集大小?

大师 :工作集大小 = 活跃查询中频繁访问的数据和索引的总和。估算方法------在高峰期运行 db.serverStatus().wiredTiger.cache 查看缓存使用率。如果 16GB 缓存中有 14GB 在被频繁访问且 eviction > 0------说明工作集 > 16GB,缓存不够。实际工作集 = 缓存当前使用量 + 因淘汰而额外产生的磁盘读对应的数据量。

技术映射:工作集 ≈ 缓存大小 × (1 + eviction_io读 / 缓存命中)。如果缓存命中率 < 95%,说明工作集显著大于缓存。

小白:容量规划怎么做?磁盘、内存、CPU 各留多少余量?

大师:一张表说清楚三者的规划:

资源 规划公式 警戒线
磁盘 (当前数据大小 + 日均增量 × 保留天数) × 1.5(索引+碎片) 使用率 > 70% 开始扩容
内存 工作集大小 × 1.2 + 2GB(系统开销) WiredTiger 缓存命中 < 95% 或 eviction > 0
CPU (目标 QPS × 查询平均 CPU 时间) × 1.3 单核利用率 > 80% 或整体 CPU > 70%

小胖:那系统调优呢?我听人说 NUMA、透明大页、文件系统这些都要调?

大师:对。快速清单:

  • NUMA :numactl --interleave=all mongod------防止 MongoDB 被限制在单 NUMA 节点上只能用一半内存。
  • 透明大页 :echo never > /sys/kernel/mm/transparent_hugepage/enabled------WiredTiger 的 4KB 页和 2MB 大页冲突。
  • 文件系统:XFS 比 ext4 更适合高并发小 IO------WiredTiger 大量 4KB 页读写更匹配 XFS 的 allocator。
  • readahead:WiredTiger 有自己的预读逻辑------操作系统层的 readahead 反而干扰了------设为 8-16KB(而非默认的 128KB)。
  • ulimit :文件描述符 ulimit -n 64000,进程数 ulimit -u 64000。
  • swappiness :vm.swappiness = 1------尽量不使用 swap,避免 WiredTiger 缓存被换出。

大师 (总结):今天记住------性能调优三步走,USE 方法找瓶颈,系统参数做基线(NUMA/THP/XFS),容量规划留余量(30% 磁盘 + 20% CPU + 缓存命中 95%)。最后别忘了------压测后和压测中都要监控所有指标,单靠压测报告的数字是片面的。

3. 项目实战

3.1 环境准备

需要 Linux 服务器环境(用于系统级调优)。Docker 容器内部分参数(NUMA/THP)受宿主机控制。

3.2 分步实现

步骤一:系统级性能基线检查

bash 复制代码
# === 内核参数检查清单 ===

# 1. 透明大页状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 期望: [never] 或 always madvise [never] ------ never 最前面表示当前禁用
# 禁用命令: echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 2. 内存交换倾向
sysctl vm.swappiness
# 期望: 1(尽量不用 swap)

# 3. 文件描述符限制
ulimit -n
# 期望: >= 64000

# 4. 磁盘 I/O 调度器(SSD 推荐 noop 或 none)
cat /sys/block/sda/queue/scheduler
# 对 NVMe SSD: [none] 多队列无调度

# 5. 文件系统预读
blockdev --getra /dev/sda
# 期望: 8-16(KB)------WiredTiger 有自己的预读逻辑

# 6. NUMA 状态
numactl --hardware
# 如果有多 NUMA 节点------mongod 启动时用 numactl --interleave=all 绑定

步骤二:工作集与缓存命中率诊断

javascript 复制代码
// working-set-diagnose.js ------ 工作集诊断

use admin
var s = db.serverStatus()
var cache = s.wiredTiger.cache

var cacheUsedMB = cache["bytes currently in the cache"] / 1024 / 1024
var cacheMaxMB = cache["maximum bytes configured"] / 1024 / 1024
var dirtyMB = (cache["tracked dirty bytes in the cache"] || 0) / 1024 / 1024
var pagesRead = cache["pages read into cache"] || 0
var pagesEvicted = (cache["pages evicted by eviction server"] || 0)
                 + (cache["pages evicted by application threads"] || 0)

print("=== 工作集诊断 ===")
print("缓存使用:", cacheUsedMB.toFixed(0), "MB /", cacheMaxMB.toFixed(0), "MB",
      "(", (cacheUsedMB / cacheMaxMB * 100).toFixed(1), "%)")
print("脏页:", dirtyMB.toFixed(0), "MB")
print("页读入:", pagesRead)
print("页淘汰:", pagesEvicted)

// 粗略的缓存命中率估计
var totalPageAccess = pagesRead + cache["pages requested from the cache"]
var hitRate = totalPageAccess > 0
  ? (1 - pagesRead / totalPageAccess) * 100
  : null
if (hitRate !== null) {
  print("缓存命中率:", hitRate.toFixed(1), "%",
        hitRate > 95 ? "PASS" : "⚠ 建议增大缓存")
}

// 工作集估算
// 如果 eviction > 0 且频繁------说明工作集 > 当前缓存
if (cache["pages evicted by application threads"] > 0) {
  print("⚠ 应用线程被迫淘汰------缓存严重不足!")
  print("  建议 cacheSizeGB 至少增加到当前使用量的 1.5 倍")
}

步骤三:容量规划计算器

javascript 复制代码
// capacity-planning.js ------ 容量规划计算

var s = db.serverStatus()
var dbStats = db.getSiblingDB("local_life").stats()

// 输入参数(运维填写)
var growthRatePerDay = 50  // GB/天
var retentionDays = 90     // 保留天数
var targetQP = 100000      // 目标 QPS
var avgQueryTimeMs = 2     // 平均查询耗时

// 1. 磁盘规划
var currentSizeGB = dbStats.dataSize / 1024 / 1024 / 1024
var indexSizeGB = dbStats.indexSize / 1024 / 1024 / 1024
var totalCurrentGB = currentSizeGB + indexSizeGB
var projectedSizeGB = totalCurrentGB + growthRatePerDay * retentionDays
var safetyMarginGB = projectedSizeGB * 0.3  // 30% 安全余量

print("=== 容量规划 ===")
print("当前数据:", currentSizeGB.toFixed(1), "GB + 索引:", indexSizeGB.toFixed(1), "GB")
print("", retentionDays, "天后预计:", projectedSizeGB.toFixed(0), "GB")
print("建议磁盘:", (projectedSizeGB + safetyMarginGB).toFixed(0), "GB")

// 2. 内存规划
var cacheSizeGB = cache["maximum bytes configured"] / 1024 / 1024 / 1024
var recommendedCache = (cacheUsedMB / 1024) * 1.5  // 当前使用的 1.5 倍
print("\n当前缓存:", cacheSizeGB.toFixed(1), "GB, 建议:", recommendedCache.toFixed(1), "GB")

// 3. CPU 规划
var estimatedCpuPerQuery = avgQueryTimeMs / 1000  // 秒
var requiredCpuSec = targetQP * estimatedCpuPerQuery
var requiredCores = Math.ceil(requiredCpuSec * 1.3)  // 30% 余量
print("\n目标 QPS:", targetQP, "→ 需要约", requiredCores, "个 CPU 核心")

步骤四:磁盘 IOPS 与 Checkpoint 毛刺诊断

javascript 复制代码
// iops-check.js ------ 磁盘 IO 与 Checkpoint 监控

// 在 mongosh 中每 2 秒采样一次 IO 相关指标
function sampleIO() {
  var s = db.serverStatus()
  var wt = s.wiredTiger
  return {
    time: new Date(),
    reads: wt["block-manager"]["blocks read"] || 0,
    writes: wt["block-manager"]["blocks written"] || 0,
    checkpointMs: wt.checkpoint?.["most recent time msecs"] || 0,
    evictionApp: wt.cache["pages evicted by application threads"] || 0
  }
}

var before = sampleIO()
sleep(5000)
var after = sampleIO()

var blocksRead = after.reads - before.reads
var blocksWritten = after.writes - before.writes
var iops = (blocksRead + blocksWritten) / 5  // 5 秒间隔 → 每秒 IOPS

print("=== IOPS 采样 (5秒) ===")
print("读块:", blocksRead, "写块:", blocksWritten)
print("估算 IOPS:", iops.toFixed(0))
print("最近 Checkpoint 耗时:", after.checkpointMs, "ms")
print("应用线程淘汰:", after.evictionApp - before.evictionApp)

// 如果 Checkpoint > 1000ms(1秒)------说明脏页过多,checkpoint 期间 IO 风暴
// 如果 evictionApp 在 5 秒内新增 > 0------缓存不够,应用被迫参与淘汰

步骤五:mongostat + mongotop 实时监控

bash 复制代码
# === mongostat 实时指标 ===
# 每秒刷新一次,输出 QPS、连接数、内存、锁等关键指标
mongostat --uri="mongodb://admin:pass@host:27017/?authSource=admin" -n 30 1

# 关键列解读:
# insert/query/update/delete: 每秒操作数
# vsize/res: 虚拟内存/物理内存
# qr/qw: 读/写队列长度(> 0 说明请求在等待)
# ar/aw: 活跃读/写连接数
# netIn/netOut: 网络流量
# conn: 连接数
# dirty: 脏页百分比
# used: 缓存使用率

# === mongotop 集合级读写负载 ===
mongotop --uri="mongodb://admin:pass@host:27017/?authSource=admin" 5
# 每 5 秒刷新一次,显示每个集合的读写时间占比
# 找出"最忙的集合"------读写时间最高的那个 → 优化它的索引或分片

步骤六:火焰图定位 CPU 热点

bash 复制代码
# perf 采集 30 秒调用栈并生成火焰图(核心步骤)

# 1. 采集(在生产环境低峰期进行)
sudo perf record -F 99 -p $(pgrep mongod) -g -- sleep 30

# 2. 生成火焰图
sudo perf script | ./FlameGraph/stackcollapse-perf.pl > mongod.folded
./FlameGraph/flamegraph.pl mongod.folded > mongod_flamegraph.svg

# 3. 分析火焰图中的主要 CPU 消耗
# 常见热点:
# - __wt_btree_insert → 索引写入热点 → 考虑批量写入或分片
# - __wt_row_search → B-Tree 查找 → 索引选择性差或缺少索引
# - mongo::BSONObj::toString → BSON 序列化 → 文档过大或返回字段过多
# - malloc/free → 频繁内存分配 → 文档碎片化或工作集不稳定

3.3 完整代码清单

文件 用途
mongodb-lab/tuning/kernel-check.sh 内核参数基线检查
mongodb-lab/tuning/working-set-diagnose.js 工作集与缓存命中率诊断
mongodb-lab/tuning/capacity-planning.js 容量规划计算器
mongodb-lab/tuning/iops-checkpoint.js IOPS 与 Checkpoint 毛刺诊断
mongodb-lab/tuning/mongostat-capture.sh mongostat 捕获脚本

3.4 测试验证

javascript 复制代码
use admin

// 1. 验证系统指标可采集
var s = db.serverStatus()
print("WiredTiger:", s.wiredTiger ? "PASS" : "FAIL")
print("连接:", s.connections ? "PASS" : "FAIL")

// 2. 验证磁盘统计
var dbStats = db.getSiblingDB("local_life").stats()
print("磁盘统计:", dbStats.dataSize > 0 ? "PASS" : "FAIL")

// 3. 验证 Checkpoint 信息
var cp = db.serverStatus().wiredTiger.checkpoint
print("Checkpoint:", cp ? "PASS" : "FAIL")

// 4. 验证 mongostat 可用
// 在宿主机命令行运行: mongostat --version

print("\n=== 性能调优工具验证完成 ===")

4. 项目总结

4.1 性能调优速查清单

层级 调优项 推荐值/操作
内核 透明大页 禁用 (never)
内核 swappiness 1
内核 readahead 8-16KB
文件系统 XFS 优于 ext4
硬件 NUMA numactl --interleave=all
MongoDB cacheSizeGB 物理内存 × 50%-70%
MongoDB Oplog 大小 24-48 小时写入量
MongoDB 连接池 maxPoolSize 50-100(按 QPS 调)
应用 批量写入 bulkWrite 替代逐条 insert
应用 原子更新 $inc + filter 替代 read-then-write

4.2 适用场景

极端性能调优适用:

  1. 大促前的容量评估和压测调优。
  2. 数据库迁移到新硬件后的参数复查。
  3. 周期性性能衰退的根因分析(碎片、缓存命中率下降)。
  4. 新业务上线前的工作集评估和资源申请。

4.3 注意事项

注意事项 说明
内核参数修改后需重启 mongod THP、swappiness 等修改需要 mongod 重启才生效
perf 采集有性能开销 生产环境低峰期采集 30s,不要长时间运行
XFS 优于 ext4 但并非银弹 ext4 在纯读场景下可能更快
容量规划的数字是经验值 根据实际压测数据调整余量------模型需要校准

4.4 常见踩坑经验

故障案例一:NUMA 导致只用一半内存

某 128GB 服务器上 MongoDB 设置了 cacheSizeGB: 80,但实际只用了 40GB(一半)。根因:服务器是双路 NUMA 架构,mongod 进程只在一个 NUMA 节点上分配了内存------另一个节点的 64GB 空闲。解决 :numactl --interleave=all mongod 或在 systemd service 中配置 CPUAffinity 和 MemoryPolicy=interleave。

故障案例二:Checkpoint 期间的磁盘 IO 风暴

某 SSD 服务器的 MongoDB 每 60 秒出现一次 2-3 秒的写入毛刺------Checkpoint 期间脏页刷盘产生大量 IO。根因:脏页比例过高(30%+),一次性刷盘量大。解决 :缩短 Checkpoint 间隔到 30 秒(checkpoint=(wait=30,log_size=1GB)),使脏页更频繁但更平缓地刷盘;同时增大 WiredTiger 缓存以减少总脏页量。

故障案例三:压缩算法选 snappy 导致磁盘膨胀

某团队用 snappy 做默认压缩,半年后磁盘使用率超预期------数据量 800GB,snappy 压缩后还有 650GB(压缩率不到 20%)。改用 zstd 后降到 420GB------节省了 35% 的空间。代价是 CPU 升高 15%,但磁盘成本节省远大于 CPU 成本。解决:压缩算法要根据数据特征选------高重复度数据(日志/JSON)用 zstd 收益大,二进制数据(图片/文件)压缩率极低时用 none。

4.5 思考题

  1. 如果 WiredTiger 缓存命中率是 99%------说明缓存基本够用。但 P99 延迟仍然很高------可能的原因是什么?(提示:不只看缓存命中,还要看锁、网络、文档大小)
  2. 一台 64GB 内存的服务器,MongoDB 的 cacheSizeGB 可以设为 60GB 吗?为什么?

(答案将在第 40 章末尾揭晓)

上一章思考题答案:

  1. WiredTiger 的 MVCC 使用追加新版本的方式------每次更新在原文档的物理位置附近创建一个新版本,旧版本被标记为过时但不立即删除。旧版本由后台的 Checkpoint Cleanup 线程在确认没有活跃事务引用它之后清理。这就是为什么 WiredTiger 需要定期 Checkpoint 来回收旧版本占用的空间。

  2. 两个事务------A 读 X 写 Y,B 读 Y 写 X------不会形成传统意义上的死锁(因为 WiredTiger 使用乐观 MVCC 而非悲观锁)。但会触发 WriteConflict------两个事务在各自 commit 时检测到自己读过的数据被对方修改了(版本变化),后提交的那个会被 abort 重试。MongoDB 不会死锁等待,而是主动让后提交者失败并重试------这也是为什么事务需要自动重试。

相关推荐
IT大白鼠3 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
MayBaymax4 天前
MongoDB 索引与事务
java·数据库·mongodb
&不羁之风&4 天前
从 MySQL 到 MongoDB:80 万系统日志(sys_log)平滑迁移完整实战(含 Windows 环境 mongoimport 安装与避坑)
mysql·mongodb
浅念-5 天前
Redis基础详解:单线程模型、String与Hash数据结构
服务器·数据库·redis·sql·mysql·nosql数据库·nosql
databook5 天前
使用 DuckDB 分析 Parquet 文件
sql·数据分析·nosql
那人如此可好6 天前
MongoDB常用命令大全
mongodb·db
MayBaymax6 天前
MongoDB 基础概念
java·数据库·mongodb
DBA小马哥7 天前
MongoDB 迁移切流回滚怎么做?停机窗口、增量同步、数据校验全套检查清单
mongodb·迁移学习
databook7 天前
DuckDB + SQL 高效分析 JSON 数据
数据分析·json·nosql
吉甫作诵8 天前
Redis 常用命令大全:11 大类命令速查手册
运维·数据库·redis·缓存·nosql