MongoDB 索引

索引是数据库性能的生命线。没有索引,任何查询都要扫描整个集合(全表扫描),数据量一大就慢得无法忍受;给常用查询字段建上索引,查询速度能从秒级降到毫秒级。这一篇系统讲 MongoDB 索引:从单字段到复合、从唯一到 TTL,以及最重要的——用 explain 排查慢查询。

1. 为什么需要索引

想象你要在一本没有目录的书里找某个词,只能一页一页翻——这就是全表扫描(COLLSCAN)。如果书后面有索引页(按字母排序),你直接翻到索引找到页码,翻过去就行——这就是索引扫描(IXSCAN)。数据库索引原理完全一样,底层是 B 树(更准确说是 B+ 树变种)。

代价是:索引要占额外存储,且每次写入要同步维护。所以索引不是越多越好,而是要按实际查询模式选择性建

2. 单字段索引

最基础的索引形式,给一个字段建索引。单字段索引的方向(1 升序 / -1 降序)对单字段来说无所谓。

// 没有索引时,查询要"全表扫描"(COLLSCAN)
// 数据量一大就慢得无法忍受
// 给常用查询字段建索引,查询从 O(n) 降到 O(log n)

// 单字段索引:1 升序,-1 降序(单字段时方向无所谓)
db.posts.createIndex({ title: 1 })

// 创建时可以给索引起个名字(否则自动生成)
db.posts.createIndex({ title: 1 }, { name: "idx_title" })

// 查看集合上所有索引(每个集合默认在 _id 上有索引)
db.posts.getIndexes()

// 看某个查询用了什么索引、扫了多少文档
db.posts.find({ title: "x" }).explain("executionStats")

// 删除索引
db.posts.dropIndex("idx_title")
db.posts.dropIndex({ title: 1 })     // 也可以按字段删

// 删除所有非 _id 索引(谨慎)
db.posts.dropIndexes()

3. 复合索引与前缀原则

实际项目里查询常常多字段联合——这时用复合索引。复合索引的字段顺序非常关键,遵循"前缀原则":{ a, b, c } 的索引能加速 { a } / { a, b } / { a, b, c } 的查询,但不能加速跳过 a 的 { b }{ b, c }

// 复合索引:多个字段联合,顺序非常关键!
// 前缀原则:复合索引 { a: 1, b: 1, c: 1 } 能加速
//   { a } / { a, b } / { a, b, c } 的查询
// 但加速不了 { b } / { b, c } / { c } 这种"跳过 a"的查询
db.posts.createIndex({ author: 1, createdAt: -1, views: -1 })

// 上面索引能高效支持:
db.posts.find({ author: "小明" }).sort({ createdAt: -1 })
db.posts.find({ author: "小明", createdAt: { $gt: ... } }).sort({ views: -1 })

// 但对这种"没指定 author"的查询没用:
db.posts.find({ createdAt: { $gt: ... } })   // 走不了索引前缀

// ESR 规则(复合索引字段顺序的最佳实践):
// E (Equality) 等值匹配字段  -> 放最前
// S (Sort)     排序字段      -> 中间
// R (Range)    范围字段      -> 最后
// 例: 经常查 "某作者、某时间段、按时间倒序" -> { author, createdAt, views }

关于字段顺序有个著名的 ESR 规则(Equality / Sort / Range):等值匹配字段放最前,排序字段居中,范围字段放最后。这是 MongoDB 官方推荐的复合索引设计方法,实战中极其有用。

4. 唯一索引

给字段加唯一约束,禁止重复值——常用于 email、username、slug 这类业务上天然唯一的字段。除了强制唯一,它本身也是个普通索引,能加速查询。

// 唯一索引:禁止字段出现重复值(强制唯一约束)
db.users.createIndex({ email: 1 }, { unique: true })

// 试图插入重复 email 会报错
db.users.insertOne({ email: "a@x.com", name: "A" })  // OK
db.users.insertOne({ email: "a@x.com", name: "B" })  // E11000 duplicate key

// 复合唯一索引:组合起来唯一(单独字段可重复)
db.couples.createIndex({ userId: 1, partnerId: 1 }, { unique: true })

// 部分索引 partialFilterExpression:只对满足条件的文档建索引
db.posts.createIndex(
  { slug: 1 },
  { unique: true, partialFilterExpression: { slug: { $exists: true } } }
)
// 这样没设 slug 的文档不参与唯一约束(避免大量 null 冲突)

部分索引(partialFilterExpression)是个高级技巧:只对满足条件的文档建索引。比如给"已发布文章的 slug"加唯一约束,草稿就不参与——既保证已发布文章 slug 唯一,又避免大量 null/缺失值冲突。

