MongoDB 索引策略完全指南:从单字段到复合索引的性能调优

深入 MongoDB 索引机制,覆盖单字段/复合/文本/地理空间索引的创建、选择与优化,以及 explain 分析和覆盖索引技术

MongoDB 索引:性能调优的核心武器

想象一下,你需要在一座没有门牌号的巨型图书馆里找到一本书。只能一本一本地翻过去,这个过程就是全表扫描(COLLSCAN)。当图书馆只有几十本书时这不是问题,但当它拥有上亿本书时,这种查找方式就变得完全不可接受。

在 MongoDB 中,集合就是这样的"图书馆",而**索引(Index)**就是关键的"门牌号系统"。没有索引的查询,MongoDB 会从头到尾扫描每一个文档,性能随着数据量线性下降。有了合适的索引,查询性能可以从数秒压缩到毫秒级。但索引不是银弹:每个索引都有维护成本,会占用额外存储、拖慢写入速度、消耗内存资源。本文从底层原理出发,系统讲解 MongoDB 所有索引类型的创建、选择与优化方法。

索引基础:B-tree 结构与 Key 限制

为什么 MongoDB 选择 B-tree

MongoDB 的默认存储引擎 WiredTiger 使用 B-tree 存储索引数据。B-tree 的核心特点是多叉平衡树——每个节点可以有数百到数千个子节点,树高通常只有 3-4 层,百万级数据只需 2-3 次磁盘 I/O 即可定位到叶子节点。节点内部按键值有序排列,支持高效的二分查找。叶子节点存储指向实际文档的指针(Record ID),使索引与数据分离,便于独立管理。

更关键的是 B-tree 的磁盘友好性:节点大小设计为磁盘页大小的整数倍(如 4KB、8KB、16KB),一次磁盘读取即可加载整个节点,包含数百个键值和指针。这意味着即使在大数据量下,查找也只需要几次磁盘 I/O,远优于需要逐层单次读取的数据结构。

数据结构查找复杂度范围查询磁盘友好适用场景
哈希表O(1)不支持一般等值查询
红黑树O(log N)支持差(节点小)内存索引
B-treeO(log N)原生支持优秀磁盘索引
B+ treeO(log N)原生支持(叶子链表)优秀MySQL InnoDB

MongoDB 使用标准 B-tree(非 B+ tree),叶子节点之间没有链表连接,范围查询需通过树遍历完成,不像 MySQL 的 B+ tree 可直接顺序扫描叶子节点。

Index Key 核心限制

限制项数值说明
单个索引键值1024 字节超限时 insert/update 报错(代码 17280),4.2 前为 512 字节
复合索引字段数最多 32 个超过则报错
索引全名长度127 字节含集合名、库名、索引名和 $ 符号
集合索引建议数5-10 个过多会显著降低写入性能,数据量越大影响越明显

自动命名示例:db.orders.createIndex({ userId: 1 }) 生成索引名 userId_1。当复合索引字段多或字段名长时,建议显式命名:

db.orders.createIndex(
  { status: 1, createdAt: -1 },
  { name: "idx_status_time" }
);

id 索引:你无法删除的默认索引

每个集合自动创建 _id 唯一索引,无法删除。最佳实践是:如果业务有天然唯一标识(如用户 ID),直接用它作为 _id,避免额外创建索引:

// 推荐:用业务键作为 _id
db.users.insertOne({ _id: "user_9527", username: "alice" });

// 避免:自动生成 _id,再额外索引 userId
db.users.insertOne({ _id: ObjectId(), userId: "user_9527", username: "alice" });

单字段索引与复合索引

单字段索引

单字段索引是对一个字段建立的索引。1 表示升序(ascending),-1 表示降序(descending)。对于等值查询,升序降序没有区别;但对于范围查询和排序,选择与查询方向一致的索引可以避免内存排序。比如查看最新订单时,创建 { createdAt: -1 } 的降序索引更匹配 sort({ createdAt: -1 }) 的查询模式。

db.orders.createIndex({ userId: 1 });   // 升序
db.products.createIndex({ price: -1 }); // 降序

单字段索引支持等值查询、范围查询和排序。但当查询条件包含多个字段时,单字段索引往往不够用,此时需要复合索引。

复合索引与 ESR 原则

复合索引对多个字段组合建立索引,字段顺序至关重要。一个顺序正确的复合索引能让查询飞起,顺序错误的复合索引可能完全不被使用。MongoDB 官方推荐的 ESR 原则是设计复合索引的黄金法则:

  • E - Equality:等值条件字段放最前面
  • S - Sort:排序字段放中间
  • R - Range:范围条件字段放最后面

