☰
MongoDB索引优化实战:从慢查询日志到执行计划的全流程指南
2026/10/11 3:53:34 网站建设 项目流程

1. 慢查询日志:索引优化前先摸清查询的真实画像

1.1 为什么优化必须从慢查询日志开始,而不是直接建索引

我见过太多人一遇到 MongoDB 查询变慢,第一反应就是"赶紧加个索引"。这个想法本身没错,但如果连"慢在哪条查询、扫描了多少文档、返回了多少数据"都没搞清楚就动手,大概率会建出一堆没用的索引,反而把写性能拖垮。

正确的姿势是先开启慢查询日志,让数据库把执行时间超过阈值的操作记录下来。MongoDB 里最直接的方式是在 mongod 启动参数里加上slowms,默认是 100 毫秒,意思是超过 100ms 的操作会被记录到日志。实际生产环境我一般会调成 50ms,因为很多内部定时任务查询的敏感度更高,真等 100ms 才记录,一些劣化趋势很难及时暴露。

# 启动时设置慢查询阈值 mongod --slowms 50 --profile 1 # 或者运行时动态修改 db.setProfilingLevel(1, 50)

设置完以后,用db.system.profile.find().sort({ts: -1}).limit(20)去看最近慢操作,重点看几个字段:op是什么操作类型、ns是哪个集合、query长什么样、docsExamined扫描了多少文档、nreturned实际返回了多少。

1.2 索引优化的核心目标:减扫描量,而不是单纯追求"变快"

很多初学者会把"查询变快"等同于"索引优化成功",这是理解偏差。索引真正解决的是减少文档扫描数量。同样的查询,全表扫描要读 10 万份文档,有了合适索引可能只精确命中 20 份,这中间的差距才是性能提升的本质来源。

举个例子。某个订单集合有 50 万条记录,查询条件是{status: "PAID", createdTime: {$gte: ISODate("2024-01-01")}}。没有索引时,数据库必须把整个集合遍历一遍,哪怕 99% 的数据都不满足条件。如果你只看日志里显示的"耗时 1.2 秒",然后凭感觉建了{status: 1}索引,结果可能只快了一点点——因为status只有几个值,就算通过索引定位到PAID数据,依然有几万条要挨个比对时间范围。

这个阶段的核心任务是做查询画像:哪些条件高频出现、哪些字段是范围查询、排序需要什么字段。把这些信息记清楚,后面设计索引才有依据,而不是靠猜。

2. 索引类型选择:单字段、复合、多键与哈希的适用场景

2.1 单字段索引:最基础但别忘了考虑唯一性约束

单字段索引是最容易理解的,db.collection.createIndex({userId: 1})会给userId字段建一个升序索引。适合那种查询条件只有一个主要字段的场景,比如"查某个用户的所有订单""查某个商品的库存信息"。

这里有一个容易被忽略的细节:如果你在建索引时就确定这个字段在业务上不应该重复,那不要只建普通索引,直接建唯一索引。db.collection.createIndex({orderNo: 1}, {unique: true})不止让查询更快,更重要的是它在数据库层面强约束了数据不脏。经常有人代码里做各种唯一校验,却把最可靠的一道防线放在了可选位置,属实有点本末倒置。

另外要注意单字段索引在字段基数低的情况下效果有限。所谓字段基数,你可以理解为"这个字段一共有多少种不同的取值"。"性别"字段只有男女两种取值,就算建了索引,查询性别为"男"仍然要返回一半的数据,扫描量减不下来,还会额外占用索引存储空间。这类低基数字段更适合和别的字段组成复合索引,而不是单独建索引。

2.2 复合索引的字段顺序规则:等值条件放前面,范围排序放后面

复合索引是 MongoDB 优化查询最核心的武器。db.collection.createIndex({a: 1, b: 1})会先按a排序,在a相同的情况下再按b排序。这个特性决定了它支持哪些查询模式。

先说最经典的选型原则:等值匹配的字段放前面,排序或范围匹配的字段放后面。比如{status: "PAID", createdTime: {$gte: ...}}这个查询,status是等值条件,createdTime是范围条件,那就建{status: 1, createdTime: 1}。这样数据库先用索引精确定位到所有status为PAID的记录,再借助索引天然有序的特点快速跳到时间范围内的那一段,无需额外排序。

但很多人会犯一个错误:把复合索引当成"多建几个单字段索引"的替代品。比如同时建了{status: 1}和{createdTime: 1}两个独立索引,结果查询时数据库只能二选一,另一个字段仍然要回表过滤。复合索引的作用不是简单叠加,而是让多个条件在索引层一并消化。

