☰
MongoDB索引对查询性能的影响:从原理到explain实战优化
2026/10/10 10:52:44 网站建设 项目流程

MongoDB 索引对查询性能的影响,说实话是个老生常谈却又特别容易被忽视的话题。很多人对索引的印象停留在“加索引能让查询变快”,但到底快在哪、慢的时候慢在哪、为什么有时候加了索引反而更糟,能讲清楚的人并不多。这篇文章我就围绕 MongoDB 索引最核心的几个场景展开,结合我实际跑过的查询、踩过的坑、还有用 explain 排查问题的经历,把索引对查询性能的影响从头到尾拆一遍。内容不挑基础,刚入门的朋友可以当索引扫盲,已经写过不少查询的老手也能从里面找到一些平时不太注意的细节。

1. 索引到底是什么,它凭什么能提速

1.1 没有索引时 MongoDB 怎么找数据

理解索引对性能的影响,得先明白没有索引的时候,一个查询是怎么执行的。假设你有一个用户表,里面存了几百万条文档,你现在要查所有 age 等于 30 的人。没有索引的情况下,MongoDB 只能做一个集合扫描(Collection Scan),从集合里的第一条文档开始,一条一条往下读,每读一条就要比对一次 age 字段,看它是不是等于 30,直到把整个集合全扫完。

这个过程听起来好像没什么大问题,但数据量一旦上来,差距就非常恐怖。一条文档如果平均 1KB,100 万条就是大概 1GB 的数据,就算这些数据都在内存里,也要逐个比对,CPU 和 IO 的消耗都不小。如果文档更大,或者数据已经部分落到磁盘上,那一次全表扫描可能要几十秒甚至更久。而且这种扫描是线性的,数据量翻倍,扫描时间基本也翻倍,没有任何办法能绕过去。

这时候你可能会想,MongoDB 不是自带查询优化器吗?它不会自动帮我做点什么吗?确实,MongoDB 有一个查询优化器,它会为每个查询计划估算执行成本,然后选一个它认为最合理的执行方案。但当集合里没有任何索引可用时,优化器不管怎么选,最后都只能落到集合扫描上,没有别的路可以走。优化器能做的是矮子里面拔高个,而不是无中生有变出一个索引来。

1.2 索引是如何改变查询路径的

加了索引之后,情况就完全不同了。索引在底层是一棵 B 树(B-Tree),它把某个字段或某几个字段的值按照一定的顺序组织起来,每个索引条目还额外保存了指向原始文档的位置信息。这就像你在读一本很厚的书,如果没有目录,你只能一页页翻,有了目录之后,你可以直接翻到对应页码,效率当然不是一个量级。

具体到执行层面,查询优化器发现存在能匹配查询条件的索引时,会先走索引扫描(Index Scan)。比如查 age = 30,它会去索引 B 树里定位到所有 age 为 30 的索引条目,然后根据这些条目里保存的位置信息,把对应的文档取出来返回给你。因为索引本身就是有序且经过结构化的,所以定位一批具体值只需要沿着树往下走,访问的节点数量非常有限,跟全表扫描的 IO 量完全没有可比性。

这里我特别想强调一点:索引不只是省了“比对次数”,更关键的是省了“读取数据量”。集合扫描必须把文档本身从存储引擎里读出来,只有读到文档,才知道它的字段值是什么。而索引扫描只需要访问索引页,索引页里的数据是精简过的键值对,整体体积比文档小得多,所以一次查询读入内存的数据量就少,这也是索引能明显降低响应时间的根本原因。

1.3 查询优化器和索引选择的基本流程

MongoDB 的查询优化器并不是拿到查询条件就直接盲目选一个索引,它有一个相对固定的流程。第一次执行某个形状的查询时,优化器会尝试多个候选计划,并行跑一小段,看看每个计划的执行成本,然后选一个成本最低的作为最终计划,把它缓存起来。之后一段时间内,相同形状的查询就直接复用这个计划,不再重新评估。