为什么是等值在前、范围在后?因为 B-tree 的等值查找可以精确定位到一个键值,而范围查找会返回一个键值区间。如果把范围字段放在前面,索引只能从一个很大的区间开始扫描,无法在后续字段上做精确定位。等值条件在前,可以先用等值精确定位到 B-tree 的一个小子树,然后在这个小子树内执行范围扫描。

// 典型查询场景:查找 north 区域 completed 状态、金额 >= 1000 的订单,按时间倒序
db.orders.find({
  status: "completed",        // 等值 (E)
  region: "north",            // 等值 (E)
  amount: { $gte: 1000 },     // 范围 (R)
  createdAt: { $gte: new Date("2024-01-01") }  // 范围 (R)
}).sort({ createdAt: -1 });

// 遵循 ESR 原则的正确复合索引
db.orders.createIndex({ status: 1, region: 1, amount: 1, createdAt: -1 });

如果 createdAt 同时是范围条件和排序字段,通常将它放在范围位置(R),因为范围条件在索引内部已经隐含有序。如果查询没有范围条件,只有排序,则排序字段应紧跟等值条件之后。

反例──把范围字段放前面会发生什么?

// 错误顺序:范围字段 amount 放在第一位
db.orders.createIndex({ amount: 1, status: 1, region: 1, createdAt: -1 });

当查询 { status: "completed", amount: { $gte: 1000 } } 时,MongoDB 先看索引第一位 amount,发现是范围条件 $gte,无法精确定位到单个键值。虽然有等值条件 status,但它在第二位,MongoDB 可能放弃使用该索引,或使用 amount 前缀做部分过滤后再大量扫描文档。这就是性能断崖式下降的原因。

前缀匹配原理

复合索引支持前缀匹配:MongoDB 可以从最左侧开始使用任意长度的前缀服务查询。

// 假设索引为 { a: 1, b: 1, c: 1 }

db.coll.find({ a: 1 });                    // 使用前缀 { a },可以走索引
db.coll.find({ a: 1, b: 2 });              // 使用前缀 { a, b },可以走索引
db.coll.find({ a: 1, b: 2, c: 3 });        // 使用完整索引,可以走索引
db.coll.find({ b: 2 });                    // 缺少前缀 { a },不能走索引
db.coll.find({ a: 1, c: 3 });              // 跳过 b,前缀不连续,不能走索引

排序的前缀匹配同样重要。假设索引是 { a: 1, b: -1 }

db.coll.find({ a: 1 }).sort({ b: -1 });    // 方向匹配,可用索引排序
db.coll.find({ a: 1 }).sort({ b: 1 });     // 方向相反,MongoDB 8.0+ 可反向扫描,老版本触发内存排序

复合索引方向规则

多字段排序时,索引方向必须与查询排序方向完全匹配或完全相反(全部反向)。混合方向会触发内存排序(SORT stage)。

// 索引 { createdAt: -1, score: -1 }
db.posts.find().sort({ createdAt: -1, score: -1 });  // 完全匹配
db.posts.find().sort({ createdAt: 1, score: 1 });    // 完全反向扫描
db.posts.find().sort({ createdAt: -1, score: 1 });   // 混合方向,触发内存排序

// MongoDB 4.4+ 可创建混合方向索引解决这类问题
db.posts.createIndex({ createdAt: -1, score: 1 });

实际业务中,最典型的是订单列表场景:先按状态等值过滤,再按时间倒序排列。

db.orders.createIndex({ status: 1, createdAt: -1 });
db.orders.find({ status: "paid" }).sort({ createdAt: -1 }).limit(20);
// 等值定位到 status="paid" 子树,子树内部自动按 createdAt 降序排列
// sort 直接利用索引顺序,完全避免内存排序

文本索引与地理空间索引

文本索引(Text Index)

MongoDB 内建全文搜索功能,通过 "text" 类型索引实现,适用于长文本字段的关键词搜索。

// 单字段文本索引
db.articles.createIndex({ content: "text" });

// 多字段复合文本索引,带权重
db.articles.createIndex(
  { title: "text", content: "text", tags: "text" },
  { weights: { title: 10, tags: 5, content: 1 } }
);

文本索引必须使用 $text 操作符查询:

// 基本全文搜索
db.articles.find({ $text: { $search: "mongodb indexing" } });

// 精确短语搜索(加引号)
db.articles.find({ $text: { $search: "\"compound index\"" } });

// 排除某个词
// 按相关性评分排序
db.articles.find(
  { $text: { $search: "performance optimization" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } });

文本索引的重要限制:

  • 一个集合只能有一个文本索引(无论包含多少字段)
  • 默认不区分大小写,过滤英文停用词
  • 英文自动词干提取(searching 匹配 search)
  • 不支持前缀搜索(如查找以 “mongo” 开头的词)
  • 索引体积通常比普通索引大得多

