第6章:MongoDB索引入门——让慢查询第一次跑起来

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 套------每次翻字典前都得先更新所有目录,你说慢不慢?

小胖:那应该建几个?建在哪?

大师:遵循一个原则------"查什么建什么"。但这不是说每种查询组合都建一个索引,而是找到"覆盖最多查询的最小索引集合"。具体方法是:

  1. 找出最频繁、最慢的查询;
  2. 分析它们的过滤条件、排序字段;
  3. 按 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 索引特别适合

  1. 商品筛选------status + category + price + rating 多维度组合。
  2. 用户查询------手机号唯一索引,标签、城市等组合查询。
  3. 日志按时间范围查询------createdAt 索引配合范围查询。
  4. 会话/TTL 数据------利用 TTL 索引自动过期临时数据。
  5. 仪表盘数据------为 Dashboard 的固定查询建覆盖索引。

不适用场景

  1. 中文全文搜索------Text Index 分词能力有限,应接入 Elasticsearch。
  2. 低选择性字段(如性别、布尔值)------索引不提供有效过滤,浪费空间。

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 思考题

  1. 一个复合索引 {a:1, b:1, c:1} 能覆盖哪些排序场景?哪些排序场景会导致内存排序?
  2. 为什么 MongoDB 不推荐在 _id 字段上再创建额外索引?_id 索引有什么特殊之处?

(答案将在第 7 章末尾揭晓)


上一章思考题答案

  1. 查"标签恰好是 A,B 且仅有两个":{ tags: { $all: ["A","B"], $size: 2 } }。注意 $all$size 可同时使用。但这是文档级过滤,无法利用索引完全消除扫描------索引只能加速 $all$size 在索引扫描后才过滤。更高效的做法是冗余一个 tagCount 字段。

  2. 查询"过去 7 天从同一 IP 登录超 5 次的用户":纯 MongoDB 查询无法在一次查询中完成"数组元素的频率统计 + 过滤"。需要借助聚合管道的 $unwind$match$group$match,但数据量大时性能差。更合理的架构是:登录事件写入 MongoDB 的同时,通过 Change Streams 或应用层写入 Redis(计数器 + TTL),查询直接从 Redis 获取结果。

延伸阅读与资源

python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地

后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用

后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析