复合索引与索引策略
上一章讲了索引有哪些种类,这一章讲怎么设计。核心只有一条规则——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 的价值。
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 覆盖查询的三个条件
- 查询条件涉及的字段全在索引里
- 投影返回的字段全在索引里
- 必须显式
_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。
| 字段 | 基数 | 选择性 | 索引价值 |
|---|---|---|---|
_id | 1000 万 | 1/1000万 | 极高 |
email | 1000 万 | 1/1000万 | 极高 |
authorId | 50 万 | 1/50万 | 高 |
city | 300 | 1/300 | 中 |
status | 3 | 1/3 | 低 |
isDeleted | 2 | 1/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
| 查询 | E | S | R | 索引 |
|---|---|---|---|---|
| Q1 | status | createdAt | — | { status: 1, createdAt: -1 } |
| Q2 | status, authorId | createdAt | — | { status: 1, authorId: 1, createdAt: -1 } |
| Q3 | status, tags | createdAt | — | { status: 1, tags: 1, createdAt: -1 } |
| Q4 | status | stats.likes | — | { status: 1, "stats.likes": -1 } |
| Q5 | slug | — | — | { 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% 的键,且不用为草稿维护索引。
{ 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 的分析能力核心 →