目录

MongoDB 索引最佳实践

MongoDB 里的索引,不是“顺手加一下”的优化项,而是一笔持续收费的开销。

每个索引都会让某些读请求变快,同时让写入、更新、删除、索引构建、缓存占用都更贵一点。MongoDB 8.2.6 在索引机制上已经很成熟,但最核心的问题一直没变:

好索引不是从字段名里猜出来的,而是从真实查询模式里长出来的。

这篇文章不讲“跑个百万数据脚本看前后差多少倍”那种演示,而是讲生产环境里真正该怎么做索引决策。

先看查询模式,不要先看表结构

最常见的索引误区,是盯着 schema 设计索引,而不是盯着 workload 设计索引。

一个字段在业务上很重要,不代表它值得单独建索引。
索引只有在下面这些场景里才真正有价值:

  • 高频查询
  • 延迟敏感接口
  • 排序成本高
  • 过滤条件选择性强
  • 需要唯一性约束
  • 需要生命周期治理

所以建索引前,先把查询形状写清楚:

  • 过滤字段有哪些
  • 排序字段有哪些
  • 返回字段有哪些
  • 一次取多少结果
  • 调用频率高不高
  • 读多写多还是写多读少
  • 是用户请求路径还是后台任务

例如:

db.orders.find(
  {
    tenantId: "t_42",
    status: "paid",
    createdAt: { $gte: ISODate("2025-09-01T00:00:00Z") }
  },
  {
    _id: 0,
    orderNo: 1,
    total: 1,
    createdAt: 1
  }
).sort({ createdAt: -1 }).limit(50)

这比“orders 里有 tenantId、status、createdAt 三个字段”有用得多。
因为索引是为查询服务的,不是为字段表演服务的。

不看 explain(),基本就是猜

索引问题如果不看 explain(),很多判断都靠感觉。
感觉在这里通常不靠谱。

性能分析时,建议直接用:

db.orders.find({
  tenantId: "t_42",
  status: "paid",
  createdAt: { $gte: ISODate("2025-09-01T00:00:00Z") }
}).sort({ createdAt: -1 }).limit(50).explain("executionStats")

先重点看这些:

  • winningPlan
  • totalKeysExamined
  • totalDocsExamined
  • 有没有阻塞排序
  • 扫描是不是足够收敛
  • FETCH 阶段是不是在做大量无效工作

几个比较实用的判断方式:

COLLSCAN

如果集合不小、查询又是线上热点,基本就是问题。

IXSCANtotalDocsExamined 很大

这不代表“已经优化好了”。
有索引,不等于索引用得对。很多时候只是“拿索引绕了一圈,最后还是读了很多文档”。

有阻塞排序

说明过滤可能走到了索引,但排序没吃上索引红利,MongoDB 还得额外做排序工作。

totalDocsExamined: 0

通常意味着覆盖查询,这是很理想的状态。
前提是你不是为了覆盖,把索引堆得过胖。

核心目标从来不是“命中索引”,而是“让正确的索引减少真正的工作量”。

ESR 很有用,但别把它当教条

复合索引设计里,经常会提到 ESR:

  • Equality
  • Sort
  • Range

它是一个很好的起点,因为很多查询确实都符合这个模式:

  • 先按等值过滤缩小范围
  • 再顺着索引顺序拿排序结果
  • 最后处理范围条件

例如:

db.orders.find({
  tenantId: "t_42",
  status: "paid",
  createdAt: { $gte: ISODate("2025-09-01T00:00:00Z") }
}).sort({ createdAt: -1 })

一个很自然的候选索引是:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })

这里的逻辑是:

  • tenantIdstatus 是等值条件
  • createdAt 同时参与范围和排序
  • 排序方向和查询一致

但 ESR 只是候选顺序的生成规则,不是物理定律。

真正会出现取舍的地方

  1. 低选择性等值字段

    • 比如 status 只有 active / inactive / deleted
    • 这种字段放前面,不一定真有多大帮助
  2. 多租户系统

    • 很多时候 tenantId 应该尽量靠前
    • 不只是性能问题,也是工作负载隔离问题
  3. 排序很关键的接口

    • 有些接口最贵的不是过滤,而是排序
    • 那么保住排序能力,可能比多塞一个等值字段更重要
  4. 范围字段一旦进来,后面空间就变小

    • 范围扫描一旦变成关键路径,后面的字段通常就没你想象中那么有用了

所以 ESR 的正确用法是:
先给你一组靠谱候选,再用 explain() 验证。

选择性比很多人想得更重要

索引的核心作用,是帮你少做事。
如果一个条件几乎筛不掉数据,那这个索引就算能用,意义也可能很弱。

例如:

