Skip to content

MongoDB 索引设计

索引是 MongoDB 提升查询效率的核心手段。没有索引时,数据库可能需要扫描大量文档;有合适索引时,可以快速定位符合条件的数据。

索引不是越多越好。索引能加快查询,但会增加写入成本和存储空间。

学习目标

学完本页,你要能做到:

目标能力
看懂索引原理知道 IXSCANFETCHCOLLSCANSORT 分别代表什么
会设计复合索引能按等值、排序、范围和分页设计字段顺序
会读 explain能用 totalKeysExaminedtotalDocsExaminednReturned 判断索引质量
会处理商业列表能为资产列表、订单列表、采集日志、审计流水设计索引
知道索引成本能解释索引为什么会拖慢写入、占用内存、增加存储
会面试回答能回答 ESR、多键索引、TTL、唯一索引、覆盖查询、慢查询排查

查询为什么需要索引

mermaid
flowchart TD
    A["查询条件"] --> B{"是否有合适索引"}
    B -- "没有" --> C["扫描大量文档 COLLSCAN"]
    C --> D["过滤结果"]
    B -- "有" --> E["通过索引定位 IXSCAN"]
    E --> F["读取少量文档"]

核心原理:索引是有序的数据结构

MongoDB 索引可以理解成按字段值排好序的辅助数据结构。查询时如果条件能利用索引,就先在索引里定位候选文档,再回表读取文档;如果没有合适索引,就只能扫描集合里的大量文档。

索引字段顺序很重要,因为复合索引不是多个单字段索引的简单相加。它更像一本按 userId -> status -> createdAt 排好的目录,如果查询跳过最前面的 userId,后面的字段就很难充分发挥作用。

目标是让高频查询尽量走 IXSCAN,避免大集合上频繁 COLLSCAN

查询有索引为什么还可能慢

MongoDB 走索引并不等于一定快。真正要看扫描了多少索引项、读取了多少文档、是否需要额外排序。

mermaid
flowchart TD
    A["查询命中索引"] --> B["IXSCAN 扫描索引 key"]
    B --> C{"查询字段是否都在索引里"}
    C -- "是" --> D["可能覆盖查询,少读文档"]
    C -- "否" --> E["FETCH 读取原始文档"]
    E --> F{"扫描文档是否远大于返回"}
    F -- "是" --> G["索引选择性差或字段顺序不合理"]
    F -- "否" --> H["索引效果较好"]

常见慢因:

现象原因
totalKeysExamined 很大索引扫描范围大,复合索引顺序可能不匹配
totalDocsExamined 很大需要读取大量文档再过滤
nReturned 很小扫描很多但返回很少,索引过滤效率差
计划中有 SORT排序没有被索引顺序满足
有索引但仍 COLLSCAN查询条件无法使用索引或优化器认为扫描更划算

所以索引设计的目标不是“出现 IXSCAN”,而是让扫描量尽量接近返回量,并减少额外排序和 FETCH。

常见索引类型

类型示例适合场景
单字段索引{ userId: 1 }单条件查询
复合索引{ userId: 1, createdAt: -1 }多条件过滤和排序
唯一索引{ email: 1 } + unique唯一约束
TTL 索引{ expireAt: 1 }自动删除过期数据
文本索引{ title: "text" }简单全文检索
多键索引{ tags: 1 }数组字段查询
部分索引{ status: 1 } + filter只索引满足条件的文档

创建索引:

javascript
db.orders.createIndex({ userId: 1 })
db.orders.createIndex({ userId: 1, createdAt: -1 })
db.users.createIndex({ email: 1 }, { unique: true })
db.sessions.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })

唯一索引不只是加速查询,它还是并发安全的唯一性兜底。例如资产编号不能重复:

javascript
db.assets.createIndex({ assetNo: 1 }, { unique: true })

不要只在应用层先查再插入。并发下两个请求可能都查不到,然后同时插入。唯一索引让数据库在写入时兜底。

从查询模式设计索引

不要看到字段就加索引。正确步骤是:

mermaid
flowchart TD
    A["收集接口查询条件"] --> B["统计高频查询"]
    B --> C["确认过滤字段、排序字段、分页方式"]
    C --> D["设计单字段或复合索引"]
    D --> E["使用 explain 验证"]
    E --> F["观察慢查询和索引大小"]
    F --> G["保留有效索引,删除无效索引"]

例如订单列表接口:

javascript
db.orders.find({
  userId: "u1001",
  status: "PAID"
}).sort({ createdAt: -1 }).limit(20)

