第七章:索引与性能优化

最后更新: 2024-01-01 作者: MongoDB Team
页面目录

第七章:索引与性能优化

MongoDB 索引策略与查询性能调优

7.1 索引概述

┌─────────────────────────────────────────────────────────────────┐
│                     有索引 vs 无索引                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  无索引查询 (全表扫描)                                           │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Doc1 │ Doc2 │ Doc3 │ Doc4 │ Doc5 │ Doc6 │ ... │ DocN    │   │
│  └──────────────────────────────────────────────────────────┘   │
│                           ↓ 逐个检查                             │
│                                                                 │
│  有索引查询 (B-Tree 查找)                                        │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Index: [A]→[Doc] [B]→[Doc] [C]→[Doc] [D]→[Doc] ...      │   │
│  └──────────────────────────────────────────────────────────┘   │
│                           ↓ 直接定位                            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

7.2 索引类型

单字段索引

// 在单个字段上创建索引
db.users.createIndex({ username: 1 })        // 升序
db.users.createIndex({ email: -1 })          // 降序
db.users.createIndex({ created_at: -1 })    // 降序

// 后台创建索引(不阻塞其他操作)
db.users.createIndex({ username: 1 }, { background: true })

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

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

复合索引

// 创建复合索引
db.orders.createIndex({ status: 1, created_at: -1 })

// 复合索引字段顺序很重要
// 查询 { status: "completed", created_at: { $gte: ... } }
// 索引 { status: 1, created_at: -1 } ✓ 高效
// 索引 { created_at: -1, status: 1 } ✗ 无法利用

// 查询 { status: "completed" }
// 以上两个索引都可以使用

// 查询 { created_at: { $gte: ... } }
// 只有 { created_at: -1, status: 1 } 可以使用

多键索引

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

// 索引数组中的元素
db.products.find({ tags: "electronics" })

// 多键索引不能支持某些操作
// 如:整个数组的比较

文本索引

// 单字段文本索引
db.articles.createIndex({ content: "text" })

// 多字段文本索引
db.articles.createIndex({
  title: "text",
  content: "text",
  tags: "text"
})

// 带权重的文本索引
db.articles.createIndex(
  { title: "text", content: "text" },
  { weights: { title: 10, content: 1 } }
)

// 指定语言
db.articles.createIndex(
  { title: "text" },
  { default_language: "chinese" }
)

地理空间索引

// 2dsphere 索引(地球球面)
db.locations.createIndex({ location: "2dsphere" })

// 2d 索引(平面)
db.locations.createIndex({ coordinates: "2d" })

// 地理空间索引选项
db.locations.createIndex(
  { location: "2dsphere" },
  { bits: 26, min: -180, max: 180 }
)

哈希索引

// 哈希索引(用于分片)
db.events.createIndex({ timestamp: "hashed" })

// 哈希索引只支持等值查询
db.events.find({ timestamp: ISODate("2024-01-01") })

7.3 索引属性

唯一索引

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

// 复合唯一索引
db.user_sessions.createIndex(
  { user_id: 1, session_id: 1 },
  { unique: true }
)

// 唯一索引 + 稀疏
db.users.createIndex(
  { phone: 1 },
  { unique: true, sparse: true }
)

稀疏索引

// 稀疏索引不索引 null 值
db.users.createIndex({ phone: 1 }, { sparse: true })

// 非稀疏索引会索引 null
db.users.createIndex({ phone: 1 })

TTL 索引

// TTL 索引:自动删除过期文档
db.sessions.createIndex(
  { created_at: 1 },
  { expireAfterSeconds: 3600 }  // 1 小时后删除
)

// 支持不同的时间精度
db.events.createIndex(
  { timestamp: 1 },
  { expireAfterSeconds: 0 }  // 在指定时间删除
)

// 设置删除时间
db.events.insertOne({
  event_type: "scheduled",
  execute_at: ISODate("2024-01-15T10:00:00Z")
})

部分索引