2.3 多键索引和哈希索引:数组字段与精确查找的特殊处理

如果被索引的字段是数组,MongoDB 会自动创建多键索引。比如文档里有tags: ["mongodb", "database"],db.collection.createIndex({tags: 1})会让索引条目同时包含tags里的每个元素。查询{tags: "database"}时能命中这条文档。

多键索引需要注意一个限制:每个复合索引里最多只能有一个数组字段。如果你尝试创建{tags: 1, category: 1}且两个字段都是数组,MongoDB 会直接报错。因为多键索引要为每个数组元素复制一条索引项,两个数组相互组合会形成"索引项爆炸"。

哈希索引则是为等值查询准备的,db.collection.createIndex({key: "hashed"})对字段值做哈希后再建索引,好处是索引本身非常紧凑,但缺点是不支持范围查询和排序。所以哈希索引一般只用在分片集群的片键上,或者那种只需要精确匹配、基数又很高的字段上。普通业务查询里,范围条件和排序需求太常见,哈希索引的使用空间其实不大。

索引类型支持等值查询支持范围查询支持排序特别注意
单字段是是是低基数字段效果差
复合是是是字段顺序决定效果
多键是是是复合索引只能有一个数组字段
哈希是否否适合分片片键

3. explain() 执行计划:判断索引有没有真正生效

3.1 读明白 executionStats 里的关键指标

建好索引以后,最怕的就是"索引建了,但查询根本没走"。判断索引有没有生效,不要靠猜,也不要看语句执行时间(缓存、热数据都会干扰),直接看执行计划。

db.orders.find({status: "PAID", createdTime: {$gte: ISODate("2024-01-01")}}).explain("executionStats")

重点看executionStats里的这么几个字段:

  • totalDocsExamined:实际扫描的文档数。如果这个数字等于集合总文档数,基本可以断定没走索引,全表扫描了。
  • executionStages.stage:如果显示IXSCAN,说明走了索引扫描;如果是COLLSCAN,说明是集合扫描,索引没用上。
  • totalKeysExamined:扫描了多少索引条目。这个数值应该明显小于totalDocsExamined,因为只用索引就完成了大部分过滤。
  • nReturned:最终返回的文档数。

一个理想的执行计划应该是:totalKeysExamined大但totalDocsExamined小,甚至为 0,然后nReturned和实际数据量一致。

3.2 索引建了却没用上的三个典型原因

我遇到过好几次这种情况:看集合里getIndexes()明明有合适的索引,但 explain 出来还是COLLSCAN。常见原因有几个,逐个排查:

第一个原因:前缀字段不匹配。索引{userId: 1, status: 1}能支持{userId: 1, status: 1}的查询,也能支持只查{userId: 1}的查询,但不支持跳过 userId 直接查 status。这就是复合索引的"最左前缀原则"。如果你的查询条件里没有索引的第一个字段,这个索引在这种查询下就是一块废铁。

第二个原因:字段类型不匹配。查询条件用的是字符串,索引字段存的是数字,或者查询里用了$exists、$type这类操作符,都可能让优化器放弃索引。MongoDB 里字符串和数字在 BSON 层面是不同类型,索引排序时也要区分类型,所以{age: "18"}和{age: 18}看起来都是"18",但走的完全不是一条路。

第三个原因:索引选择性太低,优化器主动放弃。MongoDB 的查询优化器会基于采样统计、索引基数、集合大小等信息做成本估算,如果它觉得走一个低效索引和全表扫描差不多,或者走索引反而要回表更多次更慢,就会选择COLLSCAN。这个时候你需要检查索引字段的基数,以及查询条件返回的数据量占比。如果一次查询命中了一半以上的集合数据,那确实不如全扫——顺序读盘在多数情况下不比随机回表慢。

3.3 用 hint() 强制走索引来验证判断

当你怀疑优化器选错计划时,可以先用hint()强制它走指定索引,对比一下真实耗时和扫描量:

db.orders.find({status: "PAID", createdTime: {$gte: ISODate("2024-01-01")}}) .hint({status: 1, createdTime: 1}) .explain("executionStats")

这不会影响线上其他请求,只是当前这个命令手动指定了索引路径,很适合用来验证"为什么没走索引""走了是不是更快"。

有一点要说清楚:hint()只能作为诊断手段,不建议长期写在业务代码里。原因很简单,你的数据分布是变化的,今天最优的索引策略明天可能就是次优。让优化器自己选,配合定期分析执行计划,才是长期健康的状态。

