在MongoDB中,索引(Index)是提升查询性能的关键机制,类似于关系型数据库中的索引。它通过创建数据的有序结构,帮助数据库快速定位和访问数据,避免全集合扫描(full collection scan),从而显著提高查询效率。

一、索引的基本概念

  1. 作用:加速查询操作,减少数据扫描范围。
  2. 代价:索引会占用额外存储空间,且在插入、更新、删除操作时需要维护索引,可能略微降低写性能。
  3. 默认索引:MongoDB在创建集合时,会自动为_id字段创建唯一索引(_id_),确保文档唯一性。

二、索引的类型

MongoDB支持多种类型的索引,适用于不同的查询场景:

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

对文档中的单个字段创建索引,最常用的索引类型。

创建语法

db.collection.createIndex({ field: 1 })  // 1表示升序,-1表示降序

示例:为users集合的name字段创建升序索引

db.users.createIndex({ name: 1 })

适用场景:针对单个字段的查询、排序操作。

2. 复合索引(Compound Index)

对多个字段联合创建索引,索引的顺序会影响查询效率。

创建语法

db.collection.createIndex({ field1: 1, field2: -1 })

示例:为orders集合的userId(升序)和createTime(降序)创建复合索引

db.orders.createIndex({ userId: 1, createTime: -1 })

注意

  • 遵循“最左前缀原则”:查询条件中必须包含索引的第一个字段,才能有效使用复合索引。
  • 例如,上述索引对{userId: ...}{userId: ..., createTime: ...}的查询有效,但对{createTime: ...}的查询无效。
3. 多键索引(Multikey Index)

当字段值为数组时,MongoDB会自动为数组中的每个元素创建索引,无需显式指定。

示例products集合的tags字段是数组

// 文档结构
{ _id: 1, name: "手机", tags: ["电子", "通讯", "智能"] }

// 创建索引(自动成为多键索引)
db.products.createIndex({ tags: 1 })

适用场景:查询数组中是否包含某个元素(如db.products.find({ tags: "电子" }))。

4. 地理空间索引(Geospatial Index)

用于存储和查询地理空间数据(如经纬度)。

类型

  • 2dsphere:适用于球形表面的地理坐标(如GPS数据)。
  • 2d:适用于平面上的坐标(较少使用)。

示例:为locations集合的coordinates字段创建地理空间索引

db.locations.createIndex({ coordinates: "2dsphere" })

适用场景:查询附近的地点(如$near操作符)。

5. 文本索引(Text Index)

用于全文搜索,支持对字符串字段进行分词和匹配。

创建语法

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

// 多字段文本索引(合并多个字段的内容)
db.collection.createIndex({ field1: "text", field2: "text" })

示例:为articles集合的titlecontent创建文本索引

db.articles.createIndex({ title: "text", content: "text" })

查询示例:搜索包含“MongoDB”或“数据库”的文档

db.articles.find({ $text: { $search: "MongoDB 数据库" } })
6. 哈希索引(Hashed Index)

对字段值进行哈希运算后创建索引,适用于基于哈希值的分片操作(Sharding)。

创建语法

db.collection.createIndex({ field: "hashed" })

注意:哈希索引不支持范围查询,仅支持精确匹配。

7. 唯一索引(Unique Index)

确保索引字段的值唯一,类似于_id索引的特性。

创建语法

db.collection.createIndex({ field: 1 }, { unique: true })

示例:确保users集合的email字段唯一

db.users.createIndex({ email: 1 }, { unique: true })

注意:如果插入重复值,会抛出DuplicateKey错误。

三、索引的操作

1. 创建索引
// 基础语法
db.collection.createIndex(
  { field: order },  // 索引字段及排序方向
  { options }        // 可选参数(如unique、name等)
)

// 示例:创建名为"age_idx"的唯一索引
db.users.createIndex({ age: 1 }, { unique: true, name: "age_idx" })
2. 查看索引
// 查看集合的所有索引
db.collection.getIndexes()

// 示例:查看users集合的索引
db.users.getIndexes()
3. 删除索引
// 按索引名称删除
db.collection.dropIndex("index_name")