中文分词效果通常不够理想。生产环境中核心业务的全文搜索,建议用 Elasticsearch 或 MongoDB Atlas Search(基于 Lucene)。

地理空间索引(2dsphere)

MongoDB 通过 GeoJSON 格式支持地理位置数据,使用 "2dsphere" 索引类型。2dsphere 使用 WGS84 坐标系统,支持球面几何计算,适合真实世界的地理位置应用。

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

// 插入 GeoJSON 文档,注意坐标顺序是 [经度, 纬度]
db.places.insertOne({
  name: "故宫",
  category: "museum",
  location: { type: "Point", coordinates: [116.397428, 39.90923] }
});

邻近查询与区域查询:

// $near:查找最近的 10 个地点,5 公里内
db.places.find({
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [116.397428, 39.90923] },
      $maxDistance: 5000  // 单位:米
    }
  }
}).limit(10);

// $geoWithin + $centerSphere:圆形区域查询
// 弧度 = 公里 / 地球半径(约 6378.1 公里)
db.places.find({
  location: {
    $geoWithin: {
      $centerSphere: [
        [116.397428, 39.90923],  // 圆心 [经度, 纬度]
        5 / 6378.1                 // 5 公里的弧度表示
      ]
    }
  }
});

// $geoWithin + Polygon:多边形区域查询
db.places.find({
  location: {
    $geoWithin: {
      $geometry: {
        type: "Polygon",
        coordinates: [[
          [116.3, 39.8], [116.5, 39.8], [116.5, 40.0], [116.3, 40.0], [116.3, 39.8]
        ]]
      }
    }
  }
});

聚合管道中的 $geoNear 可以计算距离并自动排序:

db.places.aggregate([
  {
    $geoNear: {
      near: { type: "Point", coordinates: [116.397428, 39.90923] },
      distanceField: "distance",      // 输出距离字段(单位:米)
      maxDistance: 10000,             // 最大 10 公里
      query: { category: "restaurant" }  // 附加过滤条件
    }
  },
  { $limit: 20 }
]);

地理空间索引常与其他字段组成复合索引。一般将筛选条件放在 2dsphere 前面,因为 $near 包含隐式排序,筛选条件在前可以缩小地理空间计算的范围:

db.places.createIndex({ category: 1, location: "2dsphere" });

哈希索引与 TTL 索引

哈希索引(Hashed Index)

哈希索引只存储字段值的哈希结果,不支持范围查询和排序,仅支持等值查询。主要用途是作为分片集群的哈希分片键,实现数据的均匀分布。

// 创建哈希索引
db.users.createIndex({ userId: "hashed" });

// 分片集群中使用哈希分片键
sh.shardCollection("mydb.users", { userId: "hashed" });
// 范围查询无法使用哈希索引,会回退到全表扫描
db.users.find({ userId: { $gte: "u1000" } });

// 只有等值查询能有效使用
db.users.find({ userId: "u1234" });

TTL 索引(Time To Live)

TTL 索引让 MongoDB 自动删除超过指定时间的文档,适合会话、验证码、临时日志等有过期时间的数据。

// 方式一:固定时间后过期(从 createdAt 起 60 秒)
db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 60 }
);
db.sessions.insertOne({ userId: "u123", token: "abc123", createdAt: new Date() });

// 方式二:指定过期时间点
db.codes.createIndex(
  { expireAt: 1 },
  { expireAfterSeconds: 0 }
);
db.codes.insertOne({
  phone: "13800138000",
  code: "123456",
  expireAt: new Date(Date.now() + 5 * 60 * 1000)
});

TTL 索引的限制:

  • 清理线程每 60 秒运行一次,删除可能延迟最多 60 秒
  • 字段必须是 Date 类型,不能是数字时间戳
  • 必须是单字段索引,不能是复合索引
  • 不支持 partialFilterExpression
  • 不能在 _id 字段或 capped collection 上创建

TTL 适合做验证码过期等宽松时效场景,不适合需要精确秒级控制的定时任务。

explain() 深度分析:读懂查询执行计划

如果说索引设计是艺术,那么 explain() 就是艺术家的显微镜。它是 MongoDB 中最重要的性能诊断工具,日常调优中几乎每次都要用到。

三种 explain 模式

// queryPlanner:只展示查询计划,不实际执行
db.orders.find({ userId: "u123" }).explain("queryPlanner");

// executionStats:执行查询并返回统计信息(最常用)
db.orders.find({ userId: "u123" }).explain("executionStats");