4. 覆盖索引与排序优化:从减少文档访问到避免内存排序

4.1 覆盖查询:让索引自己把数据都"带出来"

普通索引查询流程是:先通过索引找到匹配的文档 ID,再拿着 ID 去集合数据文件里取出完整文档。这一步叫回表。回表操作本身有随机 IO 成本,数据量越大越明显。

如果能做到查询只需要返回索引里已经包含的字段,数据库就可以直接读索引条目的数据返回,完全不用碰原始文档。这种查询叫覆盖查询。

打个比方:你要查通讯录里某个人在哪个城市。普通索引的做法是先在索引目录里找到这个人,翻到通讯录正文,看完再告诉你结果;覆盖查询则是索引目录里本身就附带着"城市"这一列,你直接在目录页就得到答案了。

实现方式很简单:复合索引的字段覆盖所有查询条件和投影字段。比如查询是db.users.find({age: {$gte: 18}}, {name: 1, age: 1, _id: 0}),如果建了{age: 1, name: 1}的复合索引,那age用于筛选,name和age都在索引里,数据库不需要回表。

但注意_id字段默认会返回,如果不想让它出现在投影里,必须显式写明{_id: 0}。_id不在索引里的话,覆盖查询就实现不了。这是一个非常隐蔽的坑,我甚至在不少生产项目里见过,明明特意设计了覆盖索引,就因为没排除_id,回表一步没少。

4.2 索引天然有序:把排序代价从内存移到索引

MongoDB 处理排序时,如果排序字段不在索引里,而且排序结果超过内存限制(默认 100MB),就会直接报错或者产生非常可怕的性能问题。而如果排序字段正好是索引的一部分,MongoDB 可以按索引顺序直接读出数据,省掉整个 SORT 阶段。

对于查询db.orders.find({status: "PAID"}).sort({createdTime: -1}),索引最好设计成{status: 1, createdTime: -1}。注意createdTime排序方向是 -1(降序),索引也要建成 -1,这样升序读索引就对应业务上的时间倒序,两者完全吻合。

如果逆序建,MongoDB 的索引结构本身支持正向反向扫描,{status: 1, createdTime: 1}也可以满足sort({createdTime: -1}),性能这块不会有太大差别。真正要避免的是:排序字段不在已命中索引的字段序列里,导致 MongoDB 必须把所有匹配文档取出来,再在内存里单独排序。

还有一点容易被忽略:复合索引的排序字段必须和等值字段在一起才能靠索引完成排序。如果索引是{status: 1, createdTime: 1},查询条件里只有{createdTime: {$gte: 某时间}}而没有status前缀,那这个索引压根不会命中,更别提用索引排序了。所以在设计复合索引时,把"哪个字段常做等值筛选、哪个字段常做排序"一起考虑进去,顺序和方向都要想清楚。

5. 索引失效的隐藏场景:这些坑我在生产里踩过

5.1 对索引字段做函数转换,索引直接失效

这是最经典也最容易犯的错。假如你有一个loginTime字段,存的是日期,想统计"2024 年 1 月登录过的用户",很自然会写成:

db.users.find({$expr: {$eq: [{$year: "$loginTime"}, 2024]}})

看起来逻辑没毛病,但这样的查询对loginTime字段的索引来说完全无效。因为 MongoDB 需要在每条索引条目上先执行$year函数把日期变成年份,再和 2024 比对,这等于把索引变成了一张需要逐个现场计算的表,优化器只能全扫。

正确的做法是查询时把范围直接写成日期区间:

db.users.find({loginTime: {$gte: ISODate("2024-01-01"), $lt: ISODate("2025-01-01")}})

这样就能命中loginTime索引的范围扫描。同理,如果你经常需要按月份查询,不如把月份单独存一个字段,或者用聚合框架提前清洗出独立的yearMonth字段并建索引。

5.2 正则表达式、否定查询与 $or 的组合陷阱

正则查询在 MongoDB 里容易踩坑。db.users.find({name: /^张/})如果字段建了普通索引,前缀正则(即以^开头)是可以用索引的,MongoDB 会把前缀部分转成范围扫描。但如果你写的是{name: /张/},没有前缀锚点,优化器就只能遍历全部索引条目做正则匹配,索引直接失效。

否定查询是另一个头疼的场景。{status: {$ne: "CANCELLED"}}这种条件本质上是"扫描所有值,排除某个值"。如果status基数很低,优化器通常会选择全表扫描。类似的还有$nin、$not。这些操作符在语义上就很难利用索引的有序性,因为你要的是"不匹配",而索引擅长的是"匹配"。

