图解 MongoDB 08|ESR 原则:复合索引的字段顺序怎么定

复合索引是 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 阶段)有两个坏处:一是慢,要把候选文档全部读进内存排;二是有内存上限(find().sort() 默认 32MB,聚合 $sort 默认 100MB),候选集太大直接报错 QueryExceededMemoryLimitNoDiskUseAllowed。让排序走索引(IXSCAN 直接返回有序结果),是避免这类问题的正解。

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

R · 范围字段放最后

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

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

一个完整的例子

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

javascript 复制代码
// 查询:已支付、金额大于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。

公众号搜索「十三Tech」

本文首发:rubyfun.cn/posts/%E5%9...

相关推荐
葫芦和十三9 小时前
图解 MongoDB 07|索引类型:七种索引,七种访问形状
后端·mongodb·agent
朦胧之10 小时前
AI 编程-老项目改造篇
java·前端·后端
爱勇宝13 小时前
我做了一个只用来搜歌词的小 App
android·前端·后端
艾逗笔14 小时前
从 OpenClaw 到 FastClaw:如何设计优秀的多 Agent 架构
agent
IT_陈寒14 小时前
SpringBoot自动配置坑了我一晚上,原来问题出在这
前端·人工智能·后端
SelectDB15 小时前
Litefuse 开源并推出单进程轻量模式,25 秒就能跑起来的 Agent 可观测与评估平台
运维·后端·自动化运维
SelectDB15 小时前
秒级弹性、最高降本 70%:SelectDB Serverless 如何重塑云数仓资源效率
大数据·后端·云原生
PinkSun15 小时前
Spring AI ChatMemory踩坑实录:重启丢数据、Agent丢记忆、对话溢出
后端·ai编程
壹方秘境15 小时前
我用Go语言开发了一个跨平台的HTTPS抓包和调试工具
前端·后端·ios