说到MongoDB缓存机制,很多玩NoSQL的老炮第一反应是"这不就是靠内存缓存么,把热点数据留在内存里,顶多调一调cacheSizeGB"。但实际动手做性能调优的时候,缓存命中率上不去、抖动脉冲、checkpoint把主线程拖慢、secondary读不到热数据,各种问题全是藏在缓存这一层。这篇文章从存储引擎底层的缓存设计讲起,把WiredTiger的页驱逐、脏页回收、cacheSizeGB计算、命中率监测、TTL做业务缓存这些关键环节完整拆一遍。适合那些已经有MongoDB基础、想深入理解缓存机并结合实际场景做高性能NoSQL数据缓存体系的开发、DBA和架构师参考。
1. 缓存机制整体设计与思路拆解
1.1 为什么缓存是MongoDB性能的命门
磁盘IO和内存之间的速度差距,是理解MongoDB缓存机制的起点。机械盘顺序读能有个每秒一两百MB就很不错了,随机读更是掉到个位数毫秒级别;即便换成NVMe固态硬盘,单次随机读的延迟也普遍在几十到上百微秒。而内存随机访问的延迟是几十纳秒级别,两者差了三个数量级不止。数据库如果每次读写都直接落在磁盘上,再好的查询计划也扛不住业务流量。
MongoDB把缓存放在存储引擎层,本质上是拿内存空间换磁盘IO。热点数据和索引页在内存里待着,读写请求尽量在内存中完成,只有缓存未命中的时候才有可能碰磁盘。这个逻辑听起来简单,真正复杂的地方在于:什么时候淘汰、淘汰哪些页、脏页怎么落盘、应用线程会不会被卷入驱逐过程,这些细节直接决定了数据库在压力下的行为。
1.2 WiredTiger缓存层的设计哲学
从3.2版本开始,MongoDB默认存储引擎换成WiredTiger。WiredTiger的缓存设计不是简单的"一个大HashMap存文档",而是基于B+树和页(page)的缓存体系。数据文件、索引文件在逻辑上都组织成树结构,树上的节点以页为单位加载进缓存。读写操作命中某一页时,先把页放入缓存,再在缓存里做修改和查询。缓存页的状态大体分三种:干净页(clean page)、脏页(dirty page)、正在被驱逐的页(evicted page)。干净页随便淘汰,脏页必须先写回磁盘才能释放,驱逐中的页则要等IO完成。
这套设计和传统关系型数据库的buffer pool思路类似,但WiredTiger做得更激进的一点是:它把缓存当作整个存储引擎的内存管理者来用,包括内部会话、游标、事务、日志缓冲等都吃同一块缓存预算。所以你在配置cacheSizeGB的时候,给的不是"文档缓存",而是整个WiredTiger引擎的内存上限。我见过不少人在小规格机器上把cacheSizeGB调满,结果MongoDB不光缓存页要用内存,连后台线程、排序、连接上下文也在要求内存,最后OOM或者触发swap,整库卡死,这种错误在早期踩过的人非常多。
1.3 别把MongoDB当内存数据库
有一个概念必须分清楚:MongoDB不是像某些内存缓存产品一样"数据全在内存,磁盘只是备份"。MongoDB的数据持久化在磁盘文件上,缓存只是提高访问速度的加速层。这意味着冷启动时,所有数据都得从磁盘读一遍,缓存才会慢慢热起来。所谓"我内存有128GB,数据才40GB,是不是全都能进缓存"这种想法,在实际运行中很容易碰壁。因为缓存里不只有你业务的数据页,还有索引页、集合元数据、事务信息、内部结构等。而且WiredTiger的页驱逐有自己的节奏,内存大不代表它会把所有热数据都留在内存里,它更在意的是保持整个树的访问平衡,而不是单纯追求"一个集合全驻留内存"。
这一点决定了我们后续做缓存调优时的基本思路:不要用"数据量是否小于内存"来衡量缓存是否够用,而要用"访问是否均匀命中缓存页"来判断。
2. 核心细节解析与实操要点
2.1 页、块与缓存的最小单元
WiredTiger把文件切分成块(block),默认块大小通常是4KB,索引块可能更大。读请求落到某个块上时,整个块作为一个整体加载进缓存。这意味着你只更新一个文档里的一个字段,也要先把整个4KB块读进内存,改完之后这个块整体变脏。所以文档越小越密集,一个块里能容纳的文档越多,缓存利用率越高;反之,如果集合里单个文档动辄几百KB甚至几MB,一个块只能装下一两个文档,缓存的"命中效率"就很差。
缓存页的粒度直接影响到驱逐策略和磁盘IO放大。实际运维中,我遇到过一种情况:某个集合的文档平均1MB,读取频次不高但每次请求都更新其中几个字段。结果磁盘IO看起来不低,缓存命中率却上不去,因为每次更新都要加载一个1MB左右的页,改一点点内容又驱逐出去,等于整页往复读写。这个场景下,与其调缓存参数,不如先把文档结构拆小,或者把高频更新的字段单独放到子集合里,从源头减小缓存页的膨胀。缓存机制不是万能的,它擅长的是"小字段、高访问密度"的数据模式。
2.2 LRU驱逐策略与脏页的博弈
WiredTiger的页驱逐整体上遵循LRU(最近最少使用)思路,但不是教科书式的死板LRU,而是带权重的近似LRU。每个缓存页被访问时,它的热度标志会被更新;当缓存内存使用超过一定水位时,后台驱逐线程开始扫描并选择最久未使用的页淘汰。关键点在于驱逐线程和执行线程是分工的,WiredTiger希望驱逐主要由后台线程完成,不要让应用线程在关键路径上去做磁盘写入。
脏页的处理是驱逐策略里最容易出问题的地方。一个页只要被修改过,从内存中被淘汰前必须先把修改写回磁盘。如果脏页数量积累太快,后台驱逐线程来不及写盘,WiredTiger就会让应用线程"帮忙"驱逐。这时用户请求会突然变慢,延迟毛刺明显,这就是大家常说的缓存驱逐导致的应用线程阻塞。为了避免这种情况,缓存调优真正要盯的指标不是"缓存用了多少",而是"脏页数量是否在持续拉升""应用线程是否参与了驱逐"。后面我会给出具体的监控命令和判断阈值。
2.3 cacheSizeGB配置:先算账再动手
cacheSizeGB是WiredTiger缓存的核心配置,默认情况下大约是物理内存的一半减去1GB,下限1GB。比如一台64GB内存的机器,默认缓存大约31.5GB。这里很多人有个错误认知:cacheSizeGB设得越大,缓存命中率越高。实际不是这么简单。缓存太大,后台驱逐线程要维护的页LRU链表非常庞大,扫描成本升高;缓存太小,又会让热点页频繁被甩出内存,造成抖动。
我的建议是:先给操作系统和其他进程留足空间,再给WiredTiger分配缓存。单机只跑MongoDB的话,缓存可以占到总内存的50%到60%,但前提是预留出文件系统缓存、连接线程栈、排序缓冲、备份工具等常驻内存。如果需要跑容器、监控Agent、日志采集,那缓存占比还得往下压。调整cacheSizeGB不是一锤子买卖,每次改动后至少观察24小时的业务高峰和低峰期,看驱逐相关指标是否平稳。另外别忘了,cacheSizeGB只约束了WiredTiger引擎内部,操作系统文件系统缓存是另一层,两个一起算才是真正常驻内存的构成。
2.4 第二道缓存:文件系统缓存的作用
很多人用 top 看内存占用时会疑惑:MongoDB进程实际内存才用了不到一半,为什么free命令里可用内存一直很低?答案就是文件系统缓存。MongoDB读取磁盘文件时,数据会先进操作系统页缓存,再拷贝到WiredTiger缓存。当WiredTiger需要驱逐一个页时,如果对应内容还在文件系统缓存里,重复读取同一页可以走文件系统缓存,避免真正砸到物理磁盘。
所以在Linux环境下,我会刻意保留一部分内存给文件系统,而不是让cacheSizeGB把内存全部吃掉。判断文件系统缓存是否在起作用的办法是看iostat的读写速率,如果应用在正常跑但物理设备读速率很低,说明有相当部分读被文件系统缓存吸收。文件系统缓存天然是"自动管理"的,内存紧张时内核会让它收缩,不需要你额外伺候,但如果你把cacheSizeGB顶满,文件系统缓存空间被挤压,WiredTiger一旦发生缓存重建,所有读取都得真打磁盘,性能会断崖式下跌。
3. 实操过程与性能监控
3.1 一次典型的缓存调优过程复盘
我拿一个模拟项目X来做实例,这个系统的核心业务是查询商品实时库存和价格,数据总量约120GB,机器配置是物理内存64GB,4核CPU,SSD磁盘。一开始所有参数都是默认的,业务高峰时发现查询P99延迟从30ms飙到220ms,明显是缓存出了问题。
先做的第一件事是确认缓存配置是否是默认值。通过配置文件和滚动命令可以查看当前cacheSizeGB,默认约为31.5GB。同时用iostat观察磁盘,发现每秒写盘量很大,但物理读并不高。初步判断是脏页刷盘拖了后腿。接着连进mongosh,跑一次完整的serverStatus,专门盯wiredTiger.cache下面的驱逐类指标。这一看就发现问题:application thread page evictions数量每分钟上百次,说明应用线程已经频繁参与驱逐,这是缓存容量不足或脏页回收不及时的典型信号。
3.2 读懂serverStatus里的缓存指标
db.runCommand({ serverStatus: 1 })返回的结果里,wiredTiger.cache字段是最直接的监控入口。重点看这么几项:
bytes currently in the cache:当前缓存中有多少字节数据。tracked dirty bytes in the cache:缓存中的脏字节数。pages currently held in the cache:当前缓存的页数。pages evicted by application threads:应用线程完成的驱逐次数。pages read into cache:从磁盘读入缓存的页数。pages requested from the cache:从缓存请求的页数。
第一次看这些指标的人容易一头雾水,因为字段值都是一路累加的计数器。比如pages evicted by application threads只有随时间增加才能看出趋势,单看当前值没有意义。判断方法是每隔60秒采样一次,算差值。如果差值里应用线程驱逐的数量显著上升,说明缓存压力不是缓存的“总量不够”,更可能是脏页写盘速度跟不上写入速度。注意,tracked dirty bytes in the cache如果持续在缓存总字节的百分之几十以上,就要考虑检查checkpoint频率和磁盘fsync速度了。
3.3 计算缓存命中率的两种姿势
缓存命中率是衡量缓存体系好坏最直观的指标。最常用的是用pages requested from the cache和pages read into cache这两个计数器。近似命中率公式是:
命中率 = 1 - (pages read into cache 的增量) / (pages requested from the cache 的增量)采样时间段内,用结束值减去开始值得到增量,再代入公式。这个命中率包含了索引页和数据页的读取。实际经验里,读写比例比较健康的业务,整体命中率在95%以上算良好;如果跌到90%以下,缓存容量或者数据访问局部性就要重新评估。
另一种姿势是从操作日志和查询执行统计里看:db.serverStatus().metrics.queryExecutor.scannedObjects除以scannedObjects加上returned得到扫描命中比例。这个更偏执行层面。比如全表扫描场景下,即使页缓存命中很高,查询本身也慢,因为要遍历的页太多。缓存命中率高不等于查询快,这是两回事,我经常提醒团队:先把查询计划写对,再谈缓存命中率。
3.4 优化措施与实际效果记录
那个模拟项目X的调整过程是这样:先把cacheSizeGB从默认31.5GB压降到24GB,因为这台机器上还跑了监控和日志组件,压缩WiredTiger缓存给文件系统缓存腾出空间。同时把写入并发模型调低,减少瞬时脏页生成量。改完之后观察脏字节比例,从之前的占比45%降到了20%左右,应用线程驱逐次数基本归零,P99延迟恢复到40ms以内。
另一处重要调整是手动触发了一次checkpoint观察响应。用db.adminCommand({ fsync: 1 })可以强制把所有脏页写盘并产生一个检查点,但这个命令会阻塞写操作,所以只能放在业务低峰期测。这次手动fsync之后,缓存里干净页比例明显提升,观察几分钟内应用线程驱逐没有再回升。我这里特别说一下,手动fsync不要频繁执行,频繁强刷脏页等于打断后台驱逐的节奏,反而容易造成写放大。
4. 常见问题与排查技巧实录
4.1 缓存颤动:应用线程被卷进驱逐
这是MongoDB缓存体系里最常见的性能杀手。现象是业务高峰期查询延迟规律性抖动,serverStatus里pages evicted by application threads快速增长,同时tracked dirty bytes in the cache居高不下。本质上就是脏页产生速度超过后台刷盘速度,后台线程hold不住了,WiredTiger启动应用线程帮忙驱逐,而那些线程本来是要处理用户请求的,于是延迟全部恶化。
排解思路先分清:是写入量太大导致脏页激增,还是缓存容量被缩小导致驱逐频繁。前者要降低写入并发、调整journal提交策略,或者在业务层把批量写拆得更细;后者才需要调整cacheSizeGB。很多人的第一反应是加内存,但实际上如果脏页刷盘的速度上限就在那,内存再大也只是让脏页堆得更久,不能治本。我曾经遇到一个案例,用户往一个集合里批量导数据,每秒写入上千条文档,每条都带二级索引更新,脏页生成速率极高。此时无论怎么调cacheSizeGB,应用线程驱逐都压不下来,最后通过分批提交、降低每秒写入速率,才把延迟稳住。
4.2 脏页堆积与checkpoint阻塞
WiredTiger默认按60秒周期做checkpoint,把缓存中的脏数据固化到磁盘。如果60秒内脏页堆积过多,checkpoint写盘时间会很长,期间会占用大量磁盘IO,导致应用读写下降。观察方法是看db.serverStatus().wiredTiger.transaction里checkpoint相关的信息,配合iostat查看写盘速率峰值。
如果发现脏页峰值出现在每个检查点周期末尾,说明checkpoint间隔相对业务写入速度太长。MongoDB的checkpoint并不适合调得太频繁,频繁checkpoint会放大磁盘写入量。更稳妥的方向是让后台驱逐线程更早开始干活,也就是把触发驱逐的水位调低。WiredTiger有eviction_target和eviction_trigger这类高级参数,虽然默认值已经经过官方调优,但在特定写入模型下,稍微调整触发水位可以显著缓解checkpoint阻塞。调整时务必小步走,每次只改一个参数,观察一个完整业务周期再决定下一步。
4.3 内存看着一直涨:进程内存与缓存内存的区别
有人发现MongoDB的RSS内存和cacheSizeGB对不上,甚至RSS超过了cacheSizeGB设置值,就以为缓存配置失效了。其实WiredTiger的缓存只是进程内存的一部分,MongoDB进程还包含连接线程的堆栈、运行时的内部结构、排序与聚合操作的内存、以及内部的内存分配器缓存。当遇到大量排序、hash聚合、批量游标时,这部分临时内存可能陡增。因此RSS超过cacheSizeGB是完全正常的。
判断缓存是否超出预期,仍以bytes currently in the cache为准。如果这个值远小于cacheSizeGB,说明缓存还没用满;如果长期逼近甚至略有超过,也不一定异常,因为WiredTiger允许缓存有一点弹性空间。真正要注意的是全系统内存是否吃紧,出现持续swap就危险了。此时如果cacheSizeGB已设得较大,优先降低缓存配置,再看连接数和并发排序占用的内存。
4.4 副本集secondary读缓存不热的问题
MongoDB副本集的secondary默认不承担读流量时,它的缓存冷得很自然。一旦你启用副本集读扩展,把部分读请求分发到secondary,会发现secondary前期查询延迟反而比primary更高,因为它的缓存是冷的,数据页要从磁盘重新加载。这不是bug,而是缓存预热机制缺失。
解决办法没有太多黑科技:让secondary在业务低峰期主动做一轮数据扫描,把热点集合的页尽量加载到缓存。可以用db.collection.find({}).snapshot()这种全量扫描,但要注意这会消耗大量IO,只能在低峰期跑。更精细的办法是打开secondary的采样查询,模拟真实业务模式,把一个时期的热点查询在secondary上先跑一遍。另外,如果secondary读流量占比稳定,可以考虑调大secondary的cacheSizeGB,让它在冷启动后有更充裕的页驻留空间。需要注意的是,副本集节点配置如果允许不同,secondary和primary的缓存参数要分开评估,不能直接照搬。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查命令/手段 | 处理建议 |
|---|---|---|---|
| 查询延迟周期性抖动 | 应用线程参与驱逐 | 看pages evicted by application threads增量 | 降低写入并发、检查脏页占比 |
| 缓存命中率持续走低 | cacheSizeGB偏小或访问局部性差 | 统计缓存命中率公式 | 调整缓存容量、优化数据模型 |
| 内存占用远超cacheSizeGB | 排序/聚合/连接占用 | 查看serverStatus里的mem信息 | 控制并发排序、分批查询 |
| secondary读延迟高 | 缓存未预热 | 对比primary与secondary缓存指标 | 低峰预热、单独调缓存大小 |
| checkpoint导致写毛刺 | 脏页堆积过多 | 观察transaction状态与iostat | 降写入速率、调驱逐水位 |
这五类问题覆盖了我实际运维中遇到的绝大部分缓存相关事故,其中缓存颤动和secondary预热是最容易被忽略的。每次排查不要只看一两个孤立的计数器,要把bytes currently in the cache、tracked dirty bytes、application thread evictions、磁盘IO、业务延迟这五类信息放到同一个时间轴上看,异常发生的那一分钟前后各读一次数据,能定位到到底是缓存容量问题、脏页写盘问题还是外部IO争抢问题。
5. 基于MongoDB构建业务级缓存体系的实战扩展
5.1 什么场景才值得用MongoDB当缓存
聊完底层机制,话题落到业务层:怎么拿MongoDB构建一套高性能NoSQL数据缓存体系。首先要明确,MongoDB不是专门的缓存中间件,拿它做缓存,要么是业务数据本身就要持久化,要么是它在架构中的角色本来就是“数据主存储”,而非纯加速层。如果用一句话总结:数据需要持久化、需要查询、需要过期淘汰,但又不愿意多引入一套Redis运维时,MongoDB的TTL索引加二级索引已经能承担轻量级缓存职责。
典型场景有几个。一个是会话数据,天然按用户ID读写,时效性强,写入后几小时甚至几十分钟就失效,非常契合TTL集合。另一个是用户维度的聚合结果缓存,比如首页聚合、榜单数据,可以定时刷新到MongoDB集合里,读多写少,辅以索引保证查询毫秒级返回。还有一个是消息队列的备份存储,把推送中的消息状态和内容放MongoDB里,客户端轮询查询,TTL过期自动清理,这样既省了Redis内存,又不用担心重启丢数据。判断标准还是看数据规模和访问模型:单条数据几百字节、访问量很大、时效性要求高,就适合;数据很大、每次查询要扫大量文档,就别硬塞进MongoDB当缓存。
5.2 TTL索引:自愈式缓存淘汰
TTL索引是MongoDB做缓存淘汰最顺手的工具,在字段上建一个索引并指定expireAfterSeconds,后台的TTL Monitor大约每60秒扫描一次,把过期文档物理删除。用法是这样的:
// 在集合上创建TTL索引,文档超过3600秒自动删除 db.cache_collection.createIndex( { createdAt: 1 }, { expireAfterSeconds: 3600 } )这里有三个坑必须讲清楚。一是TTL索引只对单字段日期类型生效,如果字段是数组或嵌套文档,TTL不会按预期工作。二是TTL Monitor的删除频率不是实时的,过期时间会有最长接近60秒的延迟,所以业务侧不能把“过了expireAfterSeconds立刻查不到”当成强约束。三是删除大量文档时会带来额外的写压力和锁竞争,如果某个集合的TTL数据特别多,分批往集合里插数据、错开过期时间点,避免在同一时刻集中过期。实际项目里,我见过有人把过期时间都设在同一分钟内,TTL Monitor一扫描就同时删除几十万文档,直接拖垮主节点,这种操作在缓存体系里属于事故级的失误。
5.3 组合拳:热数据Redis,温数据MongoDB
MongoDB做缓存体系并不意味着要把Redis排挤掉。更务实的架构是冷热分层:Redis放最热的数据,要求亚毫秒级响应、并发极高;MongoDB放温数据,响应允许几毫秒到十几毫秒,但要求短时间内可持久化回溯。这样设计的好处是,Redis的内存成本被控制在最小范围,MongoDB分担了更多数据量,又不牺牲查询能力。
实际落地时,我会给每份缓存数据设计一个双写流程。比如订单状态:写入时先更新MongoDB的事实表,再刷新Redis缓存;读取时先查Redis,没命中再查MongoDB,回填Redis并设置不长的过期时间。这里缓存一致性永远是绕不开的话题。最简单的做法是Cache Aside模式:读未命中则查库后回填,写则先更新库再删除缓存。MongoDB因为支持事务,可以做到在文档更新后同事务里给缓存下发无效化消息,比Redis先行后库更新出错的概率小一些。对于一致性要求没那么苛刻的场景,也可以用版本号机制,在MongoDB文档里维护一个版本字段,Redis缓存里同时存版本号,读取时发现版本不匹配就主动刷新。
5.4 缓存一致性:从失效到版本号
缓存一致性问题在这套体系里分成三个层次。第一层是数据失效不实时,容忍短暂不一致,比如榜单、热门推荐这类展示类数据,定时刷新即可,过期时间可以设长一些。第二层是强时效但允许最终一致,比如用户会话状态,更新MongoDB后主动删除或刷新Redis,配合TTL兜底,万一删除失败,过期时间也能把脏数据清掉。第三层是强一致要求的场景,比如库存扣减、交易状态,那就不能以缓存为准,所有写操作必须走MongoDB事务,再通过合适的转发策略让缓存失效或更新。
版本号机制是我比较推荐的一个折中方案。在MongoDB的文档里增加一个整型version字段,每次更新文档时原子自增。Redis缓存值里同时存数据和version,读取时拿到版本号,如果跟新的查询结果不一致就覆盖。这个方案实现简单,不需要引入消息队列,对大多数缓存体系已经完全够用。唯一的代价是每次读可能多一次MongoDB查询来校验版本,这又回到了缓存命中率的核心问题:只有保证校验版本的那次查询足够快,整个体系才是划算的。所以版本校验查询尽量走主键,确保落在索引页和缓存页里。
写在最后的实战体会
折腾MongoDB缓存机制这么多年,我最深的感受是:缓存不是配置出来的,是监控出来的。很多人上来就调cacheSizeGB,调完看俩小时没问题就觉得万事大吉,结果业务高峰一来,延迟毛刺照样出现。缓存体系设计得好不好,要看的是压力下的行为:脏页是不是稳定在可控水位,应用线程有没有在关键路径上被拖去驱逐,secondary的冷缓存能不能快速热起来。我的建议是每做一个改动,都围绕serverStatus里那几个缓存计数器和业务延迟建立对照,多留几次高峰期的采样数据。等你手里有了一两周的数据曲线,再看MongoDB缓存就清楚多了。这套经验不仅适用于单机,放到副本集、分片集群里也一样成立,只是每个节点的缓存水位需要单独观察,千万别想当然地套用单机参数。