MongoDB 索引设计
索引是 MongoDB 提升查询效率的核心手段。没有索引时,数据库可能需要扫描大量文档;有合适索引时,可以快速定位符合条件的数据。
索引不是越多越好。索引能加快查询,但会增加写入成本和存储空间。
学习目标
学完本页,你要能做到:
| 目标 | 能力 |
|---|---|
| 看懂索引原理 | 知道 IXSCAN、FETCH、COLLSCAN、SORT 分别代表什么 |
| 会设计复合索引 | 能按等值、排序、范围和分页设计字段顺序 |
| 会读 explain | 能用 totalKeysExamined、totalDocsExamined、nReturned 判断索引质量 |
| 会处理商业列表 | 能为资产列表、订单列表、采集日志、审计流水设计索引 |
| 知道索引成本 | 能解释索引为什么会拖慢写入、占用内存、增加存储 |
| 会面试回答 | 能回答 ESR、多键索引、TTL、唯一索引、覆盖查询、慢查询排查 |
查询为什么需要索引
flowchart TD
A["查询条件"] --> B{"是否有合适索引"}
B -- "没有" --> C["扫描大量文档 COLLSCAN"]
C --> D["过滤结果"]
B -- "有" --> E["通过索引定位 IXSCAN"]
E --> F["读取少量文档"]核心原理:索引是有序的数据结构
MongoDB 索引可以理解成按字段值排好序的辅助数据结构。查询时如果条件能利用索引,就先在索引里定位候选文档,再回表读取文档;如果没有合适索引,就只能扫描集合里的大量文档。
索引字段顺序很重要,因为复合索引不是多个单字段索引的简单相加。它更像一本按 userId -> status -> createdAt 排好的目录,如果查询跳过最前面的 userId,后面的字段就很难充分发挥作用。
目标是让高频查询尽量走 IXSCAN,避免大集合上频繁 COLLSCAN。
查询有索引为什么还可能慢
MongoDB 走索引并不等于一定快。真正要看扫描了多少索引项、读取了多少文档、是否需要额外排序。
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 | 只索引满足条件的文档 |
创建索引:
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 })唯一索引不只是加速查询,它还是并发安全的唯一性兜底。例如资产编号不能重复:
db.assets.createIndex({ assetNo: 1 }, { unique: true })不要只在应用层先查再插入。并发下两个请求可能都查不到,然后同时插入。唯一索引让数据库在写入时兜底。
从查询模式设计索引
不要看到字段就加索引。正确步骤是:
flowchart TD
A["收集接口查询条件"] --> B["统计高频查询"]
B --> C["确认过滤字段、排序字段、分页方式"]
C --> D["设计单字段或复合索引"]
D --> E["使用 explain 验证"]
E --> F["观察慢查询和索引大小"]
F --> G["保留有效索引,删除无效索引"]例如订单列表接口:
db.orders.find({
userId: "u1001",
status: "PAID"
}).sort({ createdAt: -1 }).limit(20)可以考虑复合索引:
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })商业场景:资产列表索引设计
医疗资产平台常见列表:
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)推荐索引:
db.assets.createIndex({
hospitalId: 1,
status: 1,
deleted: 1,
createdAt: -1
})为什么这样排:
| 字段 | 原因 |
|---|---|
hospitalId | 租户/医院隔离,几乎所有查询都带 |
status | 等值过滤,继续缩小范围 |
deleted | 软删除过滤,避免已删数据参与扫描 |
createdAt | 游标分页和排序 |
如果查询经常只看未删除数据,也可以考虑部分索引:
db.assets.createIndex(
{ hospitalId: 1, status: 1, createdAt: -1 },
{ partialFilterExpression: { deleted: false } }
)部分索引只索引满足条件的文档,索引更小。但查询条件必须包含相同过滤语义,否则可能无法使用它。
复合索引顺序
常见经验是:等值条件在前,排序字段靠后,范围字段通常放最后。
// 查询
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 是复合索引设计的常用思路:
| 字母 | 含义 | 解释 |
|---|---|---|
| E | Equality 等值 | { hospitalId: "H001", status: "USED" } |
| S | Sort 排序 | .sort({ createdAt: -1 }) |
| R | Range 范围 | { createdAt: { $lt: xxx } } 或 { amount: { $gte: 100 } } |
例如:
db.assets.find({
hospitalId: "H001",
status: "USED",
createdAt: { $lt: ISODate("2026-07-06T00:00:00Z") }
}).sort({ createdAt: -1 }).limit(20)索引:
db.assets.createIndex({
hospitalId: 1,
status: 1,
createdAt: -1
})这里 hospitalId/status 是等值,createdAt 同时承担排序和范围游标。MongoDB 可以先定位医院和状态,再按时间倒序往后取 20 条。
错误例子:
db.assets.createIndex({
createdAt: -1,
hospitalId: 1,
status: 1
})这个索引先按全局时间排序。如果全库数据很多,查询某个医院某个状态时可能要扫很多不属于该医院的数据,再过滤,扫描量会明显变大。
注意:ESR 是经验,不是死规则。字段区分度、排序方向、范围条件位置、查询频率都会影响最终选择,所以必须用 explain("executionStats") 验证。
explain 基础
db.orders.find({ userId: "u1001" }).explain("executionStats")重点看:
| 字段 | 含义 |
|---|---|
winningPlan.stage | 执行阶段,关注 IXSCAN 或 COLLSCAN |
totalDocsExamined | 实际扫描文档数 |
totalKeysExamined | 扫描索引 key 数 |
nReturned | 返回文档数 |
executionTimeMillis | 执行耗时 |
理想情况是扫描数量接近返回数量。如果扫描 100 万条只返回 10 条,索引设计通常有问题。
explain 案例:索引好不好怎么判断
假设查询:
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)好索引:
db.asset_collect_logs.createIndex({
assetNo: 1,
collectTime: -1
})理想 explain:
nReturned: 50
totalKeysExamined: 50 到几百
totalDocsExamined: 50 到几百
winningPlan 包含 IXSCAN
没有大范围 SORT坏现象:
nReturned: 50
totalKeysExamined: 800000
totalDocsExamined: 800000
stage: COLLSCAN 或 SORT含义:数据库为了返回 50 条扫描了 80 万条,说明索引没有按查询路径工作。可能原因是缺少 assetNo + collectTime 复合索引,或者排序方向/字段顺序不匹配。
覆盖查询
如果查询条件、排序字段、返回字段都在索引里,MongoDB 可能不需要 FETCH 原始文档,这叫覆盖查询。
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 | 复合索引有最左前缀原则 |
| 对字段做复杂计算 | 查询表达式无法使用索引 | 尽量存储可直接查询的字段 |
| 低区分度字段单独建索引 | gender、deleted | 过滤效果差 |
| 大范围模糊匹配 | 非前缀正则 | 很难利用普通索引 |
| 排序字段不匹配 | 查询和排序顺序与索引不一致 | 会产生额外排序 |
正则、模糊查询和索引
前缀正则可能使用索引:
db.assets.find({ assetNo: /^A2026/ })非前缀模糊通常很难利用普通 B-tree 索引:
db.assets.find({ assetNo: /2026/ })如果业务需要复杂搜索,例如资产名称分词、拼音、模糊匹配、高亮、相关性排序,MongoDB 普通索引不是最佳选择,通常要考虑 Elasticsearch 或专门搜索方案。不要把所有搜索需求都压到正则上。
skip 深分页为什么慢
db.assets.find({ hospitalId: "H001" })
.sort({ createdAt: -1 })
.skip(200000)
.limit(20)skip 不是直接跳到第 200001 条,而是要按顺序扫描并丢弃前面的结果。页码越深,浪费越大。
更适合列表无限滚动的是游标分页:
db.assets.find({
hospitalId: "H001",
createdAt: { $lt: ISODate("2026-07-06T10:00:00Z") }
}).sort({ createdAt: -1 }).limit(20)如果 createdAt 可能重复,可以加入 _id 做稳定游标:
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)索引成本
每个索引都会带来成本:
- 插入数据时要写文档,也要写索引。
- 更新索引字段时要维护索引。
- 索引占用磁盘和内存。
- 太多索引会让优化器选择变复杂。
所以索引要服务于高频、核心、慢查询,而不是覆盖所有字段。
索引治理和删除
生产不是“只加索引不删索引”。无用索引会长期拖慢写入。
可以看索引使用统计:
db.assets.aggregate([
{ $indexStats: {} }
])重点关注:
| 指标 | 含义 |
|---|---|
accesses.ops | 索引被访问次数 |
| 索引大小 | 是否占用大量内存/磁盘 |
| 最近是否有访问 | 长期不用可能是冗余索引 |
删除索引前要确认:
- 是否有低频但关键的月末/审计/报表任务使用。
- 是否被唯一约束依赖。
- 是否被隐藏在某个接口的排序中。
- 是否能在测试环境验证删除影响。
不要只看一天访问次数就删除索引,很多业务是周/月周期。
常见设计建议
- 列表页查询通常需要同时考虑过滤、排序和分页。
- 高频等值字段适合放在复合索引前面。
- 排序字段要和索引方向匹配。
- 唯一业务标识使用唯一索引做兜底。
- 定期清理无用索引。
- 分页深度很大时,优先考虑基于游标的分页方式,而不是超大
skip。
练习
- 为用户邮箱登录设计唯一索引。
- 为订单列表
userId + status + createdAt desc设计复合索引。 - 使用
explain("executionStats")比较建索引前后的扫描数量。 - 找一个低区分度字段,思考为什么它不适合单独建索引。
- 设计一个 TTL 索引用于清理 7 天后过期的验证码记录。
小结
MongoDB 索引设计要从查询模式出发,而不是从字段出发。复合索引尤其要关注字段顺序、过滤条件、排序和分页。每次新增索引都要用 explain 验证收益,并评估写入成本和存储成本。
