03. MongoDB 索引类型与查询优化

MongoDB 索引全解: 单键、复合、多键、文本、TTL、稀疏与部分索引,Explain 分析与查询优化

索引是数据库性能的核心。MongoDB 使用 B-Tree 索引,与关系型数据库类似,但有一些独特之处——如多键索引(数组索引)、文本索引、TTL 索引等。理解索引原理并正确使用 Explain 进行分析,是 MongoDB 调优的关键。

1. 索引基础

1.1 创建与管理

// 单键索引
db.users.createIndex({ email: 1 });  // 1 = 升序, -1 = 降序

// 复合索引(最左前缀原则)
db.orders.createIndex({ status: 1, createdAt: -1 });

// 唯一索引
db.users.createIndex({ email: 1 }, { unique: true });

// 查看索引
db.users.getIndexes();
db.users.totalIndexSize();  // 索引占用空间

// 删除索引
db.users.dropIndex("email_1");
db.users.dropIndexes();

1.2 索引对性能的影响

操作无索引有索引
查询集合扫描 COLSCAN索引扫描 IXSCAN
写入快慢(需更新索引)
更新慢可能快(通过索引定位)
内存无额外占用常驻内存

2. 索引类型

2.1 单键索引

// 最基础的索引
db.users.createIndex({ age: 1 });

// 适用查询:
db.users.find({ age: 30 });
db.users.find({ age: { $gte: 18, $lte: 60 } }).sort({ age: 1 });

2.2 复合索引

// 查询模式: WHERE status = ? AND createdAt > ? ORDER BY createdAt DESC
// 索引设计: { status: 1, createdAt: -1 }
db.orders.createIndex({ status: 1, createdAt: -1 });

// ✅ 有效使用:
db.orders.find({ status: "pending" });
db.orders.find({ status: "paid", createdAt: { $gt: yesterday } });
db.orders.find({ status: "pending" }).sort({ createdAt: -1 });

// ❌ 无法使用(缺少 status):
db.orders.find({ createdAt: { $gt: yesterday } });
// ❌ 无法排序(排序方向不符):
db.orders.find({ status: "pending" }).sort({ createdAt: 1 });

最左前缀原则:复合索引 {a, b, c} 可被以下查询使用:

  • {a}, {a, b}, {a, b, c}
  • {a} 加按 b 排序

2.3 多键索引(数组索引)

// 自动为数组字段创建多键索引
db.products.createIndex({ tags: 1 });

// 查询:
db.products.find({ tags: "electronics" });  // IXSCAN
db.products.find({ tags: { $all: ["electronics", "sale"] } });

// 限制: 复合索引中最多一个多键字段
db.orders.createIndex({ items: 1, status: 1 });  // ❌ 可能失败

2.4 文本索引

// 全文搜索索引
db.articles.createIndex({
    title: "text",
    content: "text",
    tags: "text"
}, {
    weights: {
        title: 10,      // 标题匹配权重更高
        content: 5,
        tags: 3
    },
    default_language: "chinese"
});

// 全文查询
db.articles.find({ $text: { $search: "MongoDB indexing" } },
                 { score: { $meta: "textScore" } })
          .sort({ score: { $meta: "textScore" } });

// 短语搜索
db.articles.find({ $text: { $search: "\"exact phrase\"" } });

2.5 TTL 索引

// 自动删除过期文档(适用于验证码、临时数据)
db.sessions.createIndex(
    { createdAt: 1 },
    { expireAfterSeconds: 3600 }  // 1小时过期
);

// TTL 后台线程每 60 秒运行一次,过期时间可能有延迟

2.6 部分索引与稀疏索引

// 稀疏索引: 只对存在该字段的文档建立索引
db.users.createIndex({ phone: 1 }, { sparse: true });

// 部分索引: 只对满足条件的文档建立索引(更有用)
db.orders.createIndex(
    { orderId: 1 },
    { partialFilterExpression: { status: { $eq: "pending" } } }
);
// 只为 pending 订单建立索引,大幅减少索引大小

2.7 其他索引类型

索引类型用途示例
Hashed分片键哈希db.users.createIndex({ _id: "hashed" })
Wildcard动态字段索引db.logs.createIndex({ "$**": 1 })
Clustered按 _id 聚簇存储仅在 MongoDB 5.0+ 时间序列集合支持

3. Explain 分析

3.1 执行计划关键字段

db.orders.find({ status: "pending" }).explain("executionStats");
{
  "queryPlanner": {
    "winningPlan": {
      "stage": "FETCH",
      "inputStage": {
        "stage": "IXSCAN",
        "indexName": "status_1_createdAt_-1",
        "direction": "forward"
      }
    }
  },
  "executionStats": {
    "nReturned": 150,        // 返回文档数
    "totalDocsExamined": 150, // 扫描文档数
    "totalKeysExamined": 150, // 扫描索引键数
    "executionTimeMillis": 2,  // 执行时间
    "executionStages": {
      "stage": "IXSCAN",
      "works": 151,
      "advanced": 150,
      "isEOF": 1
    }
  }
}

3.2 常见执行阶段

Stage含义是否可接受
COLLSCAN集合扫描大数据量时 ❌
IXSCAN索引扫描✅
FETCH取完整文档需回表
PROJECTION_COVERED覆盖索引查询✅ 最优
SORT内存排序大数据量时 ❌
LIMIT限制返回数✅

3.3 索引覆盖查询

// 索引 { _id: 1, status: 1 }
db.orders.find({ _id: 1 }, { status: 1, _id: 1 });
// Covered Query: 只从索引获取数据,不回表

