复合索引是 MongoDB 性能优化里最常用、也最容易用错的工具。很多人建复合索引的方式是「查询用到哪几个字段,就按想到的顺序建一个」,结果发现索引只用了第一个字段,查询照样慢。问题不在「有没有建索引」,而在字段顺序

复合索引的字段顺序,决定了它能服务哪些查询、能用上几个字段。同样的三个字段 {a, b, c},排成 {a, b, c}{c, b, a} 是两棵完全不同的 B-tree,能加速的查询也完全不同。这一篇讲清楚复合索引字段排序的核心原则——ESR(Equality, Sort, Range),这是 MongoDB 索引设计里最值钱的一条经验。

先把机制边界说清楚

复合索引在底层是一棵 B-tree,它的「键」是多个字段值的拼接。{a:1, b:1} 这棵树,先按 a 排序,a 相同再按 b 排序。这带来一个关键性质:复合索引是前缀有序的

  • 能用 {a:1, b:1} 服务 {a:5}(前缀匹配)。
  • 能用它服务 {a:5, b:3}(完整匹配)。
  • 不能用它单独服务 {b:3}(缺了前导字段 a,b 在树里是无序的)。

这个「前缀有序」性质,是 ESR 原则的物理基础。我们要做的,就是把查询里的字段,按「让索引尽量多用、尽量别中断」的目标排序。

ESR:等值、排序、范围

复合索引的 ESR 原则

ESR 是复合索引字段顺序的黄金法则:Equality(等值)→ Sort(排序)→ Range(范围)。这个顺序不是拍脑袋定的,是 B-tree「前缀有序 + 范围中断」性质推导出来的必然结果。

E · 等值字段放最前

等值条件({status: "paid"}{userId: ObjectId(...)})是最精确的定位。把它放在复合索引最前面,是因为它能把候选集一次砍到最小:树里直接跳到 status="paid" 这个子区间,后续字段只在这个小区间内继续。

等值字段的选择性越高(唯一性越强),砍掉的范围越大。userId 这种近乎唯一的字段放最前,效果远好于 status 这种只有几个枚举值的字段。所以多个等值字段时,选择性高的排前面

S · 排序字段居中

排序条件(.sort({createdAt: -1}))放等值之后、范围之前。原因是:索引本身是有序的,如果排序字段在索引里的顺序和查询要求一致,就能直接用索引的顺序,省掉内存排序

内存排序(explain 里的 SORT 阶段)有两个坏处:一是慢,要把候选文档全部读进内存排;二是有 32MB 上限,候选集太大直接报错 QueryExceededMemoryLimit。让排序走索引(IXSCAN 直接返回有序结果),是避免这类问题的正解。

排序字段必须在范围字段之前,是因为范围字段会「打散」后续字段的有序性。一旦索引用到范围条件,它访问的是树里一段连续区间,区间内后续字段就不再全局有序了,排序就没法靠索引完成。

R · 范围字段放最后

范围条件({amount: {$gt: 100}}{createdAt: {$gte: ...}})放最后,是因为它会中断后续字段的索引使用。范围访问的是树里一段区间,这段区间里后续字段的值是跳跃的,没法再用来做等值定位或排序。

所以范围字段一定要放在等值和排序之后,让前面的字段先把范围砍到最小、排序需求被索引满足,再用范围做最后的过滤。范围字段之后不要再接需要索引支持的字段(接了也用不上)。

一个完整的例子

把 ESR 用到一个真实查询上:

// 查询:已支付、金额大于100、按时间倒序
db.orders.find({
  status: "paid",
  amount: { $gt: 100 }
}).sort({ createdAt: -1 })

按 ESR 分析:

  • E(等值):status
  • S(排序):createdAt
  • R(范围):amount

所以正确的复合索引是 { status: 1, createdAt: -1, amount: 1 }。注意 createdAt 的方向要和查询的 .sort({createdAt: -1}) 一致(都是 -1),否则索引顺序和排序要求不符,还是得内存排序。

如果建错了,比如 { amount: 1, status: 1, createdAt: 1 },把范围字段 amount 放最前:索引只能用 amount 范围扫描一段大区间,statuscreatedAt 的有序性都被打散,排序退回内存 SORT,查询既慢又可能报错。

几个容易踩的边界

排序方向必须和索引一致。 {a:1, b:-1} 的索引,能服务 .sort({a:1, b:-1}),也能服务 .sort({a:-1, b:1})(反向全用),但不能服务 .sort({a:1, b:1})(方向不一致)。复合索引里每个字段的方向都要匹配。

$in 算半个范围。 {status: {$in: ["paid", "shipped"]}} 虽然看起来像等值,但实际是多个等值的并集,行为接近范围,会削弱后续字段的使用。大量 $in 的字段,位置要往后放。

前缀索引能复用。 建了 {a:1, b:1, c:1},它能同时服务 {a}{a,b}{a,b,c} 三种查询的前缀。所以设计复合索引时,让多个查询共享一个前缀,能减少索引总数。

不要为了排序硬加字段。 如果查询的等值条件已经把候选集砍到很小(比如几十条),排序用内存也很快,不必为了省掉 SORT 在索引里塞排序字段。ESR 是优化方向,不是死规矩。

判断框架

把 ESR 收敛成几条可执行的步骤:

  1. 把查询条件分成三类:等值(E)、排序(S)、范围(R)。
  2. 等值字段按选择性从高到低排在前。
  3. 排序字段紧跟等值,方向和 .sort() 一致。
  4. 范围字段放最后。
  5. 多个查询共享前缀,尽量用一个复合索引覆盖多个查询。
  6. 建完后用 explain 验证:看走了几个字段、有没有 SORTtotalDocsExamined 是否接近返回数。

ESR 原则的价值,是让复合索引从「拍脑袋排顺序」变成「按 B-tree 性质推导顺序」。下一篇会讲怎么用 explain 验证索引到底用上了几个字段。


关于十三Tech

我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。

我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。

如果你想继续跟完这套「图解 MongoDB」,欢迎关注公众号 「十三Tech」。后续会按索引优化、存储引擎、高可用和分片集群这条线更新。

十三Tech公众号二维码