我还在用原生的聚合管道时踩过一个很大的坑:数据量一上来,查询慢了整整十几倍,后来一查,问题出在两个地方——索引没有覆盖住查询字段,导致 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_id=123在叶子节点中的位置; - 拿到这条索引条目里的 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索引 | 是 | 回表后还要在文档里逐个过滤status |
find({ 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,而不是FETCH;docsExamined为 0;keysExamined等于返回条数(或略多,视筛选逻辑而定);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 天里 status=PAID 且来源是 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")看:
| 指标 | 值 |
|---|---|
| stage | COLLSCAN |
| docsExamined | 1800 万 |
| nReturned | 50 |
| totalDocsExamined | 1800 万 |
| totalKeysExamined | 0 |
全集合扫描,1.8 秒其实已经是缓存全热的状态。冷状态时这个查询能跑上十几秒。
当时集合上只有三个单键索引:user_id、status、created_at,没有channel索引。
4.2 第二步:先试覆盖查询方案,不够再上索引交集
我的优化顺序是:
- 针对
user_id + status + channel的固定组合,直接建复合索引; - 在这个复合索引里,把需要返回的字段和排序字段也塞进去,形成全覆盖;
- 同时保留一套单键索引方案(status + channel 单键),作为其他可变筛选组合的“交集后备军”。
最终执行了两条createIndex:
db.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_id+status+channel,然后用created_at排序,最后amount满足覆盖查询的返回字段需求。
第二个组合{ status: 1, channel: 1, created_at: -1 }是为了单独筛选“全部已支付订单里来自 APP 的最近记录”,这是一个独立高频查询。
第三条channel单键是为了其他条件组合时能跟status索引或user_id索引做交集。
4.3 第三步:验证覆盖查询是否生效
重新跑explain:
db.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 } } }关键指标:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| docsExamined | 1800 万 | 0 |
| keysExamined | 0 | 284 |
| nReturned | 50 | 50 |
| totalDocsExamined | 1800 万 | 0 |
| 执行耗时 | 1800ms | 12ms |
你要注意一个细节:即使查询完全覆盖,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单条件查询和a+b组合查询,但服务不了只有b的查询。所以复合索引设计的黄金准则是:
- 先放等值过滤字段——比如
user_id、status; - 再放排序字段——比如
created_at; - 最后放范围过滤字段——比如
amount、age。
这个顺序(业界叫 ESR:Equality、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 长时间高位,先别急着换磁盘或堆机器,试着从覆盖查询和索引交集的视角重新审视一遍你的查询模式。