// 执行计划中: "executionStats.totalDocsExamined" = 0

4. 查询优化技巧

技巧说明
使用投影减少回表find({}, { needed: 1 })
利用覆盖索引查询字段都在索引中
$in 数量控制单数组合并为一个 $in,但不超过 1000 个
避免 skip 深分页使用最后文档的 _id 作为游标
排序使用索引复合索引包含排序字段
hint 强制索引确认优化器选择错误时使用

5. 索引优化实战

5.1 ESR 原则(Equality, Sort, Range)

复合索引字段顺序决定查询效率,遵循 ESR 原则排序:

// 假设查询模式: WHERE status = ? AND category IN (?) AND createdAt > ? ORDER BY createdAt DESC
// ✅ ESR 顺序: Equality → Sort → Range
// Equality: status = "active"
// Sort: createdAt DESC
// Range: category IN [...]
db.orders.createIndex({ status: 1, createdAt: -1, category: 1 });

// ❌ 错误顺序: Range 在 Sort 前
db.orders.createIndex({ status: 1, category: 1, createdAt: -1 });
// 当 category IN 有多个值时,索引无法按 createdAt 有序返回
字段类型示例索引顺序建议
Equalitystatus: "active"最前面
SortORDER BY createdAtEquality 后面
Rangeage: { $gte: 18 }最末尾
Multi-Valuetags: "electronics"避免放在 Sort 前

5.2 索引选择性分析

选择性 = 不同值数量 / 文档总数。选择性越高,索引效果越好:

// 分析字段选择性
db.users.aggregate([
  { $group: { _id: "$gender", count: { $sum: 1 } } },
  { $sort: { count: -1 } }
])
// gender 只有两个值,选择性极低,单独索引无意义

db.users.aggregate([
  { $group: { _id: "$email", count: { $sum: 1 } } },
  { $count: "distinct_count" }
])
// email 几乎唯一,高选择性,适合单键索引

低选择性字段处理策略:放入复合索引前缀,与其他字段组合提高整体区分度。

5.3 索引交集与冗余索引清理

// 索引交集: { a: 1 } 和 { b: 1 } 可以联合服务 { a: 1, b: 1 }
// 但不保证有序,性能通常不如专用复合索引

// 查看冗余索引(MongoDB 4.4+)
db.orders.aggregate([
  { $indexStats: {} },
  {
    $project: {
      name: 1,
      key: 1,
      accesses: "$accesses.ops",
      since: "$accesses.since"
    }
  }
])

// 清理策略:
// 1. 前缀冗余: { a: 1, b: 1 } 已存在时,{ a: 1 } 通常冗余
// 2. 30天 0 访问的索引考虑删除
// 3. 用 db.currentOp() 观察索引构建对写入的影响

6. 大规模集合的索引管理

6.1 后台索引构建

前台构建会阻塞读写,生产环境必须使用后台模式:

// 后台创建(不会阻塞读写,但耗时更长)
db.large_collection.createIndex(
  { createdAt: -1 },
  { background: true }
)

// 监控进度(MongoDB 4.2+)
db.adminCommand({
  "currentOp": true,
  "$or": [
    { "desc": "Indexer" },
    { "type": "op", "originatingCommand.createIndexes": { $exists: true } }
  ]
})

// 4.2+ 支持 hybrid 构建:开始前台,达到阈值后自动切换后台

6.2 TTL 索引的注意事项

// TTL 单字段索引
db.sessions.createIndex(
  { lastAccessed: 1 },
  { expireAfterSeconds: 3600 }
)

// 限制:
// 1. TTL 字段必须是 Date 类型
db.sessions.insertOne({ lastAccessed: Date.now() }) // ❌ 不是 Date

// 2. 无法在复合索引上直接设置 TTL
// 变通:使用部分索引 + 应用层清理
db.sessions.createIndex(
  { status: 1, createdAt: 1 },
  {
    partialFilterExpression: {
      status: { $eq: "pending" },
      createdAt: { $lt: new Date(Date.now() - 3600 * 1000) }
    }
  }
)

// 3. 删除延迟:TTL 线程每 60 秒运行一次
db.serverStatus().metrics.ttl.deletedDocuments

6.3 分片环境下的索引

// 分片键前缀原则:分片键必须是索引前缀
// ✅ 正确:分片键 { userId: 1 },索引 { userId: 1, createdAt: -1 }
sh.shardCollection("mydb.orders", { userId: 1 })

// 全局唯一索引必须包含分片键
// ❌ 错误:无法直接创建 { email: 1 } 唯一索引
// ✅ 变通:{ email: 1, userId: 1 } 唯一索引
db.orders.createIndex(
  { email: 1, userId: 1 },
  { unique: true }
)

//hashed 分片键(均匀分布)
sh.shardCollection("mydb.logs", { _id: "hashed" })

7. 索引监控与调优工具

工具命令用途
Profilerdb.setProfilingLevel(2)捕获慢查询
Query Plan Cachedb.collection.getPlanCache().list()查看优化器缓存
Index Stats$indexStats索引使用情况
mongostatCLI 工具实时性能监控
mongotopCLI 工具集合级读写耗时
// 启用慢查询日志(>100ms)
db.setProfilingLevel(1, { slowms: 100 })
db.system.profile.find().sort({ ts: -1 }).limit(5)

// 强制清理计划缓存
// 升级到新版本后、删除索引后、大量数据导入后执行
db.orders.getPlanCache().clear()

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践