// 只索引满足条件的文档
db.orders.createIndex(
  { customer_id: 1, created_at: -1 },
  {
    partialFilterExpression: {
      status: { $in: ["pending", "processing"] }
    }
  }
)

// 部分索引应用场景
// 1. 只需要索引活跃用户
db.users.createIndex(
  { last_active: 1 },
  {
    partialFilterExpression: { status: "active" }
  }
)

// 2. 只索引大文档
db.logs.createIndex(
  { timestamp: 1 },
  {
    partialFilterExpression: { level: { $in: ["ERROR", "CRITICAL"] } }
  }
)

案例索引

// 忽略大小写的索引
db.users.createIndex(
  { username: 1 },
  { collation: { locale: "en", strength: 2 } }
)

// 查询时指定 collation
db.users.find({ username: "ALICE" }).collation({ locale: "en", strength: 2 })

7.4 索引管理

查看索引

// 查看集合的所有索引
db.users.getIndexes()

// 查看索引详细信息
db.users.getIndexSpecs()

// 查看索引大小
db.users.totalIndexSize()

删除索引

// 删除单个索引(通过字段名)
db.users.dropIndex({ username: 1 })

// 删除单个索引(通过索引名)
db.users.dropIndex("username_1")

// 删除所有非 _id 索引
db.users.dropIndexes()

// 删除除 _id 外的所有索引
db.users.getIndexes().forEach(idx => {
  if (idx.name !== "_id_") {
    db.users.dropIndex(idx.name)
  }
})

重命名索引

// 通过重新创建删除来重命名
db.users.dropIndex("old_name")
db.users.createIndex({ username: 1 }, { name: "new_name" })

7.5 查询计划分析

explain()

// 查看查询计划
db.users.find({ email: "alice@example.com" }).explain()

// 查看执行统计
db.users.find({ email: "alice@example.com" }).explain("executionStats")

// 查看所有计划信息
db.users.find({ email: "alice@example.com" }).explain("allPlansExecution")

解释计划输出

db.users.find({ email: "alice@example.com" }).explain("executionStats")

输出示例:

{
  queryPlanner: {
    plannerVersion: 1,
    namespace: "mydb.users",
    indexFilterSet: false,
    parsedQuery: { email: { $eq: "alice@example.com" } },
    winningPlan: {
      stage: "FETCH",          // 执行阶段
      inputStage: {
        stage: "IXSCAN",       // 索引扫描
        keyPattern: { email: 1 },
        indexName: "email_1",
        isMultiKey: false,
        direction: "forward",
        indexBounds: {
          email: ["[\"alice@example.com\", \"alice@example.com\"]"]
        }
      }
    },
    rejectedPlans: []  // 被拒绝的执行计划
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 1,           // 返回文档数
    executionTimeMillis: 0, // 执行时间(毫秒)
    totalKeysExamined: 1,   // 扫描的索引键数
    totalDocsExamined: 1,  // 扫描的文档数
    executionStages: { ... }
  }
}

执行阶段说明

Stage 说明
COLLSCAN 全集合扫描(没有索引)
IXSCAN 索引扫描
FETCH 获取完整文档
SHARD_MERGE 合并分片结果
SORT 内存排序
LIMIT 限制返回数量
SKIP 跳过文档

7.6 覆盖查询

// 覆盖查询:所有查询字段都在索引中
// 索引
db.users.createIndex({ username: 1, email: 1 })

// 覆盖查询
db.users.find(
  { username: "alice" },
  { username: 1, email: 1, _id: 0 }
)

// 验证覆盖查询
db.users.find(
  { username: "alice" },
  { username: 1, email: 1, _id: 0 }
).explain("executionStats")
// 期望看到 stage: "PROJECTION_COVERED" 或 IXSCAN + FETCH 变为 IXSCAN

7.7 索引使用策略

选择性原则

