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-tree | O(log N) | 原生支持 | 优秀 | 磁盘索引 |
| B+ tree | O(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 操作
- 内存中完成:整个查询在索引数据页中进行,如果索引在内存中则速度极快
- 减少锁竞争:不访问文档数据,减少对文档级锁的竞争
实现条件
- 查询投影(projection)只包含索引字段和
_id - 如果
_id不在索引中,必须显式排除{_id: 0} - 查询条件中的所有字段都属于同一个索引
- 不能有引用文档内容的条件(如
$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: 45,totalDocsExamined: 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 万个索引键中逐个检查 level 和 service,扫描量仍然巨大。
结果:耗时 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:如何给大表安全地添加索引?
推荐步骤:
- 在测试环境用相同数据量验证索引创建时间和对性能的影响。
- 生产环境选择业务低峰期执行。
- 使用
db.currentOp()监控创建进度。 - 创建后立即用
explain("executionStats")验证查询是否真正使用了新索引。 - 如果是备选索引,先用 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 做灰度测试。
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。