这个机制带来的一个实际影响是:如果你建了一个新索引,它未必会立刻替换掉已有的查询计划。因为缓存计划还在,优化器不会每次查询都去重新竞争。我见过有人给集合新增了一个明显更好的索引,但线上查询时间没有任何变化,排查了半天,发现是查询计划缓存还挂着旧索引。这种情况最直接的解决办法是清掉相关集合的查询计划缓存,或者用 hint 强制走新索引验证效果,确认没问题后再让优化器慢慢切换。

另外,优化器选择索引时,并不是以“哪个索引名字更贴切”为标准,而是靠估算出来的代价。对于能精确匹配等值条件的索引,代价通常很低;对于只能做范围扫描的索引,代价会高一些;如果索引选择性太差,比如一个字段总共只有两个值,分布又非常不均匀,优化器甚至可能觉得走索引不如直接全表扫描来得痛快。所以索引能否真正提升性能,得看实际执行计划,不能光看“有没有索引”。

2. 索引设计选型:动手建索引之前要想清楚的事

2.1 单字段索引的适用场景与建法

最基础的索引就是单字段索引,语法非常简单,一条命令就能建出来:

db.users.createIndex({ age: 1 })

这条命令的意思是给 users 集合的 age 字段建一个升序索引。建完之后,所有针对 age 字段的等值查询、范围查询、还有按 age 排序的操作,都有可能用上这个索引。

单字段索引最大的优点就是简单直接,查询条件里只要包含这个字段,优化器就有机会走索引。但要注意一个常见误解:字段上有索引,不代表所有包含该字段的查询都会走索引。比如你的查询条件是db.users.find({ name: { $regex: /^张/ } }),即使你在 name 上建了索引,因为使用了正则前缀匹配,这个查询还是可以利用索引的,但如果你用的是不区分大小写的正则,或者在正则前面加了通配符,索引基本就废了,后面会详细展开。

还有一个更隐蔽的情况:有些字段虽然经常出现在查询条件里,但它的基数(Cardinality)非常低。什么叫基数低?就是字段可能的取值很少,比如 status 只有 “active” 和 “inactive” 两种值,gender 只有 “male” 和 “female”。对这种字段建索引,索引本身的区分度很差,优化器可能会认为走索引需要扫描太多索引条目,反而不如集合扫描划算。所以单字段索引我更建议建在那些取值丰富、查询选择性高的字段上,比如用户 ID、订单号、时间戳等。

2.2 复合索引:顺序和方向决定了性能上限

实际业务里,单字段索引经常不够用。最常见的场景是查询条件里同时有多个字段,比如“查某个用户在某段时间内的订单”,这时候你会想,分别给 user_id 和 create_time 建两个单字段索引不就行了?MongoDB 确实会尝试用索引合并(Index Intersection)来同时利用两个索引,但效果通常不如一个设计良好的复合索引。

复合索引的关键在于字段顺序。凡是涉及复合索引的查询,优化器都是按照索引字段顺序来匹配的。最经典的一条经验法则叫“等值先行,排序次之,范围最后”。什么意思?假设你要建一个索引来支持{ user_id: 'xxx', status: 'active', create_time: { $gt: 某个时间 } }这个查询,那比较合理的索引顺序是{ user_id: 1, status: 1, create_time: 1 }。因为 user_id 是等值匹配,status 也是等值匹配,create_time 是范围匹配,把等值字段放前面,范围字段放最后,索引扫描就能最大程度缩小范围。

反过来,如果你把 create_time 放在最前面,那么即使你有 user_id 和 status 条件,索引也没法先利用 user_id 来缩小范围,只能先沿着时间轴扫一段,再在结果里过滤 user_id,扫描的索引条目会多出很多。这个排序规则我再强调一遍:对于复合索引来说,走不走索引、走得好不好,跟你字段顺序的关系非常大。很多时候你建了复合索引,却发现查询还是慢,就是顺序没放对。

除了顺序,索引方向也很重要。升序索引(用 1 表示)和降序索引(用 -1 表示)对等值查询没有影响,但对排序操作有直接影响。如果查询里既要按字段 A 排序,又要按字段 B 排序,而且两个方向不同,比如sort({ price: -1, create_time: 1 }),那么你的复合索引也应该建成方向一致的形式:{ price: -1, create_time: 1 }。这样 MongoDB 就可以顺着索引直接返回结果,而不需要在内存里做一次额外的排序。如果索引方向跟排序方向不一致,MongoDB 可能还是会用索引扫描拿数据,但随后必须多花一步 sort 阶段,在数据量大时这一步非常吃内存和 CPU。