可以考虑复合索引:

javascript
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })

商业场景:资产列表索引设计

医疗资产平台常见列表:

javascript
db.assets.find({
  hospitalId: "H001",
  status: "USED",
  deleted: false,
  createdAt: { $lt: ISODate("2026-07-06T00:00:00Z") }
}, {
  assetNo: 1,
  name: 1,
  status: 1,
  createdAt: 1
}).sort({ createdAt: -1 }).limit(20)

推荐索引:

javascript
db.assets.createIndex({
  hospitalId: 1,
  status: 1,
  deleted: 1,
  createdAt: -1
})

为什么这样排:

字段原因
hospitalId租户/医院隔离,几乎所有查询都带
status等值过滤,继续缩小范围
deleted软删除过滤,避免已删数据参与扫描
createdAt游标分页和排序

如果查询经常只看未删除数据,也可以考虑部分索引:

javascript
db.assets.createIndex(
  { hospitalId: 1, status: 1, createdAt: -1 },
  { partialFilterExpression: { deleted: false } }
)

部分索引只索引满足条件的文档,索引更小。但查询条件必须包含相同过滤语义,否则可能无法使用它。

复合索引顺序

常见经验是:等值条件在前,排序字段靠后,范围字段通常放最后。

javascript
// 查询
db.orders.find({
  userId: "u1001",
  status: "PAID",
  amount: { $gte: 100 }
}).sort({ createdAt: -1 })

// 可能的索引
db.orders.createIndex({
  userId: 1,
  status: 1,
  createdAt: -1,
  amount: 1
})

但这不是绝对规则,还要看字段区分度和真实查询频率。最终要用 explain 验证。

ESR 原则讲清楚

ESR 是复合索引设计的常用思路:

字母含义解释
EEquality 等值{ hospitalId: "H001", status: "USED" }
SSort 排序.sort({ createdAt: -1 })
RRange 范围{ createdAt: { $lt: xxx } }{ amount: { $gte: 100 } }

例如:

javascript
db.assets.find({
  hospitalId: "H001",
  status: "USED",
  createdAt: { $lt: ISODate("2026-07-06T00:00:00Z") }
}).sort({ createdAt: -1 }).limit(20)

索引:

javascript
db.assets.createIndex({
  hospitalId: 1,
  status: 1,
  createdAt: -1
})

这里 hospitalId/status 是等值,createdAt 同时承担排序和范围游标。MongoDB 可以先定位医院和状态,再按时间倒序往后取 20 条。

错误例子:

javascript
db.assets.createIndex({
  createdAt: -1,
  hospitalId: 1,
  status: 1
})

这个索引先按全局时间排序。如果全库数据很多,查询某个医院某个状态时可能要扫很多不属于该医院的数据,再过滤,扫描量会明显变大。

注意:ESR 是经验,不是死规则。字段区分度、排序方向、范围条件位置、查询频率都会影响最终选择,所以必须用 explain("executionStats") 验证。

explain 基础

javascript
db.orders.find({ userId: "u1001" }).explain("executionStats")

重点看:

字段含义
winningPlan.stage执行阶段,关注 IXSCANCOLLSCAN
totalDocsExamined实际扫描文档数
totalKeysExamined扫描索引 key 数
nReturned返回文档数
executionTimeMillis执行耗时

理想情况是扫描数量接近返回数量。如果扫描 100 万条只返回 10 条,索引设计通常有问题。

explain 案例:索引好不好怎么判断

假设查询:

javascript
db.asset_collect_logs.find({
  assetNo: "A001",
  collectTime: {
    $gte: ISODate("2026-07-01T00:00:00Z"),
    $lt: ISODate("2026-07-02T00:00:00Z")
  }
}).sort({ collectTime: -1 }).limit(50)

好索引:

javascript
db.asset_collect_logs.createIndex({
  assetNo: 1,
  collectTime: -1
})

理想 explain:

text
nReturned: 50
totalKeysExamined: 50 到几百
totalDocsExamined: 50 到几百
winningPlan 包含 IXSCAN
没有大范围 SORT

坏现象:

text
nReturned: 50
totalKeysExamined: 800000
totalDocsExamined: 800000
stage: COLLSCAN 或 SORT

含义:数据库为了返回 50 条扫描了 80 万条,说明索引没有按查询路径工作。可能原因是缺少 assetNo + collectTime 复合索引,或者排序方向/字段顺序不匹配。

覆盖查询

如果查询条件、排序字段、返回字段都在索引里,MongoDB 可能不需要 FETCH 原始文档,这叫覆盖查询。