// allPlansExecution:执行所有候选计划,返回详细对比
db.orders.find({ userId: "u123" }).explain("allPlansExecution");

日常诊断推荐使用 executionStats 模式,因为它既展示 MongoDB 选择的执行计划,又提供真实的执行统计:文档扫描数、返回数、执行时间。

Stage 类型速查表

执行计划中的 stage 字段揭示了查询的真正执行方式:

Stage含义性能影响
COLLSCAN全表扫描,逐个检查文档最差,应极力避免
IXSCAN索引扫描,遍历 B-tree良好,是期望看到的主阶段
FETCH根据索引的 Record ID 获取完整文档每次 FETCH 都可能触发磁盘 I/O
SORT内存排序(top-k 或全量)消耗大量内存和 CPU,默认上限 100MB
LIMIT限制返回文档数在 pipeline 靠前位置时非常有益
SKIP跳过 N 个文档大数据量分页时性能极差
PROJECTION_COVERED覆盖查询的投影阶段最优,无需 FETCH 即可返回结果
TEXT文本索引扫描专门用于 $text 查询
GEO_NEAR地理空间邻近查询需要 2dsphere 索引

executionStats 核心指标解读

{
  "executionStats": {
    "nReturned": 10,               // 最终返回给客户端的文档数
    "totalKeysExamined": 10,       // 扫描的索引键数量(越接近 nReturned 越好)
    "totalDocsExamined": 10,       // 扫描的实际文档数量(覆盖查询时为 0)
    "executionTimeMillis": 5       // 总执行时间(毫秒),生产环境通常要求 < 100ms
  }
}

健康检查公式:

  • 理想状态totalKeysExamined ≈ totalDocsExamined ≈ nReturned
  • 警告状态totalKeysExamined >> nReturned(索引选择性差,范围过大)
  • 危险状态:出现 COLLSCAN,或 SORT 伴随大数据量

有无索引的诊断对比:

// 无索引时 ── COLLSCAN
db.orders.explain("executionStats").find({ status: "paid" });
// nReturned: 15000
// totalDocsExamined: 1000000   // 扫描了 100 万条!
// executionTimeMillis: 850

// 创建索引后 ── IXSCAN
db.orders.createIndex({ status: 1 });
db.orders.explain("executionStats").find({ status: "paid" });
// nReturned: 15000
// totalKeysExamined: 15000     // 只扫描了 1.5 万个索引键
// totalDocsExamined: 15000
// executionTimeMillis: 12     // 快 70 倍

四大红旗信号及解决方案

红旗 1:COLLSCAN(全表扫描)

"winningPlan": { "stage": "COLLSCAN" }

说明查询没有使用任何索引。解决方案:为查询条件创建合适的复合索引,遵循 ESR 原则。如果查询包含多个过滤条件,优先将等值条件字段放在索引前面。

红旗 2:SORT(内存排序)

"executionStages": {
  "stage": "SORT",
  "sortPattern": { "createdAt": -1 },
  "memUsage": 52428800  // 已经用了 50MB 内存
}

没有索引支持的排序会把所有符合条件文档加载到内存。如果超过 100MB 限制会报 Sort exceeded memory limit of 104857600 bytes解决方案:将排序字段加入复合索引。比如 { status: "paid" }.sort({ createdAt: -1 }) 应该创建索引 { status: 1, createdAt: -1 }

红旗 3:totalDocsExamined 远大于 nReturned

"nReturned": 10,
"totalKeysExamined": 50000,
"totalDocsExamined": 50000  // 扫描 5 万条,只返回 10 条

索引被使用了,但选择性很差。常见原因:范围条件范围过大;查询带有 $ne/$not/$nin$or 某些分支未走索引。解决方案:缩小查询范围;改写负向条件为正向条件;为 $or 各分支分别建索引。

红旗 4:不必要的 FETCH 阶段

当查询只需要返回 _id 或索引中已有的字段时,如果执行计划中仍有 FETCH,说明可以优化为覆盖查询。

覆盖查询(Covered Query):索引即数据

覆盖查询是 MongoDB 索引优化的终极形态。当查询的所有需要字段都包含在索引中时,MongoDB 不需要读取原始文档,直接从索引返回结果。

核心优势

  • 零 FETCH 阶段:不需要根据索引键再到数据文件中定位文档,消除了最昂贵的 I/O 操作
  • 内存中完成:整个查询在索引数据页中进行,如果索引在内存中则速度极快
  • 减少锁竞争:不访问文档数据,减少对文档级锁的竞争

实现条件

  1. 查询投影(projection)只包含索引字段和 _id
  2. 如果 _id 不在索引中,必须显式排除 {_id: 0}
  3. 查询条件中的所有字段都属于同一个索引
  4. 不能有引用文档内容的条件(如 $where$expr 引用文档字段)