// 按索引字段删除
db.collection.dropIndex({ field: order })

// 示例:删除name字段的索引
db.users.dropIndex({ name: 1 })

// 删除所有索引(保留默认的_id索引)
db.collection.dropIndexes()

四、索引的使用与优化

1. 分析查询是否使用索引

使用explain()方法查看查询计划,判断是否命中索引:

db.collection.find(query).explain("executionStats")
  • executionStats.executionStages.stageIXSCAN,表示使用了索引。
  • 若为COLLSCAN,表示全集合扫描,需优化索引。
2. 索引优化建议
  • 只为常用查询创建索引:避免创建过多索引,影响写性能。
  • 优先使用复合索引覆盖多字段查询:减少索引数量。
  • 遵循最左前缀原则:复合索引的字段顺序需与查询条件匹配。
  • 避免索引过度冗余:例如,已创建{a:1, b:1},无需再创建{a:1}
  • 定期清理无用索引:通过getIndexes()检查并删除不常用的索引。

五、索引的限制

  • 每个集合最多支持64个索引
  • 索引键的总长度不能超过1024字节。
  • 某些操作(如$where$expr)可能无法使用索引。

索引使用案例

在企业级MongoDB应用中,复合索引(多列索引)是优化复杂查询的常用手段。以下结合真实业务场景,提供几个典型企业案例,并详细说明索引设计思路和注释。

案例1:电商平台订单查询系统

业务场景
电商平台需要频繁查询“某个用户在特定时间范围内的订单”,并按订单创建时间倒序展示。
文档结构orders集合):

{
  _id: ObjectId("..."),
  userId: "u12345",       // 用户ID
  orderNo: "ORD20230801", // 订单编号
  createTime: ISODate("2023-08-01T10:30:00Z"), // 创建时间
  status: "PAID",         // 订单状态
  amount: 199.99          // 订单金额
}

查询需求
db.orders.find({ userId: "u12345", createTime: { $gte: ISODate("2023-08-01"), $lte: ISODate("2023-08-31") } }).sort({ createTime: -1 })

复合索引设计

// 创建复合索引:{ userId: 1, createTime: -1 }
db.orders.createIndex(
  { userId: 1, createTime: -1 },  // 索引字段及排序方向
  { name: "idx_userId_createTime" }  // 自定义索引名称(便于管理)
)

索引注释

  1. 字段顺序userId在前,createTime在后

    • 遵循“最左前缀原则”:查询条件中必须包含userId才能触发索引,而createTime作为范围查询条件放在后面。
    • 若颠倒顺序(createTime在前),则无法通过userId快速过滤数据,索引失效。
  2. 排序方向createTime: -1(降序)

    • 与查询中的sort({ createTime: -1 })一致,避免数据库额外排序(索引本身已按时间倒序排列,可直接返回结果)。
  3. 业务价值

    • 原本需要扫描全表匹配userId,再过滤时间范围,索引将查询效率提升100倍以上(数据量100万级时)。

案例2:社交媒体动态流系统

业务场景
社交平台需要查询“某个用户关注的人发布的动态,且动态类型为‘图文’,并按发布时间倒序展示”。
文档结构posts集合):

{
  _id: ObjectId("..."),
  authorId: "user890",    // 作者ID(被关注人)
  publisherId: "user123", // 发布者ID(关注人)
  type: "IMAGE_TEXT",     // 动态类型:图文/视频/纯文本
  publishTime: ISODate("2023-08-01T15:20:00Z"), // 发布时间
  content: "今天天气真好..."
}

查询需求
db.posts.find({ publisherId: "user123", type: "IMAGE_TEXT" }).sort({ publishTime: -1 })

复合索引设计

// 创建复合索引:{ publisherId: 1, type: 1, publishTime: -1 }
db.posts.createIndex(
  { publisherId: 1, type: 1, publishTime: -1 },
  { name: "idx_publisher_type_publishTime" }
)

