MongoDB全表扫描为何变成随机I/O?从WiredTiger碎片化到iostat排查实践 如果你维护过MongoDB大概率遇到过这种场景一条没走索引的查询或者凌晨跑批时全表读取某个大集合磁盘立刻开始狂响线上写入延迟跟着飙到几十毫秒。最早我也理所当然地以为全表读取就是从文件头一路读到尾标准顺序I/O磁盘再忙也快可实测数据完全不是这么回事。MongoDB的全表读取在很多情况下呈现的是典型随机I/O特征IOPS很高、吞吐很低、单次等待很长。这篇文章就把“为什么数据库逻辑顺序不等于磁盘物理顺序”这件事讲透接着列一下真实项目里最常见的全表读取触发点再给出可以自己在测试环境复现的iostat观测方法最后复盘一次把随机I/O问题暴露得很彻底的故障排查。无论你是DBA、后端开发还是刚接触MongoDB的运维应该都能从中找到对应的场景。1. 一个容易误解的问题全表读取到底是顺序还是随机1.1 逻辑顺序不等于物理顺序很多人对“全表扫描”的第一反应是数据库把集合从头到尾读一遍那磁盘I/O当然是顺序的相当于一条直线读过去。这个直觉在概念层面有道理但落到磁盘上就完全不成立了。数据库里的“从头到尾”指的是集合逻辑上的文档顺序磁盘上的“顺序”指的是物理扇区地址是否连续。这两个“顺序”之间没有必然关系。打个比方一本书按目录从第1页看到第300页如果你翻页的顺序和物理装订顺序一致确实是顺序读。但假如这本书被拆散过第5页到第10页散落在房间不同角落你按页码去找每找一页都要重新定位一次这个动作就叫随机读。数据库的文档在底层文件里也会因为更新、删除、页分裂而到处搬家逻辑上相邻的两条文档物理上可能隔了十万八千里。顺序I/O和随机I/O的差别在机械硬盘上会被放大得非常明显。一次随机寻道通常要花7到10毫秒而顺序读同样大小的数据只需要不到1毫秒。MongoDB一次典型的页读取在几KB到几十KB不等如果这些页分散在磁盘各处一次全表扫描会变成成千上万次小规模随机读取吞吐自然上不去。1.2 WiredTiger的页组织和B树遍历方式MongoDB从3.2版本开始默认使用WiredTiger存储引擎。这个引擎里每个集合和每个索引都是一棵B树树的节点就是磁盘上的数据页。叶子节点保存实际的文档内容内部节点保存指向子节点的指针。全表扫描时MongoDB会从最左侧的叶子节点开始沿着叶子节点之间的链表一路读到最右侧把每条文档都访问一遍。问题就出在这个链表的“逻辑相邻”上。叶子节点之间的next/prev指针只是逻辑关系完全不关心这两个页在文件里的物理偏移量是多少。WiredTiger的块管理器在给新页分配空间时会优先寻找空闲块找不到才追加到文件末尾。系统运行时间越长更新和删除越多空闲块分布越零散新分配的页就越容易落在文件各处。页的大小也不是固定的。默认情况下WiredTiger的叶子页上限在几十KB量级底层块默认4KB一次逻辑页的读取可能要拆成多个块的I/O。如果这些块恰好不连续那就不是一次顺序读而是好几次随机读。正因如此即使我们只是单纯地顺着B树遍历集合发出的磁盘请求地址也是分散的。1.3 为什么“从头读到尾”用不上顺序I/O优势操作系统和磁盘本身对顺序I/O有很强的优化。内核会做预读readahead检测到你在连续读扇区时会提前把后面一大块数据加载进来磁盘控制器也能把相邻的读请求合并成一次大传输。这些优化能让顺序读的吞吐轻松跑到100MB/s以上。但随机读基本享受不到这些红利因为每次请求的扇区都不挨着预读还没生效就已经过期了。有人可能会问那MongoDB为什么不干脆把整个数据文件从头到尾物理读一遍反正全表也要扫因为集合文件里除了数据页还有索引页、空闲块、元数据页物理读整个文件读到的很多块都不是当前集合需要的数据。而且数据文件可以大到数百GB物理顺序读一遍的成本并不比按B树逻辑遍历低多少还会污染缓存。所以MongoDB选择了逻辑遍历代价就是I/O模式上更接近随机读。再加上WiredTiger有自己的内部缓存也有文件系统page cache兜底。如果集合完全放进了内存全表扫描根本不会产生磁盘I/O那就是内存里的顺序遍历速度很快。真正出问题的场景永远是集合大于可用内存、且每次遍历都有一部分页需要从磁盘换入的时候。2. 真实工作中哪些操作在反复触发全表读取2.1 不只无索引查询count、聚合和导出都算最常见的全表读取触发源当然是查询条件没有命中索引。比如对一个非索引字段做范围查询对字符串字段做正则匹配或者查询条件里用了某些无法走索引的表达式执行器只能退化成COLLSCAN。但很多团队踩坑的场景其实是一些“看起来跟查询无关”的操作。countDocuments({}) 就是一个典型。老版本里不带条件的count方法确实可能走全表统计新版本虽然能从集合元数据直接返回总数但如果你用countDocuments({})做精确计数它依然会遍历集合里的每一条文档。聚合管道里的 $count 同理如果前面没有 $match 或 $match 没走索引整个聚合输入就是一次全表扫描。聚合管道里还有个容易被忽略的点$match 放的位置。如果管道一开始就做昂贵的操作比如 unwind 一个大数组或者先 group 再 filterMongoDB为了拿到最终结果往往也要把整个集合的文档都过一遍。很多报表类任务就是这么把磁盘打爆的。2.2 用explain快速确认COLLSCAN判断一个查询是否触发了全表扫描最直接的办法就是explain。在MongoDB Shell里执行查询时追加.explain(executionStats)返回结果里最关键的有三个字段stage、docsExamined、nReturned。db.scan_test.find({ tag: 999 }).explain(executionStats)如果看到 stage: COLLSCAN说明查询计划选择了全表扫描。如果 docsExamined 接近整个集合的文档数而 nReturned 只有个位数说明扫描效率极低。这时候再去查一下集合上的索引通常会发现自己以为有的索引根本没建或者查询条件里用了函数包裹字段导致索引失效。explain的输出会被很多监控工具直接抄进慢查询日志。日常看慢日志的时候看到 planSummary: COLLSCAN 就要提高警惕。偶尔一次全表扫描不可怕可怕的是这种扫描出现在每小时执行几十次的低频循环里或者出现在每天凌晨的大任务里持续几十分钟。2.3 多线程并发的全量导出随机I/O放大器很多人对导出的理解是用mongodump或者mongoexport导出全量数据数据库顺序读一遍文件就行。这也是个典型的误区。导出全量集合时服务端执行的就是一个不带条件的find操作内部依然是COLLSCAN从B树最左侧叶子一路读到最右侧物理上仍然是一堆随机页读取。我见过一个团队为了提高导出速度把一个大集合拆成分片条件用八个并发进程同时跑mongoexport各自拉一部分数据。结果磁盘直接被随机I/O打满导出速度不但没提升反而因为磁盘排队严重变得更慢。原因很简单八路并发并没有把随机读变成顺序读只是把同一块盘上的随机访问密度放大了八倍。合理的导出方式应该是用单个游标加batchSize控制每次拉取量按主键_id范围分批扫描或者从隐藏的secondary节点导出让主库完全不受影响。如果要导出的数据量极大且业务允许宁可多花一点时间错峰执行也不要贪图并发加速。3. 用iostat实测顺序I/O和随机I/O差在哪3.1 构造一个可复现的5GB测试集合纸上谈兵没意思我建议你在测试环境自己跑一遍实验。准备一台16GB内存的Linux机器MongoDB的数据目录放在一块7200转机械盘上新建一个集合插入500万条文档每条文档包含一个seq字段、一个tag字段、一个约900字节的content字段集合体积大约5GB。数据量一定要明显超过内存否则全表扫描全被page cache挡住了磁盘根本没动作。插入完成之后先清一次系统缓存确保后续观察的是真实磁盘I/Osudo sync sudo sysctl -w vm.drop_caches3清除缓存这一步只能在测试机执行生产环境千万别碰。清完后用下面这段Python脚本做一次全表遍历from pymongo import MongoClient client MongoClient(mongodb://127.0.0.1:27017) db client[iotest] col db[scan_test] count 0 for doc in col.find({}, {_id: 0}): count 1 print(fscanned: {count})跑这段脚本的同时另开一个终端用iostat记录磁盘状态iostat -x 1 60 /tmp/iostat_scan_before.log3.2 对新集合和碎片化集合分别观察第一次实验用刚插入完、还没经过任何更新的集合做全表扫描。由于插入是顺序追加的数据页在物理上的连续性相对好观察到的I/O指标会带有顺序读特征每秒读请求次数不算高吞吐比较大平均等待时间比较低。第二次实验对同样的集合做一轮破坏随机挑100万条文档把content字段从900字节改成2200字节触发文档移动和页分裂再随机删除200万条文档制造大量空闲块。然后重新清缓存再做一次全表扫描。两次的I/O表现会有很直观的差异。指标新集合全表扫接近顺序I/O碎片化集合全表扫随机I/Or/s每秒读请求数约等于IOPS300-6002000-4000rkB/s每秒读吞吐80-150 MB/s20-60 MB/sawait平均每次I/O耗时5-15 ms20-50 ms%util磁盘利用率60%-90%100%具体数字会因硬盘和MongoDB配置不同有差异但趋势几乎不会变经过大量更新和删除之后同样的逻辑全表扫描IOPS变高、吞吐变低、等待变长。你甚至可以观察磁盘的总IO吞吐发现集合变“瘦”了扫描反而变慢了这是碎片化集合的典型特征。3.3 解读iostat关键指标的几个细节iostat输出里r/s代表每秒完成的读请求次数基本可以看作IOPSrkB/s是每秒读的字节数反映吞吐await表示一次I/O从提出到完成的时间包含排队和服务时间它一高就说明I/O在排队。rrqm/s也值得看它表示每秒合并的读请求数顺序读的时候内核经常把多个相邻请求合并成一个大请求rrqm/s会比较高随机读时这个值通常接近0。%util在机械盘上是比较可靠的饱和度指标100%意味着磁盘队列已经满了。但在SSD上要小心现代SSD可以并行处理大量请求%util即使到100%也可能不觉得卡顿所以判断SSD是否饱和更该看响应延迟和排队长度。如果你想更直观地看到I/O的随机性可以再用blktrace抓一次块设备访问记录把每次读请求对应的扇区号画出来。扇区连续的就是顺序读扇区跳来跳去的就是随机读。实测中碎片化集合的扇区散点图会像夜空里的星星一样毫无规律。4. 更新、删除和页分裂如何把顺序读悄悄变成随机读4.1 文档移动与页分裂是怎么发生的很多人以为数据库里更新一条文档就是原地把新内容写进去磁盘位置不变。这在文档大小不变或变小的时候基本成立WiredTiger会尽量原地更新。可一旦更新让文档变大当前页剩余空间放不下文档就得搬家到另一个有足够空间的页里原来的位置就变成空洞。一次大规模字段增长更新可能让几百万条文档四散到文件各个空闲区块里。页分裂则是另一个重要的碎片来源。当一个页的数据量超过页大小上限B树会把这一页拆成两页把一半数据挪到新页。新页的物理位置由块管理器分配很可能离原来的兄弟页非常远。从逻辑上看这两页还是相邻的从磁盘看中间已经隔了其他数据块。全表扫描时每次从A页跳到C页再跳到B页每一步都是寻道。索引页同样会经历这个过程。数据集合的B树在频繁更新下叶子页物理位置会越来越散。索引的页可能还好一点毕竟索引字段体积小、页分裂频率低但集合的数据页分裂频率完全取决于文档大小和更新模式文档越大越容易触发。4.2 碎片率对全表扫描的放大效应碎片对全表扫描最直接的影响是需要读的页数变多了。删除文档不会立刻归还磁盘空间只是把页标记为空闲或者留给后续复用页分裂又会产生半空的页。假设集合里实际还剩300万条文档但storageSize里大小还维持在500万条文档的规模那一次全表扫描要访问的页数就比理论上多出将近一倍。更麻烦的是这些半空的页散布在文件各个位置。扫描时读A页只拿到10条有效文档然后跳去读B页又只拿到8条。每次跳跃都付出寻道代价但每次传输的有效数据却很少。机械盘在这种情况下几乎是在空转IOPS很高吞吐很低延迟很高。这也是为什么stats里的size和storageSize比值值得定期观察。新集合这个比值接近1长期更新删除后storageSize往往明显超过size说明集合内部积累了大量碎片和空闲空间。很多团队惊讶于“删了2020以前的旧数据磁盘空间一点没释放”原因就是这些空闲块还在数据文件里没有归还给文件系统。4.3 compact的收益、代价与正确姿势针对碎片化MongoDB提供了一个compact命令在WiredTiger引擎下它会把集合的数据重写一遍重新打包页让逻辑相邻的页尽量在物理上靠拢并回收空闲块。执行方式很简单db.runCommand({ compact: scan_test })compact的效果在机械盘上非常直观。我测试过一个碎片率超过40%的集合compact之前全表扫描吞吐只有30MB/s左右compact之后能回到100MB/s以上。但如果你的磁盘已经是SSD这个提升幅度会小很多因为SSD对随机读本身就比较从容。代价也要说清楚。compact会重写大量数据期间磁盘I/O会显著增加对同盘上的其他读写操作造成影响。WiredTiger引擎下业务通常还可以继续读写但性能会打折所以一定要安排在维护窗口执行。执行前确认磁盘有足够的临时空间并且对生产数据做好备份和心理准备。经验是先在一个从节点上跑一遍确认耗时和影响可以接受再决定正式操作。5. 一次全表读取拖垮磁盘的完整排查链路5.1 症状只读任务为什么把写入拖垮某个做账单存储的实例数据量约180GB跑在一台带机械盘的物理机上。每周日凌晨有一个对账脚本会把整个账单集合全量拉出来跟数仓做比对。某个周日开始对账任务一启动MongoDB主库的写入延迟就从1ms以内飙到30ms以上磁盘利用率直接顶到100%。当时团队第一反应是“查询是只读的怎么会影响写入”。这个想法在理解上有个盲区MongoDB实例跑在同一块物理盘上数据文件、日志、操作系统都在共享这块盘的I/O带宽。全表扫描产生的随机读占用大量盘片寻道时间写请求在磁盘队列里排不上队自然跟着遭殃。不是写入本身变慢了而是所有I/O都被随机读阻塞了。更隐蔽的是这个对账脚本已经在生产环境跑了好几个月之前一直没事为什么这次突然出问题后来查了记录才发现最近业务方做了一轮历史数据清洗删了大量旧文档集合碎片率明显上升。同一张逻辑表物理读取成本变高了以前勉强扛得住的任务现在直接把磁盘拖爆。5.2 定位慢日志、explain、iostat三件套排查的第一步是看MongoDB慢日志。用grep把COLLSCAN相关的语句筛出来很容易看到大量find操作带planSummary: COLLSCANdocsExamined列出的数字接近整个集合的文档总量。这说明问题确实出在全表扫描上而不是某个慢操作偶发。第二步是看活跃会话确认当前到底是谁在打I/Odb.currentOp({ active: true, secs_running: { $gt: 10 } })返回结果里的opid和planSummary直接指向那个对账脚本发起的find和后续的getmore。getmore是游标分批拉取数据的操作一次全表扫描会发起非常多次getmore每一批都在继续做collection scan。第三步就是用iostat确认I/O特征。r/s在几千以上rkB/s只有二三十MB/sawait飙到几十毫秒基本就能判定这是随机读。如果这步不做只停留在“有全表扫描”这个层面很容易得出一堆错误结论比如以为是CPU不够或者网络带宽问题。5.3 修复验证批处理、隐藏节点、限速确认根因之后短期止血分三步走。第一步给对账脚本加上按_id分批处理的逻辑每批5000条批次之间sleep几十毫秒。这么做不会把随机I/O变成顺序I/O但能大幅降低磁盘上的并发读压力给正常业务腾出I/O时间。第二步把脚本的读请求切到一个隐藏的secondary节点。隐藏节点priority为0不会被选为主也不会承担普通读流量专门拿来扛分析任务。这样即使磁盘被打满也是从库自己的盘被打满主库完全不受影响。这一步的收益远比加内存明显因为问题本质是I/O争用不是容量不足。第三步如果任务实在没法完全避开高峰期就显式限制整体执行时长。比如把全量对账改成增量对账只处理前一天变更的数据。验证的时候继续用iostat观察主库磁盘%util从100%回落到20%以内写入延迟恢复正常说明问题确实出在随机读上。5.4 长期优化归档、compact与硬件升级这次故障还有一个值得反思的地方对账脚本跑了这么久都没事为什么偏偏这周出事因为集合的物理结构已经悄悄变差。所以长线优化比止血更重要。首先做数据分层把超过一年的历史账单归档到另一个低频访问的集合活跃集合从180GB降到60GB左右热数据能比较舒服地驻留在内存里。其次在维护窗口对主要集合做一次compact回收碎片。compact之后观察stats里的storageSize明显回落全表扫描的吞吐恢复到了接近正常水平。最后就是硬件层面的决定这种规模的业务读写模式机械盘早晚会成为瓶颈换成SSD之后随机I/O的代价大幅下降这类问题的爆破半径会小很多。6. 优化全表读取的实用清单按影响面排序6.1 查询层让COLLSCAN不要成为常态查询层的优化优先级最高也最容易落地。首先检查业务里高频查询的条件字段确认索引有没有建对。不是随便建个索引就行要跟查询条件的等值字段、排序字段、范围字段匹配起来。查询条件里对字段做函数运算、正则表达式、$not、$ne等操作都容易导致索引失效这些要逐一排查。对于只需要少量字段的分析任务用projection把返回字段收窄能帮助执行器少读很多数据。更进一步如果查询条件命中的索引已经覆盖了所有需要的字段explain里会出现covered index连数据页都不用读直接读索引页就能返回结果。这种查询的I/O成本比全表扫描低一个数量级是分析类场景的终极优化手段。聚合管道的优化同样重要。$match一定要放在管道最前面$project提前把无用字段去掉能在进入group或unwind之前就大幅裁剪数据量。如果管道里根本没法避免全量读取那就考虑把结果缓存到单独集合不要让代价高昂的聚合频繁重复执行。6.2 架构层分离读路径、控制任务并行度架构层的核心思路是让全表读取永远不要发生在业务的主路径上。最有效的做法就是读分析任务走隐藏secondary或者搭建独立的分析实例。很多团队觉得“MongoDB副本集从节点除了备份没别的用”其实隐藏节点就是为这种场景准备的。任务编排上把多个全量扫描任务错开执行不要让它们在同一个凌晨同时跑。控制并行度很重要一个集合拆成多路并发导出往往适得其反单个大游标顺序拉取反而对磁盘更友好。数据量到了几百GB级别就应该考虑按时间做冷热分层让活跃集合的体积始终受控。另一个容易忽略的点是读关注级别。全量扫描任务如果走secondary要注意读关注设置成什么级别。大部分分析类查询用默认级别就够了别为了所谓的“一致性”去读primary那样等于前面所有架构优化全部白做。6.3 存储与内存SSD、page cache和IOPS配额存储层是最后的兜底也是最容易被误解的一层。机械盘上顺序I/O和随机I/O的差距可能达到几十倍所以只要业务里有不可控的全表扫描场景机械盘就是一颗定时炸弹。换成SSD之后随机读的延迟降两个数量级全表扫描虽然依然不是顺序读但至少不会把磁盘拖到100%饱和。内存的作用也不要高估。增加内存确实能让更多热数据驻留page cache但数据量不断增长全表扫描总会有一部分页不在缓存里。更麻烦的是一次大规模全表扫描会把系统page cache里的大量热页挤出去导致随后正常业务查询反而变慢。这也是为什么有些团队给MongoDB加了内存全表扫描任务一跑线上还是出现毛刺。如果你的MongoDB跑在云上的云盘里还要关注IOPS配额。现在很多云盘的按量性能其实不错但超过基准IOPS之后会被限流表现还不如本地机械盘。全表扫描这种高IOPS低吞吐的访问模式特别容易触发配额告警。碰到这种情况先优化扫描量再考虑提升云盘性能档位顺序别搞反。最后分享一个我在实测里获得的体会很多人一看到COLLSCAN就条件反射式地说“加索引”但导出、对账、全量统计这类任务加索引并不能解决根本问题真正决定体验的是集合体积、碎片率、存储介质和读路径分离这四件事。与其祈祷一次全表扫描能跑出顺序I/O的速度不如主动把集合变小、让热数据留在内存、定期整理物理碎片、把分析任务挪到独立节点上。建议你在测试环境把上面那组对比实验完整跑一遍很多关于随机I/O的认知一次实测就能彻底扭转。