Learn
MongoDB/09-index-strategy

复合索引与索引策略

上一章讲了索引有哪些种类,这一章讲怎么设计。核心只有一条规则——ESR——但要真正用好它,需要理解 B 树上到底发生了什么。

1. 复合索引的本质:多列排序

db.posts.createIndex({ status: 1, authorId: 1, createdAt: -1 })

这个索引在物理上是一张按三列排序的有序表:

status      authorId    createdAt      → 文档地址
─────────────────────────────────────────────────
draft       alice       2024-06-04     → doc 91
draft       alice       2024-05-20     → doc 88
draft       bob         2024-06-01     → doc 77
published   alice       2024-06-03     → doc 12
published   alice       2024-06-01     → doc 09
published   alice       2024-05-28     → doc 05
published   bob         2024-06-02     → doc 33
published   carol       2024-05-30     → doc 41

理解这张表,索引的所有行为都能推导出来:

  • 查 status = "published":能定位到连续区间 ✔
  • 查 status = "published" AND authorId = "alice":区间更窄 ✔
  • 查 authorId = "alice"(不带 status):alice 的行分散在两处,无法定位连续区间 ✘

2. 索引前缀规则

复合索引 { a: 1, b: 1, c: 1 } 能服务的查询是它的前缀组合:

查询条件能否用索引说明
a✔前缀
a, b✔前缀
a, b, c✔完整
a, c部分用 a 定位,c 只能在结果里过滤
b✘不是前缀
b, c✘不是前缀

这条规则的直接推论是:{ a: 1, b: 1 } 存在时,不需要再建 { a: 1 }。前者已经完全覆盖后者的能力。

// 冗余索引,应该删掉 authorId_1
db.posts.getIndexes()
[
  { "key": { "_id": 1 }, "name": "_id_" },
  { "key": { "authorId": 1 }, "name": "authorId_1" },
  { "key": { "authorId": 1, "createdAt": -1 }, "name": "authorId_1_createdAt_-1" }
]
💡定期清理冗余索引

用 db.collection.aggregate([ { $indexStats: {} } ]) 查看每个索引的使用次数。累计使用次数为 0 且已经上线一段时间的索引,基本可以删。前缀被其他索引覆盖的索引,也可以删。

3. ESR 规则

设计复合索引时,字段顺序按 Equality → Sort → Range 排列。

3.1 为什么是这个顺序

看这个查询:

db.posts.find({ status: "published", views: { $gt: 100 } }).sort({ createdAt: -1 })

三个部分:等值条件 status、范围条件 views、排序 createdAt。

错误顺序(ERS):{ status: 1, views: 1, createdAt: -1 }

status=published, views>100 定位到的索引区间:
 
  published  101  2024-05-01   ← createdAt 在这个区间里是乱的!
  published  150  2024-06-04
  published  180  2024-03-11
  published  200  2024-06-01
  published  340  2024-04-22
 
  因为 views 是范围,每个不同的 views 值下 createdAt 各自有序,
  合起来就是无序 → 必须做内存排序(SORT 阶段)

正确顺序(ESR):{ status: 1, createdAt: -1, views: 1 }

status=published 定位到的索引区间:
 
  published  2024-06-04  150
  published  2024-06-03  90     ← 不满足 views>100,跳过
  published  2024-06-02  340
  published  2024-06-01  200
  published  2024-05-30  50     ← 跳过
 
  createdAt 天然有序 → 无需排序,边扫边过滤,扫到 limit 条就停

3.2 三部分的角色

位置类型作用
E(前)等值条件把索引锁定到一个窄区间
S(中)排序字段保证区间内天然有序,免掉 SORT 阶段
R(后)范围条件只能边扫边过滤,放最后损失最小

3.3 用 explain 验证

db.posts.find({ status: "published", views: { $gt: 100 } })
  .sort({ createdAt: -1 })
  .limit(20)
  .explain("executionStats")

ERS 顺序的结果:

{
  "executionStats": {
    "nReturned": 20,
    "totalKeysExamined": 42000,
    "totalDocsExamined": 42000,
    "executionTimeMillis": 310,
    "executionStages": {
      "stage": "SORT",
      "memUsage": 18400000,
      "inputStage": { "stage": "FETCH", "inputStage": { "stage": "IXSCAN" } }
    }
  }
}

ESR 顺序的结果:

{
  "executionStats": {
    "nReturned": 20,
    "totalKeysExamined": 63,
    "totalDocsExamined": 20,
    "executionTimeMillis": 1,
    "executionStages": {
      "stage": "LIMIT",
      "inputStage": { "stage": "FETCH", "inputStage": { "stage": "IXSCAN" } }
    }
  }
}

两个信号:执行计划里没有 SORT 阶段,且 totalKeysExamined 从 4.2 万降到 63。这就是 ESR 的价值。

⚠️看到 SORT 阶段就意味着有优化空间

