MongoDB: 索引类型与高级特性
最后更新:2026-08-26
高级索引特性——掌握 TTL、partial、ESR 规则,构建生产级索引体系。
1. 你将学到
- 唯一索引(unique)
- 稀疏索引(sparse)
- TTL 索引(自动过期)
- partial 部分索引
- ESR 规则(Equality/Sort/Range)
- 索引设计最佳实践
graph TB
A[索引类型] --> B[unique<br/>唯一索引]
A --> C[sparse<br/>稀疏索引]
A --> D[TTL<br/>自动过期]
A --> E[partial<br/>部分索引]
A --> F[compound<br/>复合索引]
B --> B1[保证唯一性<br/>重复抛错]
C --> C1[跳过 null<br/>节省空间]
D --> D1[自动删除<br/>定时清理]
E --> E1[仅索引子集<br/>性能 + 节省]
style D fill:#d4edda
style E fill:#d4edda
2. 唯一索引(unique)
概念说明:唯一索引保证索引字段值在整个集合中不重复,是数据完整性的数据库级保障。与应用层校验不同,唯一索引是数据库引擎强制的——无论通过哪个客户端写入,重复值都会被拒绝。
工作原理:唯一索引在 B+Tree 中额外维护唯一性约束。每次插入或更新时,引擎先检查索引中是否已存在相同键值,若存在则抛出 E11000 duplicate key error。复合唯一索引要求字段组合唯一(单个字段可重复)。
使用场景:
- 用户邮箱、手机号(全局唯一标识)
- SKU 编号(商品唯一标识)
- 复合唯一:如
(userId, productId)保证每个用户只能给同一商品评一次分 - 不适合:允许重复值的字段(如
category、status)
| 维度 | 普通索引 | 唯一索引 |
|---|---|---|
| 值约束 | 允许重复 | 不允许重复 |
| 插入检查 | 仅更新 B+Tree | 更新 B+Tree + 唯一性校验 |
| 写入性能 | 基准 | 略慢(+5% 校验开销) |
| 错误提示 | 无 | E11000 duplicate key error |
| 部分唯一 | 不支持 | 配合 partialFilterExpression 实现 |
// === 创建唯一索引 ===
db.users.createIndex({ email: 1 }, { unique: true });
// email 字段值必须唯一
// === 复合唯一索引 ===
db.products.createIndex({ sku: 1, variant: 1 }, { unique: true });
// (sku, variant) 组合必须唯一
// === 唯一索引 + 部分字段 ===
db.users.createIndex(
{ email: 1 },
{ unique: true, partialFilterExpression: { email: { $exists: true } } }
);
// 只对存在 email 字段的文档应用唯一约束
要点解析:
- 唯一索引允许
null值存在,但整个集合只能有一个null(null 被视为相同值) - 使用
partialFilterExpression可让唯一约束仅对部分文档生效,解决 null 唯一性问题 - 唯一索引创建前,集合中不能已有重复值,否则创建失败
3. 稀疏索引(sparse)
概念说明:稀疏索引只对存在索引字段且非 null 的文档建索引,跳过字段缺失或为 null 的文档。普通索引会对所有文档建索引(缺失字段视为 null),稀疏索引通过排除无效条目节省空间。
工作原理:创建稀疏索引时,引擎在插入文档后检查索引字段是否存在且非 null,只有满足条件才会向 B+Tree 插入索引条目。查询时,如果使用稀疏索引,结果中不会包含字段缺失的文档(因为它们不在索引中)。
使用场景:
- 可选字段(如
discount、middleName),大部分文档缺失该字段 - 唯一索引 + 稀疏 = 允许多个文档缺失该字段但仍保持已有值的唯一性
- 不适合:查询需要返回字段缺失的文档(稀疏索引无法覆盖这些文档)
graph LR
subgraph "集合文档"
D1["{sku:'A', discount:10}"]
D2["{sku:'B'}"]
D3["{sku:'C', discount:null}"]
D4["{sku:'D', discount:20}"]
end
subgraph "普通索引"
I1["A→D1, B→D2, C→D3, D→D4"]
end
subgraph "稀疏索引"
I2["10→D1, 20→D4<br/>(仅2条)"]
end
style I2 fill:#d4edda
| 场景 | 推荐 | 原因 |
|---|---|---|
| 字段经常缺失(如可选字段) | 稀疏索引 | 节省空间,仅索引有值的文档 |
| 字段必有值 | 普通索引 | 无需跳过,稀疏无优势 |
| 唯一 + 允许多个 null | 稀疏唯一索引 | null 不参与唯一性检查 |
| 查询需返回 null 文档 | 普通索引 | 稀疏索引会遗漏 null 文档 |
// === 稀疏索引:跳过 null 字段 ===
db.products.createIndex({ discount: 1 }, { sparse: true });
// 仅索引有 discount 字段的文档
// === 稀疏索引 vs 普通索引 ===
// 普通索引:所有文档都建索引(包括 null)
// 稀疏索引:仅非 null 文档建索引
要点解析:
- 稀疏索引不覆盖字段缺失的文档,
find({discount: null})不会返回缺失该字段的文档 - 稀疏 + 唯一组合:允许多个文档缺失该字段,同时保证已有值唯一
- partial 索引是稀疏索引的超集(更灵活),推荐优先使用 partial 索引
4. TTL 索引(自动过期)
概念说明:TTL(Time-To-Live)索引是 MongoDB 的自动过期删除机制,它基于日期字段自动删除超过指定时间的文档。无需手动清理,无需定时任务,数据库引擎后台自动完成——是会话管理、日志清理的利器。
工作原理:TTL 索引在 B+Tree 之上增加了一个后台清理线程。该线程每 60 秒运行一次,扫描索引中的日期字段,计算 当前时间 - 字段值 > expireAfterSeconds,删除所有过期文档。删除操作本身也有写开销,大量过期文档可能造成突发写入压力。
sequenceDiagram
participant App as 应用
participant TTL as TTL后台线程
participant IX as TTL索引B+Tree
participant DOC as 集合文档
App->>DOC: 插入 {token:'abc', createdAt: T1}
DOC->>IX: 索引条目 createdAt=T1
Note over TTL: 每60秒运行一次
loop 每60秒
TTL->>IX: 扫描 createdAt 值
IX-->>TTL: 返回所有条目
TTL->>TTL: 计算当前时间 - createdAt
alt 过期 ( > expireAfterSeconds )
TTL->>DOC: 删除过期文档
DOC->>IX: 删除对应索引条目
else 未过期
Note over TTL: 跳过
end
end
使用场景:
| 场景 | expireAfterSeconds | 典型值 |
|---|---|---|
| 用户会话 | 30 分钟 | 1800 |
| 验证码 | 10 分钟 | 600 |
| 日志数据 | 保留 90 天 | 7776000 |
| 临时令牌 | 1 小时 | 3600 |
| 限流记录 | 1 天 | 86400 |
⚠️ TTL 限制:
| 限制 | 说明 | 解决方案 |
|---|---|---|
| 不能是复合索引 | TTL 只能基于单个日期字段 | 需要复合条件用 partial 索引 + 应用层清理 |
| 字段必须是 Date 类型 | Number/String 不支持 | 使用 new Date() 存储时间 |
| 删除延迟 | 后台线程每 60 秒扫描一次 | 最多延迟 60 秒,非精确时间 |
| 不保证精确删除 | 大量过期文档可能分批删除 | 业务层做好容错 |
// === 创建 TTL 索引(30 天后自动删除)===
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 30 * 24 * 60 * 60 }
);
// === 修改 TTL ===
db.runCommand({
collMod: 'sessions',
index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 7 * 24 * 60 * 60 }
});
// === 取消 TTL(设为 false)===
db.runCommand({
collMod: 'sessions',
index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: -1 }
});
典型场景:
- 用户会话(30 分钟过期)
- 验证码(10 分钟过期)
- 日志数据(保留 90 天)
- 临时令牌(1 小时过期)
5. Partial 部分索引
概念说明:Partial 索引只对满足过滤条件的文档建索引,是稀疏索引的升级版。它通过 partialFilterExpression 指定哪些文档参与索引,既能节省空间,又能提高索引精度,是生产环境最推荐的索引优化手段之一。
工作原理:创建 partial 索引时,引擎仅将满足 partialFilterExpression 的文档插入 B+Tree。查询时,如果查询条件"覆盖"了 partialFilterExpression(即查询条件是过滤条件的子集或等价),优化器才会选择该索引;否则不会使用。
使用场景:
- 仅索引"在售商品"(
isActive: true, stock: {$gt: 0}),跳过下架和缺货 - 仅索引"已验证用户"(
emailVerified: true),跳过未验证用户 - 唯一约束仅对部分文档生效(如已发布的文章标题唯一,草稿允许重复)
graph TB
subgraph "集合(10000文档)"
A[全部文档<br/>10000条]
end
subgraph "普通索引"
B[索引条目<br/>10000条<br/>~10MB]
end
subgraph "Partial索引<br/>isActive=true, stock>0"
C[索引条目<br/>3000条<br/>~3MB]
end
A --> B
A --> C
style C fill:#d4edda
| 对比维度 | 普通索引 | Partial 索引 | Sparse 索引 |
|---|---|---|---|
| 过滤条件 | 无 | 支持 $eq/$gt/$gte/$lt/$lte/$exists/$type/$and | 仅字段是否存在 |
| 空间节省 | 0% | 30-70% | 取决于缺失比例 |
| 灵活性 | 基准 | ⭐⭐⭐ 最灵活 | ⭐ 较局限 |
| 查询限制 | 无 | 查询条件需覆盖 filter 条件 | 查询需包含字段条件 |
| 推荐 | 默认 | ✅ 生产首选 | 仅简单场景 |
| 表达式 | 支持 |
|---|---|
$eq / $gt / $gte / $lt / $lte |
✅ |
$exists: true |
✅ |
$type |
✅ |
$and |
✅ |
$or / $in / $nin |
❌ |
// === 部分索引:仅索引满足条件的文档 ===
db.products.createIndex(
{ category: 1, price: 1 },
{
partialFilterExpression: {
isActive: true,
stock: { $gt: 0 }
}
}
);
// 仅索引在售商品(isActive=true, stock>0)
// === 部分索引节省空间 ===
// 普通索引:1 万文档全部建索引
// 部分索引:仅 3000 个在售商品建索引(节省 70% 空间)
▶ 示例 1: Partial 索引实战
// ShopHub:仅索引在售且有库存的商品,节省 70% 索引空间
db.products.insertMany([
{ sku: 'A001', category: 'Electronics', price: 599, isActive: true, stock: 50 },
{ sku: 'A002', category: 'Electronics', price: 299, isActive: false, stock: 0 },
{ sku: 'A003', category: 'Books', price: 29, isActive: true, stock: 100 },
{ sku: 'A004', category: 'Books', price: 49, isActive: true, stock: 0 }
]);
// Partial 索引仅索引 isActive=true AND stock>0 的文档(A001, A003)
db.products.createIndex(
{ category: 1, price: 1 },
{ partialFilterExpression: { isActive: true, stock: { $gt: 0 } } }
);
// 查询必须包含 filter 条件才能命中
db.products.find({
category: 'Electronics',
isActive: true,
stock: { $gt: 0 },
price: { $gte: 100 }
}).explain();
// winningPlan.stage: IXSCAN ✅
// 缺少 filter 条件 → 不命中
db.products.find({ category: 'Electronics', price: { $gte: 100 } }).explain();
// winningPlan.stage: COLLSCAN(不使用 partial 索引)
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
6. ESR 规则
Equality → Sort → Range:复合索引字段顺序的金科玉律。
概念说明:ESR 规则是 MongoDB 索引设计最重要的规则,它规定了复合索引中字段的最优排列顺序:等值过滤(Equality)排在最前,排序字段(Sort)居中,范围查询(Range)放最后。违反 ESR 规则会导致索引效率大幅下降甚至失效。
工作原理:
- Equality 字段最先排列:等值条件能将搜索空间从 N 快速缩小到 K(K << N),为后续字段提供最精确的起始位置
- Sort 字段居中:索引天然有序,如果排序字段紧接在等值字段之后,查询结果已按索引顺序排列,无需内存排序(避免 SORT stage)
- Range 字段放最后:范围查询会扫描一段连续的索引区间,这段区间内的后续字段无法利用索引有序性,因此范围字段必须放最后
graph TB
subgraph "ESR 索引 {status, createdAt, price}"
A["Equality: status='paid'<br/>→ 定位到所有 paid 文档"] --> B["Sort: createdAt: -1<br/>→ 索引已排序,无需内存排序"]
B --> C["Range: price >= 100<br/>→ 范围扫描,中断后续字段"]
end
subgraph "反例:RSE 索引 {price, status, createdAt}"
D["Range: price >= 100<br/>→ 扫描大量索引条目"] --> E["Sort: status<br/>→ 需内存排序"]
E --> F["Equality: createdAt<br/>→ 已无法利用索引"]
end
style A fill:#d4edda
style B fill:#cce5ff
style C fill:#fff3cd
style D fill:#f8d7da
style E fill:#f8d7da
style F fill:#f8d7da
ESR 规则对比表:
| 顺序 | 类型 | 示例 | 作用 |
|---|---|---|---|
| 1st | Equality | category: 'Electronics' |
快速缩小范围 |
| 2nd | Equality | isActive: true |
进一步缩小范围 |
| 3rd | Sort | sort: { createdAt: -1 } |
利用索引有序性,免排序 |
| 4th | Range | price: { $gte: 100 } |
范围扫描,放最后 |
违反 ESR 的后果:
| 错误排列 | 后果 | explain 表现 |
|---|---|---|
| Range 在 Equality 前 | 范围扫描面太广 | totalKeysExamined 远大于 nReturned |
| Sort 在 Range 后 | 无法利用索引排序 | 出现 SORT stage(内存排序) |
| 跳过 Equality 直接 Sort | 排序无精确起点 | 全索引扫描 + 排序 |
// === 查询:等值过滤 + 排序 + 范围 ===
db.products.find({
category: 'Electronics', // Equality
isActive: true // Equality
}).sort({ createdAt: -1 }) // Sort
.limit(20);
// 价格范围
db.products.find({
category: 'Electronics',
createdAt: { $gte: new Date('2026-01-01') } // Range
}).sort({ rating: -1 });
// === 最佳索引 ===
db.products.createIndex({
category: 1, // E - 等值
isActive: 1, // E - 等值
createdAt: -1, // S - 排序(索引方向匹配)
rating: -1 // R - 范围(放最后)
});
| 顺序 | 类型 | 示例 |
|---|---|---|
| 1st | Equality | category: 'Electronics' |
| 2nd | Equality | isActive: true |
| 3rd | Sort | sort: { createdAt: -1 } |
| 4th | Range | createdAt: { $gte: ... } |
▶ 示例 2: ESR 规则对比实战
// TechCorp 订单系统:对比 ESR 正确 vs 错误排列的执行计划
db.orders.insertMany([
{ userId: 'user_001', status: 'paid', createdAt: new Date('2026-07-01'), total: 100 },
{ userId: 'user_002', status: 'pending', createdAt: new Date('2026-07-02'), total: 200 },
{ userId: 'user_001', status: 'paid', createdAt: new Date('2026-07-03'), total: 50 }
]);
// ❌ 错误索引:Range 在 Sort 前
db.orders.createIndex({ userId: 1, total: 1, status: 1, createdAt: -1 }, { name: 'idx_wrong' });
db.orders.find({ userId: 'user_001', status: 'paid' })
.sort({ createdAt: -1 }).explain('executionStats');
// 出现 SORT stage(内存排序),totalKeysExamined 远大于 nReturned
// ✅ 正确索引:ESR 顺序
db.orders.dropIndex('idx_wrong');
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1, total: 1 }, { name: 'idx_esr' });
db.orders.find({ userId: 'user_001', status: 'paid' })
.sort({ createdAt: -1 }).explain('executionStats');
// 无 SORT stage,totalKeysExamined ≈ nReturned
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
7. 索引设计最佳实践
概念说明:索引设计不是孤立的技术决策,而是基于查询模式、数据特征和业务需求的综合权衡。好的索引设计能让查询飞起来,坏的设计则浪费空间、拖慢写入。
设计原则:
- 基于查询模式设计——先分析
explain()和慢查询日志,再建索引 - ESR 优先——复合索引字段顺序遵循 Equality → Sort → Range
- 高选择性优先——唯一值多的字段(如
userId)比低选择性字段(如isActive)更适合索引 - 索引覆盖——高频查询考虑索引覆盖,避免回表
- 定期清理——用
$indexStats发现并删除未使用索引
graph TB
A[分析查询模式<br/>慢查询日志] --> B[识别高频查询]
B --> C{查询类型?}
C -->|等值查询| D[单字段/唯一索引]
C -->|多条件组合| E[复合索引 ESR]
C -->|排序+筛选| F[ESR复合索引]
C -->|仅显示少量字段| G[索引覆盖]
B --> H[评估选择性]
H -->|高选择性| I[✅ 建索引]
H -->|低选择性| J[❌ 不建/用partial]
I --> K[创建+验证<br/>explain]
K --> L[监控使用率<br/>$indexStats]
L --> M{使用率低?}
M -->|是| N[删除索引]
M -->|否| O[保留]
style D fill:#d4edda
style E fill:#d4edda
style F fill:#d4edda
style G fill:#d4edda
(1) 推荐做法
// ✅ 推荐 1:单字段索引覆盖高频查询
db.products.createIndex({ sku: 1 });
// ✅ 推荐 2:复合索引遵循 ESR 原则
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });
// ✅ 推荐 3:TTL 自动清理
db.logs.createIndex({ createdAt: 1 }, { expireAfterSeconds: 30 * 86400 });
// ✅ 推荐 4:部分索引节省空间
db.products.createIndex(
{ category: 1 },
{ partialFilterExpression: { isActive: true } }
);
// ✅ 推荐 5:唯一索引保证数据完整性
db.users.createIndex({ email: 1 }, { unique: true });
(2) 反模式
常见反模式及后果:
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 低选择性字段建索引 | 索引效率差,优化器可能放弃 | 用 partial 索引或复合索引 |
| 过多索引(>10/集合) | 写性能严重下降 | 仅保留高频查询索引 |
| 复合索引字段顺序错误 | 索引部分失效 | 遵循 ESR 规则 |
| 不必要的索引 | 浪费空间 + 写入开销 | 用 $indexStats 定期清理 |
| 索引未验证命中 | 可能根本没走索引 | 创建后必须 explain 验证 |
// ❌ 反模式 1:低选择性字段建索引
db.users.createIndex({ isActive: 1 });
// isActive 只有 true/false 两个值,索引效率低
// ❌ 反模式 2:过多索引
// 一个集合超过 20 个索引,写性能严重下降
// ❌ 反模式 3:复合索引字段顺序错误
db.products.createIndex({ price: 1, category: 1 });
// 错:price 应放 category 之后
// ❌ 反模式 4:不必要的索引
db.users.createIndex({ lastLoginAt: 1 });
// 如果很少按 lastLoginAt 查询,索引浪费
8. 索引性能监控
概念说明:索引监控是生产环境必不可少的运维环节。通过慢查询日志和索引使用统计,可以发现未使用的索引(浪费空间)、低效的索引(需要优化)、缺失的索引(需要新建)。
工作原理:MongoDB 内置 Profiler 记录慢查询,$indexStats 聚合管道统计每个索引的使用次数和时间。两者结合,可以全面了解索引的健康状况。
监控指标:
| 指标 | 命令 | 健康值 | 警告值 |
|---|---|---|---|
| 索引使用率 | $indexStats |
ops > 1000/天 | ops = 0(未使用) |
| 慢查询数 | system.profile |
0 | > 10/小时 |
| 索引大小 | totalIndexSize() |
< 数据量 50% | > 数据量 100% |
| 索引碎片化 | collStats |
平均填充率 > 80% | < 60% |
// === 慢查询分析 ===
db.setProfilingLevel(2, { slowms: 100 });
// 记录超过 100ms 的查询
// === 查看慢查询 ===
db.system.profile.find({ millis: { $gt: 100 } })
.sort({ ts: -1 })
.limit(10);
// === 查看索引使用统计 ===
db.products.aggregate([
{ $indexStats: {} }
]);
// 找出 unused index
// === 删除未使用的索引 ===
db.products.dropIndex('unused_index_name');
要点解析:
- Profiling Level 0=关闭,1=仅慢查询,2=全部记录(Level 2 影响性能,仅调试用)
$indexStats的accesses.ops为 0 表示自 MongoDB 启动以来从未使用- 生产环境建议 Level 1 + slowms: 100,平衡监控粒度与性能
▶ 示例:TTL 索引 + 部分索引 + ESR 实战
// 场景 1:TTL 索引自动清理会话(30 分钟过期)
db.sessions.insertMany([
{ userId: 'user_001', token: 'abc', createdAt: new Date() },
{ userId: 'user_002', token: 'xyz', createdAt: new Date(Date.now() - 31 * 60 * 1000) } // 已过期
]);
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 30 * 60 } // 30 分钟
);
// 等待 60 秒后,过期文档被自动删除
// db.sessions.find() → 只剩 user_001 的会话
// 场景 2:部分索引 - 仅索引在售商品(节省 70% 空间)
db.products.createIndex(
{ category: 1, price: 1 },
{
partialFilterExpression: {
isActive: true,
stock: { $gt: 0 }
}
}
);
// 部分索引只在查询包含 filter 条件时使用
db.products.find({
category: 'Electronics',
isActive: true, // 必须匹配 partialFilterExpression
stock: { $gt: 0 }, // 必须匹配 partialFilterExpression
price: { $gte: 100, $lte: 500 }
}).explain();
// winningPlan.inputStage.stage: IXSCAN(使用了部分索引)
// 场景 3:ESR 规则实战 - 订单查询
db.orders.insertMany([
{ userId: 'user_001', status: 'paid', createdAt: new Date('2026-07-01'), total: 100 },
{ userId: 'user_001', status: 'paid', createdAt: new Date('2026-07-02'), total: 200 },
{ userId: 'user_002', status: 'pending', createdAt: new Date('2026-07-03'), total: 50 }
]);
// ESR 最佳索引:Equality → Sort → Range
db.orders.createIndex({
userId: 1, // E - 等值过滤
status: 1, // E - 等值过滤
createdAt: -1, // S - 排序(方向匹配)
total: 1 // R - 范围查询(放最后)
});
// 高效查询:用户已支付订单,按时间倒序,价格范围
db.orders.find({
userId: 'user_001', // E
status: 'paid', // E
total: { $gte: 50, $lte: 300 } // R
}).sort({ createdAt: -1 }).limit(20) // S
.explain('executionStats');
// 输出:完全使用复合索引,无需额外排序
// totalKeysExamined: 2, totalDocsExamined: 2, nReturned: 2
输出:TTL 自动清理过期会话;部分索引节省空间;ESR 复合索引让查询达到最优性能。
❓ 常见问题
$gt/$gte/$and),sparse 仅基于字段是否存在。推荐 partial(更灵活)。sort({ createdAt: -1 }) 用 createdAt: -1 索引。dropIndex;(2) 集合删除;(3) TTL 索引自动过期(仅日期字段)。📖 小节
- 唯一索引:保证字段值唯一
- 稀疏索引:跳过 null 字段
- TTL 索引:自动过期删除
- partial 索引:仅索引满足条件的文档
- ESR 规则:Equality → Sort → Range
- 索引设计:高频查询字段、低选择性字段慎建、复合索引遵循 ESR
📝 作业
- 基础题(⭐):为 users 集合创建 email 唯一索引。
- 基础题(⭐):为 sessions 集合创建 TTL 索引(30 分钟过期)。
- 进阶题(⭐⭐):为 products 集合创建 partial 索引(仅在售商品)。
- 进阶题(⭐⭐):应用 ESR 规则为 orders 集合设计最优索引。
- 挑战题(⭐⭐⭐):完整电商索引设计(10+ 索引),用 $indexStats 分析使用率,删除未使用索引。