Files
Cloud-book/数据库/MongoDB_2025/MongoDB索引优化.md
2025-08-27 17:10:05 +08:00

10 KiB
Raw Blame History

索引优化

索引是提升 MongoDB 查询性能的关键。本章节将深入探讨索引的类型、创建策略、如何分析查询性能以及索引的维护方法,帮助大家构建高性能的数据库应用。


索引基础

什么是索引?

索引是一种特殊的数据结构它以易于遍历的形式存储了集合中一小部分的数据。通过索引MongoDB 可以直接定位到符合查询条件的文档,而无需扫描整个集合,从而极大地提高了查询速度。

索引的类型

MongoDB 支持多种类型的索引,以适应不同的查询需求。

  1. 单字段索引 (Single Field Index)

    • 最基础的索引类型,针对单个字段创建。
    • 示例: db.inventory.createIndex({ price: 1 }) (按价格升序)
  2. 复合索引 (Compound Index)

    • 在多个字段上创建的索引。字段的顺序非常重要,决定了索引的查询效率。
    • 示例: db.inventory.createIndex({ price: 1, qty: -1 }) (先按价格升序,再按数量降序)
  3. 多键索引 (Multikey Index)

    • 为数组字段创建的索引。MongoDB 会为数组中的每个元素创建一条索引项。
    • 示例: db.inventory.createIndex({ tags: 1 }) (为 tags 数组中的每个标签创建索引)
  4. 文本索引 (Text Index)

    • 用于支持对字符串内容的文本搜索查询。
    • 示例: db.inventory.createIndex({ description: "text" })
  5. 地理空间索引 (Geospatial Index)

    • 用于高效地查询地理空间坐标数据。
    • 类型: 2dsphere (用于球面几何) 和 2d (用于平面几何)。
    • 示例: db.places.createIndex({ location: "2dsphere" })
  6. 哈希索引 (Hashed Index)

    • 计算字段值的哈希值并对哈希值建立索引,主要用于哈希分片。
    • 示例: db.inventory.createIndex({ status: "hashed" })

索引策略与创建

创建索引

使用 createIndex() 方法在集合上创建索引。

// 在 students 集合的 email 字段上创建一个唯一的升序索引
db.students.createIndex({ student_id: 1 }, { unique: true })

// 创建一个后台索引,避免阻塞数据库操作
db.students.createIndex({ name: 1 }, { background: true })

索引属性

  • unique: 强制索引字段的值唯一,拒绝包含重复值的文档插入或更新。
  • sparse: 稀疏索引,只为包含索引字段的文档创建索引项,节省空间。
  • background: 在后台创建索引,允许在创建过程中进行读写操作(但会稍慢)。
  • expireAfterSeconds: TTL (Time-To-Live) 索引,自动从集合中删除超过指定时间的文档。

复合索引的字段顺序

复合索引的字段顺序遵循 ESR (Equality, Sort, Range) 法则:

  1. 等值查询 (Equality) 字段应放在最前面。
  2. 排序 (Sort) 字段其次。
  3. 范围查询 (Range) 字段(如 $gt, $lt)应放在最后。

正确的字段顺序可以使一个索引服务于多种查询,提高索引的复用率。

案例分析:

假设我们有一个 products 集合,需要频繁执行一个查询:查找特定 category 的商品,按 brand 排序,并筛选出价格 price 大于某个值的商品。

查询语句:

db.products.find(
  { 
    category: "electronics",       // 等值查询 (Equality)
    price: { $gt: 500 }            // 范围查询 (Range)
  }
).sort({ brand: 1 })                 // 排序 (Sort)

根据 ESR 法则创建索引:

遵循 ESR 法则,我们应该将等值查询字段 category 放在最前面,然后是排序字段 brand,最后是范围查询字段 price

最佳索引:

db.products.createIndex({ category: 1, brand: 1, price: 1 })

为什么这个顺序是最高效的?

  1. 等值查询优先 (category): MongoDB 可以立即通过索引定位到所有 category"electronics" 的文档,快速缩小查询范围。
  2. 排序字段次之 (brand): 在已筛选出的 "electronics" 索引条目中,数据已经按 brand 排好序。因此MongoDB 无需在内存中执行额外的排序操作 (in-memory sort),极大地提升了性能。
  3. 范围查询最后 (price): 在已经按 brand 排序的索引条目上MongoDB 可以顺序扫描,高效地过滤出 price 大于 500 的条目。

如果索引顺序不当,例如 { price: 1, brand: 1, category: 1 }MongoDB 将无法有效利用索引进行排序,可能导致性能下降。


查询性能分析

explain() 方法

explain() 是分析查询性能的利器。它可以显示查询的执行计划,包括是否使用了索引、扫描了多少文档等信息。

db.students.find({ age: { $gt: 21 } }).explain("executionStats")

分析 explain() 结果