// 创建复合索引
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });

// 非覆盖查询:返回完整文档,需要 FETCH
db.orders.find({ userId: "u123", status: "paid" });

// 覆盖查询:投影只包含索引字段
db.orders.find(
  { userId: "u123", status: "paid" },
  { _id: 0, userId: 1, status: 1, createdAt: 1 }
);

覆盖查询的执行计划会出现 PROJECTION_COVERED stage,且没有 FETCH

"winningPlan": {
  "stage": "PROJECTION_COVERED",
  "inputStage": { "stage": "IXSCAN", ... }
}

分页优化:Keyset Pagination

传统 skip + limit 分页在深度翻页时性能极差,因为 MongoDB 仍需扫描并丢弃所有跳过的文档:

// 低效深度分页:翻到第 1000 页需扫描 20000 条
db.orders.find({ status: "paid" })
  .sort({ createdAt: -1 })
  .skip(19980)
  .limit(20);

改用游标分页(Keyset Pagination)

// 第 1 页查询
db.orders.find({ status: "paid" })
  .sort({ createdAt: -1 })
  .limit(20);
// 记录最后一条的 createdAt,假设为 2024-06-01T10:00:00Z

// 第 N 页:用上页最后一条记录的值作为继续条件
db.orders.find({
  status: "paid",
  createdAt: { $lt: ISODate("2024-06-01T10:00:00Z") }
})
  .sort({ createdAt: -1 })
  .limit(20);

对于 createdAt 相同的多条记录,需要用额外字段保证唯一性:

db.orders.find({
  $or: [
    { status: "paid", createdAt: { $lt: ISODate("2024-06-01T10:00:00Z") } },
    { status: "paid", createdAt: ISODate("2024-06-01T10:00:00Z"), _id: { $lt: ObjectId("...") } }
  ]
}).sort({ createdAt: -1, _id: -1 }).limit(20);

可以进一步优化为覆盖查询获取 _id 列表,再用 $in 获取完整文档:

db.orders.createIndex({ status: 1, createdAt: -1, _id: 1 });

// 步骤 1:覆盖查询取 _id(无 FETCH)
const ids = db.orders.find(
  { status: "paid", createdAt: { $lt: ... } },
  { _id: 1 }
).sort({ createdAt: -1 }).limit(20).toArray().map(d => d._id);

// 步骤 2:用 _id 索引获取完整文档(_id 索引天然高效)
db.orders.find({ _id: { $in: ids } });

索引性能陷阱与规避策略

陷阱 1:过多索引拖垮写入性能

每个索引都是一份需要维护的额外数据结构。文档的每次插入、更新、删除,都会触发所有相关索引的同步更新。

以 100 万文档的集合为例,索引数量对写入性能的影响大致如下:

索引数量插入吞吐量写入延迟
0 个(无索引)~15,000 doc/s~0.5ms
3 个~8,000 doc/s~1.2ms
5 个~5,000 doc/s~2.0ms
10 个~2,000 doc/s~5.0ms
20 个~500 doc/s~20ms

应对策略:单个集合索引控制在 5-10 个以内;删除长期未使用的索引(通过 $indexStats 检查 accesses.ops);用复合索引替代多个单字段索引(一个 { a: 1, b: 1 } 复合索引可以替代独立的 { a: 1 }{ b: 1 },注意前缀匹配限制)。对于日志等写多读少的集合,宁可牺牲部分查询性能,也要减少索引数量。

陷阱 2:低选择性索引

索引选择性 = 不同键值数量 / 总文档数。选择性越接近 1,索引越高效。

// 低选择性:status 只有 3 个值
db.orders.createIndex({ status: 1 });
// 选择性 ≈ 3 / 1000000 = 0.000003,极差

低选择性索引的问题在于,查询时需要匹配的文档太多。MongoDB 优化器可能直接拒绝使用该索引,选择全表扫描,因为随机 I/O 的代价比顺序扫描还高。

应对策略:将低选择性字段与高选择性字段组合成复合索引。

// 高选择性字段 userId 在前,低选择性 status 在后
db.orders.createIndex({ userId: 1, status: 1 });
// userId 可能有数万个不同值,先精确定位到用户,再在其订单中过滤状态

陷阱 3:负向操作符的索引陷阱

$ne$not$nin$exists 很难有效利用索引,因为这些操作符需要扫描除匹配项外的所有键值。

// 这些查询效果接近全表扫描
db.orders.find({ status: { $ne: "deleted" } });
db.orders.find({ status: { $nin: ["deleted", "archived"] } });
db.orders.find({ remark: { $exists: true } });