explain 输出里出现 "stage": "SORT",说明 MongoDB 在内存里排序。数据量小时无所谓,量一大就会撞上 32MB 限制或者拖慢响应。调整索引顺序把 SORT 消掉,通常是收益最大的单点优化。

3.4 排序方向的匹配

索引可以正向或反向整体扫描,所以排序方向要么完全一致,要么完全相反:

db.posts.createIndex({ authorId: 1, createdAt: -1 })
 
.sort({ authorId: 1,  createdAt: -1 })   // ✔ 正向扫描
.sort({ authorId: -1, createdAt: 1  })   // ✔ 反向扫描
.sort({ authorId: 1,  createdAt: 1  })   // ✘ 方向混合,需要内存排序

单字段索引不受此影响({ a: 1 } 能服务 sort({ a: -1 })),因为反向扫描即可。

4. 覆盖查询

如果查询只需要索引里已有的字段,MongoDB 可以完全不读文档:

db.posts.createIndex({ authorId: 1, createdAt: -1, title: 1 })
 
db.posts.find(
  { authorId: "alice" },
  { title: 1, createdAt: 1, _id: 0 }        // 注意必须排除 _id
).sort({ createdAt: -1 })
{
  "executionStats": {
    "nReturned": 12,
    "totalKeysExamined": 12,
    "totalDocsExamined": 0,
    "executionStages": {
      "stage": "PROJECTION_COVERED",
      "inputStage": { "stage": "IXSCAN" }
    }
  }
}

totalDocsExamined: 0 和 PROJECTION_COVERED 就是覆盖查询的标志。省掉的是随机磁盘 IO,在大集合上收益可以达到一个数量级。

4.1 覆盖查询的三个条件

  1. 查询条件涉及的字段全在索引里
  2. 投影返回的字段全在索引里
  3. 必须显式 _id: 0(除非 _id 本身在索引里)

4.2 什么时候不适用

  • 数组字段(多键索引)无法做覆盖查询
  • 内嵌文档整体返回(比如投影 profile: 1 而索引里是 profile.city)不行
  • 分片集群中,如果索引不含分片键,mongos 需要 fetch 文档来做过滤
💡给高频列表接口做覆盖索引

「文章列表页」这类接口通常只需要标题、作者、时间、封面几个字段。把它们全部塞进索引,接口延迟能从几十毫秒降到几毫秒。代价是索引变大,需要权衡:字段总长度超过一两百字节就不太划算了。

5. 选择性与基数

选择性 = 满足条件的文档数 / 总文档数。这个值越小,索引越有效。

// 计算某字段的基数(不同值的数量)
db.posts.aggregate([
  { $group: { _id: "$status", count: { $sum: 1 } } },
  { $sort: { count: -1 } }
])
[
  { "_id": "published", "count": 9200000 },
  { "_id": "draft", "count": 750000 },
  { "_id": "deleted", "count": 50000 }
]

status 只有 3 个值,published 占 92%——对它建单字段索引几乎没用,因为查 published 要扫 920 万条索引项再回表 920 万次,还不如直接 COLLSCAN。

字段基数选择性索引价值
_id1000 万1/1000万极高
email1000 万1/1000万极高
authorId50 万1/50万高
city3001/300中
status31/3低
isDeleted21/2几乎无

5.1 低选择性字段的正确用法

低基数字段不适合单独建索引,但适合放在复合索引的最前面做等值条件(这就是 ESR 里的 E):

// 单独建没用
db.posts.createIndex({ status: 1 })                       // ✘
 
// 作为复合索引前缀很有用:先锁定 published,再按时间有序
db.posts.createIndex({ status: 1, createdAt: -1 })        // ✔
 
// 或者直接用部分索引,连 status 字段都不用存进索引
db.posts.createIndex(
  { createdAt: -1 },
  { partialFilterExpression: { status: "published" } }     // ✔✔ 最省
)
⚠️布尔字段单独建索引几乎总是浪费

isDeleted、isActive 这类字段单独建索引,扫描量是全表的一半,优化器多半会放弃它转而做 COLLSCAN。正确做法是把它做成部分索引的过滤条件。

6. 索引交集

MongoDB 可以同时用两个索引,各取一部分结果做交集:

db.posts.createIndex({ authorId: 1 })
db.posts.createIndex({ tags: 1 })
 
db.posts.find({ authorId: "alice", tags: "mongodb" }).explain()
{
  "queryPlanner": {
    "winningPlan": {
      "stage": "FETCH",
      "inputStage": {
        "stage": "AND_SORTED",
        "inputStages": [
          { "stage": "IXSCAN", "indexName": "authorId_1" },
          { "stage": "IXSCAN", "indexName": "tags_1" }
        ]
      }
    }
  }
}

看起来很美,但实际上优化器很少选择索引交集,因为它要扫两个索引、做去重合并,通常不如一个设计良好的复合索引。