5. 特殊索引:TTL、文本、地理

MongoDB 还有几种针对特定场景的索引,功能非常强大。

// TTL 索引:文档到期自动删除(做会话、缓存、日志神器)
db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 3600 }    // 1 小时后自动删除
)
// 注意:删除是后台任务每 60 秒跑一次,不保证精确到期
// 字段必须是 BSON Date 类型

// 文本索引:支持全文检索(多语言)
db.posts.createIndex({ title: "text", content: "text" })
db.posts.find({ $text: { $search: "MongoDB 入门" } })
db.posts.find(
  { $text: { $search: "MongoDB 入门" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })   // 按相关度排序

// 地理空间索引:支持"附近的人"这类查询
db.places.createIndex({ location: "2dsphere" })     // GeoJSON 点/多边形
// 查附近的咖啡店(按距离由近到远)
db.places.find({
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [116.4, 39.9] },
      $maxDistance: 2000      // 2 公里内
    }
  }
})

// 大小写不敏感索引(Collation,适合做用户名)
db.users.createIndex(
  { username: 1 },
  { collation: { locale: "zh", strength: 2 } }
)

TTL 索引是后端神器——给"过期时间"字段建索引并设置 expireAfterSeconds,MongoDB 后台每 60 秒扫一次,自动删除到期文档。做 session、验证码、缓存、日志滚动都很合适。

文本索引支持全文检索(分词、相关度排序),适合博客搜索、商品搜索等场景。但中文分词支持有限,生产级全文检索通常还是上 Elasticsearch。地理索引则让你能查"附近的咖啡店""两公里内的用户",美团的"附近"就是用它。

6. explain:排查慢查询的神器

建了索引不等于查询一定会用它——MongoDB 优化器会根据统计信息选择执行计划。要确认查询是否走了索引,必须用 explain

// explain 是排查慢查询的神器
// 三种详细程度: "queryPlanner" < "executionStats" < "allPlansExecution"

db.posts.find({ title: "x" }).explain("executionStats")

// 重点看几个字段:
//   winningPlan.stage = "IXSCAN"      -> 走了索引 (好)
//   winningPlan.stage = "COLLSCAN"    -> 全表扫描 (坏,要建索引)
//   totalKeysExamined                 -> 扫了多少索引项
//   totalDocsExamined                 -> 扫了多少文档
//   executionTimeMillis               -> 执行耗时(毫秒)
//   nReturned                         -> 返回了多少文档

// 理想状态: totalKeysExamined ≈ nReturned
// 糟糕状态: totalDocsExamined 远大于 nReturned(查了半天扔掉一大半)

// 在 mongosh 里查慢一点的查询:
db.posts.find({ views: { $gt: 10 } }).explain("executionStats").executionStats

重点看 stage:IXSCAN 表示走了索引(好),COLLSCAN 表示全表扫描(坏)。再看 totalDocsExamined(扫了多少文档)和 nReturned(返回了多少)——理想状态下两者接近;如果扫了 10 万条只返回 10 条,说明索引没选好或没用上。

7. 索引的代价与权衡

索引不是越多越好。每个索引都是一棵独立的 B 树,占存储、占内存,且每次写入(insert / update / delete)都要同步维护。所以建索引要权衡:

// 索引不是免费的午餐!权衡:
//   + 加速查询
//   - 占用额外存储(每个索引都是一棵 B 树)
//   - 降低写入速度(insert/update/delete 都要同步维护索引)
//   - 占用内存(索引要常驻内存才快)

// 实战经验:
// 1. 只给"经常出现在查询条件里"的字段建索引
// 2. 优先给基数高(取值多)的字段建(如 email > status)
// 3. 复合索引比多个单字段索引更高效(且能覆盖排序)
// 4. 用 explain 验证索引是否被真正使用
// 5. 定期清理不再使用的索引(db.collection.aggregate([{$indexStats:{}}]))

// 看索引占用多少存储
db.posts.stats().indexSizes

// 后台建索引(对在线服务影响小,但慢)
db.posts.createIndex({ bigField: 1 }, { background: true })
// 注意: 4.2+ 版本默认就是非阻塞的, background 选项已废弃

几条实战经验:

8. 索引类型速查表

小结

这一篇讲清了索引这件事:为什么需要、单字段/复合/唯一/特殊索引怎么用、ESR 规则怎么排字段顺序、用 explain 验证索引是否生效、以及索引的代价权衡。掌握索引是从"能用 MongoDB"到"用好 MongoDB"的关键分水岭。下一篇换个视角,聊模式设计——文档该怎么组织、什么时候嵌入、什么时候引用。

← 上一篇 MongoDB 聚合管道

下一篇 MongoDB 模式设计

✈️💬