关注 executionStats 中的关键指标:

  • executionSuccess: 查询是否成功执行。
  • nReturned: 查询返回的文档数量。
  • totalKeysExamined: 扫描的索引键数量。
  • totalDocsExamined: 扫描的文档数量。
  • executionTimeMillis: 查询执行时间(毫秒)。
  • winningPlan.stage: 查询计划的阶段。
    • COLLSCAN: 全集合扫描,性能最低。
    • IXSCAN: 索引扫描,性能较高。
    • FETCH: 根据索引指针去获取文档。

理想情况: totalKeysExaminedtotalDocsExamined 应该尽可能接近 nReturned

覆盖查询 (Covered Query)

当查询所需的所有字段都包含在索引中时MongoDB 可以直接从索引返回结果,而无需访问文档,这称为覆盖查询。覆盖查询性能极高。

条件:

  1. 查询的所有字段都是索引的一部分。
  2. 查询返回的所有字段都在同一个索引中。
  3. 查询的字段中不包含 _id 字段,或者 _id 字段是索引的一部分(默认情况下 _id 会被返回,除非显式排除)。

案例分析:

假设我们有一个 students 集合,并且我们经常需要通过 student_id 查找学生的姓名 name

  1. 创建复合索引: 为了优化这个查询,我们可以在 student_idname 字段上创建一个复合索引。

    db.students.createIndex({ student_id: 1, name: 1 })
    
  2. 执行覆盖查询: 现在,我们执行一个只查询 student_id 并只返回 name 字段的查询。我们使用投影 (projection) 来显式排除 _id 字段,并只包含 name 字段。

    db.students.find({ student_id: 'S1001' }, { name: 1, _id: 0 })
    
  3. 性能验证: 使用 explain() 方法来查看查询的执行计划。

    db.students.find({ student_id: 'S1001' }, { name: 1, _id: 0 }).explain('executionStats')
    

    executionStats 的输出中,我们会发现:

    • totalDocsExamined 的值为 0。这表明 MongoDB 没有扫描任何文档。
    • totalKeysExamined 的值大于 0,说明扫描了索引。
    • winningPlan.stage 会显示为 IXSCAN,并且没有 FETCH 阶段。

    这个结果证明了该查询是一个覆盖查询。MongoDB 仅通过访问索引就获取了所有需要的数据,完全避免了读取文档的磁盘 I/O 操作,从而实现了极高的查询性能。


索引维护

查看索引

使用 getIndexes() 方法查看集合上的所有索引。

db.students.getIndexes()

删除索引

使用 dropIndex() 方法删除指定的索引。

// 按名称删除索引
db.students.dropIndex("email_1")

// 按键模式删除索引
db.students.dropIndex({ last_login: -1 })

索引大小和使用情况

使用 $indexStats 聚合阶段可以查看索引的大小和使用情况(自上次重启以来的操作次数)。

db.students.aggregate([{ $indexStats: {} }])

实践操作

在本节中,我们将使用 products_for_indexing 集合进行实践,数据已在 data.js 中定义。

需求描述

假设我们有一个电商平台的 products02 集合,需要支持以下查询场景:

  1. 频繁按商品类别 (category) 查找商品,并按价格 (price) 排序。
  2. 需要快速获取特定类别和品牌的商品信息,且只关心品牌和价格。

实践细节和结果验证

// 准备工作:确保 products_for_indexing 集合存在且包含数据
// (数据已在 data.js 中定义,请先加载)

// 1. 创建复合索引以优化排序查询
// 需求:按类别查找并按价格排序
db.products_for_indexing.createIndex({ category: 1, price: 1 });

// 2. 使用 explain() 分析查询性能

// -- 无索引查询 (模拟,假设索引未创建) --
// db.products_for_indexing.find({ category: 'Electronics' }).sort({ price: -1 }).explain('executionStats');
// 结果会显示 COLLSCAN (全表扫描),效率低

// -- 有索引查询 --
db.products_for_indexing.find({ category: 'Electronics' }).sort({ price: -1 }).explain('executionStats');
// **结果验证**: 
// winningPlan.stage 应为 IXSCAN (索引扫描),表明使用了索引。
// totalDocsExamined 数量远小于集合总数,性能高。

// 3. 构造并验证覆盖查询
// 需求:只查询电子产品的品牌和价格

// -- 创建一个能覆盖查询的索引 --
db.products_for_indexing.createIndex({ category: 1, brand: 1, price: 1 });

// -- 执行覆盖查询 --
db.products_for_indexing.find(
  { category: 'Electronics', brand: 'Sony' },
  { brand: 1, price: 1, _id: 0 }
).explain('executionStats');
// **结果验证**:
// totalDocsExamined 应为 0表明没有读取文档。
// winningPlan.stage 为 IXSCAN且没有 FETCH 阶段,证明是高效的覆盖查询。

// 4. 索引维护

// -- 查看当前集合上的所有索引 --
db.products_for_indexing.getIndexes();

// -- 删除一个不再需要的索引 --
// 假设 { category: 1, price: 1 } 这个索引不再需要
db.products_for_indexing.dropIndex('category_1_price_1');

// -- 再次查看索引,确认已删除 --
db.products_for_indexing.getIndexes();