2.3 覆盖查询和投影带来的额外红利

索引影响性能还有一个很容易被忽略的点,就是覆盖查询(Covered Query)。当一个查询需要的所有字段都包含在索引里时,MongoDB 甚至不需要去读原始文档,直接从索引中就能拿到全部结果。这种情况下,查询的耗时几乎等于索引扫描耗时,跟集合文档大小完全无关。

比如你有这样一个索引:db.orders.createIndex({ user_id: 1, status: 1, amount: 1 }),然后执行查询:

db.orders.find( { user_id: 'u_10001', status: 'paid' }, { _id: 0, user_id: 1, status: 1, amount: 1 } )

这个查询的过滤条件正好命中索引的前两个字段,投影里需要的字段也全在索引里,而且排除了 _id。MongoDB 就会认为这是一个覆盖查询,不需要再回表取文档。在数据量大、文档比较大的场景里,覆盖查询对性能的提升极其明显,你可以省掉大量的随机磁盘读取。

要做到覆盖查询,有两点需要注意。一是投影里不要带上索引之外的字段,二是一定要显式排除 _id。默认情况下 MongoDB 的查询都会返回 _id,而 _id 通常不在索引里,所以一旦没有排除它,就必须回表去拿 _id,覆盖查询就失效了。实际开发中我发现很多人根本不知道这个细节,明明索引建好了,查询也走了索引,但执行计划里还是会有 FETCH 阶段,原因就在这里。

2.4 索引类型补充:TTL、唯一索引、文本索引什么时候用

除了普通的单字段和复合索引,MongoDB 还提供一些特殊索引类型,它们也会明显影响查询性能或者系统行为。

TTL 索引是一种特殊的单字段索引,专门用于自动删除过期数据。它要求字段类型必须是日期类型,MongoDB 会有一个后台线程定期扫描 TTL 索引,把超过指定时间的文档删除。这类索引对查询本身也有加速作用,尤其是你经常按时间过滤数据的场景,反正都是索引,建了不亏。需要特别注意的是,TTL 索引只能是一个单字段索引,不能跟其他字段组成复合索引。

唯一索引则更多是业务约束层面的东西,比如保证某个业务编号不能重复,或者用户名不能重复。唯一索引在写入时会多做一次唯一性检查,会带来一些额外的写入开销,但对查询来说依然是很好的索引,因为唯一索引的选择性是最高的,优化器通常会非常偏好它。

文本索引(Text Index)则是针对全文搜索场景设计的,适合做简单的关键词搜索。但文本索引有自己的语法,比如$text查询,而且它不支持一些普通索引能覆盖的排序和范围查询场景。如果你只是想在标题里做模糊匹配,很多时候普通的前缀正则查询加单字段索引就够用了,不一定非要上文本索引,文本索引的维护成本和查询限制都需要单独评估。

3. 用 explain 实测量化索引的影响

3.1 explain 的三种模式分别该看什么

索引对查询性能的影响到底多大,嘴上说了不算,必须用 explain 看实际执行计划。MongoDB 的 explain 支持三种模式:queryPlanner、executionStats、allPlansExecution。

queryPlanner只生成查询计划,不真正执行查询,所以速度很快,适合快速确认某个查询会不会走某个索引。它返回的winningPlan里会告诉你执行阶段是什么,比如是IXSCAN(索引扫描)、COLLSCAN(集合扫描)还是FETCH(从文档中取数据)。日常开发里我通常先跑这一种,确认查询有没有走预期的索引。

executionStats会真的把查询执行一遍,然后返回详细的执行统计信息,包括扫描了多少条文档、多少条索引条目、执行耗时、是否发生了内存排序等。这个模式是分析索引性能的主力,因为它的数据非常直观。要注意的是,它默认会执行查询并返回结果,在数据量很大的生产库上直接跑可能压力不小,建议加db.collection.explain('executionStats').find(...)这种方式,它依旧会执行,但对线上影响相对可控。如果确实担心,可以配合 limit 或使用queryPlanner先看计划。

