1. 项目背景
业务场景:本地生活电商上线三个月,商品数量从 500 增长到 50 万。运营后台的商品列表页越来越慢,从最初"秒开"变成"点击搜索后去接杯水回来还没出来"。运维在告警群里贴出一张监控图:MongoDB 单次查询平均耗时从 12ms 飙升到 3400ms,CPU 使用率 95% 以上。技术负责人紧急让开发排查,发现罪魁祸首是商品表没有任何索引,每次列表查询都在做全表扫描(COLLSCAN)。大家的第一反应是"加索引",但加到哪几个字段?复合索引的顺序怎么定?加多少索引合适?这些问题让团队陷入新一轮争论。
痛点 :索引设计不当,轻则性能不达预期,重则写入性能崩溃、磁盘空间浪费。常见翻车现场:给每个查询字段都建单列索引,结果 Insert 慢到没法用------因为每插入一行要维护 8 个索引;建了复合索引但查询条件是 {b: 1, a: 1} 而索引是 {a: 1, b: 1},索引完全没命中;遇到正则查询 /keyword/,即使字段上有索引也走了全表扫描;用 explain() 看了执行计划但只看到 COLLSCAN,不知道下一步该做什么。
2. 项目设计
小胖(一脸郁闷地啃着苹果):大师救命!运营那边快提刀过来了------后台查个商品要 3 秒多,我加了 8 个索引反而更慢了!到底该怎么建索引?
大师:先别急,说说你加了什么索引。
小胖:商品表有 name、category、price、stock、rating、tags、status、createdAt 总共 8 个字段,我给每个字段都建了一个单列索引,一个不落!
大师(扶额头):这是典型的索引滥用。索引不是免费的------每次你插入、更新、删除一个文档,MongoDB 都要同步维护所有索引。8 个索引意味着每次写入要做 8 次额外的 B-Tree 操作,写入性能会降到原来的 1/8 甚至更差。打个比方:你在一本 500 页的字典里,目录却做了 8 套------每次翻字典前都得先更新所有目录,你说慢不慢?
小胖:那应该建几个?建在哪?
大师:遵循一个原则------"查什么建什么"。但这不是说每种查询组合都建一个索引,而是找到"覆盖最多查询的最小索引集合"。具体方法是:
- 找出最频繁、最慢的查询;
- 分析它们的过滤条件、排序字段;
- 按 ESR 原则排字段顺序。
小白(合上笔记本):ESR 原则是什么?
大师:ESR = Equality → Sort → Range,是复合索引字段顺序的黄金法则。
- Equality(等值查询) :最先放的字段应该是精确匹配的,如
status: "在售"、category: "数码"。 - Sort(排序字段) :然后是排序字段,如
sort({ createdAt: -1 })。 - Range(范围查询) :最后放范围查询字段,如
price: { $gt: 100, $lt: 500 }。
为什么按这个顺序?因为索引一旦碰到范围查询($gt、$lt),后面的字段就无法用于精确过滤,但排序仍能利用索引顺序避免内存排序。
小胖:举个例子呗,比如说我现在最频繁的查询是:"在售的商品,按某类目筛选,按价格区间筛选,按创建时间倒序排列"。
大师:好,我们拆解一下:
| 条件 | 类型 | ESR 位置 |
|---|---|---|
| status = "在售" | Equality(等值) | 第 1 位 |
| category = "数码" | Equality(等值) | 第 2 位 |
| price: gte 100, lte 500 | Range(范围) | 最后 |
| sort: createdAt -1 | Sort(排序) | 第 3 位 |
所以推荐的复合索引是:{ status: 1, category: 1, createdAt: -1, price: 1 }。
技术映射:B-Tree 索引按字段顺序排列数据。等值条件先过滤,减少后续扫描范围;排序字段利用索引天然有序性避免内存排序;范围条件放最后,保证前序字段仍能精确定位。
小白:但这样 price 排在 createdAt 后面,如果用户只搜价格区间不搜类目呢?索引还能用上吗?
大师 :好问题!这就是复合索引的"前缀匹配"特性。索引 {a:1, b:1, c:1} 可以覆盖以下查询:{a:1}、{a:1, b:1}、{a:1, b:1, c:1},但无法覆盖 {b:1} 或 {c:1}。也就是说,复合索引只对前缀字段生效。所以你的场景中,如果用户可能不选类目只搜价格,就需要为这个查询路径单独建一个索引。
小胖:那岂不是又回到了"每种查询建一个索引"?
大师:不是,是做好权衡。通常我们会用覆盖率分析------如果一个查询每月只跑 3 次,全表扫描也不怕;但如果每小时跑 3 万次,那就必须专有索引。
大师 :对了,用 explain() 验证。你们现在会看 explain 了吗?
小胖:我只会看 COLLSCAN 和 IXSCAN......
大师:那今天就把 explain 看懂。几个关键指标:
| 指标 | 含义 | 好值 |
|---|---|---|
| winningPlan.stage | 执行方式 | IXSCAN(好)/ COLLSCAN(坏) |
| totalDocsExamined | 扫描的文档数 | 越小越好,最好等于 nReturned |
| nReturned | 返回的文档数 | 你的查询预期 |
| totalKeysExamined | 扫描的索引键数 | 接近 totalDocsExamined 为佳 |
| executionTimeMillis | 总耗时 | < 100ms 一般可接受 |
技术映射 :totalDocsExamined >> nReturned 说明大量无效扫描;totalKeysExamined >> totalDocsExamined 说明索引选择性差;stage = SORT 说明排序未命中索引,发生了内存排序。
小胖:那唯一索引、稀疏索引、TTL 索引这些我什么时候用?
大师:快速对照表:
| 索引类型 | 用法 | 典型场景 |
|---|---|---|
唯一索引 unique: true |
保证字段值唯一 | 手机号、邮箱、订单号 |
稀疏索引 sparse: true |
只对包含该字段的文档建索引 | 可选字段如 vipExpireAt |
部分索引 partialFilterExpression |
只对符合条件的文档建索引 | 只对"在售"商品建索引,下架的不入索引 |
| TTL 索引 | 文档过期自动删除 | 验证码、临时会话、日志归档 |
小胖:懂了,我先回去把 8 个单列索引全删了,按 ESR 重建复合索引!
大师 :等等,再加一条------索引不是越多越好。MongoDB 默认给每个集合建 _id 索引就够了,业务索引按需来。每次加索引前,先在测试环境用 explain 验证收益,再在生产环境用 background 模式创建(MongoDB 4.2+ 已默认后台构建)。
3. 项目实战
3.1 环境准备
bash
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:构造慢查询场景
目标:准备 50 万商品数据,制造全表扫描的慢查询环境。
javascript
use local_life
db.products_idx.drop()
// 批量插入 50 万商品数据(分批次,每次 1000 条)
function createSampleData(total = 500000, batchSize = 1000) {
const categories = ["数码影音", "手机配件", "家居生活", "美妆个护", "食品饮料"]
const statuses = ["在售", "下架", "已删除"]
const tagsPool = ["新品", "热销", "包邮", "限时", "推荐", "秒杀", "清仓"]
for (let round = 0; round < total; round += batchSize) {
const batch = []
const actualSize = Math.min(batchSize, total - round)
for (let i = 0; i < actualSize; i++) {
const idx = round + i
const category = categories[idx % 5]
const status = statuses[idx % 10 === 0 ? 1 : (idx % 20 === 0 ? 2 : 0)]
batch.push({
name: `商品_${String(idx).padStart(7, '0')}`,
category: category,
brand: ["华为", "小米", "苹果", "三星", "OPPO"][idx % 5],
price: NumberDecimal((Math.random() * 5000 + 10).toFixed(2)),
stock: Math.floor(Math.random() * 1000),
rating: parseFloat((Math.random() * 5).toFixed(1)),
sales: Math.floor(Math.random() * 50000),
tags: [
tagsPool[idx % tagsPool.length],
tagsPool[(idx + 1) % tagsPool.length]
],
status: status,
createdAt: new Date(2025, 0, 1, 0, 0, 0, idx * 10),
updatedAt: new Date()
})
}
db.products_idx.insertMany(batch, { ordered: false })
if ((round + batchSize) % 50000 === 0) {
print(`已插入 ${round + batchSize} 条...`)
}
}
print(`插入完成,共 ${db.products_idx.countDocuments()} 条`)
}
// 执行插入(如果机器性能有限,可调小 total 到 10 万)
createSampleData(100000, 1000)
// 慢的话,试试 50000
步骤二:观察无索引时的查询性能
目标:用 explain 对比无索引和有索引的查询差异。
javascript
// ---- 无索引场景 ----
// 查询:数码类目 + 价格 100-500 + 按评分排序
const noIndexExplain = db.products_idx.find({
category: "数码影音",
price: { $gte: NumberDecimal("100"), $lte: NumberDecimal("500") },
status: "在售"
}).sort({ rating: -1 }).limit(20).explain("executionStats")
print("=== 无索引执行计划 ===")
print("执行阶段:", noIndexExplain.executionStats.executionStages.stage)
print("扫描文档数:", noIndexExplain.executionStats.totalDocsExamined)
print("返回文档数:", noIndexExplain.executionStats.nReturned)
print("耗时(ms):", noIndexExplain.executionStats.executionTimeMillis)
// 期望:stage = COLLSCAN,totalDocsExamined ≈ 总文档数
步骤三:按 ESR 原则创建复合索引
目标:为最频繁查询创建最优复合索引。
javascript
// ---- 创建索引 ----
// 索引1:最频繁的查询场景
// (status=等值, category=等值, rating=排序, price=范围)
db.products_idx.createIndex(
{ status: 1, category: 1, rating: -1, price: 1 },
{ name: "idx_status_category_rating_price" }
)
// 索引2:按品牌 + 状态筛选
db.products_idx.createIndex(
{ status: 1, brand: 1, sales: -1 },
{ name: "idx_status_brand_sales" }
)
// 索引3:唯一索引------商品名称(实际项目中 name 不适合做唯一)
// 这里仅作演示
// db.products_idx.createIndex({ name: 1 }, { unique: true })
// 索引4:部分索引------只对"在售"商品建索引(节省空间)
db.products_idx.createIndex(
{ category: 1, price: 1 },
{
name: "idx_active_category_price",
partialFilterExpression: { status: "在售" }
}
)
// 查看所有索引
db.products_idx.getIndexes().forEach(idx => {
print(`索引: ${idx.name}, 键: ${JSON.stringify(idx.key)}`)
})
步骤四:用 explain 验证索引命中
目标:对比建索引前后的 explain 输出,学会读懂执行计划。
javascript
// ---- 场景A:命中复合索引 ----
const explainA = db.products_idx.find({
status: "在售",
category: "数码影音",
price: { $gte: NumberDecimal("100"), $lte: NumberDecimal("500") }
}).sort({ rating: -1 }).limit(20).explain("executionStats")
print("\n=== 场景A:命中 idx_status_category_rating_price ===")
print("执行阶段:", explainA.executionStats.executionStages.stage)
print("索引名:", explainA.queryPlanner.winningPlan.inputStage
? explainA.queryPlanner.winningPlan.inputStage.indexName
: explainA.queryPlanner.winningPlan.indexName)
print("扫描索引键:", explainA.executionStats.totalKeysExamined)
print("扫描文档数:", explainA.executionStats.totalDocsExamined)
print("返回文档数:", explainA.executionStats.nReturned)
print("耗时(ms):", explainA.executionStats.executionTimeMillis)
// 期望:stage = FETCH/IXSCAN,totalDocsExamined 显著减少
// ---- 场景B:只用等值条件,索引前缀匹配 ----
const explainB = db.products_idx.find({
status: "在售",
category: "家居生活"
}).explain("executionStats")
print("\n=== 场景B:只用前缀条件 ===")
print("索引名:", explainB.queryPlanner.winningPlan.inputStage.indexName)
print("扫描索引键:", explainB.executionStats.totalKeysExamined)
print("耗时(ms):", explainB.executionStats.executionTimeMillis)
// 这个查询只用了复合索引的前两个字段,仍能命中
// ---- 场景C:跳过前缀字段,索引失效 ----
const explainC = db.products_idx.find({
category: "食品饮料"
// 没有 status 条件!跳过了复合索引的前缀
}).explain("executionStats")
print("\n=== 场景C:跳过前缀字段 status ===")
print("执行阶段:", explainC.executionStats.executionStages.stage)
// 期望:COLLSCAN------因为没有 status 条件,复合索引的前缀断裂
// 但是!MongoDB 实际上可能会选择另一个可用索引(如果有的话)
// 或者使用部分索引 idx_active_category_price
// ---- 场景D:排序方向不匹配 ----
const explainD = db.products_idx.find({
status: "在售",
category: "家居生活"
}).sort({ rating: 1 }) // 索引中是 rating: -1(降序),这里用升序
.explain("executionStats")
print("\n=== 场景D:排序方向与索引不匹配 ===")
print("执行阶段:", explainD.executionStats.executionStages.stage)
// 期望:可能仍有 SORT 阶段(内存排序),因为方向不匹配
// MongoDB 可以反向遍历索引,所以 rating: 1 也能利用 rating: -1 索引
// ---- 场景E:正则不走索引 ----
const explainE = db.products_idx.find({
name: /商品_000/ // 没有 ^ 前缀锚定
}).explain("executionStats")
print("\n=== 场景E:正则无前缀锚定 ===")
print("执行阶段:", explainE.executionStats.executionStages.stage)
// 期望:COLLSCAN
// 正确做法:前缀锚定正则
const explainE2 = db.products_idx.find({
name: /^商品_00001/
}).explain("executionStats")
print("正则 ^锚定后:", explainE2.executionStats.executionStages.stage)
// 如果有 name 索引,这里可以走 IXSCAN
步骤五:索引优化实践------隐藏、删除与分析
目标:掌握索引生命周期管理。
javascript
// ---- 查看索引使用统计 ----
const stats = db.products_idx.aggregate([
{ $indexStats: {} }
]).toArray()
print("\n=== 索引使用统计 ===")
stats.forEach(s => {
print(` 索引: ${s.name}, 命中次数: ${s.accesses.ops || 0}`)
})
// 如果某个索引 access 长期为 0,说明从未被使用,可以考虑删除
// ---- 隐藏索引(不删除先测试影响) ----
// MongoDB 4.4+
db.products_idx.hideIndex("idx_status_brand_sales")
print("隐藏索引: idx_status_brand_sales")
// 观察业务是否有性能下降,没有的话再真正删除
// 恢复隐藏索引
db.products_idx.unhideIndex("idx_status_brand_sales")
// ---- 删除不需要的索引 ----
// 确认无影响后再删除
// db.products_idx.dropIndex("idx_status_brand_sales")
// ---- 查看索引大小 ----
const indexSize = db.products_idx.stats().indexSizes
print("\n=== 索引占用磁盘空间 ===")
Object.keys(indexSize).forEach(k => {
print(` ${k}: ${(indexSize[k] / 1024 / 1024).toFixed(2)} MB`)
})
步骤六:TTL 索引实战
目标:用 TTL 索引实现验证码自动过期。
javascript
// 创建验证码集合
db.verification_codes.drop()
db.verification_codes.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 300, name: "ttl_5min" }
)
// 文档将在 createdAt 时间 300 秒后被自动删除
// 插入验证码
db.verification_codes.insertOne({
phone: "13800138000",
code: "8842",
createdAt: new Date()
})
print("验证码文档数:", db.verification_codes.countDocuments())
// 等待 5 分钟后,MongoDB 的后台 TTL 线程(每 60 秒运行一次)会自动删除
// 可以手动触发观察效果
print("提示:等待 5-6 分钟后再次 countDocuments(),应为 0")
可能遇到的坑:
- TTL 索引的过期时间是近似值------后台线程每 60 秒扫描一次,实际删除时间可能有 ±60 秒偏差。
- TTL 索引字段必须是 Date 类型或包含 Date 的数组,其他类型无效。
- TTL 索引不能是复合索引,只能单字段。
_id字段不能有 TTL 索引。
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch06-create-data.js |
批量生成测试数据 |
mongodb-lab/scripts/ch06-index-basics.js |
索引创建与 explain 验证 |
mongodb-lab/scripts/ch06-ttl-demo.js |
TTL 索引演示 |
3.4 测试验证
javascript
use local_life
// 1. 确认索引已创建
const indexes = db.products_idx.getIndexes()
const hasKey = (name) => indexes.some(i => i.name === name)
print("idx_status_category_rating_price:", hasKey("idx_status_category_rating_price") ? "PASS" : "FAIL")
print("idx_active_category_price:", hasKey("idx_active_category_price") ? "PASS" : "FAIL")
// 2. 验证 ESR 索引生效
const explain = db.products_idx.find({
status: "在售", category: "数码影音"
}).sort({ rating: -1 }).limit(10).explain("executionStats")
const ixscan = JSON.stringify(explain.queryPlanner.winningPlan).includes("IXSCAN")
print("EXPLAIN 包含 IXSCAN:", ixscan ? "PASS" : "FAIL")
// 3. 验证文档扫描数显著小于总数
const scanned = explain.executionStats.totalDocsExamined
const returned = explain.executionStats.nReturned
print(`扫描/返回: ${scanned}/${returned} (扫描越接近返回越好)`)
// 4. 验证 partialFilterExpression 生效
const activeCount = db.products_idx.countDocuments({ status: "在售" })
const totalCount = db.products_idx.estimatedDocumentCount()
print(`在售商品: ${activeCount}, 总数: ${totalCount}`)
print("部分索引只覆盖在售商品,节省磁盘空间")
// 5. 清理
// db.products_idx.dropIndexes() // 删除所有非 _id 索引
4. 项目总结
4.1 MongoDB 索引 vs MySQL 索引
| 维度 | MongoDB | MySQL (InnoDB) | 说明 |
|---|---|---|---|
| 索引结构 | B-Tree(WiredTiger) | B+Tree | 原理一致,查询策略相似 |
| 复合索引前缀 | 必须从最左前缀开始 | 同,最左前缀原则 | 两者一致 |
| 唯一约束 | 唯一索引 + 可选稀疏 | UNIQUE KEY | 语义相近 |
| 全文索引 | Text Index(中文弱) | FULLTEXT(中文弱) | 生产环境都建议用 ES |
| TTL 索引 | 原生支持 | 需事件调度器或 cron | MongoDB 更简单 |
| 部分索引 | partialFilterExpression |
WHERE 条件索引 |
MongoDB 的稀疏+部分更灵活 |
| explain | explain("executionStats") |
EXPLAIN |
MongoDB 输出为 JSON,可编程解析 |
4.2 适用场景
MongoDB 索引特别适合:
- 商品筛选------status + category + price + rating 多维度组合。
- 用户查询------手机号唯一索引,标签、城市等组合查询。
- 日志按时间范围查询------
createdAt索引配合范围查询。 - 会话/TTL 数据------利用 TTL 索引自动过期临时数据。
- 仪表盘数据------为 Dashboard 的固定查询建覆盖索引。
不适用场景:
- 中文全文搜索------Text Index 分词能力有限,应接入 Elasticsearch。
- 低选择性字段(如性别、布尔值)------索引不提供有效过滤,浪费空间。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| ESR 顺序不可违反 | E→S→R 顺序错会导致排序不在索引内完成 |
| 复合索引前缀 | 查询条件跳过了最左前缀,索引失效 |
| 索引数量 | 每集合通常不超过 5 个,监控 $indexStats 剔除未使用的 |
| 后台建索引 | 生产环境建索引始终用后台模式(4.2+ 默认) |
| 唯一索引 + 缺失字段 | 缺失字段在唯一索引中被视为 null,第二个缺失字段会冲突 |
4.4 常见踩坑经验
故障案例一:索引顺序错误导致排序打爆内存
某报表系统在 orders 集合建了 {status:1, createdAt:1} 索引,但查询是 find({status:"已完成"}).sort({totalAmount:-1})。explain 显示有 SORT 阶段,每次排序消耗 200MB 内存。根因:排序字段 totalAmount 不在索引中,且放在第一个范围条件之后。解决 :重建索引为 {status:1, totalAmount:-1},SORT 阶段消失,耗时从 8s 降到 50ms。
故障案例二:正则无前缀导致库崩溃
某运营后台的搜索框,后端直接构建 {name: /${keyword}/} 查询。大促期间运营频繁搜索,50 万商品全部做全表扫描,CPU 100%,其他正常业务请求排队超时。根因:正则未用 ^ 前缀锚定,无法利用 B-Tree 索引。解决 :改用 {name: {$regex: "^" + keyword}},且加上 limit 100 避免返回过多数据。
故障案例三:TTL 索引未生效,数据无限膨胀
某短信验证码表建了 TTL 索引,但半年后磁盘爆了,发现历史验证码数据有 3 亿条。根因:TTL 索引字段存的是字符串 "2025-01-01" 而非 ISODate,TTL 线程跳过了解析失败的文档。解决 :统一使用 new Date() 存时间,历史数据通过数据迁移脚本逐个修正类型。
4.5 思考题
- 一个复合索引
{a:1, b:1, c:1}能覆盖哪些排序场景?哪些排序场景会导致内存排序? - 为什么 MongoDB 不推荐在
_id字段上再创建额外索引?_id索引有什么特殊之处?
(答案将在第 7 章末尾揭晓)
上一章思考题答案:
查"标签恰好是 A,B 且仅有两个":
{ tags: { $all: ["A","B"], $size: 2 } }。注意$all和$size可同时使用。但这是文档级过滤,无法利用索引完全消除扫描------索引只能加速$all,$size在索引扫描后才过滤。更高效的做法是冗余一个tagCount字段。查询"过去 7 天从同一 IP 登录超 5 次的用户":纯 MongoDB 查询无法在一次查询中完成"数组元素的频率统计 + 过滤"。需要借助聚合管道的
$unwind→$match→$group→$match,但数据量大时性能差。更合理的架构是:登录事件写入 MongoDB 的同时,通过 Change Streams 或应用层写入 Redis(计数器 + TTL),查询直接从 Redis 获取结果。
延伸阅读与资源
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地