几天前我临时去救一个MongoDB集群,故障现象很奇怪:业务高峰期P99从几十毫秒一路涨到五百毫秒以上,连接堆积报警不断,可数据库CPU和磁盘IO都远没跑满。慢查询日志翻了好几遍,除了几条几十毫秒的普通查询,根本找不到元凶。最后让我破案的不是查询语句,而是db.serverStatus()里的WiredTiger部分——WiredTiger存储引擎的缓存与并发参数始终在用默认值,跟真实负载完全错配。
这篇文章就把WiredTiger调优这件事说透,重心放在两个最核心、也最容易踩坑的方向:缓存参数(cacheSizeGB)和并发参数(WiredTiger tickets)。我会带你先搞懂WiredTiger内部的内存与并发机制,再结合监控指标判断瓶颈,最后给出一套可落地的调整步骤和回滚方案。不管你是刚接手集群的开发者,还是已有几年经验的DBA,都可以照着这套思路去排查自己的MongoDB集群。
1. 调优前先搞懂WiredTiger的内存账本
很多人上来就问“cacheSizeGB该设多大”,但如果不先搞清楚WiredTiger缓存里到底装了什么,这个问题的答案很容易变成拍脑袋。我建议所有人在动参数之前,先花半小时理解WiredTiger的内存账本。
1.1 缓存不只是“数据和索引的副本”
WiredTiger的缓存是一个综合性的内存池,它不只缓存你常用的数据和索引页,还要容纳事务运行时产生的中间数据。具体来说,至少包含这么几类:
- 数据页:从磁盘读入的文档数据,缓存命中后查询就不需要再走磁盘;
- 索引页:索引的B+树节点,包括正在被访问的内部节点和叶子节点;
- 事务版本链:未提交事务产生的旧版本数据,用于支撑多版本并发控制(MVCC),这部分在高并发写入时会快速膨胀;
- 检查点与刷盘待处理数据:checkpoint期间需要落盘的脏页,以及预写日志相关的缓冲;
- 表结构和元数据:每个集合的schema信息、配置项等。
正因为缓存里有这么多跟“热数据”没有直接关系的内容,你经常会看到一个现象:就算把cacheSizeGB配置成32GB,mongod进程的RSS内存也经常跑到40GB以上。这不是内存泄漏,而是WiredTiger在缓存之上还有系统文件页缓存、连接处理线程、排序缓冲等额外开销。如果看到RSS比配置的缓存大,不用太惊慌。
1.2 默认缓存大小的算法与容器陷阱
WiredTiger内部默认缓存大小的计算公式,我记忆里一直是“(物理内存 - 1GB)/ 2”。也就是说一台物理内存64GB的服务器,默认缓存大约31.5GB;一台32GB内存的小机器,默认大约15.5GB。
这个默认值在物理机上还算合理,但在容器环境里非常容易翻车。早年MongoDB版本如果跑在Docker或K8s里,识别的是宿主机物理内存而不是容器cgroup限制。比如宿主机有128GB内存,你的Pod只分配了4GB,WiredTiger却按照128GB去算默认缓存,结果可能是60GB以上,Pod直接被OOM Kill到重启循环。这是MongoDB容器化早期特别经典的事故。新版MongoDB已经能识别cgroup v1/v2的内存限制,但如果你手里还维护着老版本集群,这一条一定要去检查。
| 部署场景 | 默认缓存计算 | 示例 |
|---|---|---|
| 物理机/虚拟机 | (物理内存 - 1GB) / 2 | 64G物理内存 => 31.5G |
| 容器(老版本) | 误用宿主机物理内存 | 容器限4G却按宿主机128G算 |
| 容器(新版本) | 按cgroup限制计算 | 容器限4G => 约1.5G |
1.3 调整前先量工作集,别把缓存当无底洞
真正决定cacheSizeGB该调多大的,是你的工作集大小——也就是业务读写最频繁的那一批数据到底有多大。如果工作集很小,却把缓存调得很大,虽然不会出事故,但浪费了内存;如果工作集远大于缓存,再怎么调大也只是缓解时间差问题。
量工作集最直接的办法是看集合和索引的大小:
// 查看单个库的数据量与索引量 db.stats().dataSize db.stats().indexSize // 查看某个集合的数据量与索引量 db.getCollection("orders").stats().dataSize db.getCollection("orders").stats().indexSize但注意,dataSize是整个集合的文件总大小,不等于用户“同时访问”的热数据量。更靠谱的做法是持续观察WiredTiger缓存里的页面读入情况:
let c = db.serverStatus().wiredTiger.cache; print("bytes read into cache: " + c["bytes read into cache"]); print("bytes written from cache: " + c["bytes written from cache"]); print("pages currently held in cache: " + c["pages currently held in cache"]);如果bytes read into cache持续快速增长,说明大量数据页从磁盘被反复读入,缓存命中率不理想,工作集可能已经逼近甚至超过了缓存容量。有了这组数据,再去定cacheSizeGB才有依据,否则就是在黑暗中盲调。
2. cacheSizeGB实战:几个直接说明“缓存不够”的信号
缓存到底够不够,不是看进程用了多少内存,而是要看WiredTiger的驱逐(eviction)压力。我见过不少团队盯着RSS内存占用,说“内存都用了50GB,肯定够了”,结果缓存的脏页比例早已高到危险区。这里有几个比RSS更值得关注的信号。
2.1 驱逐压力指标比“缓存占用”更早报警
WiredTiger的后台驱逐线程会持续扫描缓存里的页,把干净页或者脏页从缓存里刷出去,腾出空间给新页面。当缓存占用达到一定阈值(默认配置下大约在80%左右开始触发),驱逐线程就会进入忙碌状态;如果脏页比例继续上升,逼近更高阈值,应用线程会被迫介入帮忙刷脏页,这时候业务延迟就会骤然变差。
可以用下面几条命令看驱逐与脏页状态:
let wt = db.serverStatus().wiredTiger.cache; print("tracked dirty bytes: " + wt["tracked dirty bytes in the cache"]); print("bytes currently in cache: " + wt["bytes currently in the cache"]); print("eviction calls: " + wt["eviction calls"]); print("eviction currently operating: " + wt["eviction currently operating"]);其中最关键的是“tracked dirty bytes in the cache”。脏页是被修改过但还没刷盘的数据页,它们必须等checkpoint或日常驱逐阶段被写回磁盘。如果脏页字节数占当前缓存总字节数的比例持续超过20%甚至30%,说明写入压力已经很大,刷盘速度开始跟不上写入速度。
注意:不同MongoDB版本里
serverStatus()的部分字段名会略有差异,有的版本是“tracked dirty pages”,有的版本是“tracked dirty bytes”。建议先打印一遍整个cache对象,确认字段名再写采集脚本。
2.2 一次写入密集场景的连锁延迟故障
这里分享一个让我印象很深的案例。某物流订单系统的MongoDB集群,在压力演练时大量订单状态不断更新,每个文档的字段都很小,但文档数量巨大,写入量一下子翻了数倍。最开始大家只看到P99延迟飙升,CPU却没满,磁盘IO也没满。
我抓了serverStatus()后发现,WiredTiger缓存里脏页占比已经冲到50%以上,驱逐线程完全跟不上,应用线程被迫参与驱逐工作。应用线程一旦停下来刷脏页,就相当于把原本用于处理用户请求的CPU时间让给刷盘操作,表现出来就是“什么都没做,数据库却卡顿”。后来做了三件事,延迟才彻底降下来:
- 合理调大
cacheSizeGB,把脏页容纳能力撑上去; - 将大量小文档的随机更新改成批量更新,减少版本链和日志压力;
- 评估journal刷盘频率,把
journalCommitInterval从默认100ms适当放宽到150ms,降低fsync次数。
这个案例的关键不是某一条参数,而是写入密集场景下脏页生成速度与刷盘速度的赛跑。缓存调大只是给了脏页更多的“临时候车区”,真正解决长期问题还要靠优化写入模式。
2.3 缓存不是无限往上加,三个反效果要注意
很多人的第一反应是“缓存不够就加大啊”,但无限调大缓存会带来三个并不少见的问题。
- checkpoint时间变长:WiredTiger默认每60秒做一次checkpoint,缓存越大,需要落盘的脏页可能越多,checkpoint期间磁盘IO和延迟抖动都会更明显;
- 崩溃恢复变慢:重启后重新加载缓存页需要更长时间,故障恢复窗口被拉长;
- 挤占系统页缓存:WiredTiger之上还有操作系统页缓存,过度分配给WiredTiger,页缓存被压缩,反而影响文件系统层的读写效率。
我个人建议cacheSizeGB在满足工作集的前提下,再留出10%到20%余量即可。如果机器内存特别大,也别超过物理内存的50%到60%。剩下的内存留给OS页缓存、连接线程、运维工具,比全部塞给WiredTiger更划算。
3. 并发参数:WiredTiger tickets也可能是隐藏瓶颈
缓存调完之后还有一类非常隐蔽的瓶颈,就是WiredTiger的并发tickets。它的隐蔽性在于:表面上连接数没到上限、CPU没跑满,可吞吐量就是上不去。
3.1 tickets到底是什么
WiredTiger内部用tickets来限制并发进入存储引擎的操作数量,分为读ticket和写ticket两类,默认值都是128。每个读操作或写操作在执行前必须获取一个ticket,操作完成后再归还。如果当前所有的读ticket都被占用,后续读操作只能在队列里等待。
这里要特别强调:tickets不等于连接数。MongoDB可以同时有几千个连接,但真正同时进入WiredTiger引擎的并发操作只有128个读操作加128个写操作。所以当业务侧连接堆积时,很多人以为是连接池耗尽,实际是ticket已经用光了。
3.2 怎么确认瓶颈在tickets而不是查询本身
查看ticket使用情况很简单,执行:
db.serverStatus().wiredTiger.concurrentTransactions返回的JSON结构大致是这样:
{ "read": { "out": 127, "available": 1 }, "write": { "out": 30, "available": 98 } }如果read.out长时间接近128,说明读ticket已经饱和,新读操作在排队。如果只是偶尔瞬时冲到127,还可以接受;但如果持续几分钟都处于饱和状态,那就要考虑调大读ticket了。
我建议用一段简单的循环脚本持续观测,而不是只看某一个瞬间的快照:
setInterval(() => { let s = db.serverStatus().wiredTiger.concurrentTransactions; print("read out: " + s.read.out + ", available: " + s.read.available + " | write out: " + s.write.out + ", available: " + s.write.available); }, 3000);挂上三五分钟,你就能看到读写ticket随业务波动的真实曲线,比单点快照靠谱得多。
3.3 调tickets的实操方法与设计取舍
MongoDB在较新的版本里支持通过setParameter动态调整读/写ticket数量,不需要重启。以读ticket为例:
db.adminCommand({ setParameter: 1, wiredTigerConcurrentReadTransactions: 256 });写ticket同理:
db.adminCommand({ setParameter: 1, wiredTigerConcurrentWriteTransactions: 256 });不过先别急着抄命令。我很强调“先确认版本支持不支持”,因为不同MongoDB版本的参数暴露方式不完全一样。执行下面这个命令,先看看当前版本里有没有这两个参数:
db.adminCommand({ getParameter: "*" }).wiredTigerConcurrentReadTransactions如果返回undefined,就说明当前版本不支持动态调整,需要去查对应版本的官方文档确认是否通过启动参数或配置文件设置。
调整tickets时的取舍也很重要。调大ticket,本质上允许更多操作同时进入引擎,理论上可以提高吞吐;但如果磁盘IO、CPU缓存或锁竞争已经成为新的瓶颈,调大tickets反而会让每个操作都变慢,整体吞吐不升反降。我的一般建议是从256起步,逐步增加到512,并同步观察CPU、磁盘队列深度和QPS,找到临界点后停手。
提示:
setParameter动态调整是临时的,重启mongod后就会恢复到默认配置或配置文件中的值。几周内测试满意,一定要把参数固化到对应的配置方式里,否则一次机房重启后会回到老的默认状态。
4. 把调优落地:一次完整参数调整的实操过程
很多资料只讲参数意义,不讲怎么改、怎么验证、怎么回滚。这里我把一套完整的实操流程写出来,你可以直接当作操作清单用。我习惯把一次调优拆成“基线采集、参数变更、灰度验证、回滚预案”四步。
4.1 先建基线,别在真空里调参数
调整之前一定要有数据基础。我至少会采集下面这些信息:
mongostat输出,记录当前节点的QPS、连接数、非活跃连接、page fault等基础指标;db.serverStatus().wiredTiger里的缓存、ticket、checkpoint相关指标;- 业务侧的关键指标,比如读接口P99、写接口P99、超时次数。
采集时间窗口建议覆盖一个完整的业务高峰和低谷,这样你才知道调整前的“正常水位”是多少。我给客户做调优时,会把采集结果和调整后的结果做成截图对比,否则过两个星期自己都忘了当初为什么要调成这个值。
4.2 参数修改的两种方式
方式一:配置文件修改,重启生效
缓存大小的修改一般走配置文件,因为cacheSizeGB不是每次都能用setParameter动态改。在mongod配置文件中这样写:
storage: wiredTiger: engineConfig: cacheSizeGB: 48保存后重启mongod,用下面的命令确认生效:
db.serverStatus().wiredTiger.cache["bytes currently in the cache"]方式二:动态修改,立即生效
tickets这类参数可以先用setParameter动态调整,快速观察效果:
db.adminCommand({ setParameter: 1, wiredTigerConcurrentReadTransactions: 256 }); db.adminCommand({ setParameter: 1, wiredTigerConcurrentWriteTransactions: 128 });这里强调一个细节:动态调整之后一定要立刻重新采集一次监控数据,确认新参数已经生效,再继续观察业务表现。
4.3 灰度、验证与回滚
MongoDB集群如果是副本集架构,我不建议直接在主节点上边运行边改。标准做法是:
- 先选一个节点摘出副本集,执行
rs.stepDown()让它降级为从节点; - 在从节点上修改配置、重启,观察日志和复制延迟;
- 确认从节点稳定后再把它加回副本集,让它追平数据;
- 等过一两个稳定窗口,再逐个把主节点和其他节点按同样方式轮转。
如果调整后发现问题,回滚有两种路径。动态参数可以直接用setParameter设置回原值。配置文件里的参数,则需要改回原配置后重启,或者批量替换出问题的节点。无论哪种情况,我都建议提前准备好回滚脚本,并且把“当前参数值、修改时间、修改人、修改原因”记录在案,这一点对长期运维特别重要。
5. 踩坑记忆:几个让我印象深刻的调优反例
参数调优最怕的不是不会调,而是方向错了还拼命加油门。这几年我在各种环境里见到过不少反例,挑三个最容易误导人的写下来。
5.1 加缓存没效果,因为根本问题是全表扫描
有一个报表查询集群,开发团队反复调大缓存,从16GB一路加到48GB,读延迟几乎没有任何变化。我接手后先看执行计划,结果发现他们的查询全部走了COLLSCAN,每次查询都要把大量数据页读入缓存,缓存再大也只是一个更大的“临时中转站”,根本无法解决扫描问题。后来帮他们建了合适的复合索引,查询走了IXSCAN,延迟瞬间降了一个数量级,缓存反而可以调小。
这个案例给我最大的提醒是:调优前先看explain()。缓存只能优化热数据命中,不能优化垃圾查询。如果业务本身是低效查询,再大的缓存和并发参数都是治标不治本。
5.2 ticket调到很大以后CPU先“炸”
另一个团队是典型的“传说是大参数好”——把读ticket从默认128直接调到了1024。结果CPU使用率冲到90%以上,QPS反而没有明显变化。原因是ticket开得过大以后,大量线程同时进入引擎内部,锁竞争和内存分配的开销急剧增加,CPU时间都花在了上下文切换和排队上,而不是实际的数据处理。
之后他们回退到256,磁盘IO的使用率上来了,QPS反而比调成1024时更好。这个经历让我对tickets的态度非常保守:小步快跑,观察一个完整的业务周期的负载变化后再决定是否继续增加。从128跳到1024,不是优化,是赌博。
5.3 别忘了联动检查磁盘队列与NUMA
缓存和并发参数调得再好,如果底层磁盘队列已经打满,性能还是会被卡住。我给集群做调优时一定会同时看iostat里的avgqu-sz和%util,如果磁盘队列深度过高,优先解决磁盘能力问题,比如换SSD、调整预读参数或重构写入模型。
另外,在多路CPU的物理机上,MongoDB对NUMA架构非常敏感。如果Node 0和Node 1的内存访问不对称,进程的线程被调度到远端内存,延迟会显著上升。条件允许时,用numactl --interleave=all启动mongod,能避免很多莫名其妙的性能抖动。这些周边因素虽然不是WiredTiger参数,但和WiredTiger的缓存与并发调优是强联动的,缺一个环节都可能让调优失效。
最后再分享一个我自己的习惯:每次调优都建立一页纸档案,包含调整前的监控截图、调整后的监控截图、改动参数、生效方式、回滚方案。两个月后再回头看,这份档案比任何巡检脚本都管用。MongoDB调优是一件需要数据支撑和持续观察的事,别指望一次改动就一劳永逸,把每次实验记录下来,才是让集群越跑越稳的正路。