MongoDB: 索引基础与原理
最后更新:2026-08-26
索引是数据库性能的关键——掌握索引原理和优化能提升 1000 倍查询速度。
1. 你将学到
- 索引原理(B-Tree 数据结构)
- createIndex() 语法
- 单字段索引与复合索引
- explain() 执行计划解读
- 索引覆盖(covered query)
- 索引代价与权衡
2. 索引原理(B-Tree)
概念说明:索引是一种辅助数据结构,类似于书籍的目录,它让数据库无需扫描整个集合就能快速定位文档。MongoDB 使用基于 B-Tree 的索引结构(WiredTiger 引擎使用 B+Tree 变体),将字段值与文档位置建立有序映射,把查询的时间复杂度从 O(N) 降至 O(log N)。
工作原理:MongoDB 的 B+Tree 索引将字段值存储在内部节点用于路由,将实际键值和文档指针存储在叶子节点,叶子节点之间通过双向链表连接,天然支持范围查询和排序。当查询命中索引时,引擎从根节点逐层向下比较键值,最终在叶子节点定位目标文档的物理位置,避免全集合扫描(COLLSCAN)。
使用场景:
- 频繁作为查询条件的字段(如
sku、userId) - 排序字段(如
createdAt: -1) - 需要唯一性约束的字段(如
email) - 不适合:低选择性字段(如
isActive仅 true/false)、写入极频繁但极少查询的字段
graph TB
A[Root Node<br/>50-100] --> B[Internal Node 1<br/>20-50]
A --> C[Internal Node 2<br/>20-50]
B --> D[Leaf Node 1<br/>指向文档]
B --> E[Leaf Node 2]
C --> F[Leaf Node 3]
C --> G[Leaf Node 4]
D <--> E <--> F <--> G
style A fill:#cce5ff
style D fill:#d4edda
style E fill:#d4edda
style F fill:#d4edda
style G fill:#d4edda
B+Tree 查询流程:等值查询从根节点逐层比较 → 到达叶子节点 → 返回文档指针;范围查询找到起始叶子节点后,沿链表顺序扫描 → 收集所有符合条件的文档。
sequenceDiagram
participant App as 应用查询
participant WT as WiredTiger引擎
participant IX as B+Tree索引
participant DOC as 集合文档
App->>WT: find({sku: 'SKU-005'})
WT->>IX: 根节点比较 50<005<100 → 左子树
IX-->>WT: Internal Node: 20<005<50 → 左叶
WT->>IX: 叶子节点查找 SKU-005
IX-->>WT: 找到 → doc_pointer=0x7F3A
WT->>DOC: 读取 0x7F3A 位置的文档
DOC-->>App: 返回匹配文档
Note over WT,IX: 时间复杂度 O(log N)<br/>无需扫描全部文档
| 操作 | 全表扫描 (COLLSCAN) | B+Tree 索引 (IXSCAN) | 性能差异 |
|---|---|---|---|
| 等值查询 | O(N) | O(log N) | 10万行: 100K vs 17 |
| 范围查询 | O(N) | O(log N + K) | 10万行: 100K vs 17+K |
| 排序 | O(N log N) | O(log N + K) | 索引天然有序,免排序 |
| 插入/更新 | O(1) | O(log N) | 索引维护额外开销 |
(1) WiredTiger 索引存储细节
| 维度 | 说明 |
|---|---|
| 索引格式 | B+Tree,键值对有序存储 |
| 叶子节点 | 包含索引键 + RecordID(文档位置指针) |
| 内部节点 | 仅包含路由键 + 子节点指针 |
| 链表连接 | 叶子节点双向链表,支持顺序扫描 |
| 压缩 | 前缀压缩(Prefix Compression)减少存储 |
3. createIndex 语法
概念说明:createIndex() 是 MongoDB 创建索引的核心命令,它告诉引擎为指定字段构建 B+Tree 索引结构。索引创建后,查询优化器会自动选择是否使用索引——开发者无需修改查询语句。
工作原理:创建索引时,MongoDB 扫描集合中所有文档,提取索引字段值并排序,构建 B+Tree 结构写入磁盘。创建期间会持有集合的写锁(background: true 可后台构建),大集合创建索引可能耗时数分钟到数小时。
语法规则:
| 参数 | 类型 | 说明 |
|---|---|---|
keys |
object | 索引字段及方向:1 升序,-1 降序 |
unique |
boolean | 是否唯一索引,默认 false |
background |
boolean | 是否后台构建(不阻塞读写),默认 false |
name |
string | 自定义索引名称,默认 field_1 |
partialFilterExpression |
object | 部分索引条件(仅索引满足条件的文档) |
sparse |
boolean | 稀疏索引,跳过 null 字段 |
expireAfterSeconds |
number | TTL 索引,自动过期删除(秒) |
v |
number | 索引版本,默认 v=2 |
// === 创建单字段索引 ===
db.products.createIndex({ sku: 1 }); // 升序
db.products.createIndex({ createdAt: -1 }); // 降序
// === 创建复合索引 ===
db.products.createIndex({ category: 1, price: -1 });
// === 创建唯一索引 ===
db.products.createIndex({ sku: 1 }, { unique: true });
// === 后台创建(不阻塞)===
db.products.createIndex({ tags: 1 }, { background: true });
// === 自定义索引名称 ===
db.products.createIndex({ title: 1 }, { name: 'idx_title' });
要点解析:
- 1 和 -1 影响索引排序方向,对单字段索引无实质影响,对复合索引排序优化至关重要
background: true在 MongoDB 4.2+ 已默认后台构建,参数保留但不再需要显式指定- 每个集合默认有
_id唯一索引(不可删除),无需额外创建
▶ 示例 1: createIndex 与索引构建监控
// ShopHub 电商:为商品集合创建关键索引,监控构建进度
db.products.createIndex({ category: 1, price: -1, rating: -1 }, { name: 'idx_category_price_rating', background: true });
// 查看索引构建进度
db.currentOp({
$or: [
{ op: 'command', 'command.createIndexes': { $exists: true } },
{ op: 'none', ns: /shopdb\.products/ }
]
});
// 查看所有索引
db.products.getIndexes();
// [
// { v: 2, key: { _id: 1 }, name: '_id_' },
// { v: 2, key: { category: 1, price: -1, rating: -1 }, name: 'idx_category_price_rating' }
// ]
输出:
// 执行成功
4. explain() 执行计划
概念说明:explain() 是 MongoDB 的查询分析工具,它返回查询优化器选择的执行计划,包括是否使用索引、扫描了多少文档、耗时多少等关键指标。它是索引优化的"X光机"——先 explain(),再优化。
工作原理:MongoDB 查询优化器会为每个查询生成多个候选计划(Plan),实际执行并比较性能,选择最优计划缓存。explain() 输出三个层级:
queryPlanner:优化器选择的计划(未执行)executionStats:实际执行统计(需传'executionStats'参数)allPlansExecution:所有候选计划的执行统计
graph LR
A[查询请求] --> B[查询优化器]
B --> C[生成候选计划]
C --> D[Plan A: IXSCAN]
C --> E[Plan B: COLLSCAN]
D --> F[执行比较]
E --> F
F --> G[选择最优计划]
G --> H[缓存 + 执行]
style G fill:#d4edda
style E fill:#f8d7da
使用场景:
- 查询慢时,用
explain()确认是否走了索引 - 上线新索引前,验证查询是否命中
- 对比不同索引方案的性能差异
- 发现
COLLSCAN(全表扫描)必须优化
// === 查看查询执行计划 ===
db.products.find({ category: 'Electronics' }).explain('executionStats');
// === 关键指标 ===
{
queryPlanner: {
winningPlan: {
stage: 'IXSCAN', // 索引扫描(✅)
// stage: 'COLLSCAN', // 全集合扫描(❌)
inputStage: {
stage: 'IXSCAN',
indexName: 'category_1'
}
}
},
executionStats: {
totalDocsExamined: 250, // 扫描文档数
totalKeysExamined: 250, // 扫描索引键数
nReturned: 250, // 返回文档数
executionTimeMillis: 5, // 执行时间(毫秒)
totalQueryPlanExecutionTime: 7
}
}
(1) 关键指标解读
| 指标 | 理想值 | 问题 | 说明 |
|---|---|---|---|
stage |
IXSCAN | COLLSCAN(全表扫描) | COLLSCAN 必须加索引优化 |
totalDocsExamined |
≈ nReturned | 远大于 nReturned | 大于 10 倍说明索引精度低 |
totalKeysExamined |
≈ nReturned | 远大于 nReturned | 索引扫描范围过大 |
executionTimeMillis |
< 50ms | > 100ms | 考虑索引覆盖或复合索引 |
indexName |
目标索引名 | id 或无 | 确认是否命中预期索引 |
explain 三种模式对比:
| 模式 | 输出内容 | 适用场景 |
|---|---|---|
'queryPlanner' |
仅计划,不执行 | 快速查看是否走索引 |
'executionStats' |
计划 + 执行统计 | 性能分析(最常用) |
'allPlansExecution' |
所有候选计划统计 | 优化器选择分析 |
▶ 示例 2: explain() 诊断慢查询
// TechCorp 系统发现订单查询变慢,用 explain 诊断
// 索引前:COLLSCAN
db.orders.find({ status: 'paid', total: { $gte: 100 } }).explain('executionStats');
// stage: 'COLLSCAN', totalDocsExamined: 100000, executionTimeMillis: 450
// 创建复合索引
db.orders.createIndex({ status: 1, total: -1 });
// 索引后:IXSCAN
db.orders.find({ status: 'paid', total: { $gte: 100 } }).explain('executionStats');
// stage: 'IXSCAN', indexName: 'status_1_total_-1'
// totalDocsExamined: 5000, nReturned: 5000, executionTimeMillis: 8
// 效率评估:examined/returned = 1.0(理想值),性能提升 56 倍
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
5. 复合索引与最左前缀
概念说明:复合索引是对多个字段建立的单一索引(如 { category: 1, price: -1, rating: 1 }),它比多个单字段索引更高效,因为一次索引查找就能同时满足多条件查询。最左前缀原则是复合索引的核心规则——索引从最左字段开始连续使用才有效。
工作原理:复合索引按字段顺序构建 B+Tree,先按第一字段排序,第一字段相同时按第二字段排序,以此类推。查询时必须从最左字段开始匹配,跳过任何前缀字段会导致后续字段索引失效——就像查字典时必须先确定首字母。
graph TB
subgraph "复合索引 {category, price, rating}"
A[Electronics<br/>$100<br/>★5] --> B[Electronics<br/>$200<br/>★4]
B --> C[Electronics<br/>$300<br/>★3]
C --> D[Books<br/>$10<br/>★5]
D --> E[Books<br/>$20<br/>★4]
end
subgraph "查询命中分析"
F["✅ {category}"] --> G["✅ {category, price}"]
G --> H["✅ {category, price, rating}"]
I["❌ {price}"] --> J["跳过 category"]
K["❌ {rating}"] --> L["跳过 category, price"]
M["❌ {category, rating}"] --> N["跳过 price"]
end
style F fill:#d4edda
style G fill:#d4edda
style H fill:#d4edda
style I fill:#f8d7da
style K fill:#f8d7da
style M fill:#f8d7da
使用场景:
- 多条件组合查询(如
category + price) - 筛选 + 排序组合(如
status = 'paid'+sort by createdAt) - 不适合:各条件独立查询(此时多个单字段索引更灵活)
| 查询模式 | 是否命中 {category, price, rating} | 原因 |
|---|---|---|
{category: 'A'} |
✅ 全部命中 | 使用 category 前缀 |
{category: 'A', price: {$gte: 100}} |
✅ 全部命中 | 使用 category + price 前缀 |
{category: 'A', price: {$gte: 100}, rating: 5} |
✅ 全部命中 | 完整匹配三字段 |
{price: {$gte: 100}} |
❌ 不命中 | 跳过最左 category |
{rating: 5} |
❌ 不命中 | 跳过 category, price |
{category: 'A', rating: 5} |
⚠️ 仅 category | price 断裂,rating 无法使用 |
// === 复合索引:{ category: 1, price: -1, rating: 1 } ===
db.products.createIndex({ category: 1, price: -1, rating: 1 });
// ✅ 使用索引的查询:
db.products.find({ category: 'Electronics' }); // 用 category
db.products.find({ category: 'Electronics', price: { $gte: 100 } }); // 用 category + price
db.products.find({ category: 'Electronics', price: { $gte: 100 }, rating: 5 }); // 用全部 3 字段
// ⚠️ 不使用索引的查询:
db.products.find({ price: { $gte: 100 } }); // 跳过 category
db.products.find({ rating: 5 }); // 跳过 category, price
db.products.find({ category: 'Electronics', rating: 5 }); // 跳过 price
最左前缀原则:复合索引从最左字段开始连续使用才有效。跳过中间字段会断裂索引链——后续字段无法使用索引。
ESR 规则(Equality → Sort → Range):复合索引字段顺序的金科玉律——等值过滤字段在前,排序字段居中,范围查询字段在后。后续课程(第 19 课)将深入讲解。
6. 索引覆盖
概念说明:索引覆盖(Covered Query)是指查询所需的所有字段都包含在索引中,引擎直接从索引返回结果,无需回表(fetch)读取文档原文。这是索引优化的终极形态——查询完全不需要访问文档数据。
工作原理:普通查询流程为"索引扫描 → 获取文档指针 → 回表读取文档 → 提取字段 → 返回";索引覆盖流程为"索引扫描 → 直接从索引提取字段 → 返回"。省去回表环节,IO 量减半,性能提升 50% 以上。
graph LR
subgraph "普通查询"
A1[索引扫描] --> A2[获取 doc pointer]
A2 --> A3[回表读文档]
A3 --> A4[提取字段]
A4 --> A5[返回结果]
end
subgraph "索引覆盖查询"
B1[索引扫描] --> B2[直接提取索引字段]
B2 --> B3[返回结果]
end
style A3 fill:#f8d7da
style B2 fill:#d4edda
使用场景:
- 高频查询仅需要少量字段(如列表页仅显示
sku, title, price) - 大文档集合,回表成本高
- 关键条件:投影(projection)必须排除
_id(_id: 0),且所有投影字段都在索引中
| 维度 | 普通查询 | 索引覆盖 |
|---|---|---|
| 执行流程 | 索引扫描 → 回表查文档 | 索引扫描 → 直接返回 |
| IO 次数 | 2 次(索引 + 文档) | 1 次(仅索引) |
| 性能 | 基准 | 快 50%+ |
| explain 标识 | FETCH stage |
PROJECTION_COVERED |
| 限制 | 无 | 投影必须排除 _id |
// === 普通查询:需回表查文档 ===
db.products.find(
{ category: 'Electronics' },
{ sku: 1, title: 1, price: 1 }
);
// === 索引覆盖查询:直接从索引返回 ===
db.products.createIndex({ category: 1, sku: 1, title: 1, price: 1 });
db.products.find(
{ category: 'Electronics' },
{ sku: 1, title: 1, price: 1, _id: 0 } // _id: 0 必须排除
);
// explain 中显示 stage: 'PROJECTION_COVERED' ✅
要点解析:
_id默认总是返回且不在普通索引中,必须用_id: 0排除才能实现覆盖- 索引字段越多,覆盖查询越多,但索引体积也越大——需要权衡
- 索引覆盖对大文档效果最显著(节省的 IO 量与文档大小成正比)
7. 索引管理
概念说明:索引管理包括索引的创建、查看、删除和重建。生产环境中索引不是"建完就忘"的,需要持续监控使用情况,删除无用索引,重建碎片化索引。
索引生命周期:
graph LR
A[分析查询模式] --> B[设计索引]
B --> C[创建索引<br/>background:true]
C --> D[验证命中<br/>explain()]
D --> E[监控使用率<br/>$indexStats]
E --> F{使用率低?}
F -->|是| G[删除索引]
F -->|否| H[继续监控]
G --> A
H --> E
style G fill:#f8d7da
style D fill:#d4edda
| 管理操作 | 命令 | 说明 |
|---|---|---|
| 查看索引 | db.col.getIndexes() |
列出所有索引及键定义 |
| 删除索引 | db.col.dropIndex(name) |
按名称或键定义删除 |
| 删除全部 | db.col.dropIndexes() |
删除所有索引(保留 _id) |
| 重建索引 | db.col.reIndex() |
重建所有索引(修复碎片) |
| 索引大小 | db.col.totalIndexSize() |
查看索引占用空间(字节) |
| 索引使用率 | db.col.aggregate([{$indexStats:{}}]) |
查看各索引使用次数 |
// === 查看集合所有索引 ===
db.products.getIndexes();
// === 删除索引 ===
db.products.dropIndex('sku_1');
db.products.dropIndex({ sku: 1 });
// === 删除所有索引(保留 _id)===
db.products.dropIndexes();
// === 重建索引 ===
db.products.reIndex();
// === 查看索引大小 ===
db.products.totalIndexSize();
要点解析:
reIndex()会锁集合,生产环境建议在维护窗口执行dropIndex()前建议先用$indexStats确认索引确实未使用- 每个集合索引数建议不超过 10 个,过多影响写入性能
8. 索引代价
概念说明:索引不是免费的——每个索引都会占用磁盘空间、增加写入开销、消耗内存。理解索引代价是做出正确权衡的基础。"索引越多越好"是最常见的误区。
工作原理:每次插入、更新、删除文档时,MongoDB 必须同步更新所有相关索引的 B+Tree 结构。集合有 N 个索引,写操作就需要维护 N 棵 B+Tree。索引越多,写入越慢,内存占用也越大(WiredTiger 缓存需加载索引页)。
graph LR
A[建索引] --> B[读快]
A --> C[写慢]
A --> D[占空间]
B --> B1[查询 +1000x]
C --> C1[插入/更新 +50% 开销]
D --> D1[每索引 1-10 MB]
subgraph "权衡决策"
E[查询频率高?] -->|是| F[✅ 建索引]
E -->|否| G[❌ 不建]
H[写入频率高?] -->|是| I[⚠️ 谨慎]
H -->|否| F
end
索引代价量化:
| 代价维度 | 影响 | 量化 |
|---|---|---|
| 磁盘空间 | 每个索引约占数据量的 5-20% | 10GB 数据 × 8 索引 ≈ 4-16GB 额外空间 |
| 写入延迟 | 每个索引增加 ~5-10% 写入耗时 | 8 索引 → 写入慢 40-80% |
| 内存占用 | WiredTiger 缓存需加载索引页 | 索引未缓存时查询退化 |
| 维护成本 | reIndex、监控、重建等运维操作 | 索引越多运维越复杂 |
索引权衡:
- ✅ 频繁查询字段 → 建索引
- ⚠️ 高频更新字段 → 谨慎建索引
- ⚠️ 大数组字段 → multikey index 可能过大
索引选择策略:
| 场景 | 建议 | 原因 |
|---|---|---|
| 电商商品查询 | category + price 复合索引 | 高频筛选 + 排序 |
| 用户登录 | email 唯一索引 | 等值查询 + 唯一性约束 |
| 日志查询 | createdAt TTL 索引 | 时间范围 + 自动过期 |
| 状态字段(低选择性) | 不建议单独建 | isActive 仅 2 个值,索引效率极低 |
| 文本搜索 | text 索引 | 全文检索专用 |
9. 综合实战
(1) 索引设计原则
ESR 规则详解:复合索引字段顺序应遵循 Equality → Sort → Range。等值过滤字段放最前(快速缩小范围),排序字段居中(利用索引有序性避免内存排序),范围查询字段放最后(范围扫描会中断后续字段索引使用)。
graph LR
E["Equality<br/>等值过滤<br/>category='A'"] --> S["Sort<br/>排序<br/>createdAt: -1"]
S --> R["Range<br/>范围<br/>price >= 100"]
style E fill:#d4edda
style S fill:#cce5ff
style R fill:#fff3cd
| 索引顺序 | 查询效率 | 原因 |
|---|---|---|
{E, S, R} |
⭐⭐⭐ 最优 | 等值精确定位 → 索引排序 → 范围扫描 |
{E, R, S} |
⭐⭐ 良好 | 等值定位 → 范围扫描 → 内存排序 |
{R, S, E} |
⭐ 较差 | 范围扫描面太广,等值优势丧失 |
// === 电商商品索引设计 ===
db.products.createIndex({ sku: 1 }, { unique: true }); // 唯一索引
db.products.createIndex({ category: 1, price: -1 }); // 分类页
db.products.createIndex({ category: 1, rating: -1 }); // 分类+评分
db.products.createIndex({ isActive: 1, createdAt: -1 }); // 上架时间
db.products.createIndex({ title: 'text', description: 'text' }); // 全文搜索
db.products.createIndex({ tags: 1 }); // 标签筛选
(2) 索引使用分析
// === 分析慢查询 ===
db.products.find({
category: 'Electronics',
price: { $gte: 100, $lte: 1000 },
isActive: true
}).sort({ createdAt: -1 }).limit(20);
// 查看是否使用索引
const explain = db.products.find({...}).explain('executionStats');
print('Stage:', explain.queryPlanner.winningPlan.stage);
print('Docs Examined:', explain.executionStats.totalDocsExamined);
print('Time:', explain.executionStats.executionTimeMillis, 'ms');
▶ 示例:索引设计与性能分析实战
// 1. 创建测试集合(10 万条商品数据)
for (let i = 0; i < 100000; i++) {
db.products.insertOne({
sku: 'SKU-' + i.toString().padStart(6, '0'),
title: 'Product ' + i,
category: ['Electronics', 'Books', 'Clothing', 'Home'][i % 4],
price: Math.random() * 1000,
stock: Math.floor(Math.random() * 100),
createdAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000),
isActive: true
});
}
// 2. 创建索引
db.products.createIndex({ sku: 1 }, { unique: true });
db.products.createIndex({ category: 1, price: -1 }); // 复合索引
db.products.createIndex({ createdAt: -1 });
// 3. 对比:有索引 vs 无索引
console.time('无索引查询');
db.products.find({ category: 'Electronics', price: { $gte: 100, $lte: 500 } }).toArray();
console.timeEnd('无索引查询'); // ~500ms
// 创建索引后
db.products.createIndex({ category: 1, price: 1 });
console.time('有索引查询');
db.products.find({ category: 'Electronics', price: { $gte: 100, $lte: 500 } }).toArray();
console.timeEnd('有索引查询'); // ~5ms(性能 ↑100x)
// 4. explain() 分析执行计划
const explain = db.products.find({
category: 'Electronics',
price: { $gte: 100, $lte: 500 }
}).sort({ createdAt: -1 }).limit(20).explain('executionStats');
print('Stage:', explain.queryPlanner.winningPlan.stage); // IXSCAN
print('Index:', explain.queryPlanner.winningPlan.inputStage?.indexName); // category_1_price_1
print('Docs Examined:', explain.executionStats.totalDocsExamined);
print('Keys Examined:', explain.executionStats.totalKeysExamined);
print('Returned:', explain.executionStats.nReturned);
print('Time:', explain.executionStats.executionTimeMillis, 'ms');
// 5. 索引覆盖查询(无需回表)
db.products.createIndex({ category: 1, sku: 1, price: 1 });
db.products.find(
{ category: 'Electronics' },
{ sku: 1, price: 1, _id: 0 } // 仅返回索引中已有的字段
).explain();
// Stage: PROJECTION_COVERED(无需查文档)
输出:索引让查询性能提升 100 倍,explain() 显示 IXSCAN(索引扫描)+ PROJECTION_COVERED(索引覆盖)。
❓ 常见问题
📖 小节
- 索引原理:B-Tree 数据结构,O(log N) 查询
- createIndex 语法:单字段、复合、唯一、文本
- explain() 解读:winningPlan + executionStats
- 复合索引最左前缀原则
- 索引覆盖:直接从索引返回,避免回表
- 索引代价:写慢 + 占空间
📝 作业
- 基础题(⭐):为 products 集合创建 sku、category、createdAt 三个索引。
- 基础题(⭐):用 explain() 分析一个查询,确认使用了索引(IXSCAN)。
- 进阶题(⭐⭐):创建复合索引 { category: 1, price: -1 },测试最左前缀原则(哪些查询用索引)。
- 进阶题(⭐⭐):实现索引覆盖查询(所有字段都在索引中)。
- 挑战题(⭐⭐⭐):电商商品索引完整设计(8 个索引),分析每个索引的查询场景。