db.users.find({ isActive: true })

如果 97% 的用户都是活跃状态,那单独给 isActive 建索引,大概率不值。

更可能有价值的,是这种组合:

db.users.createIndex({ tenantId: 1, isActive: 1, lastSeenAt: -1 })

前提是你的真实查询是“某个租户下,查活跃用户,并按最近活跃排序”。

建索引前可以先问几个问题:

  • 这个条件到底能过滤掉多少数据?
  • 这个字段是不是大多数文档都一个值?
  • 真实查询的入口是不是另一个字段?
  • 我是在优化真热点,还是在优化一个几乎没人用的查询?

低选择性索引最典型的问题就是:写入成本明显增加,读收益却很有限。

好的复合索引,通常胜过一堆单字段索引

很多系统里会出现一种“看起来很努力,实际上很散”的索引状态:

  • tenantId 一个索引
  • status 一个索引
  • createdAt 一个索引
  • type 一个索引

结果线上真正常跑的是:

db.events.find({
  tenantId: "t_42",
  eventType: "login",
  createdAt: { $gte: ISODate("2025-09-01T00:00:00Z") }
}).sort({ createdAt: -1 }).limit(100)

这种查询,通常更值得直接上:

db.events.createIndex({ tenantId: 1, eventType: 1, createdAt: -1 })

而不是把希望寄托在一堆单字段索引上。

原因很简单:

  • 它更贴近真实查询形状
  • 它可以直接支持排序
  • 执行计划更稳定
  • 减少无意义的回表和候选集膨胀

单字段索引不是没用,但生产里的主力查询,往往都不是单字段场景。

覆盖查询很好,但别为了“全覆盖”把索引做胖

覆盖查询的理想状态是:
查询需要的字段,索引里都有,MongoDB 不需要再去取原文档。

比如:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1, orderNo: 1, total: 1 })

配合:

db.orders.find(
  { tenantId: "t_42", status: "paid" },
  { _id: 0, orderNo: 1, total: 1, createdAt: 1 }
).sort({ createdAt: -1 }).limit(20)

如果 explain() 里看到 totalDocsExamined: 0,通常是非常好的结果。

但问题在于,很多团队一看覆盖查询好,就开始追求“所有接口都覆盖”。
这会带来几个副作用:

  • 索引体积变大
  • 更新成本变高
  • 缓存命中变差
  • 跟别的索引高度重叠

所以覆盖查询应该用在真正热的读路径上,而不是当成统一口号。

很多索引失败,不是过滤没命中,而是排序没吃到

一个查询即便过滤走了索引,也可能因为排序不走索引而慢。

例如:

db.tickets.find({
  tenantId: "t_42",
  priority: "high"
}).sort({ updatedAt: -1 })

如果索引只是:

db.tickets.createIndex({ tenantId: 1, priority: 1 })

那 MongoDB 很可能还是得做额外排序。

更贴近这个查询形状的索引通常是:

db.tickets.createIndex({ tenantId: 1, priority: 1, updatedAt: -1 })

实战里,一个很有用的判断是:

  • 如果接口依赖排序结果
  • 且过滤后的候选集可能还不小
  • 那排序字段大概率就应该进入复合索引

否则你就会遇到那种“明明有索引,为什么还是慢”的情况。

Partial Index 是生产里很划算的工具

Partial index 的好处在于:它逼你正视真实工作负载。

如果线上只关心活数据,就别把死数据也一起索引进去。

例如软删除场景:

db.users.createIndex(
  { tenantId: 1, email: 1 },
  { partialFilterExpression: { deletedAt: { $exists: false } } }
)

这通常比对整张表所有文档建索引更划算,尤其是在软删除数据长期累积的系统里。

比较适合 partial index 的场景:

  • 软删除
  • 只查 active 数据
  • 只索引带某字段的文档
  • 只关注待处理任务
  • 只关注公开内容
  • 只关注未解决事件

它的好处很实际:

  • 索引更小
  • 写放大更低
  • 缓存更省
  • 跟真实查询更贴近

需要注意的是:查询条件得和 partial filter 对得上。
如果查询没有包含兼容条件,优化器不一定会用这个索引。

Unique Index 是拿来落业务约束的,不是拿来“假设不会重复”的

只要某个值在业务上必须唯一,就应该用唯一索引去兜底。

只靠应用层“先查一下有没有”,在并发下是不够的。

例如:

db.users.createIndex({ tenantId: 1, email: 1 }, { unique: true })
db.apiKeys.createIndex({ keyHash: 1 }, { unique: true })

这里一个常见错误是把唯一性范围搞错。

  • 如果 email 是“租户内唯一”,就必须把 tenantId 带上
  • 如果是“全局唯一”,那就老老实实做全局唯一

