1. 索引不是银弹:先搞清楚MongoDB在什么场景下会“慢”
很多人第一次接触MongoDB索引,是因为某条查询突然变慢了。翻日志、看监控,发现一个原本几十毫秒的查询涨到了几秒,第一反应就是“加个索引吧”。这个直觉不算错,但如果连慢在哪、为什么慢都没搞清楚就动手加索引,大概率会陷入“加了索引还是慢”或者“索引越加越多、写入越来越慢”的泥潭。
MongoDB的查询慢,通常逃不出三种情况。第一种是集合扫描,也就是没有可用的索引,MongoDB只能把整个集合的文档从头到尾读一遍。数据量小的时候感觉不出来,一旦文档数上了几十万,扫描的代价就非常明显。第二种是索引选择不当,明明有索引,但查询优化器选了一个区分度很差的索引,导致扫描了大量索引条目后才命中目标文档。第三种是索引本身没问题,但查询写法让索引失效,比如对索引字段做了函数运算、用了不支持的运算符,或者排序方向与索引方向不匹配。
理解这三种情况,是后面所有索引操作的前提。因为索引的本质是用额外的存储空间和写入开销,换取查询时的定位效率。它就像一本书的目录:有了目录,你不需要翻遍整本书找某一章;但每往书里加一页内容,你都得顺手更新目录,书越厚、目录越多,更新目录的成本就越高。MongoDB的索引也是这个道理,每个索引都意味着每次插入、更新、删除文档时,都要同步维护这个索引结构。
所以我在实际项目里判断“要不要加索引”时,会先问自己三个问题:这个查询的频率高不高?返回的结果集占集合的比例大不大?这个字段的区分度高不高?如果查询频率低、结果集又大、字段区分度还差,那加索引的收益可能还不如优化查询本身。索引操作的核心不是“会建索引”,而是“知道什么时候该建、建什么样的、什么时候该删”。
2. 单字段索引的创建与验证:从一次真实的慢查询说起
2.1 用explain看清查询的真实执行路径
假设有一个存储订单的集合,文档结构大概长这样:
{ _id: ObjectId("..."), orderNo: "ORD20240101001", userId: "U10086", status: "paid", amount: 299.00, createdAt: ISODate("2024-01-01T10:00:00Z") }某天运营反馈,按订单号查订单特别慢。我先不急着加索引,而是用explain看一下执行计划:
db.orders.find({ orderNo: "ORD20240101001" }).explain("executionStats")返回结果里重点看几个字段。winningPlan.stage如果是COLLSCAN,说明走的是集合扫描;如果是IXSCAN,说明走了索引。executionStats.totalDocsExamined表示扫描了多少文档,executionStats.nReturned表示返回了多少文档。如果totalDocsExamined远大于nReturned,比如扫描了十万条只返回一条,那就是典型的“扫描效率低”。
那次的结果是COLLSCAN,totalDocsExamined是十二万多,nReturned是1。问题很明确:没有索引,全表扫描。这时候加索引就是对的。
2.2 创建单字段索引并对比前后差异
创建索引的命令很直接:
db.orders.createIndex({ orderNo: 1 })这里的1表示升序,-1表示降序。对于单字段索引来说,升序和降序在查询时都能用,因为MongoDB可以反向扫描索引。但如果是复合索引,方向就有讲究了,后面会细说。
创建完再跑一次同样的explain,winningPlan.stage变成了IXSCAN,totalDocsExamined降到了1,nReturned也是1。查询耗时从原来的几百毫秒降到了个位数毫秒。这就是索引最直观的价值。
不过这里有个细节值得注意:createIndex是异步的,在数据量很大的集合上创建索引会消耗较多资源。生产环境里我一般会加两个参数:
db.orders.createIndex( { orderNo: 1 }, { background: true, name: "idx_orderNo" } )background: true让索引在后台构建,不阻塞其他读写操作。虽然从4.2版本开始,MongoDB的索引构建机制已经优化,前台后台的差异没那么大了,但显式写上background仍然是一个好习惯,尤其是在老版本或者混合版本的环境里。name参数给索引起一个有意义的名字,方便后续管理和排查。默认名字是orderNo_1这种,字段多了以后根本分不清哪个是哪个。
2.3 索引命中的边界条件
加完索引不代表万事大吉。我见过不少人加完索引发现查询还是慢,原因往往出在查询写法上。比如下面这几种情况,索引就命中不了:
- 对索引字段做了类型转换:
db.orders.find({ orderNo: 12345 }),而orderNo存的是字符串,MongoDB不会自动把数字转成字符串去匹配索引。 - 用了不支持的运算符:比如
$where、$regex(非前缀匹配)这类,索引基本帮不上忙。 - 查询条件里字段顺序和复合索引不匹配:这个在复合索引部分展开。
所以每次加完索引,我都会用explain再验证一遍,确认IXSCAN真的生效了,而不是想当然地认为“加了索引就一定走索引”。
3. 复合索引的字段顺序:一个被低估的决策点
3.1 最左前缀原则不是背出来的,是推出来的
复合索引是MongoDB索引里最容易踩坑的地方。假设我们经常按userId和status两个字段联合查询,创建了这样一个索引:
db.orders.createIndex({ userId: 1, status: 1 })这个索引能支持的查询模式,遵循所谓的“最左前缀原则”。简单说,索引先按userId排序,userId相同的再按status排序。所以下面这些查询能用上索引:
db.orders.find({ userId: "U10086" }) db.orders.find({ userId: "U10086", status: "paid" }) db.orders.find({ status: "paid", userId: "U10086" }) // 查询优化器会自动调整顺序但下面这个查询用不上:
db.orders.find({ status: "paid" })因为索引的第一层排序是userId,跳过userId直接查status,就像跳过目录的第一级直接找第二级,索引结构帮不上忙。
这个原则很多人背得滚瓜烂熟,但真正做设计时还是会搞错顺序。我的经验是:把区分度最高的字段放在最左边。区分度高意味着这个字段的值重复少,能快速缩小扫描范围。比如userId可能有几千个不同值,而status只有“待支付、已支付、已发货、已完成”四五种,那userId放前面就比status放前面效率高得多。
3.2 排序方向对复合索引的影响
复合索引的另一个坑是排序方向。假设有查询需要按userId升序、createdAt降序排列:
db.orders.find({ userId: "U10086" }).sort({ createdAt: -1 })如果索引是{ userId: 1, createdAt: 1 },这个排序就用不上索引,因为索引里createdAt是升序排列的,而查询要降序。这时候需要建一个方向匹配的索引:
db.orders.createIndex({ userId: 1, createdAt: -1 })或者反过来{ userId: -1, createdAt: 1 }也行,因为MongoDB可以反向扫描整个索引。但{ userId: 1, createdAt: 1 }和{ userId: -1, createdAt: -1 }是等价的,而{ userId: 1, createdAt: -1 }和{ userId: -1, createdAt: 1 }是等价的。这个规律记不住也没关系,用explain看winningPlan里有没有SORT阶段就行,如果有SORT,说明排序没走索引,需要调整。
3.3 复合索引与覆盖查询
覆盖查询是复合索引的一个高级用法。如果一个查询需要的所有字段都在索引里,MongoDB就可以直接从索引返回结果,不需要回表读文档。比如:
db.orders.createIndex({ userId: 1, status: 1, amount: 1 }) db.orders.find( { userId: "U10086", status: "paid" }, { userId: 1, status: 1, amount: 1, _id: 0 } )这个查询只返回索引里包含的字段,并且显式排除了_id,就能触发覆盖查询。explain里会看到totalDocsExamined为0,因为压根没读文档。覆盖查询对性能提升非常明显,尤其是在高并发场景下,能大幅减少磁盘I/O。
但覆盖查询有个容易忽略的点:_id字段默认会返回,除非显式排除。很多人写了投影但忘了排除_id,结果覆盖查询没生效,还纳闷为什么totalDocsExamined不是0。
4. 索引的日常维护:查看、删除与重建
4.1 查看现有索引,避免重复建设
随着业务迭代,集合上的索引会越来越多。我接手过一个项目,某个集合上有十几个索引,其中好几个是功能重复的。比如已经有了{ userId: 1, status: 1 },又建了一个{ userId: 1 },后者完全是前者的前缀,属于冗余索引。
查看索引的命令:
db.orders.getIndexes()返回结果里会列出所有索引的名称、键和选项。我一般会定期跑一遍这个命令,对照慢查询日志,看看哪些索引从来没被用过。MongoDB提供了$indexStats聚合阶段来统计索引使用情况:
db.orders.aggregate([{ $indexStats: {} }])返回结果里的accesses.ops表示这个索引被使用的次数。如果某个索引的ops长期为0,那它大概率是冗余的,可以考虑删除。但要注意,$indexStats统计的是自上次MongoDB进程启动以来的数据,重启后会重置,所以最好观察一段时间的累计值再下结论。
4.2 删除索引的正确姿势
删除索引用dropIndex:
db.orders.dropIndex("idx_orderNo")或者按键删除:
db.orders.dropIndex({ orderNo: 1 })删除索引本身很快,但有个前提:不能删除_id索引,也不能删除正在被唯一性约束依赖的索引。如果某个索引是唯一索引,删除前要确认没有业务逻辑依赖它的唯一性保证,否则可能出现重复数据。
我踩过一次坑:删了一个看似没用的索引,结果那个索引是某个唯一约束的一部分,删完之后业务侧出现了重复插入。所以删除前一定要用getIndexes()看清楚索引的unique选项,并且和业务方确认没有依赖。
4.3 索引重建的时机与方式
索引用久了可能会出现碎片,尤其是频繁增删改的集合。碎片会导致索引扫描效率下降。判断是否需要重建,可以看db.collection.stats()里的indexSizes和实际数据量的比例,如果索引大小远超预期,可能存在碎片。
重建索引有两种方式。一种是删除后重建:
db.orders.dropIndex("idx_orderNo") db.orders.createIndex({ orderNo: 1 }, { name: "idx_orderNo" })另一种是用reIndex命令:
db.orders.reIndex()reIndex会重建集合上的所有索引,在数据量大时非常耗时,而且会阻塞其他操作。生产环境我更倾向于逐个删除重建,配合background: true,把影响降到最低。重建最好安排在业务低峰期,并且提前在从节点上验证一遍,确认没问题再操作主节点。
5. 那些让索引失效的写法:几个真实案例
5.1 正则表达式的前缀陷阱
正则查询在MongoDB里很常见,但并不是所有正则都能用索引。只有前缀匹配的正则才能利用索引,比如:
db.orders.find({ orderNo: /^ORD2024/ })这个查询可以用上orderNo的索引,因为它是从字符串开头匹配的。但如果是:
db.orders.find({ orderNo: /2024/ })这种包含匹配就用不上索引,只能全表扫描。如果业务确实需要模糊匹配,可以考虑用全文索引或者把关键字拆出来单独存字段。
5.2 类型不一致导致的索引失效
MongoDB是弱类型的,同一个字段在不同文档里可能是不同的BSON类型。比如userId有的存字符串"U10086",有的存数字10086。这种情况下,即使建了索引,查询时如果类型不匹配,索引也可能失效。
我遇到过一个案例:某次数据迁移时,部分文档的userId被写成了数字类型,导致按字符串查询时这些文档查不到,而且查询计划变成了集合扫描。排查了很久才发现是类型问题。后来统一了写入规范,并且在应用层做了类型校验,才彻底解决。
5.3 数组字段索引的注意事项
对数组字段建索引时,MongoDB会为数组里的每个元素都建一条索引条目,这叫多键索引。多键索引能加速数组元素的查询,但有几个限制:不能用于覆盖查询,不能对数组字段做排序优化。而且如果数组很大,索引体积会膨胀得很快。
比如给tags数组字段建索引:
db.articles.createIndex({ tags: 1 })查询db.articles.find({ tags: "mongodb" })能用上索引。但如果查询是db.articles.find({ tags: { $all: ["mongodb", "index"] } }),索引的效率就会打折扣,因为需要合并多个索引扫描结果。
6. 索引与写入性能的平衡:一个绕不开的取舍
6.1 每个索引都有写入成本
前面一直在讲索引对查询的好处,但索引不是免费的。每次插入文档,MongoDB都要为每个索引插入一条索引条目;每次更新文档,如果更新涉及索引字段,就要更新对应的索引条目;每次删除文档,也要删除索引条目。
假设一个集合有10个索引,那么一次插入操作实际上要写11次(1次文档+10次索引)。写入放大非常明显。在高写入场景下,索引过多会直接拖垮写入吞吐量。
我做过一个压测:同一个集合,3个索引和8个索引对比,写入QPS差了将近一倍。所以索引数量要克制,够用就行,不要“以防万一”地加一堆。
6.2 如何判断索引是否值得保留
判断一个索引是否值得保留,我一般看三个指标:使用频率、对查询性能的提升幅度、对写入性能的影响。如果某个索引使用频率很低,但占用空间大、维护成本高,那就果断删掉。
具体操作上,可以结合$indexStats和慢查询日志。先找出所有慢查询,看它们分别用了哪些索引;再对照$indexStats,看哪些索引从来没被慢查询用到。两边一交叉,冗余索引就浮出水面了。
另外,对于低频但偶发的复杂查询,可以考虑用部分索引来降低维护成本。部分索引只对满足条件的文档建索引,比如只对status: "paid"的订单建索引:
db.orders.createIndex( { createdAt: 1 }, { partialFilterExpression: { status: "paid" } } )这样索引体积更小,写入成本更低,但只对特定查询有效。用之前要确认查询条件能匹配partialFilterExpression,否则索引不会被使用。
6.3 TTL索引:一种特殊的写入优化
TTL索引是MongoDB里比较特殊的一类索引,它可以让文档在指定时间后自动过期删除。比如日志集合,只保留最近7天的数据:
db.logs.createIndex( { createdAt: 1 }, { expireAfterSeconds: 604800 } )604800是7天的秒数。MongoDB会有一个后台线程定期扫描TTL索引,删除过期文档。这个删除操作也是要维护索引的,但相比手动清理,TTL索引把清理逻辑交给了数据库,应用层不用再写定时任务。
不过TTL索引有个限制:它只能基于日期类型字段,而且删除不是精确到秒的,后台线程每60秒跑一次,所以实际删除时间可能有延迟。对时效性要求极高的场景,不能完全依赖TTL。
7. 索引监控与调优:把explain用透
7.1 explain的三种模式
explain支持三种模式:queryPlanner、executionStats和allPlansExecution。默认是queryPlanner,只返回查询优化器选择的计划,不实际执行。executionStats会实际执行查询并返回统计信息,是我最常用的模式。allPlansExecution会返回所有候选计划的执行情况,适合分析为什么优化器选了某个计划而不是另一个。
db.orders.find({ userId: "U10086" }).explain("executionStats")看executionStats时,重点对比totalDocsExamined和nReturned。理想情况下,totalDocsExamined应该接近nReturned。如果前者远大于后者,说明索引效率不高,可能需要调整索引或查询。
7.2 用hint强制指定索引
有时候查询优化器会选错索引,尤其是当集合上有多个相似索引时。这时候可以用hint强制指定:
db.orders.find({ userId: "U10086", status: "paid" }) .hint({ userId: 1, status: 1 })hint在调试阶段很有用,可以验证某个索引是否真的比优化器选的索引更好。但生产环境要慎用,因为一旦索引被删除或改名,hint会导致查询报错。我一般只在排查问题时临时用,确认问题后要么调整索引设计,要么优化查询写法,而不是长期依赖hint。
7.3 慢查询日志的配置与解读
MongoDB的慢查询日志通过profiling功能开启。可以设置一个阈值,比如超过100毫秒的查询记录到system.profile集合:
db.setProfilingLevel(1, { slowms: 100 })1表示只记录慢查询,2表示记录所有操作。生产环境一般用1,避免日志量过大。查看慢查询:
db.system.profile.find().sort({ ts: -1 }).limit(10)返回结果里会包含查询语句、执行时间、扫描文档数等信息。我习惯定期导出慢查询日志,按查询模式归类,找出最耗时的几类查询,然后针对性地优化索引。
8. 几个容易忽略的索引细节
8.1 索引的排序规则与大小写
MongoDB支持为索引指定排序规则,比如大小写不敏感:
db.users.createIndex( { username: 1 }, { collation: { locale: "en", strength: 2 } } )strength: 2表示忽略大小写。这样查询username: "Alice"和username: "alice"都能命中同一条索引。但要注意,查询时也必须指定相同的collation,否则索引不会被使用:
db.users.find({ username: "Alice" }) .collation({ locale: "en", strength: 2 })这个细节很容易漏,漏了就会发现索引明明建了却用不上。
8.2 稀疏索引与存在性查询
稀疏索引只对包含索引字段的文档建索引。如果集合里很多文档没有某个字段,用稀疏索引可以减小索引体积:
db.users.createIndex({ email: 1 }, { sparse: true })但稀疏索引有个坑:它不能用于$exists: false的查询,因为不包含该字段的文档压根不在索引里。而且从MongoDB 4.2开始,稀疏索引和部分索引的功能有重叠,部分索引更灵活,所以我更倾向于用部分索引替代稀疏索引。
8.3 唯一索引与重复键处理
唯一索引能保证字段值的唯一性,但创建时如果集合里已经有重复值,创建会失败。这时候需要先清理重复数据,或者用dropDups选项(老版本支持,新版本已移除)。新版本的做法是先找出重复值,手动处理后再建唯一索引。
db.users.createIndex({ email: 1 }, { unique: true })如果插入重复值,MongoDB会抛出E11000错误。应用层要捕获这个错误并做相应处理,而不是让异常直接冒泡。
9. 从索引设计反推数据模型
索引设计不只是查询优化的问题,它还会反过来影响数据模型。比如如果发现某个查询总是需要跨集合关联,那可能说明数据模型需要调整,把关联字段内嵌到文档里,用内嵌文档的索引来加速查询。
我经历过一个项目,最初把用户信息和订单分开存,查询时要先查用户再查订单,两次查询。后来把常用的用户信息冗余到订单文档里,并在冗余字段上建索引,一次查询就能搞定。虽然增加了数据冗余和维护成本,但查询性能提升了一个数量级。
另一个经验是:索引字段尽量选那些写入后不怎么变的字段。如果某个字段频繁更新,而它上面又有索引,那每次更新都要维护索引,写入成本很高。这种情况下,可以考虑把频繁更新的字段和稳定字段分开存,或者用其他方式规避。
索引操作看起来是MongoDB里比较基础的内容,但真正用好并不容易。我的体会是:不要为了加索引而加索引,每次加索引之前先用explain确认问题,加完之后再用explain验证效果,定期用$indexStats清理冗余索引。把这几个动作变成习惯,索引就不会成为负担,而是真正能提升性能的工具。