应对策略:尽可能把负向查询改写为正向条件。如果业务中只有少数几个有效状态,用 $in 明确列出:

// 改写为正向条件
db.orders.find({ status: { $in: ["pending", "paid", "shipped", "completed"] } });

// 更好的数据模型:添加一个 isActive 标记字段
db.orders.find({ isActive: true });
// 配合索引 { isActive: 1, createdAt: -1 } 非常高效

陷阱 4:内存排序(SORT)的灾难

没有索引支持的排序操作,MongoDB 会把所有符合条件的文档加载到内存中排序,消耗大量内存和 CPU。

// 危险:如果有 100 万条 paid 订单,需要 100MB+ 内存
db.orders.find({ status: "paid" }).sort({ createdAt: -1 });
// 超过 100MB 会报错:Sort exceeded memory limit of 104857600 bytes

应对策略:创建包含排序字段的复合索引。

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

在聚合管道中,如果确实无法避免大数据量排序,可以使用 allowDiskUse 让 MongoDB 使用磁盘排序,但性能会大幅下降。对于 find().sort(),只能依靠索引解决。

陷阱 5:大偏移量分页(Deep Paging)

// 性能灾难:MongoDB 仍需扫描并丢弃前 100 万条记录
db.orders.find()
  .sort({ createdAt: -1 })
  .skip(1000000)
  .limit(20);

应对方案:使用 Keyset Pagination(游标分页),详见前文覆盖查询章节中的分页优化部分。

索引优化实战案例

案例 1:电商订单查询优化

业务场景:5000 万条订单,用户查看自己的订单列表,可按状态筛选,按创建时间倒序排列,每页 20 条。

// 原始慢查询
db.orders.find({
  userId: "u_123456789",
  status: { $in: ["paid", "shipped", "completed"] }
}).sort({ createdAt: -1 }).limit(20);

原始执行计划:COLLSCAN + SORT,totalDocsExamined: 5000万,耗时 4200ms

按 ESR 原则分析:

  • userId 是等值条件(E)
  • status 是等值条件($in 内部每个值都是等值匹配,E)
  • createdAt 是排序字段,同时隐含范围(R/S)
// 创建复合索引
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });

优化后:totalKeysExamined: 45totalDocsExamined: 20,耗时 3ms。性能提升约 1400 倍。

进一步优化为覆盖查询。如果列表页只显示订单号、状态、金额、时间:

// 将列表页需要的字段加入索引
db.orders.createIndex({
  userId: 1, status: 1, createdAt: -1, orderNo: 1, amount: 1
});

// 查询精确匹配覆盖条件
db.orders.find(
  { userId: "u_123456789", status: { $in: ["paid", "shipped", "completed"] } },
  { _id: 0, orderNo: 1, status: 1, amount: 1, createdAt: 1 }
).sort({ createdAt: -1 }).limit(20);

执行计划变为 PROJECTION_COVERED + IXSCAN,完全消除 FETCH 阶段。

案例 2:日志系统时间范围优化

业务场景:2 亿条系统日志,查询某天的 ERROR 级别支付网关日志,按时间倒序,取最新 100 条。

db.system_logs.find({
  level: "ERROR",
  service: "payment-gateway",
  timestamp: { $gte: ISODate("2024-01-01"), $lt: ISODate("2024-01-02") }
}).sort({ timestamp: -1 }).limit(100);

第一次的错误尝试

// 反 ESR 原则:时间范围放在最前面
db.system_logs.createIndex({ timestamp: -1, level: 1, service: 1 });

问题:timestamp 范围在第一位,虽然走了索引,但一天有 500 万条日志,需要在 500 万个索引键中逐个检查 levelservice,扫描量仍然巨大。

结果:耗时 800ms,扫描 500 万索引键。

正确索引

// ESR:等值在前,范围在后
db.system_logs.createIndex({ level: 1, service: 1, timestamp: -1 });

先定位到 level="ERROR" AND service="payment-gateway" 的子树,这个子树内部按时间倒序排列,范围条件在很小的子树内生效。

结果:耗时 5ms,扫描 850 条索引键。性能提升 160 倍。

案例 3:社交时间线优化

业务场景:用户关注 500 人,获取关注列表的最新动态,按时间倒序。

db.posts.find({
  authorId: { $in: ["u_a", "u_b", "u_c", ...] },  // 约 500 个 ID
  visibility: "public"
}).sort({ createdAt: -1 }).limit(50);

普通索引方案 { authorId: 1, visibility: 1, createdAt: -1 }

  • $in 列表过长导致查询计划复杂化
  • 需要对 500 次等值查找的结果合并后再排序
  • 关注人数多时仍可能触发 SORT 阶段