allPlansExecution会评估所有候选计划,并针对每个计划都抓取执行统计。这个模式适合做更深层次的优化器行为分析,但开销更大,常规排查里用得不多。

3.2 读懂关键指标:totalDocsExamined 和 totalKeysExamined

执行统计里对性能影响最直观的两个指标是totalDocsExamined(扫描文档数)和totalKeysExamined(扫描索引条目数)。这两个数字能非常清楚地告诉你查询效率到底怎么样。

如果totalDocsExamined等于totalKeysExamined,而且返回的结果数也差不多,说明这个查询筛选效率很高,每条索引条目几乎都能命中文档,这是比较理想的状态。

如果totalDocsExamined远远大于返回结果数,说明你虽然走了索引,但索引选择性和查询条件之间的匹配度不够高,扫描了一大堆索引条目才筛出很少的结果。比如你在一个只有两种取值的字段上建了索引,筛选出其中一种,索引扫描可能扫了 50 万条索引条目,最后只返回了 30 万条文档,剩下的 20 万条并不是查询需要的,而是后续过滤掉的。这种情况下索引不是没用,而是用得不够高效。

还有一种更糟的情况是totalDocsExamined等于整个集合的文档总量,那基本就是 COLLSCAN,索引完全没有参与,要么没建索引,要么查询条件不能让优化器使用索引。

我在实际排查中养成了一个习惯:看 explain 输出时先扫一眼winningPlan,确认有没有IXSCAN,然后再看两个 Examined 指标。如果存在SORT阶段,还要额外看memUsage和是否触发了磁盘排序。一条性能良好的查询,应该是扫描的索引条目数跟返回结果数在一个数量级,而且没有额外的 SORT 或 COLLSCAN。

3.3 一个典型对比:有索引和无索引的差异实录

为了让大家更直观地感受索引带来的差异,我拿一个模拟场景来演示。假设有一个订单集合orders,里面有 500 万条文档,每条文档有一个user_id字段和一个order_amount字段。现在我要查某个用户的所有订单。

没有索引的时候,执行:

db.orders.explain('executionStats').find({ user_id: 'u_888888' })

返回的统计大概是:

{ "executionStats": { "executionTimeMillis": 5820, "totalDocsExamined": 5000000, "totalKeysExamined": 0, "winningPlan": { "stage": "COLLSCAN" } } }

500 万条文档全部扫了一遍,耗时 5.8 秒。从用户体验的角度,这个响应时间已经非常慢了,如果请求量再大一点,数据库 CPU 和 IO 很快就扛不住。

然后建一个普通单字段索引:

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

再次执行同样的查询,explain 输出变成:

{ "executionStats": { "executionTimeMillis": 12, "totalDocsExamined": 238, "totalKeysExamined": 238, "winningPlan": { "stage": "FETCH", "inputStage": { "stage": "IXSCAN" } } } }

耗时从 5800 多毫秒降到了 12 毫秒,扫描的文档数从 500 万降到 238。这是一个非常典型的对比,索引能改变的不是快一点两点,而是从秒级到毫秒级的跨越。当然,这个例子里的耗时跟机器配置、数据分布都有关系,但量级上的差异是实打实的。

这也是我在面试或者带新人时最喜欢用的一个例子:你不需要背任何调优口诀,只要会用 explain,对比一下 COLLSCAN 和 IXSCAN 的耗时、扫描量,你自然就理解索引为什么重要了。

4. 索引的代价和边界:什么时候索引帮倒忙

4.1 写入放大:每个索引都是写入时的额外开销

索引不是免费的午餐,它最大的代价体现在写入性能上。每次插入一条文档,MongoDB 不止要写入原始文档本身,还要为这条文档在每个索引上都维护对应的索引条目。如果一个集合有 5 个索引,那一次插入实际上的写入操作就是 1 次文档写入加上 5 次索引写入,也就是 6 次左右的写入操作。

对于更新操作来说也有类似的问题。如果你更新了一个被索引的字段,MongoDB 需要删除旧索引条目,再插入新索引条目,这会带来额外的 B 树结构调整成本。如果你的业务是典型的写多读少,比如日志采集、物联网传感器数据上报,那么索引数量就必须严格控制,否则写入吞吐量会急剧下降。

