MongoDB 索引类型

候选人小赵在面试阿里的数据库内核岗位时,面试官看了他简历上写的"精通 MongoDB 索引优化",抛出了第一个问题:

"MongoDB 文档里有一个数组字段 tags: ['java', 'mysql', 'mongodb'],如果要给这个字段建索引,会创建什么类型的索引?"

小赵说:"单字段索引吧,直接 db.collection.createIndex({ tags: 1 })。"

面试官又问:"那如果我查询 db.collection.find({ tags: 'java' }),索引是怎么工作的?"

小赵说:"遍历索引找到 'java'。"

面试官拿起笔在纸上画了一下,说:"数组字段的索引,MongoDB 会怎么处理每个数组元素?"

小赵沉默了三秒。

【面试官心理】 这道题我用来测试候选人是否真正理解"多键索引"(Multikey Index)的机制。很多候选人知道 MongoDB 有多键索引,但不知道它会为数组的每个元素分别建立索引条目,也不知道多键索引带来的限制——比如一个索引不能同时索引两个数组字段。90% 的候选人在这个问题上只能答出"遍历索引"四个字。

一、索引基础与单字段索引 🔴

1.1 MongoDB 索引原理

MongoDB 使用 B-Tree 索引(默认),和 MySQL 的 InnoDB 类似。索引的结构决定了查询性能:

// 创建单字段索引
db.users.createIndex({ age: 1 })  // 1 表示升序,-1 表示降序

// 等价的底层数据结构
// B-Tree:
//       [age: 18] ──┬── [age: 25]
//                  │
//                  └── [age: 20] ──┬── [age: 30]
//                                   │
//                                   └── [age: 35]
// 查找 age = 25:从根节点开始,二分查找定位到 [age: 25] 所在的叶子节点
// 时间复杂度:O(log n),而不是 O(n) 的全表扫描
💡

MongoDB 的 _id 字段默认就有唯一索引,所以不能用 insert 插入重复的 _id。但要注意:_id 的默认索引是 B-Tree 而不是 ObjectId 的字典序排列,ObjectId 的时间有序特性是逻辑上的,不是索引结构本身保证的。

1.2 索引的创建与查看

// 创建复合索引(Compound Index)
db.products.createIndex({ category: 1, price: -1, name: 1 })

// 查看索引
db.products.getIndexes()
// [
//   { "v": 2, "key": { "_id": 1 }, "name": "_id_" },
//   { "v": 2, "key": { "category": 1, "price": -1, "name": 1 }, "name": "category_1_price_-1_name_1" }
// ]

// 查看查询是否使用了索引
db.products.find({ category: "electronics" }).explain("executionStats")
// executionStats.executionStages.inputStage.stage: "IXSCAN" → 使用了索引
// executionStats.executionStages.stage: "COLLSCAN" → 全表扫描

1.3 ❌ 错误示范

候选人原话:"MongoDB 索引和 MySQL 一样,创建了就能用,没什么区别。"

问题诊断

  • 忽略了索引的方向性——复合索引有字段顺序,查询必须遵循最左前缀原则
  • 没有理解索引覆盖(Covered Query)的概念
  • 不了解索引的写放大问题——每次写入都要维护索引

面试官内心 OS:"说 MongoDB 和 MySQL 索引一样,说明这个候选人没有真正理解 MongoDB 的索引特性。MongoDB 的复合索引和 MySQL 的复合索引在字段顺序的处理上确实类似,但 MongoDB 的多键索引、文本索引、2dsphere 索引是 MySQL 没有的。而且 MongoDB 的索引选择策略比 MySQL 简单很多,没有复杂的优化器。"

1.4 标准回答

// P5 级别:知道基本索引类型
"MongoDB 支持单字段索引、复合索引、多键索引、文本索引、地理位置索引等。
创建索引用 `createIndex`,查看用 `explain`"

// P6 级别:能讲清楚复合索引字段顺序
"复合索引的字段顺序很重要,遵循最左前缀原则。
比如 `{ age: 1, name: 1 }` 的索引可以支持
`{ age: 25 }``{ age: 25, name: "张三" }` 的查询,
但不支持 `{ name: "张三" }` 的查询。
字段顺序的选择取决于实际查询中最常用的过滤条件。"

// P7 级别:能讲清楚索引覆盖和写放大
"理想的索引应该覆盖查询的所有字段(Covered Query),
这样查询可以直接在索引中返回结果,不需要回表查原始文档。
但覆盖索引会增加存储开销和写放大——每次更新文档时都要更新索引。
生产环境中需要权衡:读多写少的字段适合建覆盖索引,写多读少的字段要谨慎建索引。"