索引注释

  1. 字段顺序逻辑

    • 第一字段publisherId:过滤“关注人”的动态,是查询的核心条件,基数(不同值的数量)适中,适合作为前缀。
    • 第二字段type:进一步过滤“图文”类型,基数较小(通常只有3-5种类型),放在中间可减少索引扫描范围。
    • 第三字段publishTime: -1:匹配排序需求,避免额外排序开销。
  2. 为何不将type放在最后?

    • 若索引为{ publisherId: 1, publishTime: -1, type: 1 },查询时虽能通过publisherId过滤,但type条件需要在时间范围内再次筛选,效率低于先过滤type
  3. 业务效果

    • 支持每秒数万次的动态流查询,响应时间控制在10ms以内,满足社交平台高并发需求。

案例3:物流轨迹查询系统

业务场景
物流平台需要查询“某个物流单号在特定城市、特定状态下的轨迹记录”,并按时间正序展示。
文档结构tracks集合):

{
  _id: ObjectId("..."),
  waybillNo: "WB78901234", // 物流单号
  city: "SHANGHAI",        // 城市
  status: "IN_TRANSIT",    // 状态:运输中/已签收/异常
  updateTime: ISODate("2023-08-01T08:10:00Z"), // 更新时间
  location: "XX分拣中心"   // 位置详情
}

查询需求
db.tracks.find({ waybillNo: "WB78901234", city: "SHANGHAI", status: "IN_TRANSIT" }).sort({ updateTime: 1 })

复合索引设计

// 创建复合索引:{ waybillNo: 1, city: 1, status: 1, updateTime: 1 }
db.tracks.createIndex(
  { waybillNo: 1, city: 1, status: 1, updateTime: 1 },
  { name: "idx_waybill_city_status_time" }
)

索引注释

  1. 字段优先级

    • waybillNo(物流单号):基数极高(几乎唯一),作为首字段可瞬间定位到某单的所有轨迹,过滤效率最高。
    • 后续字段citystatus:按查询条件的过滤粒度依次排列,逐步缩小范围。
    • updateTime: 1:匹配正序排序需求,索引直接有序,无需额外处理。
  2. 索引覆盖性

    • 若查询仅需返回updateTimelocation,可扩展为“覆盖索引”:
      db.tracks.createIndex({ waybillNo: 1, city: 1, status: 1, updateTime: 1 }, { name: "...", include: { location: 1 } })
    • 覆盖索引可直接从索引返回结果,无需访问文档,性能再提升30%+。
  3. 业务价值

    • 支持百万级物流单号的实时轨迹查询,解决了原全表扫描时“查询超时”问题。

复合索引设计的核心原则总结

  1. 最左前缀匹配:查询条件必须包含索引的前N个字段,否则索引失效。
  2. 字段顺序依据:基数高(值唯一或多样)的字段放前面,过滤性强;排序字段放最后,且排序方向需与索引一致。
  3. 避免过度设计:字段数量不宜过多(建议≤5个),否则索引维护成本高,写性能下降。
  4. 结合查询频率:只为高频查询创建复合索引,低频查询可接受稍慢的响应。

MongoDB 的 explain() 方法是分析查询性能、优化索引设计的核心工具。它能展示查询的执行计划,帮助我们判断是否使用了索引、是否存在全表扫描等性能问题。

一、explain() 基本用法

explain() 需附加在查询操作后(如 find()aggregate() 等),用于生成执行计划。其语法如下:

db.collection.find(query).sort(sort).limit(limit).explain(verbosity)
  • verbosity(可选):指定执行计划的详细程度,常用取值:
    • queryPlanner(默认):返回查询计划的核心信息(推荐日常使用)。
    • executionStats:在查询计划基础上,增加实际执行的统计数据(如扫描文档数、耗时)。
    • allPlansExecution:展示所有可能的执行计划及各自的执行统计(用于复杂查询分析)。

二、核心参数解析(以 executionStats 为例)

执行计划中关键字段的含义:

字段路径 含义 优化目标
executionStats.executionStages.stage 查询执行的阶段 应出现 IXSCAN(索引扫描),避免 COLLSCAN(全表扫描)
executionStats.executedWork 执行的工作量(越低越好) 数值越小,性能越好
executionStats.nReturned 返回的文档数 totalDocsExamined 越接近越好
executionStats.totalDocsExamined 扫描的文档总数 越小越好(理想值 = nReturned
executionStats.totalKeysExamined 扫描的索引键总数 越小越好(接近 nReturned 最佳)

三、全流程案例:从查询到优化

假设场景:电商平台的 orders 集合(100万条数据),文档结构如下:

{
  _id: ObjectId("..."),
  userId: "u123",         // 用户ID
  createTime: ISODate("2023-08-01T10:00:00Z"), // 创建时间
  status: "PAID",         // 订单状态
  amount: 299.99          // 金额
}
步骤1:执行原始查询并生成执行计划

需求:查询用户 u123 在 2023年8月 的已支付订单,并按创建时间倒序排列。

// 原始查询(未创建索引)
db.orders.find({
  userId: "u123",
  status: "PAID",
  createTime: { 
    $gte: ISODate("2023-08-01"), 
    $lte: ISODate("2023-08-31") 
  }
}).sort({ createTime: -1 })
// 附加explain,获取执行统计
.explain("executionStats")
步骤2:分析执行计划(发现问题)

执行计划关键部分(简化):

{
  "executionStats": {
    "executionStages": {
      "stage": "COLLSCAN", // 全表扫描!性能极差
      "nReturned": 15,     // 符合条件的文档数
      "totalDocsExamined": 1000000, // 扫描了所有100万条文档
      "executionTimeMillis": 850    // 耗时850ms(很慢)
    }
  },
  "queryPlanner": {
    "winningPlan": {
      "stage": "SORT",     // 需要额外排序
      "sortPattern": { "createTime": -1 },
      "inputStage": { "stage": "COLLSCAN" }
    }
  }
}

问题分析

  • stage: "COLLSCAN":未使用索引,全表扫描导致扫描文档数极高。
  • stage: "SORT":查询需要在内存中排序,增加耗时。
步骤3:创建合适的索引

根据查询条件(userIdstatuscreateTime)和排序(createTime: -1),设计复合索引:

// 创建复合索引:优先过滤字段在前,排序字段在后
db.orders.createIndex(
  { userId: 1, status: 1, createTime: -1 }, 
  { name: "idx_user_status_time" }
)

索引设计逻辑

  • userId 放在最前:查询的核心过滤条件,基数高(区分度强)。
  • status 次之:进一步缩小范围(状态值有限,基数低)。
  • createTime: -1:与排序方向一致,避免额外排序。
步骤4:再次执行查询并验证优化效果
// 相同查询,再次执行explain
db.orders.find({
  userId: "u123",
  status: "PAID",
  createTime: { $gte: ISODate("2023-08-01"), $lte: ISODate("2023-08-31") }
}).sort({ createTime: -1 })
.explain("executionStats")
步骤5:分析优化后的执行计划(确认改进)

优化后的关键结果:

{
  "executionStats": {
    "executionStages": {
      "stage": "IXSCAN", // 索引扫描!成功使用索引
      "indexName": "idx_user_status_time", // 使用了我们创建的索引
      "nReturned": 15,
      "totalDocsExamined": 15,    // 扫描文档数 = 返回数(完美)
      "totalKeysExamined": 15,    // 扫描索引键数 = 返回数
      "executionTimeMillis": 5    // 耗时降至5ms(提升170倍)
    }
  },
  "queryPlanner": {
    "winningPlan": {
      "stage": "FETCH",  // 通过索引找到文档位置后,直接获取文档
      "inputStage": {
        "stage": "IXSCAN", // 索引扫描作为前置阶段
        "indexBounds": {   // 索引扫描的范围(精准匹配条件)
          "userId": ["[\"u123\", \"u123\"]"],
          "status": ["[\"PAID\", \"PAID\"]"],
          "createTime": ["[new Date(1690819200000), new Date(1693411199999)]"]
        }
      }
    }
  }
}

优化效果

  • 从全表扫描(COLLSCAN)变为索引扫描(IXSCAN)。
  • 扫描文档数从100万降至15,耗时从850ms降至5ms。
  • 排序操作消失(索引本身已按createTime: -1排序,无需额外处理)。

四、常见问题与解决方案

问题现象 原因 解决方案
stage: "COLLSCAN" 未创建合适索引,或查询条件未匹配索引前缀 按“最左前缀原则”创建复合索引
totalDocsExamined >> nReturned 索引过滤性差,扫描了大量无关文档 调整索引字段顺序,将高基数字段放前面
存在 SORT 阶段且耗时高 排序字段未包含在索引中,或排序方向与索引不一致 在索引中包含排序字段,并保持方向一致
executionTimeMillis 过高 索引设计不合理,或数据量过大 优化索引,或增加查询过滤条件缩小范围

五、explain() 在聚合管道中的使用

对于 aggregate() 聚合查询,explain() 同样适用,示例:

db.orders.aggregate([
  { $match: { userId: "u123", status: "PAID" } },
  { $group: { _id: "$createTime", total: { $sum: "$amount" } } }
]).explain("executionStats")

分析重点:关注 $match 阶段是否使用索引(IXSCAN),避免在聚合早期阶段处理大量数据。

总结

explain() 是 MongoDB 性能优化的“显微镜”,其使用流程可概括为:

  1. 对目标查询执行 explain("executionStats") 获取执行计划。
  2. 检查 stage 是否为 IXSCAN,以及扫描文档数、耗时等指标。
  3. 根据分析结果调整索引设计(如新增、删除或修改索引)。
  4. 再次执行 explain() 验证优化效果,循环迭代直至性能达标。

稀疏索引与普通索引的核心区别

场景 普通索引 稀疏索引
文档缺少索引字段 会为其创建索引项(值为null) 完全不创建索引项
索引包含的文档数量 与集合总文档数一致 可能少于集合总文档数(只包含有索引字段的文档)
索引大小 较大(包含所有文档) 较小(只包含部分文档)
查询无索引字段的文档 能通过索引定位(但效率低) 必须全表扫描(因为没有索引项)

实例验证

  1. 创建文本索引:
db.articles.createIndex({ content: "text" }) // 这是一个稀疏索引
  1. 插入不同文档:
// 文档1:有有效content字段 → 会被索引
db.articles.insertOne({ content: "MongoDB索引教程" })

// 文档2:没有content字段 → 不会被索引
db.articles.insertOne({ title: "无内容文档" })

// 文档3:content字段为空 → 不会被索引
db.articles.insertOne({ content: "" })

// 文档4:content字段为null → 不会被索引
db.articles.insertOne({ content: null })
  1. 执行文本搜索:
db.articles.find({ $text: { $search: "教程" } }) // 只能找到文档1

其他常见的稀疏索引场景

除了文本索引,还有两种常见的稀疏索引:

  1. 显式指定的稀疏索引
    通过sparse: true参数创建:

    // 只为有email字段的用户创建索引
    db.users.createIndex({ email: 1 }, { sparse: true })
    
  2. 唯一稀疏索引
    解决"唯一约束但允许部分文档缺失字段"的场景:

    // 确保email唯一,但允许用户没有email字段
    db.users.createIndex({ email: 1 }, { unique: true, sparse: true })
    

    如果没有sparse: true,第二个没有email字段的文档会因null重复而插入失败

稀疏索引的优缺点

优点:

  • 索引体积更小,节省磁盘空间和内存
  • 写入操作更快(无需为缺失字段的文档维护索引)
  • 适合字段只存在于部分文档的场景(如可选字段)

缺点:

  • 无法用于查询"缺失索引字段"的文档(会导致全表扫描)
  • 可能影响排序操作(缺少索引项的文档会被排在最后)

工作中的实用建议

  1. 当索引字段在少于50%的文档中存在时,优先考虑稀疏索引
  2. 避免对"大部分文档都包含的字段"使用稀疏索引
  3. 使用explain()验证稀疏索引是否被正确使用(尤其注意包含null条件的查询)
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