┌─────────────────────────────────────────────────────────────────┐
│                      索引选择原则                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 等值查询字段优先 → 放在复合索引前面                           │
│     { status: 1, created_at: -1 }                              │
│                                                                 │
│  2. 范围查询字段放后面                                           │
│     { status: 1, created_at: -1 }                              │
│              ↑ 等值    ↑ 范围                                    │
│                                                                 │
│  3. 高选择性字段优先                                             │
│     选择性 = 不同值数量 / 总文档数                               │
│                                                                 │
│  4. 考虑查询模式                                                 │
│     常见查询:{ a: 1, b: 1 }                                     │
│     索引:{ a: 1, b: 1, c: 1 }  可复用                          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

索引排序

// 查询排序
db.orders.find({ status: "completed" })
  .sort({ created_at: -1, _id: -1 })

// 复合索引设计
db.orders.createIndex({ status: 1, created_at: -1, _id: -1 })
//                           ↑ 过滤  ↑ 排序1  ↑ 排序2

// 注意排序方向
// 索引 { a: 1, b: 1 } 可以支持
//   { a: 1, b: 1 }   ✓
//   { a: 1, b: -1 }  ✓
//   { a: -1, b: 1 }  ✓
//   { a: -1, b: -1 } ✓
//
// 索引 { a: 1, b: -1 } 只能支持
//   { a: 1, b: -1 }   ✓
//   { a: 1 }          ✓
//   不能支持 { a: 1, b: 1 }

7.8 索引维护

重建索引

// 重建集合的所有索引
db.users.reIndex()

// 通常不需要手动重建,MongoDB 会自动维护

索引监控

// 查看索引使用统计
db.users.aggregate([
  { $indexStats: {} },
  {
    $project: {
      name: 1,
      accesses: "$accesses.ops",
      since: "$accesses.since"
    }
  }
])

// 检查未使用的索引
// 如果索引的 accesses.ops 长期为 0,考虑删除

7.9 性能优化建议

常见优化模式

// ❌ 避免:字段类型不匹配
db.users.find({ _id: "507f1f77bcf86cd799439011" })  // 字符串
db.users.find({ _id: ObjectId("507f1f77bcf86cd799439011") })  // ObjectId

// ✓ 正确:使用正确的类型
db.users.find({ _id: ObjectId("507f1f77bcf86cd799439011") })

// ❌ 避免:正则不以 ^ 开头
db.users.find({ email: /example\.com/ })

// ✓ 正确:正则以 ^ 开头
db.users.find({ email: /^[\w.-]+@example\.com$/ })

// ❌ 避免:$or 连接多个独立查询
db.users.find({
  $or: [
    { username: "alice" },
    { email: "alice@example.com" }
  ]
})

// ✓ 正确:使用 $in 替代 $or
db.users.find({
  $or: [
    { username: { $in: ["alice"] } },
    { email: { $in: ["alice@example.com"] } }
  ]
})
// 更好的方式:创建复合索引 (username, email)

分页优化

// ❌ 避免:深度分页 skip
db.articles.find().skip(10000).limit(10)

// ✓ 正确:基于键的分页
const lastDoc = db.articles.findOne({ _id: lastId })
db.articles.find({
  _id: { $gt: lastId }
}).limit(10)

// ✓ 正确:使用游标
const cursor = db.articles.find().sort({ _id: 1 }).batchSize(100)

投影优化

// ❌ 避免:返回所有字段
db.articles.find({ category: "tech" })

// ✓ 正确:只返回需要的字段
db.articles.find(
  { category: "tech" },
  { title: 1, published_at: 1, _id: 0 }
)

// ✓ 正确:排除大字段
db.articles.find(
  { category: "tech" },
  { content: 0, raw_html: 0 }
)

💡 实践提示

  1. 分析慢查询:使用 profiler 或日志识别慢查询
  2. 限制索引数量:每个索引都会增加写入开销
  3. 监控索引使用:删除不使用的索引节省空间和开销
  4. 考虑覆盖索引:避免回表查询
  5. 合理使用复合索引:字段顺序决定索引的可用性

📚 继续学习