我见过一个真实的场景:某个团队往日志集合上加了四五个索引,本意是方便后面按各种维度查询,结果发现数据写入速度从每秒几万条掉到了每秒几千条,最后只能下线一部分索引。所以建索引前一定要考虑读写比例:读多写少的场景可以适当多建索引,写多读少的场景要克制,每个索引都要有明确的使用场景。

4.2 内存占用和索引淘汰

索引是常驻内存的,或者说理想情况下尽量要常驻内存。MongoDB 使用内存映射文件管理数据,索引页会被当作普通页一样缓存到内存里。如果索引太大,无法完全放进内存,就会出现频繁的页淘汰和磁盘加载,性能会急剧恶化。

实际经验中,很多人只关注“集合大小”,忘了看“索引大小”。通过db.collection.stats()可以查看totalIndexSize这个字段。如果你的服务器内存本来就紧张,再叠加几个很大的索引,那就有可能出现数据库明明没什么查询,磁盘 IO 却很高的情况,因为后台可能一直在换入换出索引页。

有一个比较粗略的估算方式:总索引大小建议控制在可用内存的一定比例以内,具体比例没有绝对标准,但如果你发现totalIndexSize已经超过内存的一定份额,就要考虑是不是索引建得太多了,或者部分大索引利用率很低,该清理就清理。MongoDB 里可以用db.collection.getIndexes()查看索引列表,再结合db.collection.aggregate()里的$indexStats查看每个索引的真实使用率。长期没有被使用的索引,果断删掉,一个长期闲置的索引对读性能没有任何帮助,纯粹是负担。

4.3 索引失效和低效的典型查询写法

索引建好了,也不代表所有查询都能享受它的加速。MongoDB 里有一类查询模式会让索引失效,或者至少无法高效利用索引,我列几个最常见的:

  • 对索引字段使用$where表达式。$where的评估是在 JavaScript 引擎里做的,无法利用索引,只要查询里出现$where,MongoDB 基本上只能做集合扫描。
  • 对索引字段做不规范的$regex正则查询。正则表达式如果是以通配符开头,比如$regex: /张$/,索引也无法高效利用。但如果是前缀匹配,比如/^张/,在普通索引上是可以利用的。
  • 对索引字段使用了$not、$nin这类否定操作。否定操作的效率通常不高,优化器很难通过索引缩小范围,容易退化为大范围扫描甚至集合扫描。
  • 对索引字段进行了表达式转换。比如你在查询里写{ $where: "this.price * 2 > 100" },或者隐式地对字段做了运算,索引本身就失效了。需要说明的是,MongoDB 不像关系型数据库那样有函数索引的通用能力,所以这种场景很难通过调整 SQL 来解决,最好是新增一个预先计算好的字段,并对它建索引。

还有一种很容易被忽略的低效场景:复合索引本来设计得不错,但在查询时你省略了前置字段。比如你有索引{ user_id: 1, status: 1, create_time: -1 },但查询条件只用了{ status: 'active' },没有包含user_id,那这个复合索引就只能起到一个非常有限的作用,MongoDB 虽然也可能走索引,但扫描范围会非常大,效果甚至不如一个专门针对 status 建的单字段索引。查询条件必须满足复合索引的前缀原则,才能真正发挥索引价值。

4.4 没有银弹:选择性、基数、数据分布的影响

最后再聊一个比较抽象但非常重要的点:索引的效果其实取决于数据分布。选择性好的索引,性能提升是数量级的;选择性差的索引,效果可能微乎其微。

什么叫选择性?简单说就是某个字段的不同值数量占总文档数比例。用户 ID 一共 500 万,基本每个值都不同,这就是高选择性,索引能快速定位到极少数文档。但性别字段只有两个值,这就是低选择性,索引本质上是把一个 500 万的集合分成两个 250 万的子集,查询结果仍然很大,优化器会觉得还不如全表扫描一次来得直接。

