索引
这一课你将能做到
- 创建单字段、复合与唯一索引,并用
getIndexes() 核对、用 dropIndex() 清理
- 用
explain("executionStats") 区分 COLLSCAN 与 IXSCAN,读懂 totalDocsExamined 等关键指标
- 按 ESR(Equality → Sort → Range)原则为典型查询设计复合索引字段顺序,并解释最左前缀规则
为什么要学
没有索引的过滤在数据变大后会退化成全集合扫描:CPU、磁盘 IO、接口延迟一起崩。索引是数据库面试的顶流考点——MySQL 考 B+ 树最左前缀,MongoDB 考 ESR 与多键索引,底层思想相通。
MongoDB 索引在概念上贴近 MySQL(都是 B 树家族、都有复合索引前缀规则),但多出了多键索引(直接索引数组元素)——这是文档模型的独有优势。本课的目标:会建、会 explain、会按 ESR 排字段顺序。
人话:索引 = 额外维护的一本「按字段排好序的目录」。用一点写入开销与磁盘空间,换查询少翻全表。
先建立图像
查询:{ userId: "u_1001", at: { $gte: … } } sort: { at: -1 }
ESR 复合索引设计:
E(Equality) userId ← 等值字段放最前
S(Sort) at: -1 ← 排序字段其次
R(Range) (本例 at 兼任范围)
→ { userId: 1, at: -1 }
explain 关键字段速查:
| 字段 | 含义 |
|---|
stage: COLLSCAN | 全集合扫描,大集合上的危险信号 |
stage: IXSCAN | 走了索引 |
totalDocsExamined | 实际检查的文档数,越小越好 |
totalKeysExamined | 扫过的索引键数 |
nReturned | 返回条数(理想:≈ totalDocsExamined) |
executionTimeMillis | 耗时参考 |
跟着做
步骤 1:准备可观测的数据量
use coderoadmap_lab
db.lesson_logs.deleteMany({})
// 批量造 200 条:5 门课 × 10 个用户 × 2 种行为 × 28 天
for (let i = 0; i < 200; i++) {
db.lesson_logs.insertOne({
lessonSlug: i % 5 === 0 ? "mongo-indexes" : "mongo-crud",
userId: "u_" + (i % 10),
action: i % 2 === 0 ? "view" : "complete",
at: new Date(Date.UTC(2026, 0, 1 + (i % 28)))
})
}
db.lesson_logs.countDocuments() // 200
步骤 2:无索引时 explain,记下基线
db.lesson_logs.find({ userId: "u_1" })
.sort({ at: -1 })
.limit(20)
.explain("executionStats")
在输出里找 winningPlan.stage 与 executionStats:此时应是 COLLSCAN,totalDocsExamined ≈ 200。把两个数抄进笔记——这是「加索引前」基线。
步骤 3:建索引后再 explain
// 单字段
db.lesson_logs.createIndex({ lessonSlug: 1 })
// 复合:等值在前 + 时间排序(ESR)
db.lesson_logs.createIndex({ userId: 1, at: -1 })
// 唯一索引(模拟本站 cf_track.slug 的唯一约束习惯)
db.tracks.createIndex({ slug: 1 }, { unique: true })
db.lesson_logs.getIndexes() // 应看到 _id_、lessonSlug_1、userId_1_at_-1
重跑步骤 2 的 explain:应变为 IXSCAN,totalDocsExamined 掉到 20 量级(接近 limit)。对照笔记里的基线,感受差距。
步骤 4:故意建一个「字段顺序错误」的索引对比
db.lesson_logs.createIndex(
{ at: -1, userId: 1 }, // 时间在前——与 ESR 相反
{ name: "bad_order_demo" }
)
db.lesson_logs.find(
{ userId: "u_1", at: { $gte: ISODate("2026-01-01") } }
).sort({ at: -1 }).explain("executionStats")
对照两次 explain:bad_order_demo 这条因为首字段不是等值字段,效率明显差。结论写进笔记:有索引 ≠ 索引用对了。
步骤 5:验证最左前缀规则
// 复合索引 { userId: 1, at: -1 },只按第二个字段查:
db.lesson_logs.find({ at: { $gte: ISODate("2026-01-01") } })
.explain("executionStats")
// winningPlan 里看不到用 userId_1_at_-1 —— 缺少左前缀 userId,用不上
db.lesson_logs.dropIndex("bad_order_demo")
保留 { userId: 1, at: -1 } 与 lessonSlug_1 供综合练习使用。
步骤 6:体会多键索引(数组字段的独门能力)
db.tracks.createIndex({ tags: 1 }) // 数组字段也能建——多键索引
db.tracks.find({ tags: "java" }).explain("executionStats")
// stage 变成 IXSCAN;MySQL 对 JSON 数组可没有这种一等公民待遇
讲清楚要点
- 索引加速读、拖慢写、占空间:每次 insert/update 都要同步维护所有索引——「每列都建」是反模式,只为真实查询与排序建。
- 复合索引有最左前缀规则:
{ userId: 1, at: -1 } 能服务「userId 等值」或「userId + at」的查询,但只按 at 查用不上它。
- ESR 是字段顺序黄金法则:等值(Equality)→ 排序(Sort)→ 范围(Range)。记住
{ userId: 1, at: -1 } 这个模板,面试直接用。
unique: true 让数据库替你挡重复:配合业务唯一键(slug、手机号),比应用层先查后插更可靠(无并发窗口)。
- 多键索引是数组字段标配:
tags 这类数组自动升级为多键索引,每个元素一个索引项——数组越长索引越大,别给超长数组建。
- explain 是唯一证据:优化前后都要留
executionStats,凭「感觉变快了」不算数。
- 覆盖查询是进阶奖励:查询与投影的字段全在索引里时可免回表(
totalDocsExamined: 0);先保证 IXSCAN 再追求覆盖。
你可能会懵的地方
-
索引定义里的 1 / -1 是什么?
索引内该字段的排序方向。单字段查询基本无感;复合索引与查询 sort 方向一致时才能免内存排序(所有方向全部相反也等价)。
-
为什么建了索引还是 COLLSCAN?
常见四因:查询形状不匹配(缺左前缀、前导通配正则)、对字段做了函数/类型转换、集合太小优化器认为全扫更便宜、投影字段迫使回表反而更贵。用 explain 逐项排除。
-
索引建得越多越好吗?
不是。写路径要维护每条索引,过多索引显著拖慢 insert/update,还占内存与磁盘。定期用 $indexStats 看使用率,清理没用的。
-
TTL 索引是什么?
createIndex({ at: 1 }, { expireAfterSeconds: 2592000 }) 让 30 天前的日志文档自动过期删除——做题日志类数据的清理利器,面试可作加分项。
-
和 MySQL B+ 树什么关系?
WiredTiger 的索引底层就是 B+ 树变体,前缀规则与 MySQL 复合索引一致;差异在多键索引、部分索引(partialFilterExpression)等文档库特有玩法。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 不 explain,凭感觉说慢 | 无法定位、无法验证 | explain("executionStats") 拿证据 |
| 复合索引字段顺序随意 | 用不上或低效 | 按 ESR 排:等值在前 |
| 给低区分度字段单独建索引 | 选择性差收益低 | 并入复合索引 |
| 生产高峰在线对大集合建索引 | 资源压力、主从延迟 | 低峰窗口;4.4+ 默认混合构建已缓解 |
| 唯一索引遇到字段缺失/null | 意外撞唯一键 | 了解 null 与缺失的唯一性语义后再上线 |
| 无索引就上生产接口 | 数据量一上来全 COLLSCAN | 上线前 explain 走查核心查询 |
卡点急救
- 现象:explain 里
totalDocsExamined 依然很大
查询形状与索引不匹配。核对:等值字段是否都在索引里且顺序正确、正则是否前缀锚定、类型是否一致(字符串 vs 数字)。
- 现象:加了唯一索引,老数据里已有重复导致创建失败
先用聚合找出重复值清理,再建索引;或建部分索引
partialFilterExpression 只约束新数据(需评估业务语义)。
- 现象:内存告警与索引相关
检查索引总量与单条大小,删除
$indexStats 中长期零使用的索引;大集合建索引选低峰期并监控复制延迟。
面试常问
-
MongoDB 索引的底层结构是什么?
B+ 树(WiredTiger 实现):非叶节点只放键做路由,叶子节点存键与值指针,天然支持等值查找与范围扫描。复合索引按定义顺序多键排序,遵守最左前缀规则——这一点与 MySQL 一致。
-
什么是 ESR 原则?举例说明。
复合索引字段顺序:Equality 等值条件放最前,Sort 排序其次,Range 范围最后。例:find({ userId }).sort({ at: -1 }) 应建 { userId: 1, at: -1 }——先定位用户(等值收窄),索引内 at 本身有序可免内存排序。若反过来把 at 放前面,等值字段用不上索引前缀,性能骤降。
-
索引有哪些类型?各自场景?
单字段、复合、唯一(业务键防重)、多键(数组字段)、TTL(自动过期,日志/会话)、文本(搜索)、部分索引(只索引满足条件的文档)、稀疏。重点讲多键与 TTL——这是 Mongo 相对 MySQL 的特色题。
-
如何确认一条查询走没走索引?
explain("executionStats"):看 winningPlan 里是否 IXSCAN(而非 COLLSCAN),对比 totalKeysExamined、totalDocsExamined 与 nReturned 三者是否接近,理想情况接近说明选择性好。
自测三问
COLLSCAN 与 IXSCAN 各意味着什么?优化目标是什么?
- 查询「按 userId 等值 + 按 at 降序」,为什么建
{ userId: 1, at: -1 }?
- 只按
at 范围查,能否高效使用 { userId: 1, at: -1 }?为什么?
小结一句话
索引用写入与空间换读取:核心查询必须 explain 验证 IXSCAN,复合索引按 ESR 排字段,数组走多键,TTL 管过期。
延伸