【面试官心理】 面试中追问复合索引的字段顺序几乎是必问的套路。我通常会出一道具体场景:"有查询 { age: { $gte: 18 } }{ age: 18, name: "张三" },复合索引怎么建?"能答对的候选人不到 30%。

二、复合索引与最左前缀原则 🟡

2.1 最左前缀原则详解

复合索引的字段顺序决定了它能支持的查询模式:

// 创建复合索引
db.users.createIndex({ age: 1, city: 1, signUpDate: 1 })

// 以下查询可以使用这个索引(遵循最左前缀):
db.users.find({ age: 25 })                           // ✅ 使用索引前导字段
db.users.find({ age: 25, city: "北京" })             // ✅ 使用前两个字段
db.users.find({ age: 25, city: "北京", signUpDate: { $gte: new Date() } }) // ✅ 全匹配

// 以下查询不能使用完整索引:
db.users.find({ city: "北京" })                       // ❌ 跳过前导字段,无法使用
db.users.find({ signUpDate: { $gte: new Date() } })  // ❌ 跳过前两个字段
db.users.find({ city: "北京", signUpDate: { $gte: new Date() } }) // ❌ 跳过 age
⚠️

这里有一个常见的陷阱:如果查询中使用了范围条件(如 $gte$lt),索引只能使用到范围字段之前的字段,后面的字段无法被利用。例如 { age: { $gte: 18 }, city: "北京" } 中,age 是范围条件,所以 { city } 无法利用索引。

2.2 索引字段顺序选择策略

如何决定复合索引中字段的顺序?生产环境的经验法则:

// 选择性高的字段放前面
// 场景:筛选活跃用户
// { status: 1, age: 1, city: 1 }
// status 只有 3 个值(活跃/禁用/冻结),选择性低
// age 有 100+ 个值,选择性高
// 如果查询总是 { status: "active", age: 25 },索引应该是 { status: 1, age: 1 }
// 如果查询是 { status: "active", city: "北京" },索引应该是 { status: 1, city: 1 }

// 经验法则:
// 1. 等值条件字段(=)放前面
// 2. 排序字段放中间(如果排序是固定的)
// 3. 范围条件字段($gte)放最后
💡

一个具体的判断方法:用 db.collection.distinct("字段名").length 计算字段的选择性(不同值的数量)。值越多,选择性越高,越应该放在复合索引的前面。但这只是参考,实际还要结合查询频率来权衡。

三、多键索引(Multikey Index)🔴

3.1 多键索引的工作原理

MongoDB 会为数组字段的每个元素分别创建索引条目:

// 文档
{
  _id: 1,
  name: "张三",
  tags: ["java", "mysql", "mongodb"]  // 数组字段
}

// tags 字段上的多键索引结构(B-Tree):
// java     → [doc_1]
// mysql    → [doc_1]
// mongodb  → [doc_1]
// python   → [doc_2, doc_3]

// 查询
db.collection.find({ tags: "java" })
// 查找索引键 "java",直接返回 [doc_1]
// 时间复杂度:O(log n),而不是遍历所有文档的 O(n)

3.2 多键索引的限制

这是面试中最容易被问到但又最容易答错的点:

限制一:一个复合索引不能有两个数组字段

// ❌ 错误:会报错 "cannot create compound index with several array fields"
db.products.createIndex({ tags: 1, categories: 1 })

// 原因:多键索引的交叉乘积会导致索引爆炸
// tags: ["A", "B"] + categories: ["X", "Y"] → 索引条目: A+X, A+Y, B+X, B+Y

限制二:数组字段不能是复合索引的唯一字段

// ❌ 错误
db.collection.createIndex({ tags: 1 }, { unique: true })
// 报错:cannot create unique index on array field

// 因为数组去重后可能导致唯一性被破坏
📖 点击展开多键索引的限制详解
// 限制一的具体例子:
// 文档 A: { name: "产品A", tags: ["tech", "sale"], categories: ["硬件", "折扣"] }
// 文档 B: { name: "产品B", tags: ["food"], categories: ["食品"] }

// tags 上的多键索引:tech→[A], sale→[A], food→[B]
// categories 上的多键索引:硬件→[A], 折扣→[A], 食品→[B]

// 如果用复合索引 { tags: 1, categories: 1 }:
// 索引条目变成:A+tech+硬件, A+tech+折扣, A+sale+硬件, A+sale+折扣, B+food+食品
// 索引条目数量 = tags.length × categories.length
// 如果一个文档有 100 个 tags 和 100 个 categories,索引条目数 = 10000
// 这就是"索引爆炸"问题

3.3 追问升级:如何在数组字段上高效查询

面试官追问:"如果我的商品文档有 colors: ['red', 'large']sizes: ['S', 'M', 'L'] 两个数组字段,都要支持查询,怎么办?"

