大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
索引优化
这一课你将能做到
- 创建单字段、复合、多键、文本、TTL、唯一索引,并说明各自适用场景
- 用 explain("executionStats") 读懂执行计划,判断查询是否走索引、是否高效
- 用 ESR 法则(Equality → Sort → Range)设计复合索引,识别覆盖查询
为什么要学
没索引的查询是全表扫描(COLLSCAN):100 万条提交记录查一个用户,MongoDB 要逐条读 100 万条。有索引后,B+ 树几次磁盘跳转就定位,毫秒级返回。线上接口从 5 秒降到 5 毫秒,往往只是差一条 createIndex。
但索引不是免费的:每个索引都让写入变慢(要维护树)、占内存和磁盘。这一课教你「给对的查询建对的索引」,而不是见查询就建。
先建立图像
复合索引 { userId: 1, submittedAt: -1 } 的 B+ 树(逻辑视图):
userId=1024 ── submittedAt=10-04
── submittedAt=10-02
── submittedAt=10-01
userId=2048 ── submittedAt=10-05
── submittedAt=10-03
索引条目按 (userId 升序, submittedAt 降序) 整体有序。
能服务的查询:
✅ {userId: 1024} 等值
✅ {userId: 1024} sort {submittedAt: -1} 等值+排序(索引顺序即结果顺序)
❌ {submittedAt: ...} 单独查 跳过了前缀 userId,用不上
这就是最左前缀:复合索引必须从第一个字段开始连续使用。
explain 关键指标:
stage: COLLSCAN ← 全表扫描,危险信号
stage: IXSCAN ← 走索引,好
totalDocsExamined: 扫描的文档数 ← 越小越好
nReturned: 返回数
理想:docsExamined ≈ nReturned(扫的都是要的)
糟糕:docsExamined = 1000000, nReturned = 3
跟着做
准备数据(量大一点才有感觉):
use codeforge
db.submissions.drop()
// 插入 5 万条模拟提交
const bulk = []
for (let i = 0; i < 50000; i++) {
bulk.push({
userId: 1000 + (i % 500),
lessonSlug: "mdbrm-" + (i % 12),
status: i % 3 === 0 ? "failed" : "accepted",
score: i % 101,
submittedAt: new Date(Date.now() - i * 60000)
})
}
db.submissions.insertMany(bulk)
步骤 1:看没索引时的惨状
db.submissions.find({ userId: 1024 }).explain("executionStats")
看 totalDocsExamined: 50000、stage: "COLLSCAN"。
步骤 2:单字段索引
db.submissions.createIndex({ userId: 1 })
db.submissions.find({ userId: 1024 }).explain("executionStats")
stage 变成 IXSCAN,docsExamined 降到 100 左右。
步骤 3:复合索引与 ESR 法则
目标查询:「某用户的提交,按时间倒序,且分数大于 60」。
db.submissions.createIndex({ userId: 1, submittedAt: -1, score: 1 })
ESR 法则:Equality(等值)字段在前,Sort(排序)字段居中,Range(范围)字段最后。这里 userId 是等值、submittedAt 是排序、score 是范围过滤——顺序正好。
如果查询是 find({userId: 1024, score: {$gt: 60}}).sort({submittedAt: -1}),用上面的索引:userId 等值定位一段,段内按 submittedAt 有序(排序免费),score 范围在段内过滤。
步骤 4:覆盖查询(Covered Query)
查询条件和返回字段全部在索引里,连文档都不用读:
db.submissions.find(
{ userId: 1024 },
{ _id: 0, userId: 1, submittedAt: 1 }
).explain("executionStats")
explain 里出现 stage: "COVERED"(或 IXSCAN 直接接 PROJECTION_COVERED),docsExamined = 0,纯索引返回,最快形态。
步骤 5:唯一索引与 TTL 索引
// 进度集合:一个用户一个 track 只能有一条进度
db.progress.createIndex({ userId: 1, trackSlug: 1 }, { unique: true })
// 判题队列的临时锁:30 分钟后自动删除
db.judgeLocks.createIndex({ createdAt: 1 }, { expireAfterSeconds: 1800 })
TTL 索引是自动过期神器:验证码、会话、临时锁,到点 MongoDB 后台线程自动清理(约 60 秒巡检一次,删除略有延迟)。
步骤 6:多键索引(数组字段)
db.lessons.insertMany([
{ slug: "l1", tags: ["mongo", "crud"] },
{ slug: "l2", tags: ["mongo", "index"] }
])
db.lessons.createIndex({ tags: 1 })
db.lessons.find({ tags: "mongo" }).explain("executionStats") // IXSCAN
数组字段的索引叫多键索引(multikey),每个元素一个索引条目。注意:复合索引里最多只能有一个数组字段(避免索引条目笛卡尔积爆炸)。
步骤 7:文本索引
db.lessons.createIndex({ title: "text", content: "text" })
db.lessons.find({ $text: { $search: "索引 优化" } })
$text 支持分词搜索(对中文分词支持有限,中文场景上 Atlas Search)。一个集合只能有一个文本索引。
步骤 8:管理索引
db.submissions.getIndexes() // 查看所有索引
db.submissions.dropIndex("userId_1") // 删除
db.submissions.createIndex({score: -1}, { background: true }) // 7.x 默认就是在线建索引,不锁库
讲清楚要点
- 索引是 B+ 树:有序存储键值 + 文档指针,查找 O(log N)。有序性还能免费提供排序结果。
- 最左前缀:
{a:1, b:1}的索引能服务a、a+b的查询,不能服务单独b。 - ESR 法则:复合索引字段顺序 = 等值 → 排序 → 范围。违反它,要么排序走内存,要么索引利用率打折。
- 多键索引:数组字段自动成为多键索引;复合索引最多含一个数组字段。
- 索引选择性:高区分度字段(userId)做索引效果好;低区分度(status 只有 3 个值)单独建索引意义小,适合做复合索引的前缀之一。
- 索引有写成本:每次 insert/update 都要同步维护所有索引。写多读少的集合,索引要克制。
- explain 三看:看 stage(IXSCAN 还是 COLLSCAN)、看 docsExamined/nReturned 比值、看 sort 阶段是否在内存(出现 SORT stage 说明排序没用上索引)。
你可能会懵的地方
{a:1, b:1}和{b:1, a:1}一样吗? 不一样!字段顺序决定索引条目排序方式,进而决定能服务哪些查询。顺序错了等于白建。- 建了索引查询还是 COLLSCAN?
常见原因:查询字段和索引字段类型不一致;
$where/无前缀正则天然走不了索引;优化器判断全表扫描更划算(结果占比太高时)。hint() 可强制走索引验证。 - 索引越多查询越快?
查询是,写入相反。且优化器选择空间变大,偶尔还会选错索引。定期审计:用
$indexStats聚合找出从未被使用的索引删掉。 - OR 查询怎么建索引?
$or每个分支独立选索引再合并。每个分支都要有索引,否则该分支 COLLSCAN。同字段多值优先改$in(一个索引搞定)。 - 部分索引(partial index)是什么?
只索引满足条件的文档:
createIndex({...}, {partialFilterExpression: {status: "accepted"}})。只关心「有效数据」的查询用它,索引更小更快。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
查询 {a, b, c} 建三个单字段索引 | 一次查询只用一个,其余浪费 | 按 ESR 建一个复合索引 |
| 复合索引字段顺序随意 | 排序用不上索引或前缀断裂 | ESR:等值→排序→范围 |
| 给 status(3 个值)单独建索引 | 选择性太差,优化器不爱用 | 作为复合索引前缀或部分索引 |
| 两个数组字段建复合索引 | 直接报错(多键限制) | 重新建模或拆索引 |
| 建完索引不验证 | 以为快了其实没走 | explain 确认 IXSCAN |
卡点急救
- 线上查询突然变慢:先看是否新上的查询没索引;再
db.currentOp()看在跑的慢操作;$indexStats看索引使用是否变化(数据分布变化可能让优化器换计划)。 - 建索引把库拖垮:大集合建索引吃 IO。7.x 默认在线构建不阻塞读写,但仍建议在低峰期建,副本集可用滚动方式(逐个节点建)。
- 唯一索引创建失败:已有数据存在重复值。先聚合找出重复清洗掉,再建索引。
面试常问
- MongoDB 索引原理? WiredTiger 的 B+ 树索引,叶子节点存键值和 RecordId(文档位置)。最左前缀匹配;索引有序所以能免排序;覆盖查询不回表。
- 什么是覆盖查询? 查询条件、排序、返回字段全部在索引内,结果直接从索引产出,docsExamined=0。是查询优化的极致形态,对应 MySQL 的「覆盖索引不回表」。
- 索引对写入的影响? 每个索引在写时都要插入/调整树节点,索引越多写放大越严重;索引占 RAM,热索引最好能常驻内存(工作集 > 内存时性能骤降)。
自测三问
- 查询
find({userId: X, score: {$gt: 60}}).sort({submittedAt: -1}),按 ESR 法则索引字段顺序怎么排? - explain 里
docsExamined: 50000, nReturned: 5说明什么? - 为什么复合索引里不能有两个数组字段?
小结一句话
索引设计 = 读懂查询(ESR 排序)+ 读懂 explain(stage 与扫描行数)+ 克制(每个索引都是写入税)。
延伸
- 官方文档:Indexes
- 官方文档:Explain Results
- 下一课:复制集与分片——单机能跑不算完,高可用与水平扩展怎么落地。