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")先重点看这些:
winningPlantotalKeysExaminedtotalDocsExamined- 有没有阻塞排序
- 扫描是不是足够收敛
FETCH阶段是不是在做大量无效工作
几个比较实用的判断方式:
COLLSCAN
如果集合不小、查询又是线上热点,基本就是问题。
IXSCAN 但 totalDocsExamined 很大
这不代表“已经优化好了”。
有索引,不等于索引用得对。很多时候只是“拿索引绕了一圈,最后还是读了很多文档”。
有阻塞排序
说明过滤可能走到了索引,但排序没吃上索引红利,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 })这里的逻辑是:
tenantId、status是等值条件createdAt同时参与范围和排序- 排序方向和查询一致
但 ESR 只是候选顺序的生成规则,不是物理定律。
真正会出现取舍的地方
-
低选择性等值字段
- 比如
status只有active / inactive / deleted - 这种字段放前面,不一定真有多大帮助
- 比如
-
多租户系统
- 很多时候
tenantId应该尽量靠前 - 不只是性能问题,也是工作负载隔离问题
- 很多时候
-
排序很关键的接口
- 有些接口最贵的不是过滤,而是排序
- 那么保住排序能力,可能比多塞一个等值字段更重要
-
范围字段一旦进来,后面空间就变小
- 范围扫描一旦变成关键路径,后面的字段通常就没你想象中那么有用了
所以 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: {} }])重点看这些问题:
- 长期几乎没用过的索引
- 明显重叠的复合索引
- 某次事故临时加上、后来没人清理的索引
- 业务早就变了,但索引还留着
- 不同团队不同时间叠出来的历史包袱
很多老集合的真实状态就是:
索引越积越多,没人敢删,最后写性能一直被慢慢吃掉。
一个比较靠谱的生产索引工作流
生产环境里,我更建议按下面这个顺序做:
- 找出慢查询或关键查询
- 把真实查询形状写清楚
- 看
explain("executionStats") - 设计一两个候选索引
- 检查过滤、排序、投影、选择性是否都对得上
- 评估读性能改善
- 同时评估写成本和存储成本
- 后续再做索引清理和合并
最后这一步很重要。
删掉无效索引,本身就是性能优化。
一份够用的检查单
每次准备加索引前,先问自己:
- 这是在优化哪条查询?
- 这条查询真的经常跑吗?
- 它是延迟敏感路径吗?
- 过滤条件选择性够高吗?
- 这个索引能支持排序吗,还是只能支持过滤?
- 我是在优化热点,还是在满足想象中的“以后可能会查”?
- 这会给写入带来多少额外成本?
- 现有复合索引是不是已经能覆盖?
- 用 partial index 能不能更便宜地解决?
- 我有没有用
explain()验证?
MongoDB 8.2.6 的索引能力已经够强了。
真正决定效果的,不是你会不会背索引类型,而是你能不能用更少的索引,准确支撑更重要的查询。