资讯详情

资讯详情

MongoDB索引操作实战:从创建、复合索引设计到执行计划分析与性能优化

1. 为什么索引操作值得单独拎出来讲但凡用过一段时间的数据库大概率都听过“索引”这个词。但真正能把索引操作讲清楚、用明白的人比例并不高。我在实际项目里见过太多这样的情况开发阶段数据量小查询随便写都很快一上线数据量上来接口响应直接从几十毫秒飙到几秒然后开始手忙脚乱地加索引加完发现没效果甚至更慢了。问题出在哪就出在对索引操作的理解停留在“建个索引就完事”的层面。MongoDB 的索引操作远不止createIndex一个命令那么简单。它涉及索引类型的选择、创建时机的判断、执行计划的解读、性能影响的评估以及日常维护中的各种坑。这篇文章就是围绕“索引操作”这个主题把我在实际项目中积累的经验、踩过的坑、总结的方法论尽可能完整地摊开来讲。不管你是刚接触 MongoDB 的新手还是已经用了一段时间但总觉得索引这块不够通透的开发者应该都能从中找到对自己有用的东西。文章会从索引的基本操作讲起逐步深入到复合索引的设计、执行计划的分析、索引的维护与优化最后给出一些实战中高频问题的排查思路。每一部分都会配上具体的命令和参数说明你可以直接拿去在自己的环境里试。2. 索引操作的基础创建、查看与删除2.1 创建索引的正确姿势MongoDB 创建索引的命令看起来很简单db.collection.createIndex()就完事了。但这里面有几个关键参数直接决定了索引的行为和性能表现。最基本的单字段索引创建方式db.users.createIndex({ username: 1 })这里的1表示升序-1表示降序。对于单字段索引来说升序和降序在功能上几乎没有区别因为 MongoDB 可以双向遍历索引。但在复合索引中字段的排序方向就很重要了后面会详细说。创建索引时最常用的几个选项db.users.createIndex( { email: 1 }, { unique: true, name: idx_email_unique, background: true, sparse: false } )逐个解释这些参数的含义和适用场景unique唯一索引保证该字段的值在集合中不重复。这个在业务层面非常有用比如用户邮箱、手机号这类需要唯一约束的字段。但要注意如果集合中已经有重复数据创建唯一索引会直接失败。所以上线前一定要先做数据清洗。name给索引起个名字。如果不指定MongoDB 会自动生成一个类似email_1这样的名字。在复合索引中自动生成的名字会很长比如a_1_b_-1_c_1后期维护时很难一眼看出这个索引是干什么的。所以我的习惯是所有非临时索引都手动指定一个有意义的名字比如idx_user_email_unique。background这个参数在早期版本中很重要。设置为true时创建索引的过程不会阻塞其他操作。但在 MongoDB 4.2 之后这个参数已经被废弃了因为现在的索引创建过程默认就是“混合”模式只在开始和结束的短暂时间持有锁中间阶段允许读写操作。所以如果你用的是较新版本不需要再关心这个参数。sparse稀疏索引。设置为true时只有包含该字段的文档才会被索引。比如你有一个可选字段nickname大部分文档没有这个字段那用稀疏索引可以显著减小索引体积。但稀疏索引有个坑查询{ nickname: null }时稀疏索引不会返回那些不包含nickname字段的文档这和普通索引的行为不一致。所以用之前要想清楚业务查询模式。还有一个很实用的选项是expireAfterSeconds用来创建 TTL 索引db.sessions.createIndex( { createdAt: 1 }, { expireAfterSeconds: 3600 } )这个索引会让createdAt时间超过 3600 秒的文档自动被删除。非常适合会话管理、日志临时存储这类场景。但要注意TTL 线程每 60 秒才跑一次所以删除不是精确到秒的有最多一分钟的延迟。2.2 查看索引别只会 db.collection.getIndexes()getIndexes()是最常用的查看索引的命令返回一个数组包含集合上所有索引的信息db.users.getIndexes()输出大概长这样[ { v: 2, key: { _id: 1 }, name: _id_ }, { v: 2, key: { email: 1 }, name: idx_email_unique, unique: true } ]这里v是索引版本key是索引字段和方向name是索引名。_id索引是 MongoDB 自动创建的删不掉。但getIndexes()只告诉你索引长什么样不告诉你索引有多大、用了多少、效果如何。要获取这些信息得用$indexStats聚合管道db.users.aggregate([ { $indexStats: {} } ])输出会包含每个索引的accesses信息里面有ops被使用的次数和since统计起始时间。这个非常有用可以帮你找出那些“建了但从来没被用过”的索引。我见过一个集合上有十几个索引用$indexStats一查一半以上的ops是 0这些索引纯粹是浪费存储和写入性能。还有一个命令是db.collection.stats()它会返回集合的整体统计信息包括索引的总大小db.users.stats().indexSizes输出类似{ _id_: 102400, idx_email_unique: 204800, idx_username: 153600 }单位是字节。这个数据在评估索引开销时很有参考价值。2.3 删除索引什么时候该动手删除索引的命令很简单db.users.dropIndex(idx_email_unique)或者按字段删除db.users.dropIndex({ email: 1 })如果要删除除_id之外的所有索引db.users.dropIndexes()但删除索引这件事比创建索引更需要谨慎。因为创建索引最多是慢一点删除索引可能导致线上查询直接崩掉。我的经验是删除索引前一定要做三件事第一用$indexStats确认这个索引确实没被使用过或者使用频率极低。注意要看since字段如果统计时间太短数据没有参考价值。第二在测试环境模拟删除后的查询性能。把生产环境的查询模式在测试环境跑一遍看看没有这个索引会慢多少。第三选择低峰期操作并且准备好回滚方案。虽然重建索引比删除索引慢得多但至少能恢复。注意在 MongoDB 4.0 之前dropIndexes会阻塞所有操作。4.0 之后虽然大部分情况下不阻塞但在某些场景下仍可能短暂持锁。生产环境操作前务必确认版本和业务负载。3. 复合索引字段顺序决定生死3.1 复合索引的字段顺序为什么重要复合索引是 MongoDB 索引操作中最容易出错的地方。很多人觉得复合索引就是把几个字段放在一起建个索引查询时只要条件里包含这些字段就能用上。这个理解是错的而且错得很危险。复合索引遵循最左前缀原则。假设你建了这样一个索引db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })这个索引能支持的查询模式包括{ userId: xxx }{ userId: xxx, status: paid }{ userId: xxx, status: paid, createdAt: { $gt: ... } }{ userId: xxx, createdAt: { $gt: ... } }注意这个跳过了 status但不能支持的查询模式{ status: paid }没有 userId{ status: paid, createdAt: { $gt: ... } }没有 userId{ createdAt: { $gt: ... } }没有 userId也就是说查询条件必须包含索引的最左字段才能用上这个索引。如果最左字段没出现在查询条件里整个索引就用不上。那第四个例子为什么可以因为 MongoDB 在执行查询时如果查询条件包含了索引的前缀字段userId即使中间跳过了某个字段status后面的字段createdAt仍然可以用于索引扫描只是效率会打折扣。具体来说MongoDB 会对 userId 匹配的文档进行索引扫描然后对 createdAt 做过滤但 status 字段的索引数据在这个查询中不会被利用。3.2 等值条件、排序和范围查询的排列顺序设计复合索引时字段的排列顺序有一个经典的经验法则等值条件在前排序字段居中范围条件在后。为什么因为等值条件可以精确定位到索引的某个点排序字段可以利用索引的有序性避免内存排序范围条件会扫描一个区间。如果把范围条件放在前面后面的字段就无法利用索引的有序性了。举个例子假设查询是这样的db.orders.find({ userId: user123, status: paid, createdAt: { $gte: ISODate(2024-01-01) } }).sort({ createdAt: -1 })这个查询中userId和status是等值条件createdAt是范围条件同时也是排序字段。最优的索引是db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })这样 MongoDB 可以先用userId和status精确定位然后createdAt的索引顺序正好满足排序需求不需要额外的内存排序。但如果索引建成{ createdAt: -1, userId: 1, status: 1 }虽然查询条件里包含了createdAt但它是范围条件MongoDB 只能对createdAt做范围扫描后面的userId和status无法有效利用索引进行精确定位。3.3 复合索引与覆盖查询覆盖查询是指查询所需的所有字段都包含在索引中MongoDB 可以直接从索引返回结果不需要回表读取文档。这能大幅提升查询性能因为索引通常比文档小得多而且可能完全在内存中。比如db.users.createIndex({ username: 1, email: 1 }) db.users.find( { username: alice }, { username: 1, email: 1, _id: 0 } )这个查询就是覆盖查询因为返回的字段username和email都在索引中而且显式排除了_id。注意如果返回结果中包含_id而_id不在索引中那就不是覆盖查询了。所以做覆盖查询时通常要显式指定_id: 0。用explain()可以看到是否命中覆盖查询db.users.find( { username: alice }, { username: 1, email: 1, _id: 0 } ).explain(executionStats)在输出中如果totalDocsExamined为 0且stage是PROJECTION_COVERED或IXSCAN且没有FETCH阶段那就是覆盖查询。覆盖查询虽然好但不要为了覆盖查询而建过大的索引。索引字段越多索引体积越大写入时的维护成本也越高。这是一个需要权衡的问题。4. 执行计划分析索引操作的指南针4.1 explain() 的三种模式explain()是分析索引使用情况的核心工具它有三种模式queryPlanner默认模式只返回查询计划信息不实际执行查询。适合快速查看 MongoDB 选择了哪个索引。executionStats执行查询并返回详细的执行统计信息包括扫描的文档数、返回的文档数、执行时间等。这是最常用的模式。allPlansExecution执行所有候选计划并返回统计信息。适合分析为什么 MongoDB 选择了某个索引而不是另一个。用法db.orders.find({ userId: user123, status: paid }).explain(executionStats)4.2 关键指标解读executionStats的输出信息很多但重点看这几个指标executionStats.totalDocsExamined扫描的文档总数。这个数字越接近返回的文档数越好。如果扫描了 10000 个文档只返回了 10 个说明索引效率很低。executionStats.totalKeysExamined扫描的索引键总数。同样越接近返回文档数越好。executionStats.executionTimeMillis执行时间单位毫秒。executionStats.nReturned返回的文档数。一个理想的查询应该是totalKeysExamined约等于nReturnedtotalDocsExamined约等于nReturned。如果totalDocsExamined远大于nReturned说明索引过滤效果不好可能需要调整索引或查询条件。在queryPlanner.winningPlan中可以看到 MongoDB 选择的执行计划。关键看stage字段COLLSCAN全集合扫描没有用索引。这是最差的情况数据量大时性能极差。IXSCAN索引扫描用了索引。FETCH根据索引位置回表读取文档。SORT内存排序说明索引没有满足排序需求。LIMIT限制返回数量。PROJECTION_COVERED覆盖查询直接从索引返回结果。如果看到COLLSCAN或者SORT阶段通常意味着索引设计有问题。4.3 一个完整的分析案例假设有一个订单集合数据量 100 万条查询某个用户的已支付订单并按时间倒序排列db.orders.find({ userId: user123, status: paid }).sort({ createdAt: -1 })先看看没有索引时的执行计划db.orders.find({ userId: user123, status: paid }).sort({ createdAt: -1 }).explain(executionStats)输出中会看到stage: COLLSCANtotalDocsExamined: 1000000executionTimeMillis可能是几百甚至上千毫秒。然后创建一个复合索引db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })再次执行同样的查询并分析db.orders.find({ userId: user123, status: paid }).sort({ createdAt: -1 }).explain(executionStats)这次应该看到stage: IXSCANtotalKeysExamined和totalDocsExamined都大幅下降executionTimeMillis可能降到个位数。但要注意如果userId和status的组合区分度不高比如某个用户的订单特别多那即使有索引扫描的文档数也可能不少。这时候可以考虑进一步优化比如增加更多过滤字段到索引中。5. 索引维护与性能优化实战5.1 索引重建什么时候需要怎么做索引在长期使用后可能会出现碎片化尤其是频繁增删改的集合。碎片化会导致索引扫描效率下降。判断是否需要重建索引可以看db.collection.stats()中的indexSizes和实际数据量的比例。如果索引大小远超预期可能存在碎片。重建索引的命令db.users.reIndex()这个命令会删除并重新创建集合上的所有索引除了_id。在 MongoDB 4.0 之前reIndex()会阻塞所有操作。4.0 之后对于副本集可以在从节点上执行reIndex()然后逐步切换。但更推荐的做法是逐个删除并重建索引这样可以更精细地控制影响范围db.users.dropIndex(idx_email_unique) db.users.createIndex({ email: 1 }, { unique: true, name: idx_email_unique })重建索引是一个资源密集型操作建议在低峰期进行并监控服务器负载。5.2 索引与写入性能的权衡每增加一个索引插入、更新、删除操作都需要维护这个索引。索引越多写入越慢。这是一个基本的权衡。我见过一个集合上有 15 个索引写入性能极差。后来用$indexStats分析发现其中 8 个索引的ops为 0直接删掉后写入性能提升了近一倍。所以定期审查索引是必要的。建议每个季度做一次索引审查流程如下用$indexStats获取所有索引的使用统计。找出ops为 0 或极低的索引。结合业务代码和查询日志确认这些索引是否真的不需要。在测试环境验证删除后的影响。低峰期执行删除。5.3 隐藏索引安全删除索引的利器MongoDB 4.4 引入了隐藏索引的功能。你可以先把索引隐藏起来它对查询优化器不可见但数据仍然在维护。如果隐藏后发现没有影响再删除如果有影响随时可以取消隐藏。隐藏索引db.users.hideIndex(idx_email_unique)取消隐藏db.users.unhideIndex(idx_email_unique)这个功能极大地降低了删除索引的风险。我现在删除索引前都会先隐藏观察一段时间确认无影响后再删除。6. 常见问题与排查技巧实录6.1 为什么建了索引还是慢这是最常见的问题。可能的原因有索引没被用上用explain()确认执行计划。如果看到COLLSCAN说明索引没被使用。检查查询条件是否包含了索引的最左字段。索引选择性差如果索引字段的区分度很低比如性别字段只有两个值那索引的效果很差。MongoDB 可能选择全集合扫描而不是走索引。查询条件类型不匹配比如索引字段是字符串查询时用了数字MongoDB 无法使用索引。这种情况在从其他系统导入数据时很常见。排序方向不匹配复合索引的排序方向必须与查询的排序方向一致或完全相反。比如索引是{ a: 1, b: 1 }查询sort({ a: 1, b: -1 })就无法利用索引排序。6.2 索引创建失败的原因创建索引失败通常有这几个原因重复数据创建唯一索引时如果集合中已有重复数据会失败。错误信息会提示重复的键值。内存不足创建大型索引时如果内存不足可能失败。可以尝试在低峰期操作或者增加服务器内存。权限不足某些托管环境可能限制了索引创建权限。索引名冲突如果指定的索引名已存在会失败。可以先删除旧索引或换个名字。6.3 索引占用空间过大怎么办索引占用空间过大可以从几个方面优化删除无用索引用$indexStats找出未使用的索引。使用稀疏索引对于可选字段稀疏索引可以显著减小体积。使用部分索引MongoDB 3.2 引入了部分索引可以只索引满足特定条件的文档db.orders.createIndex( { userId: 1 }, { partialFilterExpression: { status: active } } )这个索引只包含status为active的文档体积远小于全量索引。考虑使用通配符索引对于字段不固定的文档通配符索引可以替代多个单字段索引但通配符索引的性能通常不如专用索引需要权衡。6.4 常见问题速查表问题现象可能原因排查方法解决方案查询慢explain 显示 COLLSCAN索引未命中检查查询条件是否包含最左索引字段调整查询或创建合适索引建了索引但查询仍慢索引选择性差查看 totalDocsExamined 与 nReturned 比例考虑复合索引或部分索引唯一索引创建失败存在重复数据查看错误信息中的重复键值清洗数据后重建写入性能下降索引过多用 $indexStats 查看索引使用率删除无用索引索引占用空间大索引设计不合理查看 indexSizes使用稀疏索引或部分索引排序操作慢索引未覆盖排序explain 中查看是否有 SORT 阶段调整索引字段顺序或方向提示排查索引问题时养成先看explain(executionStats)的习惯。大部分问题都能从执行计划中找到线索。7. 一些个人体会索引操作这件事说难不难说简单也不简单。核心就几句话等值在前范围在后排序字段跟着走覆盖查询省回表无用索引及时删。但真正要在实际项目中用好需要结合具体的业务查询模式、数据分布、写入负载来综合判断。我自己的习惯是新上线一个功能前先把预期的查询模式列出来针对性地设计索引而不是等出了问题再补。另外定期用$indexStats审查索引使用情况这个习惯帮我省了不少存储和写入性能。还有一个容易被忽视的点索引不是越多越好也不是越少越好。关键是要匹配实际的查询需求。一个设计良好的复合索引往往能替代好几个单字段索引既节省空间又提升写入性能。这需要你对业务查询有深入的理解而不是简单地给每个字段都建个索引。最后分享一个小技巧在测试环境用allPlansExecution模式跑查询可以看到 MongoDB 考虑过的所有执行计划以及为什么最终选择了某个计划。这对于理解索引选择逻辑非常有帮助尤其是在多个索引都可用的情况下。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →