MongoDB 简介

用索引让查询更快

理解索引的作用,学会创建、查看和删除索引,并用 explain 验证索引是否生效。

🎯 引言

前面几篇我们一直在 user 集合上做增删改查,数据只有几条,怎么查都很快。但真实项目里一个集合可能有几十万、几百万条数据,同样一句 find,速度可能天差地别,差别往往就在索引上。

学完这篇文章,你能理解索引是什么、为什么它能让查询变快,学会创建、查看和删除索引,并能用 explain 验证索引是否真的生效。这是从「会用 MongoDB」走向「用好 MongoDB」的关键一步。

本文示例基于 MongoDB 8.0(Docker 镜像 mongo:8)编写和验证,所有操作都在 mongosh 中完成。如果还没连上,先执行:
docker exec -it mongo-demo mongosh -u root -p 123456 --authenticationDatabase admin
连上后切换到示例数据库 use blog_demo,继续沿用之前的 user 集合。

🧱 为什么需要索引

我们先想清楚一个问题:执行 db.user.find({ username: '张三' }) 时,MongoDB 是怎么找到这条数据的?

没有索引时,它只能把集合里的文档一条一条拿出来看,挨个检查 username 是不是 '张三',直到全部看完为止。这种方式叫全集合扫描(COLLSCAN):集合里有多少条数据,就要检查多少条。

可以打个生活化的比喻:你想在一本书里找一句话。如果这本书没有目录,你只能从开头起逐页翻,书越厚翻得越慢;如果有目录,你先查目录定位到页码,直接翻过去就行了。索引就是 MongoDB 给集合建的「目录」。

其实你已经用过索引了:MongoDB 会给每个集合的 _id 字段自动创建一个索引,所以按 _id 查询一直都很快。其他字段则不会自动建,需要我们手动创建。

🛠 创建与查看索引

创建索引用 createIndex,参数是一个文档,指明给哪个字段建索引:

db.user.createIndex({ username: 1 });

这里的 1 表示按升序排列索引(-1 是降序)。对单个字段的查询来说,升序和降序都能用,初学阶段统一写 1 就好。

执行成功后会返回索引的名字 username_1:字段名加排序方向,是 MongoDB 自动起的名字。

查看当前集合有哪些索引,用 getIndexes()

db.user.getIndexes();

结果里能看到两个索引:一个是 _id 自带的 _id_,另一个就是我们刚建的 username_1

如果某个索引不再需要,可以用 dropIndex 按名字删除:

db.user.dropIndex('username_1');
删除索引不会删除任何数据,只是把这个「目录」扔掉,查询会退回到全集合扫描。但删之前请确认没有查询在依赖它,否则线上接口可能突然变慢。

✨ 用 explain 验证索引是否生效

索引建了到底有没有用上?不能靠猜,MongoDB 提供了 explain 方法,可以把一次查询的执行计划展示出来。在查询后面接上 .explain('executionStats') 即可:

db.user.find({ username: '张三' }).explain('executionStats');

返回的结果很长,但初学者只看两个关键指标就够了,其他字段暂时不用管:

  • winningPlan 里的 stage:表示查询是怎么执行的。COLLSCAN 是全集合扫描(没用上索引),IXSCAN 是走了索引(用上了)。
  • executionStats 里的 docsExamined:本次查询一共检查了多少条文档,数字越小说明扫描越少。

建议你自己动手做个对比实验:

  1. 先执行一次 explain,此时还没建索引,会看到 stageCOLLSCANdocsExamined 等于集合里的文档总数。
  2. 执行 db.user.createIndex({ username: 1 }) 建好索引。
  3. 再执行一次同样的 explainstage 变成了 IXSCANdocsExamined 明显变小。

数据量小的时候,两者的耗时差距可能感觉不出来,但 docsExamined 的变化是实打实的:走索引后 MongoDB 不再需要翻看整个集合,数据越多,这个优势越明显。


💡 唯一索引:让字段不允许重复

有些字段天然不允许重复,比如用户名、手机号。普通索引只管查询快,不管值重不重复;给 createIndex 加上 { unique: true },就能创建唯一索引,顺带把「不能重复」这个规则也交给数据库来保证:

db.user.createIndex({ username: 1 }, { unique: true });

建好之后再插入一个已经存在的 username,会直接报错:

db.user.insertOne({ username: '张三', age: 26, city: '北京' });
// 报错:E11000 duplicate key error

这个错误信息的意思是违反了唯一索引约束。它很适合用户名、手机号这类字段:与其在代码里先查一遍再插入,不如让数据库在底层直接拦住重复数据,既简单又可靠。

给已有数据的字段建唯一索引前,要先确认现有数据里没有重复值,否则创建索引这一步就会失败。另外 _id 上的索引本身就是唯一索引,所以每条文档的 _id 不会重复。

🪤 索引不是越多越好

看到这里你可能会想:既然索引这么快,那把每个字段都建上索引不就行了?不行,因为索引是有代价的。

可以回到目录的比喻:书每增加一章内容,目录也要跟着更新。MongoDB 也一样,每次插入、修改、删除文档,所有相关的索引都要同步维护一遍。索引建得越多:

  • 写入操作要维护的索引越多,写入速度会变慢。
  • 索引本身要占用内存和磁盘空间,集合会变得更「重」。

正确的思路是:只为高频使用的查询条件建索引。比如用户登录要按 username 查,就给它建;某个字段一年也查不了几次,就没必要建。索引是「按需配置」的优化手段,不是「一键全开」的开关。


🧾 小节总结

  • 没有索引时,MongoDB 会做全集合扫描(COLLSCAN),逐条检查所有文档;索引就像书的目录,能让查询直接定位。
  • 每个集合的 _id 字段自带索引,其他字段需要手动创建。
  • createIndex({ username: 1 }) 创建索引,getIndexes() 查看索引,dropIndex('username_1') 按名字删除索引。
  • find(...).explain('executionStats') 查看执行计划,重点看 stage 是否从 COLLSCAN 变成 IXSCAN,以及 docsExamined 是否明显变小。
  • createIndex({ username: 1 }, { unique: true }) 创建唯一索引,重复插入相同值会报错,适合用户名、手机号等字段。
  • 索引多了会拖慢写入、占用空间,只为高频查询条件建索引。

❓ 知识问答

Q1:createIndex({ username: 1 }) 里的 1 是什么意思?写 -1 会怎样?

1 表示升序,-1 表示降序,影响的是索引里值的排列方向。对单字段查询来说两者效果相当,写 1 即可;排序方向的影响主要体现在多字段组合排序的场景,初学阶段先不用深究。

Q2:建了索引之后,查询会自动走索引吗?

会。只要查询条件和索引字段匹配,MongoDB 会自动选择走索引,不需要在查询语句里做任何特殊声明。不确定时用 explain 看一下 stage 就知道了。

Q3:为什么我执行 explainstage 还是 COLLSCAN?

常见原因有两个:索引还没建成功(用 getIndexes() 确认一下),或者查询条件里的字段和索引字段对不上(比如索引建在 username 上,查询条件用的却是 city)。

Q4:唯一索引和普通索引冲突吗?能同时建吗?

不冲突,但也不需要同时建。唯一索引本身就具备普通索引的查询加速能力,给同一个字段建一个唯一索引就够了。

Q5:删掉索引会把数据也删掉吗?

不会。索引只是数据的「目录」,删除索引只影响查询的查找方式,文档数据原封不动。真正删数据的是 deleteOne / deleteMany,别和 dropIndex 混淆。


🧪 小练习

练习一:给 user 集合的 city 字段创建一个升序索引,然后查看集合当前的所有索引,确认创建成功。

// 请在这里编写代码

练习二:用 explain('executionStats') 分别观察「按 username 查询」和「按 age 查询」的执行计划,说说两者的 stage 分别是什么、为什么不一样(假设只给 username 建了索引)。

// 请在这里编写代码

🎉 恭喜你已经掌握 MongoDB 索引技能啦!下一篇我们学习聚合管道,看看如何用 $match$group 等阶段对数据做分组统计,让 MongoDB 直接帮你算出想要的结果。