还有一点很重要:
不要在脏数据还没清理完的时候,贸然给老集合上 unique index。
索引构建阶段才发现生产里其实早就有重复值,这种事故一点都不稀奇。

TTL Index 很好用,但它解决的是过期清理,不是归档策略

TTL index 很适合做自动过期:

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

典型场景包括:

  • session
  • 临时 token
  • 短期缓存
  • 短生命周期事件
  • 一次性验证材料

但 TTL 不是:

  • 精准定时删除器
  • 归档方案
  • 关键业务数据清理策略

它本质上是后台清理机制,适合“过一会儿删掉也可以”的数据,不适合拿来承诺精确时间点。

Sparse 和 Partial 选哪个?大多数时候优先 Partial

Sparse index 的逻辑比较简单:
字段不存在的文档,不进索引。

例如:

db.profiles.createIndex({ phoneNumber: 1 }, { sparse: true })

这并不是错,但在很多生产场景里,partial index 通常表达力更强:

db.profiles.createIndex(
  { phoneNumber: 1 },
  { partialFilterExpression: { phoneNumber: { $exists: true } } }
)

为什么 partial 往往更好:

  • 条件表达更明确
  • 不只限于字段存在性
  • 更容易对齐真实业务规则

Sparse 不是不能用,只是 partial 往往更接近“我到底想索引哪些文档”。

Text 和 Wildcard 索引都有用,但都很容易被滥用

Text Index

Text index 适合“我确实需要 MongoDB 自带的全文检索能力”这个场景。

但它不是通用搜索方案,更不是“文本字段都上 text index”这种粗暴做法。

需要留意的点:

  • 相关性排序未必符合产品预期
  • 语言处理会影响结果
  • 和其他过滤、排序组合时会有现实限制
  • 搜索一旦变成核心能力,很多系统最后还是会外置搜索方案

所以 text index 更适合中小规模、内建型搜索需求,而不是承担复杂搜索产品能力。

Wildcard Index

Wildcard index 适合字段路径比较动态的场景,比如 metadata 结构比较散。

但它不是“schema 不稳定所以直接一把梭”的理由。

比较适合的场景通常是:

  • 面向运维或分析的动态字段查询
  • 对一批不固定 key 做有限支持
  • 过渡期 schema 还没完全稳定

如果你的热点查询已经很明确,针对性普通索引通常比 wildcard 更稳、更省。

每个索引都会带来写放大

这是很多团队到了生产后才真正感受到的部分。

每加一个索引,下面这些操作都会更贵一点:

  • insert
  • update
  • delete
  • 索引构建
  • 副本集复制时的相关开销

如果集合本身就是写多读少,那每一个“也许有用”的索引都应该先被怀疑。

一个很实用的问题是:

到底是哪条慢查询,值得让我以后每次写入都多付这笔成本?

如果这个问题答不清楚,那索引多半还不该加。

索引要定期复盘,不是加完就完

索引和代码一样,也需要周期性审查。

常用命令包括:

db.collection.getIndexes()

以及:

db.collection.aggregate([{ $indexStats: {} }])

重点看这些问题:

  • 长期几乎没用过的索引
  • 明显重叠的复合索引
  • 某次事故临时加上、后来没人清理的索引
  • 业务早就变了,但索引还留着
  • 不同团队不同时间叠出来的历史包袱

很多老集合的真实状态就是:
索引越积越多,没人敢删,最后写性能一直被慢慢吃掉。

一个比较靠谱的生产索引工作流

生产环境里,我更建议按下面这个顺序做:

  1. 找出慢查询或关键查询
  2. 把真实查询形状写清楚
  3. explain("executionStats")
  4. 设计一两个候选索引
  5. 检查过滤、排序、投影、选择性是否都对得上
  6. 评估读性能改善
  7. 同时评估写成本和存储成本
  8. 后续再做索引清理和合并

最后这一步很重要。
删掉无效索引,本身就是性能优化。

一份够用的检查单

每次准备加索引前,先问自己:

  • 这是在优化哪条查询?
  • 这条查询真的经常跑吗?
  • 它是延迟敏感路径吗?
  • 过滤条件选择性够高吗?
  • 这个索引能支持排序吗,还是只能支持过滤?
  • 我是在优化热点,还是在满足想象中的“以后可能会查”?
  • 这会给写入带来多少额外成本?
  • 现有复合索引是不是已经能覆盖?
  • 用 partial index 能不能更便宜地解决?
  • 我有没有用 explain() 验证?

MongoDB 8.2.6 的索引能力已经够强了。
真正决定效果的,不是你会不会背索引类型,而是你能不能用更少的索引,准确支撑更重要的查询。