复合索引是 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 是复合索引字段顺序的黄金法则: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 范围扫描一段大区间,status 和 createdAt 的有序性都被打散,排序退回内存 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 收敛成几条可执行的步骤:
- 把查询条件分成三类:等值(E)、排序(S)、范围(R)。
- 等值字段按选择性从高到低排在前。
- 排序字段紧跟等值,方向和
.sort()一致。 - 范围字段放最后。
- 多个查询共享前缀,尽量用一个复合索引覆盖多个查询。
- 建完后用
explain验证:看走了几个字段、有没有SORT、totalDocsExamined是否接近返回数。
ESR 原则的价值,是让复合索引从「拍脑袋排顺序」变成「按 B-tree 性质推导顺序」。下一篇会讲怎么用 explain 验证索引到底用上了几个字段。
关于十三Tech
我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。
我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。
如果你想继续跟完这套「图解 MongoDB」,欢迎关注公众号 「十三Tech」。后续会按索引优化、存储引擎、高可用和分片集群这条线更新。

