索引
索引类型
| 类型 | 创建 | 用途 |
|---|---|---|
| 单字段 | {age: 1} | 基础。1 升序 -1 降序 |
| 复合索引 | {a: 1, b: -1} | 多条件查询,最多 32 个字段 |
| 多键索引 Multikey | 对数组字段建索引时自动成为多键 | 为数组每个元素建一个索引项 |
| 文本索引 Text | {title: "text"} | 全文搜索,每个集合只能有一个 |
| 哈希索引 Hashed | {_id: "hashed"} | 只支持等值查询,用于哈希分片 |
| 地理空间 | 2dsphere / 2d | 附近的人、范围查询 |
| 通配符 Wildcard | {"$**": 1} | 字段名不确定时(4.2+) |
| TTL 索引 | {createdAt: 1}, {expireAfterSeconds: 3600} | 自动删除过期文档 |
| 部分索引 Partial | partialFilterExpression | 只对满足条件的文档建索引,省空间 |
| 稀疏索引 Sparse | {sparse: true} | 跳过不含该字段的文档 |
| 唯一索引 | {unique: true} | 唯一约束 |
TTL 索引的三个考点:
- 后台任务每 60 秒扫描一次,所以删除有延迟,不保证精确到秒
- 字段必须是
Date类型或 Date 数组,其他类型不会被删 - 要做「指定时间点过期」,设
expireAfterSeconds: 0并把字段值设为目标过期时间
部分索引 vs 稀疏索引:部分索引更通用(可以写任意条件),官方推荐优先用它。典型用法是只给「未删除的订单」建索引:partialFilterExpression: {isDeleted: false}。
🔥 ESR 规则
复合索引的字段顺序法则,MongoDB 面试的标志性考点:
Equality → Sort → Range
等值匹配 排序 范围查询
为什么是这个顺序:
- 等值字段放最前 —— 把 B+ 树的扫描范围缩小到一个连续区间
- 排序字段放中间 —— 在等值确定的前缀内,索引本身已经有序,可以直接利用索引顺序返回结果,避免内存排序
- 范围字段放最后 —— 范围查询之后的索引字段无法再用于排序或精确匹配(和 MySQL 的最左前缀一样)
例子:查询 {status: "active", age: {$gt: 18}} 并 sort({createdAt: -1})
// ❌ 错:range 在 sort 之前,createdAt 用不上索引排序 → SORT 阶段
db.t.createIndex({status: 1, age: 1, createdAt: -1})
// ✅ 对:E → S → R
db.t.createIndex({status: 1, createdAt: -1, age: 1})
:::warning 排序方向必须匹配
索引 {a: 1, b: -1} 能支持 sort({a: 1, b: -1}),也能支持完全反向的 sort({a: -1, b: 1})(反向扫描索引)。但不能支持 sort({a: 1, b: 1}) —— 方向组合不一致。
这个坑在「列表页支持多种排序」的需求里经常踩到。 :::
最左前缀与覆盖查询
最左前缀和 MySQL 完全一样:索引 {a:1, b:1, c:1} 能服务 {a}、{a,b}、{a,b,c} 的查询,不能服务只有 {b} 或 {c} 的查询。
覆盖查询(covered query) —— 查询和投影的字段全在索引里,不需要读取文档。条件比 MySQL 严格:
- 查询条件的所有字段 + 返回的所有字段都在同一个索引里
- 必须显式排除
_id({_id: 0}),因为_id默认返回但通常不在该索引里 - 索引字段不能是数组(多键索引无法覆盖)
db.t.createIndex({name: 1, age: 1})
db.t.find({name: "张三"}, {name: 1, age: 1, _id: 0}) // ✅ 覆盖
db.t.find({name: "张三"}, {name: 1, age: 1}) // ❌ _id 导致要读文档
验证方法:explain() 的 winningPlan.stage 是 PROJECTION_COVERED,且 executionStats.totalDocsExamined 为 0。
多键索引的限制
对数组字段建索引会自动变成多键索引(不需要声明)。三个限制:
- 一个复合索引里最多有一个字段是数组 ——
{a: 1, b: 1}中 a 和 b 不能同时是数组(否则索引项是笛卡尔积,会爆炸) - 不能作为分片键
- 不能用于覆盖查询
另外多键索引的索引项数量 = 所有文档的数组元素总数,所以给大数组建索引非常占空间。
explain 怎么读
db.t.find({...}).explain("executionStats")
三种模式:queryPlanner(只看计划,默认)、executionStats(执行并给统计)、allPlansExecution(含被淘汰的候选计划)。
关键字段:
| 字段 | 含义 |
|---|---|
winningPlan.stage | 执行阶段类型,见下表 |
nReturned | 实际返回的文档数 |
totalKeysExamined | 扫描的索引键数 |
totalDocsExamined | 扫描的文档数 |
executionTimeMillis | 耗时 |
stage 的含义(对应 MySQL 的 type):
| stage | 含义 | 好坏 |
|---|---|---|
IDHACK | 按 _id 查询的快速路径 | ✅ 最好 |
PROJECTION_COVERED | 覆盖查询,不读文档 | ✅ 很好 |
IXSCAN | 索引扫描 | ✅ 好 |
FETCH | 用索引结果去取文档(≈ 回表) | 正常 |
SORT | 内存排序,没用上索引排序 | ⚠️ 要优化 |
COLLSCAN | 全集合扫描 | ❌ 最差 |
判断健康的核心指标:totalDocsExamined / nReturned 应该接近 1。等于 1 说明扫一个返回一个,索引精准;远大于 1 说明扫了大量无用文档。
totalKeysExamined 远大于 nReturned 则说明索引字段顺序不对(扫了很多索引项但大部分被过滤掉)。
:::tip SORT 阶段的硬限制
内存排序有 100MB 上限(internalQueryMaxBlockingSortMemoryUsageBytes),超了直接报错 Sort exceeded memory limit。只能靠建对索引消除 SORT 阶段,或者在聚合里开 allowDiskUse。
生产上「列表接口偶发 500」的一个典型原因就是这个 —— 数据量涨上来之后排序超限。 :::
其他要点
索引的代价:每个索引都要在写入时维护,且占用 WiredTiger cache。索引总大小应该能装进内存,否则索引本身的随机 IO 会成为瓶颈。查看:db.t.totalIndexSize()。
后台建索引 —— 4.2 之前要写 {background: true} 否则会锁住整个数据库;4.2+ 的新建索引算法默认就不阻塞读写,不再需要这个选项。
找出无用索引 —— $indexStats 聚合可以看每个索引被使用的次数:
db.t.aggregate([{$indexStats: {}}])
从未被使用(accesses.ops 为 0)且运行足够久的索引可以删掉。
和 MySQL 索引的区别(高频对比题):
| MongoDB | MySQL (InnoDB) | |
|---|---|---|
| 数据结构 | B+ 树 | B+ 树 |
| 聚簇 | 默认非聚簇,连 _id 都要「回表」 | 主键聚簇 |
| 数组字段 | 支持(多键索引) | 不支持(只能用生成列或全文索引变通) |
| 嵌套字段 | 支持 "a.b.c" 直接建索引 | 需要 JSON 虚拟列 + 索引 |
| TTL 自动过期 | 内置 | 无,要自己写定时任务 |
| 单集合/表索引上限 | 64 | 无硬限制(建议 5~6) |