资讯动态

MongoDB全表扫描为何时快时慢?随机I/O与顺序I/O深度解析

发布时间:2026/10/8 20:13:47 来源:尧图企业网站定制
做 MongoDB 的都知道一条不带索引条件的find()会走全表扫描也就是COLLSCAN。监控一拉慢查询一堆可你有没有认真想过同样是把整张表的数据都读一遍凭什么有时候磁盘顺序读能跑到几百 MB/s有时候却卡在几千个零星小 IO 上动弹不得我之前在压测一次全量导出任务时被这个问题按在地上摩擦最后发现根子全在随机 I/O 和顺序 I/O 的博弈上。这篇文章我会从 MongoDB 全表读取的底层原理讲起把 WiredTiger 的存储布局、系统预读机制、真实压测数据以及怎么把随机 I/O 尽量改成顺序 I/O 的方法一次说透。不管你是做 DBA、后端开发还是数据迁移的只要你手上有大集合要全量扫这篇文章都能派上用场。1. 先搞明白COLLSCAN 到底在读什么很多人一说全表扫描脑子里只有“慢”这个结论但从来没细想过 MongoDB 在执行全表读取时存储引擎到底对磁盘做了什么。这会直接影响后面所有优化手段的取舍。1.1 explain 里的 COLLSCAN 只是表象一条查询如果没命中索引explain 的结果里就会出现stage: COLLSCAN这意味着 MongoDB 需要遍历集合里所有的文档。注意这里的“遍历”不是像很多文章写的“线性读文件”而是指在 WiredTiger 的 B 树里按叶子页从左到右把所有数据页翻一遍。这个顺序叫$natural顺序本质上是文档在物理存储上的排列顺序跟_id的排序不是一回事。换句话说COLLSCAN读的是集合文件里的数据页不是索引页。一个集合对应磁盘上一个collection-uuid.wt文件里面是一棵 B 树树的最底层是叶子页真正的文档就存在叶子页里。全表读取就是把整棵树从最左叶子页一路扫到最右叶子页。1.2 索引扫描和全表扫描的 I/O 口味完全不同索引扫描IXSCAN读的是索引文件索引叶子页里存的是键值和文档位置RecordId。如果查询能覆盖Covering Query那读完索引页就完事了压根不用碰集合文件如果还要返表查文档那就要根据 RecordId 去集合文件里“抓”对应的文档页。这里有个特别容易忽略的点索引扫描的叶子页在物理布局上很有规律因为 B 树索引天生就是有序的扫索引页时磁盘上相邻页面大概率也是挨着的。但返表抓文档这一步往往是灾难性的随机 I/O——因为文档在集合文件里的物理位置跟索引顺序没有任何关系上次抓一个在文件开头下次可能就要到文件尾巴。相比之下全表扫描反而不需要返表它只需要把集合文件的所有数据页读一遍。看起来是“纯顺序读”好像应该很快但现实根本没这么美好。扫描类型逻辑读取对象典型物理 I/O 模式杀手锏COLLSCAN集合 B树所有叶子页依赖页面物理连续性预读、大块连续读IXSCAN索引 B树叶子页接近顺序读覆盖查询避免返表IXSCAN FETCH索引页 集合页索引顺序 文档随机索引本身小随机抓取多全表扫描的悲剧在于如果集合文件被更新、删除、页分裂搞得七零八落那么“逻辑上的从左到右”落到磁盘上就会变成“物理上的东一榔头西一棒子”。这就是随机 I/O 的温床。2. 随机I/O与顺序I/O的本质差异附实测参考数值聊 I/O 不能只停留在术语层面。随机和顺序的差异在不同存储介质上的表现天差地别我们的优化策略也必须跟着介质走。2.1 机械硬盘寻道是绕不开的物理定律HDD 上每次随机读都要等磁头移动到目标磁道这个动作叫寻道seek典型耗时 8~10 毫秒再加上磁盘旋转到目标扇区的等待时间单次随机读的延迟轻松到 10ms 以上。换句话说一块 7200 转的 SATA 盘随机 4K 读能跑出 100~150 IOPS 已经算不错了。顺序读就不一样磁头只要定位一次剩下的就是磁盘连续转、磁头连续读吞吐可以做到 150~200MB/s。同样是读 1GB 数据顺序读只要 5 秒随机读按 100 IOPS 算需要一万多次请求就算每次读 4K也要磨蹭好几分钟。这中间差了一两个数量级。2.2 SSD/NVMe单次延迟降下来了但差距仍在SSD 没有寻道随机读的延迟一般在几十微秒量级比 HDD 好了两三个数量级。但注意随机小 IO 和顺序大 IO 之间依然存在明显差距。拿常见的 NVMe 盘来说顺序读能跑到 3~4GB/s而随机 4K 读往往只能跑出几十万 IOPS换算成吞吐也就 1~2GB/s而且 IOPS 一旦被请求队列深度卡住延迟会直线上升。SATA SSD 更明显顺序读 550MB/s随机 4K 读通常也就 10 万 IOPS 上下折合吞吐不到 400MB/s。所以在 SSD 上做全表扫描随机与顺序的吞吐差异普遍有 3~5 倍。2.3 为什么顺序 I/O 有额外 buff顺序 I/O 的优势远不止“磁头不用动”。在操作系统层面内核可以预读readahead检测到应用在按顺序读文件时会自动把后续若干个页提前读到内存缓存里。在存储硬件层面连续的大块读可以触发 DMA 批量传输减少 CPU 中断次数。打个比方顺序读就像沿着书架从第一本挨个往后抽一次可以抱走一整排随机读就像闭着眼睛在书架上到处乱点每一点都要重新确认位置、伸手去够。全表扫描要想快核心目标就是让 MongoDB 发出的每个读请求在磁盘物理位置上尽量挨着。3. WiredTiger 视角为什么逻辑顺序不等于物理顺序这是全表扫描性能问题的命门。很多人以为 $natural 顺序就是文件顺序但 WiredTiger 的实际存储远没有这么理想化。3.1 B树页面和文件偏移量的关系WiredTiger 的集合文件由固定大小默认 4KB 的分配单元页大小动态的块组成。B 树的每个节点内部页、叶子页都会映射到文件的一段偏移。直觉上如果我们按插入顺序往集合里塞文档那么 B 树最右边的叶子页也应该是文件里最后写入的部分扫全表时读到的文件偏移是单调递增的这就是理想状态下的顺序 I/O。但数据库不可能只有插入。更新文档时如果新文档体积超过了原占位空间WiredTiger 就要把文档搬迁到别的块删除文档后空闲块会被回收并进入 free list后续新插入的数据会优先复用这些物理位置不连续的“洞”。页分裂、页合并更是常态。时间一长B 树叶子页的逻辑顺序和磁盘文件偏移的顺序就会全面脱钩。3.2 日志里看不到的碎片化我在一次压测中遇到过这么个场景一个集合每天删除 30% 的历史数据新数据继续写入。从外观看集合大小没怎么变但db.collection.stats()里的wiredTiger信息看出文件碎片已经相当可观。这时候做全表导出iostat 显示读吞吐只有 20~30MB/sr/s 却高得吓人典型的“逻辑顺序读、物理随机读”。这里补充一个关键背景MongoDB 默认没有开启 WiredTiger 的direct_io数据会先经过操作系统页缓存再进入引擎缓存。好处是内核预读机制能在顺序扫描时帮你自动加载后续页坏处是冷缓存下第一次全表扫描会原原本本地把随机 I/O 暴露出来。3.3 多个并行扫描会把顺序读打回原形全量导出的工具比如 mongodump、自研导出脚本普遍会开多线程并行读。每个线程各拿一个游标在 B 树的不同数据区间上顺序扫描。单看每一个线程它读的是连续区间是顺序的但几个线程同时跑磁盘层看到的就是多个读请求流交叉在一起。HDD 上这几乎是灾难多个磁头寻道请求互相干扰总吞吐可能比单线程还低。我在老的机械盘上做过实验6 个并行连接导同一个大集合总耗时比 2 个并行还慢磁盘 %util 直接打满 100%但有效吞吐腰斩。这就是把顺序 I/O 硬生生逼成随机 I/O 的典型反面教材。4. 实操怎么判断你的扫描到底是不是随机 I/O别靠猜。下面这套组合拳可以帮你把 I/O 模式看得明明白白。4.1 先看 explain 的 executionStatsdb.collection.find({status: active}).explain(executionStats)重点看三个字段executionStats.executionTimeMillis、executionStats.totalDocsExamined、winningPlan.inputStage.stage。如果 stage 是COLLSCAN说明扫描方式已确认。但 explain 不暴露磁盘 I/O它只说明 MongoDB 逻辑上做了多少次读物理上怎么读还得看系统层。4.2 用 iostat 观察读写模式iostat -x 1盯这几个指标r/s每秒读请求次数。随机读时这个值会很高几千以上顺序读时反而不会太高因为预读会一次性带出大量数据。rkB/s每秒读吞吐。如果rkB/s只有二三十 MB 而r/s高得离谱基本可以断定是随机小 IO。rrqm/s每秒合并的读请求数。这个值高说明系统在做预读或请求合并是顺序读的好信号。await平均 I/O 等待时间。随机读时明显升高因为每次请求都在排队等寻道/传输。我自己习惯把“r/s 高 rkB/s 低 rrqm/s 低 await 高”四件套同时出现判断为随机 I/O 主导。4.3 用 strace 看文件偏移序列如果真想看得直观可以抓一下 mongod 进程实际发出的读偏移strace -f -e tracepread64 -p mongod_pid 21 | head -100注意pread64的第二个参数count、第三个参数offset。如果 offset 一直单调增长、count 比较大比如 64KB、128KB说明扫描在走顺序读如果 offset 跳来跳去、count 又小4K、8K那就是随机读实锤。有一点要提醒strace 在重负载下会严重拖慢服务性能建议只在测试环境或者业务低峰期短时间采样别在生产上长时间挂着。4.4 注意页缓存的“假快”全表扫描第二次跑往往比第一次快得多这不是磁盘变快了而是数据已经在操作系统页缓存里。想看真实的磁盘 I/O 表现得在测试环境做冷缓存测试echo 3 /proc/sys/vm/drop_caches然后立刻重新执行扫描。这个操作会让服务卡顿生产环境千万别做测试环境做之前也要确认没有线上流量。5. 真实场景哪些任务会踩中全表读取的坑不是所有“读得多”的任务都一个样下面这些场景是我实际工作中见过的高频全表扫描场景各自的 I/O 特征还不太一样。5.1 全量导出与数据迁移导出整个集合是教科书级的全表读取。如果导出的业务要求按_id排序很多人会直接find().sort({_id:1})这时候 MongoDB 为了排序可能会走_id索引但文档抓取仍然是随机的甚至比全表扫描还慢。如果只是顺序导出$natural顺序就够用不要画蛇添足加排序条件。5.2 聚合分析和报表任务$match$group的聚合语句如果$match无法通过索引过滤掉大部分数据整个分析就要全表扫。尤其报表的“全量统计”需求MongoDB 跑起来就是 COLLSCAN。这类任务如果每天都跑页缓存命中率会很高但一旦缓存被冲掉磁盘随机读的底裤就露出来了。5.3 副本集初始同步新节点加入副本集时会做 initial sync本质上是把主节点的数据整体拷一遍。这个流程里有大量顺序读的潜力但实际跑起来会受到主节点业务写入的干扰造成读写交织I/O 模式变得很乱。我见过不少机房因为初始同步期间主节点上没有做任何限流把整台物理机的 IOPS 打满的情况。5.4 分片集群 chunk 迁移chunk 迁移的数据搬迁是按 shard key 范围扫数据的相对有序但由于迁移线程和正常业务查询共享磁盘整体 I/O 也会呈现出明显的随机化特征。在 HDD 时代chunk 迁移经常能让其他查询的延迟翻好几倍。这些场景的共同点是都涉及“把集合的大部分数据读一遍”这时候 I/O 模式就成了性能瓶颈的决定性因素而不是 CPU 或网络。6. 优化手段把随机 I/O 转成顺序 I/O 的实操清单下面这些方法都是我实际跑过、对比过数据的按推荐优先级排列。你可以根据自己的存储介质和业务容忍度选着用。6.1 用 _id 范围分片把一次大随机变成多次小顺序这是全表导出场景下我最推荐的做法。具体思路是不要一次性扫整个集合而是把_id分成 N 个区间每个区间单独查询并且用_id索引来限定范围。// 例子按 _id 范围切片每片一亿条左右 db.collection.find({_id: {$gte: startId, $lt: endId}}).hint({_id: 1})为什么有效因为_id默认是 ObjectId整体上单调递增新文档追加在 B 树右侧同一_id区间的文档在物理分布上相对集中。用_id索引做完范围扫描后再按 RecordId 去集合文件抓文档抓取的目标点也集中在文件的一段区域里。虽然还是有随机成分但随机范围被圈小了加上操作系统预读整体表现会从“全表乱抓”变成“按章节顺序读书”。实测数据同样导出一个 300GB 的集合直接全表扫在 HDD 上是 30MB/s分成 16 个_id区间顺序扫吞吐能到 80MB/s 左右。SSD 上差别相对小但排队延迟也会明显下降。6.2 控制并发读的线程数并行不是越多越好。HDD 上 2~4 个并发读线程通常就是甜点位再多就是互相抢磁头SSD 上可以放宽到 8~16因为 SSD 本身要靠队列深度压榨性能没有寻道惩罚。mongodump 有--numParallelCollections参数控制并发集合数自研导出脚本的话就要自己控制连接池大小。经验法则先从小并发开始测逐步往上加观察 iostat 里 rkB/s 的拐点而不是盲目开满。6.3 调大预读窗口Linux 块设备的预读参数readahead对 HDD 全表扫描影响非常显著。默认值往往偏保守# 查看当前预读量单位是 512 字节扇区 blockdev --getra /dev/sdb # 设置为 128KB256 个扇区适合大集合顺序扫描 blockdev --setra /dev/sdb 256需要注意的是这个参数调太大会让随机读场景白白读入大量无用数据污染页缓存。建议只在做全表扫描的前后临时调整扫描完再调回去。如果是云主机挂载云盘请确认厂商是否允许透传这个参数有些虚拟化平台不支持。6.4 让扫描内容瘦身既然是全表读能不能少读一点三个思路投影裁剪find({}, {_id: 1})只读_id字段如果业务只需要主键列表MongoDB 可以直接走覆盖索引连集合文件都不碰。提前过滤$match能压到索引就用索引不能的话也尽量把过滤条件前置虽然 WiredTiger 还是要读数据页但返回和传输的数据量能省下来。使用更紧凑的文档文档越小每页能装的数据越多全表扫描需要的页数越少I/O 总量自然下降。6.5 compact 整理文件碎片如果集合历史写入复杂、碎片严重可以执行压缩db.runCommand({compact: collectionName})compact会把集合文件重写一遍把物理上分散的空闲块归拢文件体积缩小全表扫描的物理顺序也会大幅改善。注意这个命令需要 admin 权限WiredTiger 下虽然不会锁死整个库但执行期间对集合的操作会有明显影响一定安排在维护窗口。我见过一个 500GB 的集合 compact 之后全表导出从 40MB/s 升到 120MB/s效果立竿见影。6.6 根据介质选择不同的优化重心最后总结一下机械盘重点做_id范围顺序扫描 低并发 调预读这三点缺一不可。SATA SSD重点做并发控制 范围分片随机惩罚没有机械盘那么狠但小 IO 吞吐的瓶颈依然存在。NVMe SSD并发可以拉高但要注意别让单次全表扫描吃光磁盘队列影响在线业务配合 limit/分片限流更重要。7. 常见问题与排查技巧实录7.1 为什么 iostat 显示 await 很高但磁盘 %util 才 60%%util 反映的是采样周期内磁盘是否忙NVMe 这类高并发设备经常出现队列深度大但计算利用率不高的情况。await 高说明单个请求的等待时间已经上去了大概率是请求在队列里排队。这时候看avgqu-sz如果这个值很大说明并发发出的读请求太多优先降并发。7.2 为什么 explain 显示 COLLSCAN 却很快集合数据量太小或者数据恰好全部在 WiredTiger 缓存/OS 页缓存里。COLLSCAN 的耗时主要来自物理 I/O数据全在内存时几百 GB 的集合也能秒扫完。判断方式很简单把totalDocsExamined和executionTimeMillis一比如果每秒扫了几千万文档说明是内存扫描如果每秒只扫几十万且执行时间长基本可以断定被磁盘 I/O 卡住了。7.3 全表扫描把业务查询拖垮了怎么隔离最实用的做法是给全表扫描任务单独部署一个隐藏节点用secondaryslaveOk读出去跑。导出、报表任务全部打到隐藏节点主库和普通从库完全不受影响。这是成本最低、效果最干净的隔离方案。没有条件加节点的话那就一定要限制扫描任务的并发和 batchSize比如cursor.batchSize(1000)让服务端每批少读一点虽然整体变慢但能保住在线业务。7.4 同一个集合为什么有时候顺序读有时候随机读最常见的原因是页缓存状态不同。冷缓存扫描时磁盘 I/O 全量暴露随机化程度一眼就看出来热缓存扫描时大部分页从内存返回磁盘读请求数量骤降自然显得“顺序”。另外索引缺失、查询计划选择 COLLSCAN 而不是 IXSCAN也可能让同一条 SQL 的 I/O 模式在不同数据分布下表现完全不同。建议用 planCache 和 explain 固定查询计划避免统计信息变化导致执行计划漂移。7.5 并行导出偶发比串行还慢除了前面说的磁头争抢还有可能是批大小问题。每个导出线程如果batchSize设置得太小getMore 请求就会很频繁服务端每批都要重建游标上下文磁盘读请求被打散成大量小 IO。遇到这种情况把batchSize调到 5000 甚至 10000单次 getMore 读的页数变多预读也能更好发挥。最后再分享一个细节我自己踩过最大的坑是对着一个混合工作负载的机器做全表扫描测试性能数据飘忽不定怎么调都找不到规律。后来才发现问题根本不在 MongoDB而是同一台宿主机上还有别的虚拟机在抢磁盘。从那以后我给自己定了条规矩所有 I/O 压测之前先iostat -x 1确认这台机器的磁盘基线是干净的否则测出来的数据只能骗自己。如果你现在的全表读取任务也卡在几分钟甚至几十分钟级别建议先不要急着加索引、加机器花十分钟把这篇文章里的诊断手段过一遍。搞清楚了你的扫描到底是随机还是顺序优化方向自然就出来了。很多时候把并发降下来、把_id范围分好效果比你多花几万块买高 IOPS 云盘还要明显。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