更优方案:写时扩散(Fan-out on Write)

Twitter 等社交平台采用的方案。新增一个时间线索引表:

// 用户发新动态时,向所有粉丝的时间线插入记录
db.timelines.insertOne({
  userId: "u_123",           // 粉丝 ID
  postId: ObjectId("..."),   // 动态 ID
  authorId: "u_456",         // 作者
  createdAt: ISODate("...")
});

// 查询时间线变成单次索引扫描
db.timelines.find({ userId: "u_123" })
  .sort({ createdAt: -1 })
  .limit(50);

只需要一个索引:

db.timelines.createIndex({ userId: 1, createdAt: -1 });

查询始终是单次 IXSCAN + LIMIT,稳定在毫秒级。代价是发帖时需要向所有粉丝的 timeline 插入记录(N = 粉丝数)。对于社交平台的读远大于写特性,这是值得的权衡。

案例 4:O2O 平台附近商家查询

业务场景:外卖平台,查找用户附近 3 公里内、营业中的餐厅,按距离排序。

db.restaurants.find({
  category: "restaurant",
  status: "open",
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [116.397428, 39.90923] },
      $maxDistance: 3000
    }
  }
}).limit(20);

地理空间查询的索引设计有两种思路:

// 方案 A:筛选条件在前,地理空间在后
db.restaurants.createIndex({ status: 1, category: 1, location: "2dsphere" });

// 方案 B:地理空间在前,筛选条件在后
db.restaurants.createIndex({ location: "2dsphere", status: 1, category: 1 });

哪种更好取决于数据分布。如果 “open” 的餐厅占总数的 90%,那么 status 筛选掉的数据不多,把 location 放在前面可能更好。如果 “open” 只占 20%,方案 A 更好。

生产环境中的决策方法:两个候选索引都创建,用 explain("allPlansExecution") 对比实际执行代价:

db.restaurants.find({...}).hint("idx_filters_first").explain("executionStats");
db.restaurants.find({...}).hint("idx_geo_first").explain("executionStats");
// 选择 totalDocsExamined 和 executionTimeMillis 更小的方案

注意:测试完成后删除未选中的候选索引,避免维护不必要的索引。

索引监控与维护

查看与分析现有索引

// 查看集合所有索引
db.orders.getIndexes();

// 查看索引大小(字节)
db.orders.stats().indexSizes;

// 查看索引使用统计(accesses.ops 为 0 表示从未使用)
db.orders.aggregate([{ $indexStats: {} }]);
// 输出:{ name: "userId_1", key: { userId: 1 }, accesses: { ops: 1523, since: ISODate("...") } }

冗余索引判断:如果存在 { a: 1, b: 1 },那么 { a: 1 } 通常是冗余的(前缀包含关系)。但要注意唯一性约束的例外。

安全删除索引:Hidden Index

MongoDB 4.4+ 支持隐藏索引,可以在不删除的情况下测试索引删除后的查询性能:

db.orders.hideIndex("userId_1");    // 隐藏索引,查询不再使用它
db.orders.unhideIndex("userId_1");  // 恢复索引
// 确认无影响后,再真正删除
db.orders.dropIndex("userId_1");

这是生产环境安全删除索引的最佳实践。

索引重建

当索引碎片过多或统计信息不准时:

// 重建集合的所有索引(会阻塞读写,低峰期执行)
db.orders.reIndex();

部分索引(Partial Index)

MongoDB 3.2+ 支持部分索引,只为满足特定条件的文档创建索引,节省空间和写入开销:

// 只为已完成的订单创建时间索引
db.orders.createIndex(
  { userId: 1, completedAt: -1 },
  { partialFilterExpression: { status: "completed" } }
);

这会显著减少索引体积,特别适合状态机等有明确划分的数据模型。

常见问题解答

Q1:数组字段能创建索引吗?

可以。对数组字段创建索引会自动成为多键索引(Multikey Index),数组中的每个元素都会成为独立的索引键。复合索引中最多只能有一个多键索引字段。如果文档的 tags 是 ["mongodb", "index", "performance"],MongoDB 会生成 3 个索引键,每个指向同一个文档。

Q2:一个查询能同时使用多个索引吗?

MongoDB 支持索引交集(Index Intersection),即对两个独立索引的查询结果取交集。但这通常不如一个设计良好的复合索引高效,因为需要两次索引扫描加结果合并。索引交集仅作为查询优化器的备选方案,不应作为设计策略。

Q3:创建索引时阻塞读写吗?