javascript
db.assets.createIndex({
  hospitalId: 1,
  status: 1,
  createdAt: -1,
  assetNo: 1
})

db.assets.find({
  hospitalId: "H001",
  status: "USED"
}, {
  _id: 0,
  assetNo: 1,
  createdAt: 1
}).sort({ createdAt: -1 }).limit(20)

覆盖查询的好处是少读文档,减少 IO 和内存压力。代价是索引变宽,写入维护成本上升。

不要为了覆盖所有接口,把几十个字段都塞进一个巨大索引。商业项目通常只给高频、核心、字段少的列表页做覆盖。

索引失效常见原因

原因示例说明
查询字段不在索引前缀只有 { a: 1, b: 1 } 却只查 b复合索引有最左前缀原则
对字段做复杂计算查询表达式无法使用索引尽量存储可直接查询的字段
低区分度字段单独建索引genderdeleted过滤效果差
大范围模糊匹配非前缀正则很难利用普通索引
排序字段不匹配查询和排序顺序与索引不一致会产生额外排序

正则、模糊查询和索引

前缀正则可能使用索引:

javascript
db.assets.find({ assetNo: /^A2026/ })

非前缀模糊通常很难利用普通 B-tree 索引:

javascript
db.assets.find({ assetNo: /2026/ })

如果业务需要复杂搜索,例如资产名称分词、拼音、模糊匹配、高亮、相关性排序,MongoDB 普通索引不是最佳选择,通常要考虑 Elasticsearch 或专门搜索方案。不要把所有搜索需求都压到正则上。

skip 深分页为什么慢

javascript
db.assets.find({ hospitalId: "H001" })
  .sort({ createdAt: -1 })
  .skip(200000)
  .limit(20)

skip 不是直接跳到第 200001 条,而是要按顺序扫描并丢弃前面的结果。页码越深,浪费越大。

更适合列表无限滚动的是游标分页:

javascript
db.assets.find({
  hospitalId: "H001",
  createdAt: { $lt: ISODate("2026-07-06T10:00:00Z") }
}).sort({ createdAt: -1 }).limit(20)

如果 createdAt 可能重复,可以加入 _id 做稳定游标:

javascript
db.assets.find({
  hospitalId: "H001",
  $or: [
    { createdAt: { $lt: ISODate("2026-07-06T10:00:00Z") } },
    {
      createdAt: ISODate("2026-07-06T10:00:00Z"),
      _id: { $lt: ObjectId("...") }
    }
  ]
}).sort({ createdAt: -1, _id: -1 }).limit(20)

索引成本

每个索引都会带来成本:

  1. 插入数据时要写文档,也要写索引。
  2. 更新索引字段时要维护索引。
  3. 索引占用磁盘和内存。
  4. 太多索引会让优化器选择变复杂。

所以索引要服务于高频、核心、慢查询,而不是覆盖所有字段。

索引治理和删除

生产不是“只加索引不删索引”。无用索引会长期拖慢写入。

可以看索引使用统计:

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

重点关注:

指标含义
accesses.ops索引被访问次数
索引大小是否占用大量内存/磁盘
最近是否有访问长期不用可能是冗余索引

删除索引前要确认:

  1. 是否有低频但关键的月末/审计/报表任务使用。
  2. 是否被唯一约束依赖。
  3. 是否被隐藏在某个接口的排序中。
  4. 是否能在测试环境验证删除影响。

不要只看一天访问次数就删除索引,很多业务是周/月周期。

常见设计建议

  1. 列表页查询通常需要同时考虑过滤、排序和分页。
  2. 高频等值字段适合放在复合索引前面。
  3. 排序字段要和索引方向匹配。
  4. 唯一业务标识使用唯一索引做兜底。
  5. 定期清理无用索引。
  6. 分页深度很大时,优先考虑基于游标的分页方式,而不是超大 skip

练习

  1. 为用户邮箱登录设计唯一索引。
  2. 为订单列表 userId + status + createdAt desc 设计复合索引。
  3. 使用 explain("executionStats") 比较建索引前后的扫描数量。
  4. 找一个低区分度字段,思考为什么它不适合单独建索引。
  5. 设计一个 TTL 索引用于清理 7 天后过期的验证码记录。

小结

MongoDB 索引设计要从查询模式出发,而不是从字段出发。复合索引尤其要关注字段顺序、过滤条件、排序和分页。每次新增索引都要用 explain 验证收益,并评估写入成本和存储成本。