$or则要特别注意:MongoDB 对$or的每个子条件会分别尝试命中最优索引,然后做合并。看起来满足条件,但如果某个子条件无法用索引,整个查询的执行效率就被这个"木桶最短板"拖住了。我处理过的一个真实案例是:查询里$or包含三个条件,两个走了索引,其中一个字段只有单字段索引但查询模式是范围,结果那个子条件全扫了二十万文档,整体耗时依然居高不下。后来我把其中一个子条件拆出来放到$and里,再用explain逐项验证,才把查询拉回来。

5.3 索引选择性:低基数字段做复合索引前缀带来的连锁反应

索引的选择性可以理解成"这个字段过滤掉无用数据的能力"。基数越高的字段,选择性越强,越应该放在复合索引的前列。

我在一次分页查询优化中就吃过亏。集合里所有文档都有deleted字段,值是 0 或 1,绝大多数数据是 0。当初设计索引时把{deleted: 1, userId: 1, status: 1}建了出来,意图是先把未删除的数据筛出来。结果由于deleted选择性太低,查询优化器发现通过这个索引扫出来的数据量几乎等于全表,反而不如直接COLLSCAN再过滤userId来得快,索引白白占了存储不说,还拖慢了写入。

更合理的做法是把高选择性的userId放在复合索引前面,deleted放后面,改成{userId: 1, deleted: 1}。这样查询{userId: 123, deleted: 0}时,数据库先通过userId把数据缩到几百条,再用deleted做进一步过滤,索引的每一步都在减少候选集,效果立竿见影。

提示:判断字段选择性的简单方法是用db.collection.distinct("field").length除以集合文档总数。比值越接近 1,说明选择性越高;如果比值小于 5%,就要谨慎用它做复合索引的前缀了。

6. 索引不是免费的:写入代价、存储开销与维护节奏

6.1 每个索引都在拖慢写入,布局要算总账

索引带来的性能提升,本质上是在写入时预付的"预排序成本"。你每插入一条文档,MongoDB 除了写数据文件,还要同步更新所有涉及的索引结构。如果一个集合上有 6 个索引,那每次写入就等于额外维护 6 棵 B 树,写入放大肉眼可见。

所以索引设计的真正考验不是"能不能让这条查询变快",而是"在所有查询和写入的总成本之间找到平衡点"。我的习惯是:

  • 单字段能解决的,不建复合索引;
  • 复合索引尽量覆盖多条高频查询,而不是每条查询一个专属索引;
  • 上线前用db.collection.getIndexes()检查是否存在冗余索引,比如已经有{a: 1, b: 1},那单字段{a: 1}在很多场景下是多余的;
  • 对几乎没有查询压力的后台集合,保持极简索引,优先保障写入吞吐。

6.2 定期用索引扫描工具做"体检",而不是等出问题再补救

索引调优不是一锤子买卖。数据分布会变,查询模式会变,过去的最优设计半年后可能就成了瓶颈。比较务实的做法是把索引体检纳入日常巡检:

  • 用db.collection.aggregate([{$indexStats: {}}])查看每个索引的命中次数和使用情况。长期0命中的索引,基本可以判定为冗余,在非高峰期删除。
  • 打开 profiling 或慢日志,定期统计 TOP N 慢查询,看它们的docsExamined与nreturned比值。如果某个查询扫了 5 万文档只返回 50 条,这个比例严重失衡,说明索引没有覆盖好过滤条件。
  • 在版本升级或者数据量跨越数量级增长时,重新验证核心查询的执行计划。MongoDB 优化器会基于采样统计做判断,数据量从百万级涨到千万级时,之前的计划选择可能就不适用了。

我见过一个团队,每月做一次索引体检,把半年内无命中和重复的索引清理掉,结果写入性能恢复了近三成,这是实打实的收益。

6.3 删索引的谨慎原则:先标记后删除

最后分享一个经验:删除索引前不要直接dropIndex,尤其在生产环境。正确操作是先在代码和文档里标注"此索引计划废弃",观察一两个完整业务周期(覆盖所有高峰和低谷时段),确认没有告警后再删除。

原因是有些查询会走你没注意到的间接路径,比如某个报表任务一周只跑一次,平时根本不见踪影。等索引删了,下一次报表任务发现没有可用索引,慢查询瞬间可能拖垮整个实例。删索引比建索引更需要做变更管理,这也是很多人容易忽略的一环。

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

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

立即咨询