索引策略
问题
MongoDB 有哪些索引类型?复合索引的 ESR 规则是什么?如何使用 explain() 分析和优化查询?
答案
索引类型
MongoDB 支持多种索引类型,满足不同查询场景。
单字段索引
// 单字段升序索引
db.users.createIndex({ email: 1 });
// 单字段降序索引(对排序方向有影响)
db.users.createIndex({ createdAt: -1 });
// 唯一索引
db.users.createIndex({ email: 1 }, { unique: true });
复合索引(Compound Index)
在多个字段上创建的索引,字段顺序至关重要。
// 复合索引
db.orders.createIndex({ status: 1, createdAt: -1 });
复合索引遵循 最左前缀原则(类似 MySQL):索引 { a: 1, b: 1, c: 1 } 可以支持以下查询:
| 查询条件 | 是否使用索引 |
|---|---|
{ a: 1 } | ✅ 使用 |
{ a: 1, b: 2 } | ✅ 使用 |
{ a: 1, b: 2, c: 3 } | ✅ 使用(完整) |
{ b: 2 } | ❌ 不使用 |
{ b: 2, c: 3 } | ❌ 不使用 |
{ a: 1, c: 3 } | ⚠️ 部分使用(只用 a) |
多键索引(Multikey Index)
对数组字段自动创建多键索引:
// 文档:{ tags: ["electronics", "sale", "new"] }
db.products.createIndex({ tags: 1 });
// 可以高效查询数组中的任一元素
db.products.find({ tags: "sale" });
- 一个复合索引中 最多只有一个字段 可以是数组
- 不能在两个数组字段上创建复合索引
文本索引(Text Index)
用于全文搜索:
db.articles.createIndex({ title: "text", content: "text" });
db.articles.find({ $text: { $search: "MongoDB 教程" } });
哈希索引(Hashed Index)
用于哈希分片,只支持等值查询,不支持范围查询:
db.users.createIndex({ userId: "hashed" });
地理空间索引
// 2dsphere 索引(地球球面)
db.stores.createIndex({ location: "2dsphere" });
// 查询附近 1km 内的门店
db.stores.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [116.4, 39.9] },
$maxDistance: 1000 // 米
}
}
});
部分索引(Partial Index)
只对满足条件的文档建立索引,减小索引大小:
// 只对活跃用户建索引
db.users.createIndex(
{ email: 1 },
{ partialFilterExpression: { status: "active" } }
);
TTL 索引
自动删除过期文档,适合临时数据(会话、日志、验证码等):
// 文档在 expireAt 指定的时间后被自动删除
db.sessions.createIndex(
{ expireAt: 1 },
{ expireAfterSeconds: 0 }
);
// 或者:创建后 3600 秒(1小时)过期
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 3600 }
);
索引类型对比
| 索引类型 | 适用场景 | 支持范围查询 | 支持排序 |
|---|---|---|---|
| 单字段 | 等值/范围 | ✅ | ✅ |
| 复合 | 多条件组合 | ✅ | ✅ |
| 多键 | 数组字段 | ✅ | ✅ |
| 文本 | 全文搜索 | ❌ | ❌ |
| 哈希 | 等值 + 分片 | ❌ | ❌ |
| 地理空间 | 地理位置 | ✅ | ❌ |
| TTL | 自动过期 | ✅ | ✅ |
| 部分 | 条件子集 | ✅ | ✅ |
ESR 规则 — 复合索引设计的黄金法则
ESR 代表 Equality → Sort → Range,是设计复合索引字段顺序的最优原则。
规则解释
| 顺序 | 类型 | 说明 | 示例 |
|---|---|---|---|
| E | Equality | 精确匹配字段放最前面 | status = "paid" |
| S | Sort | 排序字段放中间 | ORDER BY createdAt DESC |
| R | Range | 范围查询字段放最后 | amount > 100 |
为什么是 ESR?
假设查询:
db.orders.find({ status: "paid", amount: { $gte: 100 } }).sort({ createdAt: -1 });
方案 A(❌ 错误顺序):{ amount: 1, createdAt: -1, status: 1 }
- Range 在前,跳过了 Equality,导致需要扫描大量不匹配 status 的数据
方案 B(⚠️ 次优):{ status: 1, amount: 1, createdAt: -1 }
- Equality 在前(好),但 Range 在 Sort 前面
- Range 扫描打断了索引的有序性,Sort 需要额外的内存排序
方案 C(✅ ESR 最优):{ status: 1, createdAt: -1, amount: 1 }
- E:
status = "paid"精确定位索引范围 - S:
createdAt: -1排序直接走索引,无需内存排序 - R:
amount >= 100在索引尾部做范围过滤
// 最优索引
db.orders.createIndex({ status: 1, createdAt: -1, amount: 1 });
- 先放等值:等值条件越多,索引越能精确缩小范围
- 再放排序:让排序走索引,避免内存排序(SORT_KEY_GENERATOR)
- 最后放范围:范围条件放末尾,不影响前面的扫描和排序
explain() 查询分析
explain() 是 MongoDB 的查询分析器,等价于 MySQL 的 EXPLAIN。
// 三种详细程度
db.orders.find({ status: "paid" }).explain("queryPlanner"); // 查询计划
db.orders.find({ status: "paid" }).explain("executionStats"); // 执行统计(推荐)
db.orders.find({ status: "paid" }).explain("allPlansExecution"); // 所有候选计划
关键字段解读
{
executionStats: {
nReturned: 150, // 返回文档数
executionTimeMillis: 12, // 执行时间(毫秒)
totalKeysExamined: 150, // 扫描的索引键数
totalDocsExamined: 150, // 扫描的文档数
executionStages: {
stage: "FETCH", // 执行阶段
inputStage: {
stage: "IXSCAN", // 索引扫描
indexName: "status_1",
direction: "forward",
indexBounds: { status: ["[\"paid\", \"paid\"]"] }
}
}
}
}
常见 Stage 类型
| Stage | 含义 | 好/坏 |
|---|---|---|
COLLSCAN | 全集合扫描 | ❌ 缺少索引 |
IXSCAN | 索引扫描 | ✅ 正常 |
FETCH | 根据索引回表取文档 | ✅ 正常 |
SORT | 内存排序 | ⚠️ 没走索引排序 |
SORT_KEY_GENERATOR | 排序键生成 | ⚠️ 同上 |
PROJECTION_COVERED | 覆盖查询(不需回表) | ✅ 最优 |
COUNT_SCAN | 用索引计数 | ✅ |
SHARD_MERGE | 分片结果合并 | ✅ 分片环境 |
判断索引效率的关键指标
效率比 = nReturned / totalKeysExamined
理想值 = 1(每扫描一个索引键就返回一个结果)
| 指标关系 | 含义 |
|---|---|
totalKeysExamined ≈ nReturned | ✅ 索引高效 |
totalDocsExamined ≈ nReturned | ✅ 没有多余回表 |
totalKeysExamined >> nReturned | ⚠️ 索引选择性低 |
totalDocsExamined >> nReturned | ⚠️ 回表后大量过滤 |
stage: "COLLSCAN" | ❌ 全表扫描 |
stage: "SORT" | ⚠️ 内存排序 |
索引管理
// 查看集合的所有索引
db.orders.getIndexes();
// 查看索引大小
db.orders.stats().indexSizes;
// 删除索引
db.orders.dropIndex("status_1_createdAt_-1");
// 隐藏索引(不删除,但查询不使用,用于安全验证索引是否被需要)
db.orders.hideIndex("status_1");
db.orders.unhideIndex("status_1");
// 后台创建索引(4.2+ 默认就是后台)
db.orders.createIndex({ field: 1 }, { background: true });
覆盖查询(Covered Query)
当查询和投影的所有字段都包含在索引中时,MongoDB 可以直接从索引返回结果,不需要回表读取文档(PROJECTION_COVERED),性能最优。
// 索引:{ status: 1, amount: 1 }
// 覆盖查询:查询和返回的字段都在索引中
db.orders.find(
{ status: "paid" },
{ status: 1, amount: 1, _id: 0 } // 必须排除 _id
);
_id 默认返回,但它不一定在索引中。要实现覆盖查询,必须显式排除 _id: 0(除非 _id 也在索引中)。
常见面试问题
Q1: MongoDB 索引和 MySQL 索引有什么区别?
答案:
| 维度 | MongoDB | MySQL (InnoDB) |
|---|---|---|
| 数据结构 | B-tree(WiredTiger 引擎) | B+ 树 |
| 聚簇索引 | 无聚簇索引(_id 索引是普通 B-tree) | 主键是聚簇索引 |
| 多键索引 | 原生支持数组字段索引 | 不直接支持 |
| TTL 索引 | 原生支持自动过期 | 不支持(需定时任务) |
| 部分索引 | 原生支持 | 不直接支持 |
| 全文索引 | 内置支持 | 内置支持(5.6+) |
| 覆盖查询 | 支持(需排除 _id) | 支持 |
Q2: ESR 规则是什么?为什么 Range 要放最后?
答案:
ESR 是 MongoDB 复合索引字段排序的黄金法则:Equality → Sort → Range。
Range 放最后的原因:范围扫描(如 $gt、$lt)会导致索引的扫描范围变大,打断索引的有序性。如果 Range 在 Sort 前面,Sort 字段就无法利用索引排序,需要额外的内存排序。把 Range 放最后,让 Equality 精确定位 + Sort 利用索引有序 + Range 在最小范围内过滤,整体效率最优。
Q3: 什么场景不适合建索引?
答案:
- 写多读少的集合:每次写入都需要更新所有索引,索引多会严重拖慢写入
- 低选择性字段:如布尔字段(
isActive: true/false),只有两个值,索引几乎无法缩小范围 - 很少查询的字段:索引占用内存和磁盘,不被使用的索引是纯浪费
- 结果集很大的查询:如果查询返回大部分文档,全表扫描可能比索引更快
Q4: 如何发现和清理无用索引?
答案:
// 1. 查看索引使用统计
db.orders.aggregate([{ $indexStats: {} }]);
// ops 为 0 的索引可能是无用的
// 2. 先隐藏而非直接删除(安全验证)
db.orders.hideIndex("possibly_unused_index");
// 观察几天,确认没有性能问题
// 3. 确认无用后删除
db.orders.dropIndex("possibly_unused_index");
Q5: MongoDB 的索引有大小限制吗?
答案:
- 单个索引键的最大长度:1024 字节
- 一个集合最多 64 个索引
- 索引大小应尽量放入内存,否则会频繁换页影响性能
- 可以通过
db.collection.totalIndexSize()查看索引总大小