另外还有数据分布倾斜的问题。比如 status 字段有 10 个值,但其中 99% 的文档都是 “active”,如果你查询的是那个只有 1% 的 “closed”,索引效果依然很好;但如果你查询的是 “active”,索引扫描可能需要扫出 99% 的文档,代价极高。这种情况下优化器有可能会放弃索引,转而选择集合扫描,因为集合扫描的线性读在某些场景下比大量随机读索引页更快。明白了这一点,你就能理解为什么“只要建了索引就会快”这种说法是错的,更合理的做法是结合业务查询模式,选择那些真正能缩小数据范围的字段建索引。

5. 一个完整优化案例:从慢查询到稳定快速的排查过程

5.1 慢查询现象

我接手过一个模拟订单系统的性能问题。当时线上反馈说某个统计报表的接口越来越慢,最严重的时候一次请求要 20 多秒,已经影响到体验。这个接口的底层查询大概是这样的:

db.order_records.find({ region: '华东', create_time: { $gte: ISODate('2024-01-01'), $lt: ISODate('2024-02-01') }, channel: 'app' }).sort({ create_time: -1 }).limit(50)

这个查询本身不复杂,就三个过滤条件加一个排序加一个 limit。问题在于 order_records 集合已经膨胀到了接近 2000 万条文档,而查询里涉及的字段之前完全没有任何索引。

5.2 第一步:用 explain 定位根因

我先执行了 explain(executionStats 模式),确认当前执行计划:

db.order_records.explain('executionStats').find({ region: '华东', create_time: { $gte: ISODate('2024-01-01'), $lt: ISODate('2024-02-01') }, channel: 'app' }).sort({ create_time: -1 }).limit(50)

结果显示 winningPlan 里的阶段是 COLLSCAN,totalDocsExamined 差不多等于全部 2000 万条文档,执行耗时十几秒。这就是典型的全表扫描。

这时候我做的第一件事没有直接建索引,而是先跟业务确认了一下这个统计报表的调用频率和相关字段的查询价值。因为这个集合每天还在大量写入,索引不能乱加,否则后面写入性能可能会出问题。

5.3 第二步:设计复合索引并验证

根据查询条件,region 和 channel 都是等值匹配,create_time 是范围匹配,而且需要按 create_time 倒序返回。结合前面说的“等值先行,范围放最后,排序要跟索引方向一致”,我设计了这样一个复合索引:

db.order_records.createIndex({ region: 1, channel: 1, create_time: -1 })

create_time 用 -1,是为了跟查询里的sort({ create_time: -1 })保持一致。等值字段 region 和 channel 放前面,范围字段 create_time 放最后。

建完索引之后,我重新跑了一遍 explain:

db.order_records.explain('executionStats').find({ region: '华东', create_time: { $gte: ISODate('2024-01-01'), $lt: ISODate('2024-02-01') }, channel: 'app' }).sort({ create_time: -1 }).limit(50)

这次执行计划变成了 FETCH + IXSCAN,索引扫描的条目数和返回结果数都在合理范围内,执行时间降到了 200 毫秒以内。同一个查询,从十几秒到 200 毫秒,索引的威力就是这么直接。

5.4 第三步:关注排序开销和覆盖查询优化

索引建好之后,explain 结果里已经看不到额外的 SORT 阶段了,因为索引方向跟排序方向一致,MongoDB 直接按索引顺序返回数据,省掉了内存排序。

不过我还想再压一压这个查询的耗时。因为这个报表只关心部分字段,我发现投影里其实只需要 region、channel、create_time、order_amount 这几个字段。那就让索引包含字段order_amount,更新索引为:

db.order_records.createIndex({ region: 1, channel: 1, create_time: -1, order_amount: 1 })

这样查询如果做投影,只保留这四个字段并排除 _id,就可以变成覆盖查询,连文档读取都能省掉。当然覆盖查询对字段有严格要求,不能有 _id,这一点前面已经说过。在这个案例的实际业务里,我们通过覆盖查询把接口的响应时间进一步压缩到了几十毫秒级别,效果非常理想。

这个案例其实没有什么高深的技术,整个思路就是:先确认瓶颈是全表扫描,再按照查询模式设计复合索引,最后用 explain 验证每一步的优化效果。这套流程几乎可以复用到任何 MongoDB 慢查询排查里。

6. 常见问题与避坑技巧速查

6.1 索引排查里遇到的高频问题

我在不同项目里反复遇到过一些差不多的问题,整理成一张速查表,方便你对照排查:

症状可能原因排查方向
查询还是很慢,但明明加了索引查询条件不满足复合索引前缀原则用 explain 看 winningPlan 是不是 IXSCAN,以及 totalKeysExamined 指标
索引建了,执行计划偶尔走索引偶尔全表扫描数据分布倾斜,优化器重新评估了代价查看集合 stats,确认字段基数,必要时用 hint 强制索引
查询计划一直没切换成新索引查询计划缓存未失效查询计划缓存清理,或临时用 hint 验证效果
写入变慢明显索引过多,写入放大严重用 $indexStats 检查每个索引的使用率,删除长期未使用的索引
一个简单查询返回数据很少,扫描文档数却巨大查询条件里的字段有隐式类型转换或使用了非高效操作符检查查询条件写法,避免正则、$where、否定操作等
更新一个字段导致大量延迟被更新的字段在索引里,需要维护索引条目考虑索引字段是否有必要包含该字段

这个表里的每一行,都是我实际踩过或者帮别人排查过的,价值不在于表格本身,而在于你遇到类似问题时,能有一个清晰的起点。

6.2 几个只有实操才能发现的细节

最后分享几个我在实际操作中积累的小经验,这些常规文档里很少专门提到,但遇到过一次你就忘不掉。

第一,复合索引字段顺序不是拍脑袋定的。你在设计时,要严格按“等值字段前置、排序字段次之、范围字段最后”的规则来。如果你不确定索引顺序对不对,最快的方法就是用 explain 对比当前索引和候选索引下的totalKeysExamined,哪个扫描的索引条目少,哪个通常就更合适。

第二,用hint强制索引前后测试时,要留意优化器缓存。我自己习惯的做法是,在测试环境先把新旧索引都建好,然后用 hint 分别执行同一条查询,对比耗时和扫描量,确认哪个方案更好,再决定留存哪个索引。这个流程看似繁琐,其实是在避免“凭感觉”做优化。

第三,不要忽略查询计划缓存。线上改了索引之后,如果查询时间没有变化,不一定是索引无效,可以先清理这个集合的查询计划缓存,然后再观察。具体命令因版本略有不同,但思路是一样的,你先确认新索引确实更优,再让优化器重新评估。

第四,监视索引使用率很重要。我一般会间隔一段时间跑一次$indexStats,那些长期没有access次数的索引,基本就是死索引,留着只会拖累写入和占用内存。删之前再确认一下有没有被哪个冷门查询用到,没问题就清理掉,让数据库保持轻装上阵。

第五,小心排序与索引方向的配合。很多人建索引时根本不关注 1 和 -1,只关心建了没有。实际上,一个反向的排序可能会导致 MongoDB 额外做一次内存排序,数据量大时内存根本排不下,只能落盘,那查询从毫秒级变成秒级就是一瞬间的事。

7. 用好索引,本质上是理解数据访问模式

说到最后,我想把话题稍微拉高一点。索引对查询性能的影响,本质上不是“加不加索引”这个二元问题,而是“你理不理解自己的数据访问模式”的问题。一个集合到底该建几个索引、每个索引包含哪些字段、字段顺序怎么排,全部取决于你的查询是怎么写的、排序是怎么排的、哪些字段是高频过滤条件。

我见过很多系统,索引建了一堆,但没有一个是真正贴合业务查询的。也见过一些系统,索引数量极少,但因为每个索引都建到了点子上,整体查询性能非常稳定。这两者的差别,不在于谁会的命令多,而在于谁在动手建索引之前,愿意多花几分钟时间分析一下查询模式,再用 explain 验证,而不是上来就对着字段闭眼建索引。

如果你正在处理一个新的 MongoDB 项目,我建议你从第一个查询写出来的时候就开始考虑索引。不要等到数据量大了、接口变慢了再回头补,那样不仅排查成本高,而且数据迁移和索引重建的压力也不小。索引这件事,越早规划,收益越大,踩的坑也越少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询