MongoDBNotes

第 06 章:索引

zjc 于 2026-01-06 发布

这是《MongoDB 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 索引决定查询能否以可控代价执行。MongoDB 支持单字段、复合、多键、文本、地理、哈希、部分、稀疏、TTL 和唯一索引。设计索引时,先看查询条件和排序,再看写入成本和内存占用。

6.1 没有索引会发生什么

集合扫描:

query condition
  -> scan collection documents
  -> filter one by one
  -> return matches

数据量小可以接受,数据量大后会导致:

  1. 查询延迟高;
  2. CPU 和 IO 增加;
  3. 工作集被冷数据污染;
  4. 阻塞其他请求;
  5. 复制延迟增加。

6.2 单字段索引

创建:

db.orders.createIndex({ user_id: 1 })

1 表示升序,-1 表示降序。单字段索引支持正反排序。

查询:

db.orders.find({ user_id: "u_1001" }).sort({ created_at: -1 })

该查询若没有复合索引,user_id 索引命中后仍需要内存排序。

6.3 复合索引

创建:

db.orders.createIndex(
  { user_id: 1, created_at: -1, status: 1 },
  { name: "idx_user_time_status"}
)

适用查询:

db.orders.find({ user_id: "u_1001" })
db.orders.find({ user_id: "u_1001", status: "PAID" })
db.orders.find({ user_id: "u_1001" })
  .sort({ created_at: -1 })

不适用:

db.orders.find({ status: "PAID" })

复合索引遵循最左前缀原则,字段顺序应结合等值条件、范围条件和排序。

6.4 ESR 原则

推荐顺序:

Equality -> Sort -> Range

示例查询:

db.orders.find({
  user_id: "u_1001",
  created_at: { $gte: start, $lt: end }
}).sort({ status: 1, created_at: -1 })

索引建议:

db.orders.createIndex({
  user_id: 1,
  status: 1,
  created_at: -1
})

先放等值字段,再放排序字段,最后放范围字段,可以减少扫描和内存排序。

6.5 多键索引

数组字段自动使用多键索引:

db.products.createIndex({ tags: 1 })

查询:

db.products.find({ tags: "hot" })

限制:

  1. 一个复合索引中通常只能有一个多键字段;
  2. 数组过大索引开销高;
  3. 无界数组会放大写入成本;
  4. 索引条目数量需要关注。

6.6 唯一索引

业务单号:

db.orders.createIndex(
  { order_no: 1 },
  { unique: true }
)

用户名:

db.users.createIndex(
  { email: 1 },
  { unique: true, sparse: true }
)

唯一索引是数据库层幂等和约束的重要手段。应用层校验不能替代唯一约束。

6.7 部分索引

只索引活跃订单:

db.orders.createIndex(
  { user_id: 1, created_at: -1 },
  {
    name: "idx_active_user_time",
    partialFilterExpression: {
      status: { $in: ["CREATED", "WAIT_PAY", "PAID"] }
    }
  }
)

适合:

  1. 查询永远限定子集;
  2. 历史数据量大;
  3. 希望降低索引大小;
  4. 写入热点明显。

查询条件必须覆盖部分索引条件,否则优化器不能使用。

6.8 TTL 索引

会话过期:

db.sessions.createIndex(
  { expires_at: 1 },
  { expireAfterSeconds: 0 }
)

日志固定保留:

db.login_events.createIndex(
  { created_at: 1 },
  { expireAfterSeconds: 60 * 60 * 24 * 30 }
)

注意:

  1. TTL 后台线程周期性删除,不是精确到秒;
  2. 只能用于 Date 或包含 Date 的数组;
  3. 不适合替代业务级清理审计;
  4. 删除大量数据仍会影响性能;
  5. 需要监控删除速率。

6.9 索引查看与删除

查看:

db.orders.getIndexes()

查看大小:

db.orders.stats({ indexDetails: true })

删除:

db.orders.dropIndex("idx_user_time_status")

删除前必须确认没有高频查询依赖该索引,并保留回滚脚本。

6.10 隐藏索引

MongoDB 支持隐藏索引用于验证影响:

db.orders.hideIndex("idx_user_time_status")

恢复:

db.orders.unhideIndex("idx_user_time_status")

隐藏索引仍占存储,但优化器不使用,适合删除前观察。

6.11 索引开销

每个索引都会带来:

  1. 写入放大;
  2. 磁盘占用;
  3. 内存占用;
  4. checkpoint 压力;
  5. 维护成本。

索引数量控制建议:

  1. 每个索引都有明确查询;
  2. 定期合并重复前缀索引;
  3. 删除无效索引;
  4. 用执行计划验证效果;
  5. 在预生产环境压测。

6.12 常见问题

问题 原因
查询没有走索引 条件不带最左前缀
排序内存高 排序字段不在索引中
写入变慢 索引过多
唯一冲突 历史重复或空值策略
部分索引未使用 查询条件不满足
TTL 不准时 后台删除机制

本章小结

索引设计遵循 ESR 原则,等值、排序、范围字段顺序要匹配访问模式。唯一索引用于数据库层约束,TTL、部分索引、隐藏索引是治理成本和变更风险的重要工具。索引不是越多越好,每个索引都必须被查询和监控证明值得存在。

思考题

  1. 复合索引为什么要遵循最左前缀?
  2. ESR 原则如何减少内存排序?
  3. 部分索引为什么能降低成本?
  4. TTL 索引适合什么场景?
  5. 删除索引前为什么要隐藏验证?