MongoDB 4.2 之前,前台(foreground)创建索引会阻塞集合的所有读写操作。4.2 之后默认在后台(background)创建,不阻塞读写,但速度较慢。生产环境创建大集合索引时,建议通过 db.currentOp() 监控进度:

mongosh --eval 'db.currentOp({ "originatingCommand.createIndexes": { $exists: true } })'

对于已有数亿文档的集合,还可以使用 rolling index build:在复制集的 secondary 节点逐个创建索引,确保每个节点都在安全状态下完成,最后处理 primary。

Q4:稀疏索引(Sparse)和部分索引(Partial)的区别?

  • 稀疏索引{ sparse: true }):只为字段存在的文档创建索引项。如果文档没有该字段,不写入索引。
  • 部分索引{ partialFilterExpression: {...} }):只为满足过滤条件的文档创建索引。更灵活,功能更强,是更推荐的选择。
// 稀疏索引:只为有 phone 字段的用户建索引
db.users.createIndex({ phone: 1 }, { sparse: true });

// 部分索引:只为活跃用户建索引(更推荐)
db.users.createIndex(
  { lastLoginAt: -1 },
  { partialFilterExpression: { status: "active" } }
);

Q5:索引应该如何命名?

建议采用有意义的命名规范,便于后续管理和维护:

// 推荐命名格式:idx_前缀_字段_用途
db.orders.createIndex(
  { userId: 1, status: 1, createdAt: -1 },
  { name: "idx_order_user_status_time" }
);

好的命名应包含:被索引的表/集合简称、索引字段或用途描述。避免使用自动生成的冗长名称,特别是复合索引字段名较长时。

Q6:如何给大表安全地添加索引?

推荐步骤:

  1. 在测试环境用相同数据量验证索引创建时间和对性能的影响。
  2. 生产环境选择业务低峰期执行。
  3. 使用 db.currentOp() 监控创建进度。
  4. 创建后立即用 explain("executionStats") 验证查询是否真正使用了新索引。
  5. 如果是备选索引,先用 Hidden Index 测试,确认有效后再正式启用。

Q7:为什么有时 MongoDB 不选择最优索引?

查询优化器基于统计信息做决策,但统计信息可能不够及时或准确。可以用 hint() 强制使用指定索引:

db.orders.find({ userId: "u123" }).hint("userId_1_status_1");

但应谨慎使用 hint(),如果索引被删除或查询条件变化,强制 hint 可能导致性能反而下降。通常只在明确知道某个索引更优、且统计信息不准时临时使用。

小结

MongoDB 索引设计的核心要点回顾:

1. 理解 B-tree 底层原理
MongoDB 使用标准 B-tree 存储索引,多叉平衡结构使得树高很低,百万级数据也只需几次磁盘 I/O。理解索引如何定位、扫描、获取文档,是优化的起点。

2. ESR 原则
复合索引的黄金法则是等值条件在前、排序居中、范围在后。一个顺序正确的复合索引能让查询从数秒降到毫秒;顺序错误可能完全不被使用。

3. 前缀匹配
复合索引只能从最左侧开始使用前缀服务查询和排序。设计索引时要覆盖所有常见的查询模式,避免为每个查询单独建索引。

4. explain() 是必备工具
executionStats 模式下的三个核心指标 nReturned / totalKeysExamined / totalDocsExamined,是诊断性能问题的关键。COLLSCAN、SORT 大数据量、totalDocsExamined >> nReturned 都是需要处理的红旗信号。

5. 覆盖查询是终极目标
当所有字段都在索引中时,查询无需 FETCH 文档,性能最佳。配合 Keyset Pagination 的分页策略,可以实现高并发场景下的毫秒级响应。

6. 警惕五大陷阱
索引过多拖垮写入、低选择性导致优化器放弃索引、负向操作符无法精确定位、内存排序消耗资源、深度分页扫描大量废弃文档。了解这些陷阱并掌握规避策略,是生产环境索引管理的基本功。

7. 数据模型与索引配合
有时最好的索引优化不是修改索引,而是修改数据模型。社交时间线的写时扩散、验证码的 TTL 自动过期、日志的部分索引,都是数据模型与索引设计协同优化的典范。

索引优化的本质是用空间换时间。每个索引都是双刃剑——它让读取更快,却让写入更慢,还占用存储和内存。理解业务的读写比例、查询模式、数据分布,才能做出最优的索引决策。在生产环境修改索引前,务必在测试数据上验证 explain 输出,安全时利用 Hidden Index 做灰度测试。


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 缓存架构演进之路:从单机 Redis 到亿级分布式多级缓存体系
  2. Redis 7.x 重大新特性与架构升级深度解析
  3. Redis 消息队列深度对比:Pub/Sub、Streams 与 Kafka/RabbitMQ 选型指南