P6 回答:"不能用复合索引,但可以分别建两个单字段多键索引。"

P7 回答:"两个多键索引分别建,但要注意查询时 MongoDB 只会选择一个索引(索引交叉不会自动发生)。如果需要同时利用两个数组字段过滤,可以用 $expr + $gt 技巧,或者考虑反规范化——把 colorssizes 合并为一个数组 variants: ['red-S', 'red-M', 'red-L', 'large-S', ...'],这样查询 variants: 'red-S' 只需要一个多键索引。"

【面试官心理】 多键索引的限制是 MongoDB 面试中的高频送命题。90% 的候选人知道"数组字段会建多键索引",但只有 30% 知道"不能同时有两个数组字段的复合索引"。能说出索引爆炸原理和应对策略的,基本都是 P7 级别。

四、其他索引类型 🟡

4.1 文本索引(Text Index)

// 创建文本索引
db.articles.createIndex({ title: "text", content: "text" })

// 查询:搜索包含关键词的文档
db.articles.find({ $text: { $search: "mongodb optimization" } })

// 给文本索引加权(title 的权重高于 content)
db.articles.createIndex(
  { title: "text", content: "text" },
  { weights: { title: 10, content: 1 } }
)

// 查看文本搜索的得分
db.articles.find(
  { $text: { $search: "mongodb" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

:::warning ⚠️ 文本索引的限制:

  • 一个集合只能有一个文本索引
  • 不支持中文分词(需要配合中文分析器如 mongo-icu-analyzer
  • 不支持 OR 查询的高效实现
  • 文本索引会占用大量空间 :::

4.2 地理位置索引(2dsphere)

// 存储地理位置数据
db.stores.insertOne({
  name: "星巴克中关村店",
  location: {
    type: "Point",
    coordinates: [116.312345, 39.987654]  // [经度, 纬度]
  }
})

// 创建 2dsphere 索引
db.stores.createIndex({ location: "2dsphere" })

// 查询:附近 5 公里内的商家
db.stores.find({
  location: {
    $nearSphere: {
      $geometry: { type: "Point", coordinates: [116.3, 39.9] },
      $maxDistance: 5000  // 5公里,单位:米
    }
  }
})

// 范围查询(不需要 $nearSphere,只需要 $geoWithin)
db.stores.find({
  location: {
    $geoWithin: {
      $centerSphere: [[116.3, 39.9], 5 / 6378.1]  // [中心点, 距离(弧度)]
    }
  }
})

4.3 TTL 索引(自动过期)

// 创建 TTL 索引:文档在 30 天后自动删除
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 })

// MongoDB 后台线程每 60 秒检查一次 TTL
// 适用于:会话数据、临时文件、日志

4.4 唯一索引(Unique Index)

// 普通唯一索引
db.users.createIndex({ email: 1 }, { unique: true })

// 复合唯一索引(两个字段组合唯一)
db.userOrders.createIndex({ userId: 1, orderId: 1 }, { unique: true })

// 稀疏唯一索引(只对存在字段的值强制唯一,忽略 null)
db.profiles.createIndex({ phone: 1 }, { unique: true, sparse: true })
// 如果 phone 为 null 的文档有多条,sparse: true 不会报错
// 如果没有 sparse,插入多个 phone 为 null 的文档会报错

五、索引设计最佳实践 🟢

5.1 索引选择的判断流程

// 场景分析
// 1. 查询模式分析(通过慢查询日志)
db.getSiblingDB("admin").aggregate([
  { $currentOp: { allUsers: true, ops: true } },
  { $match: { op: "command", "command.aggregate": { $exists: true } } }
])

// 2. 查看当前索引的使用情况
db.collection.aggregate([
  { $indexStats: {} }
])
// 结果包含每个索引被访问的次数、命中次数、扫描次数

// 3. 识别未使用的索引
// 访问次数为 0 的索引应该删除
db.collection.dropIndex("未使用的索引名")

5.2 常见索引设计错误

错误后果正确做法
为每个查询字段单独建单字段索引索引过多,写放大严重尽量用复合索引
复合索引字段顺序随意查询无法利用最左前缀按等值→排序→范围的顺序排列
在低选择性字段上建索引索引扫描量接近全表扫描先确认字段选择性
建了索引但 explain 从不看索引可能未被使用定期检查 indexStats
删除"没用"的索引后发现变慢写优化了但读变慢删除前对比读写性能

【面试官心理】 索引设计是 MongoDB 面试中的硬核部分。我通常会从最基础的"单字段索引"问到"多键索引限制",再问到"生产环境如何诊断和优化"。能完整回答这套追问链的候选人,在实际工作中基本不会写出"索引爆炸"或者"建了 50 个索引导致写入龟速"这类事故代码。