ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MongoDB查询优化:覆盖查询+索引交集,让磁盘I/O狂降13倍

MongoDB查询优化:覆盖查询+索引交集,让磁盘I/O狂降13倍 我还在用原生的聚合管道时踩过一个很大的坑数据量一上来查询慢了整整十几倍后来一查问题出在两个地方——索引没有覆盖住查询字段导致 MongoDB 必须回表读原始文档以及一个很宽的复合条件查询明明能用索引交集却因为索引设计不合理退化成全集合扫描。这两年帮团队优化订单、日志、用户行为这类大数据集合时几乎每次都在跟“磁盘 I/O 有没有被白白浪费”较劲。今天就把“索引交集 覆盖查询”这套组合方案的原理、实操和坑一次性说透。这个内容适合谁看凡是 MongDB 集合已经有几百万甚至上亿文档、查询开始变慢、服务器磁盘 I/O 经常被打满的同学这篇能直接给你排障方向刚入门 MongoDB 但已经把基础索引搞懂了的读者也能照着后面 4 个章节的步骤自己动手做一次查询优化实战。我尽量不写理论堆砌全部基于真实场景展开。1. 为什么索引不总能让查询变快磁盘 I/O 才是真凶先看一个基本事实MongoDB 90% 以上的慢查询根因不是 CPU 不够而是磁盘 I/O 读放大太严重。你看着一条查询在 3 秒内跑完其实它可能读了 200 万份文档但真正命中的只有 2000 份。这块被白白浪费的 I/O就是拖垮吞吐量的元凶。1.1 从 B 树到回表一条查询的真实读取路径MongoDB 默认的 WiredTiger 存储引擎索引底层是一棵 B 树。树上非叶子节点存键值范围叶子节点存真实索引条目key 以及指向文档位置的 RecordId。假设你在orders集合上有单键索引{ user_id: 1 }然后执行db.orders.find({ user_id: 123 })过程是从根节点出发按二分查找定位到user_id123在叶子节点中的位置拿到这条索引条目里的 RecordId根据 RecordId 回表也就是 fetch去读集合文件里那份完整文档把完整文档的user_id、status、amount、created_at等全部字段返回。问题就在第 3 步。一次查询明明只需要 3 个字段却把整份文档从磁盘搬到内存再搬到查询结果里。如果每份文档平均 2KB命中 100 万条记录就要额外读 2GB 的原始数据。这种回表读纯属浪费。1.2 磁盘 I/O 会被哪些操作放大我整理过一张对照表这几类操作在慢查询里占比最大操作类型是否放大 I/O原因find({ user_id: 123 })带单键索引是回表读整份文档find({ user_id: 123, status: PAID })且只有user_id索引是回表后还要在文档里逐个过滤statusfind({ status: PAID, source: APP })且无索引严重直接 COLLSCAN 全集合扫盘带排序sort({ created_at: -1 })是内存排序不够时会落盘排序临时文件聚合管道$match $group $sort视情况$group对索引感知有限可能在内存中分组后写盘一句话总结查询优化本质是在“少读数据”和“少扫数据”之间做平衡。索引交集解决“少扫数据”覆盖查询解决“少读数据”。2. 覆盖查询让 MongoDB 根本不碰原文档的查询玩法上面说的回表读取真正能避开吗能这就是覆盖查询的核心价值。2.1 覆盖查询原理索引里已经有一切覆盖查询Covered Query是指查询需要的所有字段都包含在同一个索引的索引键中MongoDB 可以直接从索引条目里取出这些字段返回完全不需要回表。这是减少磁盘 I/O 最彻底的方案。举例你有这样一个复合索引db.orders.createIndex({ user_id: 1, status: 1, amount: 1 })然后执行db.orders.find( { user_id: 123, status: PAID }, { _id: 0, user_id: 1, status: 1, amount: 1 } )这时 MongoDB 会发现你查的两个条件字段user_id、status在索引键里你要返回的三个字段user_id、status、amount也全在索引键里。于是它直接在索引 B 树叶子节点上遍历拿到结果不会再碰集合文件。我们用explain()验证一下db.orders.explain(executionStats) .find( { user_id: 123, status: PAID }, { _id: 0, user_id: 1, status: 1, amount: 1 } )重点看这几项stage显示IXSCAN索引扫描后直接PROJECTION_COVERED而不是FETCHdocsExamined为 0keysExamined等于返回条数或略多视筛选逻辑而定totalDocsExamined为 0。如果看到FETCH说明还是回表了索引没有完全覆盖。2.2 三个关键约束_id、数组字段与无法覆盖的过滤逻辑想把查询做成覆盖的有几个硬性前提踩中一个就失效约束 1必须显式排除_id。_id默认会返回但它不在你的索引键列表里除非你把_id也放进索引否则 MongoDB 为了返回_id只能去读原文档。所以上面投影里写{ _id: 0 }不是可选项是必要条件。约束 2索引键字段不能是数组。如果某个字段是数组多键索引叶子节点里存的条目虽然包含该字段但 MongoDB 无法保证索引条目能完整还原数组字段的全部语义所以带数组字段的查询无法覆盖。约束 3对索引字段做函数操作、$where、$text查询无法覆盖。因为索引里只存原始值MongoDB 需要对文档做函数求值这时候不读原始文档做不到。一个生活化类比覆盖查询就像图书馆里给你一张“书目卡片”卡片上已经写了你需要的页码、作者、出版年份你根本不用去书架上取那本厚书。索引交集则更像一次查多个目录一个目录查作者一个目录查主题两边各拿一批书号最后取交集。2.3 覆盖查询的最大收益点高频筛选 固定统计报表我个人经验最明显的场景有两个用户详情页接口按user_id查询少量字段昵称、头像、等级、状态这种查询每秒几百次做成覆盖后磁盘物理读直接降到接近 0因为索引叶节点长时间在 WiredTiger 缓存里。运营数据看板固定按某几个维度和聚合字段查数据以前要回表读 50 万条文档算sum/avg做成覆盖后只扫索引条目就够。需要注意覆盖不是全能的如果你必须返回文档里的 20 个字段很难全部塞进一个复合索引里这时权衡是“复用性的复合索引”还是“专为覆盖建的胖索引”下文第 3 节会讲怎么做这个决策。3. 索引交集的底层逻辑与适用边界现实中的查询往往有多个筛选条件比如“查最近 7 天里 statusPAID 且来源是 APP 的订单”。你当然可以给这几个字段建一个大而全的复合索引但字段一多、顺序一变复合索引的复用性就非常差。这时候索引交集就能派上用场。3.1 什么是索引交集两个索引同时扫描结果取交索引交集Index Intersection是指一个查询条件命中了两个或更多个独立索引MongoDB 会并行或并发地扫描这些索引得到多组 RecordId 集合然后在内存里做交集运算最后根据交集结果回表读取文档。举个例子db.orders.createIndex({ status: 1 }) db.orders.createIndex({ source: 1 }) db.orders.find({ status: PAID, source: APP })执行时MongoDB 会用status索引扫出所有 “PAID” 订单的 RecordId 集合 A用source索引扫出所有 “APP” 订单的 RecordId 集合 B在内存中求 A ∩ B按交集得到的 RecordId 回表读取文档。相比只建{ status: 1 }单索引然后回表过滤source索引交集的最大优势是把“回表后逐份过滤”变成了“两个索引条目集合的快速求交”磁盘 I/O 大幅减少。3.2 索引交集 vs 复合索引什么时候该用哪个这是优化时最容易纠结的问题。我用一个实际对比表帮你判断场景复合索引索引交集查询条件字段经常不同组合需要建多个复合索引索引膨胀几个单键索引自由组合复用性好字段之间存在明确的等值 范围关系复合索引能精确利用边界交集对范围字段处理较尴尬想支持覆盖查询一个复合索引直接覆盖全部返回字段两个索引合并也覆盖不了返回字段写多读少场景复合索引少写入维护成本低多个索引都要维护写入性能下降排序需求复合索引按序扫描省去sort交集后无法保证顺序通常有SORT阶段以我在订单集合上的实践经验如果某几个字段的组合查询是固定的、高频的优先复合索引如果前端筛选条件是多变的比如用户能勾选“状态、来源、渠道、城市”任意组合那就建立几个单键索引让优化器自己走交集更划算。3.3 索引交集在实践中容易踩的三个坎坎 1字段基数太低交集等于白做。如果status字段只有 2 个值PAID / UNPAID用这个单键索引扫出来的 RecordId 集合可能占全表的 50%再去和其他集合求交性能不会比直接回表过滤好多少。此时交集的收益很小不如直接建复合索引。坎 2排序场景下交集会失效。交集后得到的 RecordId 是乱序的如果查询里带了sort({ created_at: -1 })MongoDB 需要对交集结果做一次 sort。要么建一个包含排序字段的复合索引要么承受额外的内存/磁盘排序开销。坎 3为什么会“只用了其中一个索引而没走交集”优化器会评估每个索引的“选择性”也就是预估要扫描多少条索引条目。如果系统认为用status单索引选择性较好再用source索引交集的额外代价大于回表过滤的代价它就会放弃交集单独走一个索引。所以你在explain里看到IXSCAN只有一条路径时不必惊讶——这恰恰说明优化器认为走一个索引更便宜。4. 实操从慢查询到磁盘 I/O 下降的真实优化实验前面原理讲得再透不如亲手跑一次优化。我拿之前做的一个用户订单系统为例集合叫orders数据量约 1800 万条单条文档平均 1.8KB。服务器磁盘是普通 SSD查询频繁的时候 iostat 能看到%util持续接近 90%。4.1 第一步找到最吃 I/O 的慢查询慢查询来自一个管理后台列表页db.orders.find( { user_id: 52733, status: PAID, channel: APP }, { user_id: 1, status: 1, channel: 1, amount: 1, created_at: 1 } ) .sort({ created_at: -1 }) .limit(50)这条查询每次执行约 1.8 秒。用explain(executionStats)看指标值stageCOLLSCANdocsExamined1800 万nReturned50totalDocsExamined1800 万totalKeysExamined0全集合扫描1.8 秒其实已经是缓存全热的状态。冷状态时这个查询能跑上十几秒。当时集合上只有三个单键索引user_id、status、created_at没有channel索引。4.2 第二步先试覆盖查询方案不够再上索引交集我的优化顺序是针对user_id status channel的固定组合直接建复合索引在这个复合索引里把需要返回的字段和排序字段也塞进去形成全覆盖同时保留一套单键索引方案status channel 单键作为其他可变筛选组合的“交集后备军”。最终执行了两条createIndexdb.orders.createIndex( { user_id: 1, status: 1, channel: 1, created_at: -1, amount: 1 } ) db.orders.createIndex({ channel: 1 }) db.orders.createIndex({ status: 1, channel: 1, created_at: -1 })第一个复合索引的作用是给定user_idstatuschannel然后用created_at排序最后amount满足覆盖查询的返回字段需求。第二个组合{ status: 1, channel: 1, created_at: -1 }是为了单独筛选“全部已支付订单里来自 APP 的最近记录”这是一个独立高频查询。第三条channel单键是为了其他条件组合时能跟status索引或user_id索引做交集。4.3 第三步验证覆盖查询是否生效重新跑explaindb.orders.explain(executionStats) .find( { user_id: 52733, status: PAID, channel: APP }, { _id: 0, user_id: 1, status: 1, channel: 1, amount: 1, created_at: 1 } ) .sort({ created_at: -1 }) .limit(50)核心输出winningPlan: { stage: PROJECTION_COVERED, inputStage: { stage: IXSCAN, keyPattern: { user_id: 1, status: 1, channel: 1, created_at: -1, amount: 1 } } }关键指标指标优化前优化后docsExamined1800 万0keysExamined0284nReturned5050totalDocsExamined1800 万0执行耗时1800ms12ms你要注意一个细节即使查询完全覆盖keysExamined也不是刚好的 50因为要扫到匹配的 50 条后停止中间会多扫一些无关索引条目。4.4 第四步索引交集的独立验证如果只查“来自 APP 且状态为 REFUNDED 的未归档订单”这种没带user_id的场景就能看到索引交集在发挥作用。给status、channel分别建单键索引后执行db.orders.explain(executionStats) .find({ status: REFUNDED, channel: APP, archived: false })执行计划里会出现两个IXSCAN随后是AND_SORTED或AND_HASH阶段这就是索引交集。我在实际测试中观察到这个查询从原来的 800ms全表扫描降到 60ms 左右磁盘读取量少了约 13 倍。位置说明AND_SORTED表示两个索引的 RecordId 列表已经各自有序用归并方式求交AND_HASH则把其中一个集合放进哈希表另一个集合逐条探查。两者都需要额外内存后者对内存更敏感。如果你的服务内存紧张优先保证每个索引的单键字段基数够高减少哈希表的规模和溢出。5. 常见问题与排查技巧实录做 MongoDB 索引优化网上教程常把“建索引”说得像万能药但实际生产环境有一堆坑。下面这些是我真实踩过的整理成速查表常见问题现象排查思路解决建议建了复合索引却没生效explain 显示 COLLSCAN检查字段顺序ESR 原则是否满足查询里是否对索引字段用了$expr、$where按等值、排序、范围的顺序重排索引字段避免函数包索引字段覆盖查询仍出现 FETCH没有PROJECTION_COVERED看投影字段是否都在索引键里_id有没有被排除是否有数组字段补字段或显式{ _id: 0 }数组字段无法覆盖时只能接受回表索引交集没生效explain 中只有一个 IXSCAN另一个索引被忽略被忽略的索引很可能基数太低或优化器认为合并成本高于回表过滤检查被忽略字段的 cardinality如果太低则不用强行交集改为复合索引加了太多索引导致写入变慢插入/更新耗时明显上升WiredTiger 每次写入要更新所有索引 B 树删除使用率低的单键索引把高频组合改成复合索引减少索引个数内存排序变成磁盘排序explain 出现SORT且usedDisk: true排序字段没被索引包含或索引顺序与排序方向不一致在复合索引里把排序字段放在合适位置并保证方向和 sort 一致交集内存过大导致 OOM实例内存飙升查询失败AND_HASH的索引集合过大给关键字段提升选择性限制 limit或改用复合索引5.1 排查慢查询的固定套路每次排查慢查询我建议依次做四件事用explain(executionStats)看docsExamined和nReturned两个值差了几个数量级说明存在严重读放大覆盖查询是更好的选择看stage是否有FETCH如果有确认是否所有需要返回的字段都已纳入索引看是否有SORT阶段有则说明排序字段没走索引考虑重排复合索引用db.currentOp()监控正在运行的慢操作定位哪些查询吃掉了大量 I/O。5.2 一个容易被忽略的细节字段顺序的重要性复合索引的字段顺序决定它能服务多少条查询。你建{ a: 1, b: 1 }它能高效服务a单条件查询和ab组合查询但服务不了只有b的查询。所以复合索引设计的黄金准则是先放等值过滤字段——比如user_id、status再放排序字段——比如created_at最后放范围过滤字段——比如amount、age。这个顺序业界叫 ESREquality、Sort、Range能最大化索引命中率而且能给覆盖查询留出充分的字段空间。5.3 关于新建索引时对线上服务的影响生产环境建索引如果集合特别大不要直接在高峰时段执行因为初始构建会占用大量磁盘 I/O 和内存。我习惯的做法是用createIndex默认的后台构建模式新版 MongoDB 多数操作默认在后台构建通过currentOp观察构建进度构建期间盯紧磁盘 I/O 和 CPU超阈值就限速或暂停在从节点/影子库上先演练一遍估算耗时和资源消耗。6. 最后补一个很少有人讲的技巧覆盖查询 索引交集这套组合方案最适合应用的场景是“同一条业务链路里既有高确定性查询又有发散性筛选”的后台系统。我当时的实际做法是把查询分成两桶固定高频的查询全部做成覆盖查询长尾多变的筛选交给单键索引组合的索引交集两者之间用监控报表隔离开哪类查询慢了就单独优化哪一类。单独说覆盖查询的“成本陷阱”为了让少量字段覆盖你可能会往索引里硬塞一些很长、很宽的字段导致复合索引体积膨胀反而把 WiredTiger 缓存和内存用满。所以我每次都提醒自己覆盖查询覆盖的是“返回字段”不是“所有字段”加了索引但不常查且很宽的字段得不偿失。可以先统计system.profile里的日志看看高频查询到底请求哪几个字段再决定要不要把某个字段加进索引。另外system.profile是 MongoDB 自带的慢查询分析器默认不开启。建议在低峰期对整个实例开启一级 profiling只记录超过阈值的慢操作然后按millis降序排查。我见过不少团队凭感觉调索引调了一个月没效果最后开 profiling 才发现拖垮数据库的是几个聚合管道里的大分组查询跟当前索引完全没关系。如果上面这些步骤你按顺序走完一条原本要扫 1800 万文档的报表查询通常能降到几十毫秒并且磁盘 I/O 占用会出现肉眼可见的下降。这个收益不是玄学是“少读文档、少扫磁盘”换来的实在效果。你下次再看到磁盘 I/O 长时间高位先别急着换磁盘或堆机器试着从覆盖查询和索引交集的视角重新审视一遍你的查询模式。
返回列表