索引交集:
  扫 authorId_1 得到 5000 个 _id
  扫 tags_1     得到 8000 个 _id
  求交集                → 12 个
  回表读 12 个文档
  总扫描:13000 个索引项
 
复合索引 { authorId: 1, tags: 1 }:
  直接定位到 12 个索引项
  回表读 12 个文档
  总扫描:12 个索引项

差了三个数量级。不要指望索引交集,该建复合索引就建。

7. 一个完整的索引设计流程

以内容社区的帖子列表为例,假设有这些高频查询:

// Q1 首页信息流
db.posts.find({ status: "published" }).sort({ createdAt: -1 }).limit(20)
 
// Q2 某作者的帖子
db.posts.find({ status: "published", authorId: "alice" }).sort({ createdAt: -1 })
 
// Q3 按标签浏览
db.posts.find({ status: "published", tags: "mongodb" }).sort({ createdAt: -1 })
 
// Q4 热门帖子
db.posts.find({ status: "published", "stats.likes": { $gte: 100 } }).sort({ "stats.likes": -1 })
 
// Q5 根据 slug 查详情
db.posts.findOne({ slug: "why-mongodb" })

7.1 逐个套 ESR

查询ESR索引
Q1statuscreatedAt—{ status: 1, createdAt: -1 }
Q2status, authorIdcreatedAt—{ status: 1, authorId: 1, createdAt: -1 }
Q3status, tagscreatedAt—{ status: 1, tags: 1, createdAt: -1 }
Q4statusstats.likes—{ status: 1, "stats.likes": -1 }
Q5slug——{ slug: 1 } unique

7.2 合并与精简

Q1 的索引是 Q2 的前缀,可以删掉。最终方案:

db.posts.createIndex({ status: 1, authorId: 1, createdAt: -1 })
db.posts.createIndex({ status: 1, tags: 1, createdAt: -1 })
db.posts.createIndex({ status: 1, "stats.likes": -1 })
db.posts.createIndex({ slug: 1 }, { unique: true })

进一步优化:既然所有查询都带 status: "published",可以把它从索引键里拿出来变成部分索引条件:

const pub = { partialFilterExpression: { status: "published" } }
db.posts.createIndex({ authorId: 1, createdAt: -1 }, pub)
db.posts.createIndex({ tags: 1, createdAt: -1 }, pub)
db.posts.createIndex({ "stats.likes": -1 }, pub)
db.posts.createIndex({ slug: 1 }, { unique: true })

索引体积能省下 8% 到 10% 的键,且不用为草稿维护索引。

⚠️Q1 现在没索引了吗

{ authorId: 1, createdAt: -1 } 的前缀是 authorId,不能服务只按时间排序的 Q1。如果 Q1 是首页最高频的查询,需要单独保留 { createdAt: -1 } 的部分索引。不要为了减少索引数量而牺牲最高频的查询——索引设计永远是权衡,不是求最少。

8. 什么时候不该建索引

情形原因
集合很小(几千条以内)全表扫描已在内存里,索引反而增加维护成本
字段选择性极低扫描量接近全表,优化器不会用
写多读极少的集合写放大的代价超过查询收益
只在后台脚本里用一次的查询临时慢一点没关系,或者用 hint 临时建了再删
已被现有索引的前缀覆盖纯冗余

一个经验数字:单个集合的索引数量控制在 5 到 8 个以内。超过 10 个通常说明查询模式太散,应该反思建模或者拆集合。

🎯练习

一、给 posts 造 100 万条数据(status 三种值,authorId 随机 1000 个,createdAt 随机分布,views 随机);二、先建 { status: 1, views: 1, createdAt: -1 },跑本章 3.1 的查询并记录 totalKeysExamined 与是否有 SORT 阶段;三、改成 ESR 顺序 { status: 1, createdAt: -1, views: 1 },重跑对比;四、设计一个覆盖索引让「列表页只返回 title 和 createdAt」的查询达到 totalDocsExamined: 0;五、用 $indexStats 找出使用次数为 0 的索引;六、把 status 从索引键改成部分索引条件,用 db.posts.stats().indexSizes 对比索引体积变化。

小结

  • 复合索引本质是一张按多列排序的有序表,索引行为都能从这张表推导
  • 索引前缀规则:{ a, b, c } 服务 a、a b、a b c,不服务 b 或 b c
  • ESR:等值在前锁定区间,排序在中免掉 SORT,范围在后边扫边过滤
  • explain 里出现 SORT 阶段,就是调整索引顺序的信号
  • 覆盖查询要求条件、投影字段全在索引里且排除 _id,收益是省掉回表
  • 低基数字段不要单独建索引,把它当复合索引前缀或部分索引条件
  • 索引交集实际很少被选中,该建复合索引就建
  • 单集合索引数控制在 5 到 8 个,定期用 $indexStats 清理
  • 下一章进入聚合管道,MongoDB 的分析能力核心 →