MongoDB 的复合索引,真正要定下来的只有三件事:键怎么排、扫完索引要不要回表、以及计划器在生产上会不会真的选它。第一件决定扫多少索引键,第二件决定扫完之后还要拉多少文档,第三件决定前两件的结论能不能一直成立。多数人只记住了第一件,还把它记成了一条铁律——官方文档现在的标题是 The ESR (Equality, Sort, Range) Guideline,是 Guideline 不是 Rule,而且同一页就给了反例:范围谓词足够挑剔时,正确顺序是 ERS。
E 是硬约束,S 和 R 是权衡
文档把这两层分得很清楚。硬的那层:等值字段必须全部排在最前面,理由是把等值字段前置能让索引剩下的字段保持有序。多个等值字段之间的相对顺序对计划器无所谓(它们各自都被压成一个点),但对前缀复用有所谓——{tenantId:1, status:1, createdAt:-1} 能被只查 tenantId 的语句复用,反过来不行。索引前缀是复合索引唯一的免费午餐,所以等值字段内部排序时,按「最常被单独查询的放最左」比按选择性排更实用。
软的那层是 S 和 R 谁在前。文档原话是:避免内存排序最关键时,把 sort 字段放在 range 字段前面(ESR);如果范围谓词本身非常挑剔,就把它放到 sort 前面(ERS)。这不是两个并列的可选项,它们对应两种不同的代价:ESR 用「多扫一些索引键」换掉一个 blocking sort;ERS 用一个 blocking sort 换掉大量索引键扫描。判断依据只有一个——范围谓词过滤之后还剩多少行,以及这些行是不是全都要排。带 limit 的分页查询几乎总是 ESR(索引有序,扫够 N 条就能停),做全量聚合前置过滤的查询更可能是 ERS。
还有一条常被漏掉:文档把范围谓词列为 $gte、$lt、$ne、$nin、$regex。$ne 和 $nin 是范围谓词,把它们当等值放到索引最左边,得到的是一个几乎扫全索引的 IXSCAN。
举个具体的。一个订单列表接口,按租户和状态过滤,限定时间窗口,按金额倒序取前 50 条:
db.orders.find({
tenantId: "t_9021", // equality
status: "shipped", // equality
createdAt: { $gte: from, $lt: to } // range
}).sort({ amount: -1 }).limit(50)
ESR 版是 {tenantId:1, status:1, amount:-1, createdAt:1}:两个等值键把扫描定位到一段连续区间,amount 在区间内天然有序,扫到第 50 条落在时间窗口内的就能停,没有 SORT 阶段。ERS 版是 {tenantId:1, status:1, createdAt:1, amount:-1}:时间窗口先把候选压到最小,但 amount 的顺序被打散了,必须把候选全读出来做内存排序。
哪个赢取决于时间窗口有多挑剔。窗口是「最近 24 小时」而该租户三个月内有几十万单时,ERS 明显更好;窗口是「最近 90 天」几乎等于不过滤时,ESR 的提前终止是压倒性的。这道题没有静态答案,只能拿真实数据分布跑一遍 explain。(这类「参数只能拿自己的数据量出来」的取舍并不是 MongoDB 独有的,向量索引上的同款问题见HNSW 调参与量化取舍。)两个索引都建出来、用 hint() 各测一次的成本,远低于猜错之后在线上排查的成本。
$in 算等值还是范围,取决于它有几个元素
这是 ESR 那一页里最值得单独拎出来的规则:$in 元素少于 201 个时按等值处理——计划器把它「炸开」成多段索引扫描,每段各自有序,因此仍然能用索引提供排序;达到 201 个及以上时按范围处理,得放到 range 字段该在的位置。
对应的服务端参数是 internalQueryMaxScansToExplode,默认 200。explain 的 queryPlanner 里有个 maxScansToExplodeReached 布尔字段,直接告诉你有没有撞线。
生产上的表现通常长这样:灰度时 $in 里放 20 个 id,走 explode,索引提供排序,p99 很稳;放量后上游把批大小提到 300,同一条查询突然多出一个 SORT 阶段,延迟跳一个数量级。索引没变、查询形状没变、只有数组长度变了。如果 $in 的长度由调用方决定,就在应用层切成 200 以下的批次,比去调服务端参数靠谱得多。
排序能不能走索引:四条规则
比「加个索引就行」严格得多。官方规则是:
- 方向必须整体一致或整体相反。索引
{a:1, b:-1}支持sort({a:1,b:-1})和sort({a:-1,b:1}),不支持sort({a:1,b:1})。 - 索引前缀可以提供排序。
{a:1,b:1,c:1,d:1}支持sort({a:1,b:1})。 - 非前缀子集要靠等值补齐:只有当查询对排序键之前的所有索引键都给了等值条件时,索引才能提供该排序。
find({a:5}).sort({b:1,c:1})可以,find({b:3,a:4}).sort({c:1})也可以。 - 反例记住这一个就够:
find({a:{$gt:2}}).sort({c:1})不能用索引排序——a是范围不是等值,b又没条件,链在这里断掉。
多键(multikey)索引另有一条:按数组字段排序时必然出现内存排序阶段,除非所有排序字段的索引边界都是 [MinKey, MaxKey],且没有任何多键字段的边界与排序模式共享路径前缀。工程上的结论就一句:别指望对数组字段的排序能走索引。顺带记住复合多键索引的硬限制——同一份文档在一个复合索引里最多只能有一个字段是数组,两个都是数组的文档会直接插入失败。
覆盖查询:三个条件,破一个就回表
三个条件是「且」的关系:查询用到的所有字段在同一个索引里、返回的所有字段也在这个索引里、且没有任何字段的谓词等于 null({field: null} 和 {field: {$eq: null}} 都会破坏覆盖)。
真正会咬人的是这几条:
_id通常不在索引里,所以只要它不是该索引的一部分,投影里就必须显式写_id: 0。往投影里加个字段却忘了加进索引,覆盖就没了,而且不会报任何错——只是totalDocsExamined从 0 变成了nReturned。- 多键索引可以覆盖查询,前提是投影里不返回那个数组字段,且查询不含
$elemMatch。因此能覆盖的多键索引一定是复合索引。 - 分片集合上经
mongos查询时,索引必须包含 shard key 才可能覆盖——SHARDING_FILTER阶段要靠 shard key 剔除孤儿文档。 - 不是所有索引类型都能覆盖,地理空间索引就不能。
explain 里的证据是 PROJECTION_COVERED 阶段配上 totalDocsExamined: 0。把这两条写进回归断言,比写进 wiki 有用。
explain 怎么读
先看三个数:executionStats 里的 nReturned、totalKeysExamined、totalDocsExamined。官方给的理想形态是三者相等。偏离方式决定病因:
| 形态 | 含义 | 处理 |
|---|---|---|
| keys ≈ docs ≈ returned | 索引精确命中 | 别动 |
| keys ≈ docs ≫ returned | 索引把你带到大致位置,过滤发生在 FETCH 之后——谓词里有字段不在索引里 |
把该字段补进索引 |
| keys ≫ docs | 索引边界太松,或多键膨胀 | 查键顺序和 indexBounds |
docs = 0 且有 PROJECTION_COVERED |
覆盖查询 | 保住它 |
COLLSCAN 且 docs 很大 |
没有可用索引 | 建索引 |
再看两个形状。第一是有没有 SORT 阶段——它就是 blocking sort,默认内存上限 100 MB(internalQueryMaxBlockingSortMemoryUsageBytes,即 104857600 字节),超了会抛 Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting。第二是 IXSCAN 的 indexBounds:某个键的边界如果是 ["MinKey", "MaxKey"],说明这个键对收窄扫描范围毫无贡献,它只是为了排序或覆盖而存在。这是判断 ESR 顺序对不对最直接的证据——顺序摆对时,sort 键的边界通常正是全域,而 range 键的边界是收紧的。
版本相关的字段变化值得记一下:MongoDB 8.0 起 queryHash 被复制到新字段 planCacheShapeHash 并标记为废弃(8.0 里两个字段并存,未来版本会移除 queryHash),另有独立的 queryShapeHash,并新增了 EXPRESS_IXSCAN 这类 EXPRESS 阶段;8.2 起所有会溢写磁盘的阶段统一上报 spills、spilledBytes、spilledRecords、spilledDataStorageSize;8.3 起 executionStats 多了 peakTrackedMemBytes。排查排序和聚合的内存问题时,这几个字段比 executionTimeMillis 有信息量得多。
最后一个反直觉、也最容易把人带偏的点:explain() 会忽略所有已有的计划缓存条目,也不会创建新的缓存条目。也就是说 explain 告诉你的赢家是「现在重新规划一次会选谁」,未必是线上此刻正在跑的计划。想看后者用 $planCacheStats(必须是管道第一个阶段,和 $vectorSearch、$search 在$rankFusion 混合检索管道里的位置约束是同一类限制),关注 isActive、works、planCacheKey;它默认只返回单个节点的数据,排查时记得每个节点都跑一遍。
8.3 起换了计划选择机制
MongoDB 8.3 起,「多计划 + 成本排序器(CBR)兜底」成为符合条件查询的默认计划选择机制。经典多计划器仍然先跑试用期;只有它没在试用期内选出合适的计划时,服务端才按规则决定是继续多计划还是交给 CBR。文档同时明确 CBR 目前只对一小部分查询生效,所以别指望它替你把索引设计做完。
从 8.3.3 起,explain 输出里会出现 CBR 的估算字段:costEstimate、cardinalityEstimate、numKeysEstimate、numDocsEstimate,以及 estimatesMetadata.ceSource(取值为 sampling、heuristics、mixed、metadata、code)。ceSource 是其中最有用的一个:显示 heuristics 就说明它在拍脑袋,估出来的基数不必太当真。
顺带把版本关系说清:8.0 是当前主版本(2024 年 10 月 GA),8.2 和 8.3 都是 minor release,8.3 发布于 2026 年 5 月。文档明确 minor release「可能不支持某些功能,包括 Atlas Live Migration 或 mongosync」,需要这些能力就留在主版本上。
什么时候索引反而更慢
- 低选择性前导键。
{status:1, createdAt:-1}里 status 只有三个取值,等于把索引切成三段,每段还是准全表规模。前导键区分度不够时,复合索引剩下的收益只有排序。 - 候选索引太多。每个新的计划缓存形状都要跑一次多计划试用期,候选索引越多、试用越贵。MongoDB 8.2 的性能改进条目里直接写着「降低了查询多计划的开销」——这说明它是真实成本,不是理论担忧。
- 写放大与内存竞争。每个索引都要在写入时维护、都要占内存和磁盘。单集合上限 64 个索引,单个复合索引上限 32 个字段;
createIndexes默认内存预算 200 MB(maxIndexBuildMemoryUsageMegabytes)由该命令里的所有索引平分,一次建 10 个就是每个 20 MB,超出部分溢写到_tmp。 - 隐藏索引不省写入。
hidden: true(需要 FCV 6.0 及以上)只是让计划器看不见它;文档明说隐藏索引在写操作时仍会被更新、继续占用磁盘和内存,唯一索引仍然约束、TTL 索引仍然过期。它是「删之前先验证」的工具,不是省成本的工具。另外不能隐藏_id索引,也不能对隐藏索引用hint()。 - 指望两个单字段索引拼出复合索引的效果。官方索引策略页写得很直白:大多数查询只会用一个索引,只有
$or的各个子句可以各用一个索引。
清理索引时用 $indexStats,但要知道它的三个坑:accesses.ops 只统计当前节点上的用户操作,不含 TTL 删除、chunk 迁移这类内部操作,所以要在每个节点上分别跑(逐节点各查一遍这个习惯,和 Change Streams 断点续传排查是同一套);统计值会在 mongod 重启、索引删除重建、collMod 修改索引后清零。看到 ops: 0 别急着删——先确认 accesses.since 覆盖了完整业务周期(含月度报表这类低频路径),再 hide 一到两周,最后才 drop。
一份可执行的顺序
- 从慢查询日志取真实的查询形状,不要从代码里猜。
- 等值字段全部前置;其中最常被单独查询的放最左。
- 带
limit的分页走 ESR;范围谓词极挑剔且要全量排序的走 ERS。两个索引都建出来,用hint()各跑一次 explain,比三个数。 - 检查
$in的长度会不会越过 201。 - 能覆盖就覆盖,并在测试里断言
totalDocsExamined: 0。 - 下线旧索引前先
hide观察,别直接 drop。 - 需要长期固定某个索引选择时,用 query settings(
setQuerySettings,8.0 引入)而不是已废弃的 index filters——前者跨重启持久化、对全集群生效,还能用reject: true直接拦掉误上线的查询形状。