首页 / 资讯中心 / 文章详情

MongoDB索引实战:从单字段到复合索引的查询优化实验

MongoDB索引实战:从单字段到复合索引的查询优化实验 ★ FEATURED ARTICLE
1. 从“滴滴、摩拜都在用的索引”说起这个实验到底在练什么第一次看到“滴滴、摩拜都在用的索引”这个标题很多人会以为是要讲共享出行平台的业务架构其实不是。这个头歌实验的核心是把 MongoDB 里最容易被忽视、却又最能决定查询性能的东西——索引——用一组可动手的练习拆开讲透。滴滴、摩拜这类平台的特点是数据量大、写入频繁、查询模式高度集中司机位置、车辆状态、订单流水动辄千万级文档如果没有索引一次按手机号或订单号的查询就可能演变成全集合扫描响应时间从毫秒级掉到秒级甚至超时。索引就是在这种场景下被逼出来的刚需。这个实验适合谁如果你已经能连上 MongoDB、会写find()和insertOne()但对“为什么加了索引就快了”“复合索引的字段顺序为什么不能随便换”“explain 输出里那些数字到底看哪个”还说不清楚那这个实验就是为你准备的。它不要求你懂分布式也不要求你会调优整个集群只要求你把单机上的索引机制、创建方式、执行计划分析这几件事练熟。说白了这是从“会写 CRUD”到“知道 CRUD 为什么慢”的分水岭。我在实际带人的过程中发现一个普遍现象很多人建索引是“凭感觉”觉得哪个字段查得多就加一个结果索引建了一堆写入变慢、磁盘占用上涨查询却没快多少。问题往往出在两个地方——一是没搞清楚查询到底走了哪个索引二是没理解复合索引的前缀匹配规则。这个实验的价值就在于它逼着你去用explain()看执行计划去对比COLLSCAN和IXSCAN的差别去体会字段顺序对命中率的影响。这些是文档里写得很清楚、但不动手就永远记不住的东西。下面我按实验的实际推进逻辑把索引的设计思路、核心机制、动手步骤和踩坑经验完整拆一遍。你可以把它当成实验的“伴读笔记”也可以当成一次独立的索引专项训练。所有示例都基于 MongoDB 的mongosh环境集合名和数据都是模拟的你照着敲就能复现。2. 索引的整体设计与思路拆解2.1 为什么单字段索引是起点而不是终点MongoDB 的索引底层是 B 树结构这一点和关系型数据库没有本质区别。每个索引条目指向文档的物理位置查询时先在索引树里定位再回表取完整文档。单字段索引是最基础的形态语法就一句db.collection.createIndex({ field: 1 })1表示升序-1表示降序。很多人到这里就停了觉得“加了索引就完事”但实验里会立刻让你对比加索引前后的explain(executionStats)输出你会看到totalDocsExamined从全集合文档数骤降到个位数executionTimeMillis也跟着掉下来。这里有个容易被忽略的点单字段索引只对“该字段作为查询条件”的场景有效。如果你的查询是{ status: active, city: 北京 }只给status建索引MongoDB 会先用索引筛出所有 active 的文档再逐条回表判断 city回表次数可能依然很大。这就是为什么实验在单字段之后马上引入复合索引——真实业务里的查询条件几乎从来不是单一字段。滴滴查订单可能是“某城市 某时间段 某状态”摩拜查车辆可能是“某区域 可用状态”这些都需要复合索引来覆盖。2.2 复合索引的字段顺序为什么是“生死线”复合索引的语法是db.collection.createIndex({ a: 1, b: 1 })它遵循最左前缀原则索引先按 a 排序a 相同再按 b 排序。这意味着{ a: 1, b: 1 }能支持{ a: ... }、{ a: ..., b: ... }的查询但不支持单独按{ b: ... }的查询走这个索引。实验里专门设计了一组对比同样的两个字段换个顺序建索引再用只含第二个字段的查询去 explain你会看到前者是IXSCAN后者直接退化成COLLSCAN。这个规则的实践含义很直接把等值查询字段放前面范围查询字段放后面选择性高的字段优先。举个例子查询{ city: 北京, age: { $gte: 20 } }索引应该建成{ city: 1, age: 1 }因为 city 是等值匹配能快速缩小范围age 的范围扫描在这个小范围内进行。如果反过来建成{ age: 1, city: 1 }虽然也能用但 age 的范围会先扫出一大批文档再逐个判断 city效率差很多。实验里不会直接告诉你这个结论而是让你自己通过 explain 的nReturned和totalKeysExamined去对比这种“自己撞出来的认知”比背结论牢得多。2.3 索引不是越多越好写入放大与空间成本实验的后半段会引导你观察一个反直觉的现象给一个集合建了五六个索引之后插入操作的耗时明显上升。原因在于每次写入不仅要写文档本身还要更新所有相关索引的 B 树结构。索引越多写入放大越严重磁盘占用也线性增长。滴滴、摩拜这类写入密集的平台索引数量是严格控制的通常只保留支撑核心查询的那几个边缘查询宁可走全表也不轻易加索引。所以实验的设计逻辑是“先让你体会索引的好处再让你体会索引的代价”最后形成一个平衡的判断索引是为查询模式服务的不是为字段服务的。建索引之前先问自己三个问题——这个查询的频率有多高返回的结果集有多大这个字段的选择性够不够三个问题答不上来就先别建。这个思路贯穿整个实验也是我后面所有实操步骤的底层依据。3. 核心细节解析与实操要点3.1 环境准备与实验数据构造实验环境是头歌平台预置的 MongoDB 实例本地复现的话用mongosh连上任意一个 MongoDB 4.x 以上版本即可。先建一个模拟集合数据量不用太大几千条就能看出索引效果但字段要贴近真实场景。我一般用下面这段脚本造数据字段包括城市、状态、时间戳和一个模拟的手机号正好对应实验里常见的查询模式// 切换到实验数据库 use index_lab; // 清理旧数据保证每次实验起点一致 db.orders.drop(); // 构造 5000 条模拟订单数据 const cities [北京, 上海, 广州, 深圳, 杭州]; const statuses [pending, active, finished, cancelled]; const bulk []; for (let i 0; i 5000; i) { bulk.push({ orderNo: ORD String(i).padStart(6, 0), city: cities[i % cities.length], status: statuses[i % statuses.length], phone: 138 String(10000000 i).slice(0, 8), createdAt: new Date(Date.now() - i * 60000) }); } db.orders.insertMany(bulk);这段脚本的关键在于数据分布要均匀但有区分度。城市只有 5 个选择性低订单号唯一选择性高状态 4 个选择性也低。这样后面做索引对比时你能清楚看到“低选择性字段建索引收益有限”这个结论。造完数据先跑一次db.orders.countDocuments()确认是 5000 条再跑一次不带索引的查询并 explain把COLLSCAN的基线数据记下来后面所有对比都拿它当参照。注意造数据时不要用insertOne循环 5000 次那样光插入就要等很久。insertMany批量写入能把时间压到秒级这是实操中很基础但很多人忽略的效率技巧。3.2 单字段索引的创建与执行计划解读先给orderNo建一个单字段索引这是最典型的唯一性查询场景db.orders.createIndex({ orderNo: 1 }, { name: idx_orderNo });建完之后立刻用 explain 看效果db.orders.find({ orderNo: ORD002500 }).explain(executionStats);输出里重点看三个字段winningPlan.stage应该是IXSCAN索引扫描totalDocsExamined应该是 1totalKeysExamined也应该是 1。如果stage还是COLLSCAN说明索引没生效先检查索引是否建成功db.orders.getIndexes()再检查查询条件字段名是否拼错。实验里最常见的错误就是字段名大小写不一致MongoDB 是区分大小写的orderNo和orderno是两个字段。再给city建一个单字段索引做对比db.orders.createIndex({ city: 1 }, { name: idx_city }); db.orders.find({ city: 北京 }).explain(executionStats);你会看到totalKeysExamined是 1000 左右5000 条的 1/5totalDocsExamined也是 1000 左右因为要回表取完整文档。这说明低选择性字段的索引收益有限——虽然避免了全表扫描但扫描的索引条目依然很多。这个对比是实验里刻意安排的目的是让你建立“选择性”这个概念索引字段的不同值越多单次查询命中的条目越少索引越有价值。3.3 复合索引的字段顺序实验这是整个实验最核心的部分。先建一个顺序为{ city: 1, status: 1 }的复合索引db.orders.createIndex({ city: 1, status: 1 }, { name: idx_city_status });然后用三种查询分别 explain// 查询一两个字段都用上 db.orders.find({ city: 北京, status: active }).explain(executionStats); // 查询二只用第一个字段 db.orders.find({ city: 北京 }).explain(executionStats); // 查询三只用第二个字段 db.orders.find({ status: active }).explain(executionStats);结果会非常清晰地呈现最左前缀原则查询一和查询二都走IXSCAN查询三走COLLSCAN或者走idx_city如果它还在但绝不会走idx_city_status。实验要求你把这个对比记录下来然后删掉索引换个顺序重建db.orders.dropIndex(idx_city_status); db.orders.createIndex({ status: 1, city: 1 }, { name: idx_status_city });再跑一遍查询三这次它走IXSCAN了但查询二反而退化了。这个“换顺序就换命”的体验是实验设计的高明之处——它不让你背规则而是让你亲手制造规则失效的场景。我在实际项目里见过太多人建了复合索引却抱怨“没生效”一查全是字段顺序和查询模式对不上。提示复合索引的字段顺序没有绝对的对错只有“匹配不匹配查询模式”。判断标准是把最常用的等值查询字段放最左范围查询字段放右边排序字段放在范围字段之后如果排序和范围冲突优先满足等值排序的组合。3.4 唯一索引与稀疏索引的适用边界实验里还会涉及两个特殊索引类型。唯一索引用{ unique: true }创建作用是约束字段值不重复比如订单号db.orders.createIndex({ orderNo: 1 }, { unique: true, name: uniq_orderNo });建完之后尝试插入一条重复的orderNo会直接报E11000 duplicate key error。这个机制在业务上是防重的最后一道防线比应用层查重可靠得多。但要注意唯一索引对写入性能有额外开销因为每次插入都要做唯一性检查所以只给真正需要唯一的字段建。稀疏索引用{ sparse: true }创建只索引存在该字段的文档。实验里模拟了一个“部分订单有优惠券字段、部分没有”的场景给couponCode建稀疏索引后查询{ couponCode: { $exists: true } }时索引条目数明显少于文档总数。这个类型的实用场景是可选字段——如果字段在大量文档中缺失普通索引会为每个缺失文档存一个 null 条目浪费空间稀疏索引能避开这个问题。但稀疏索引有个坑它不能用于$exists: false的查询因为缺失字段根本不在索引里。4. 实操过程与核心环节实现4.1 从零复现实验的完整操作序列把上面的碎片串成一条完整的操作线你可以在本地从头到尾跑一遍。第一步永远是确认环境和数据基线use index_lab; db.orders.drop(); // 重新执行 3.1 的造数据脚本 db.orders.countDocuments(); // 确认 5000第二步记录无索引基线。这一步很多人会跳过但没有基线就没有对比后面所有“变快了”都是主观感受db.orders.find({ city: 北京, status: active }).explain(executionStats); // 记录 totalDocsExamined、totalKeysExamined、executionTimeMillis第三步按“单字段 → 复合 → 特殊类型”的顺序逐个建索引每建一个就跑一次对应查询的 explain把数据填进下面这张对比表。这张表是实验报告的核心也是你判断索引是否值得建的依据索引配置查询条件stagetotalKeysExaminedtotalDocsExamined耗时(ms)无索引citystatusCOLLSCAN05000较高idx_citycityIXSCAN~1000~1000中等idx_city_statuscitystatusIXSCAN~250~250低idx_status_citystatusIXSCAN~1250~1250中等idx_status_citycitystatusIXSCAN~250~250低表格里的数字是模拟量级你实际跑出来的会有浮动但相对关系应该一致。重点看idx_city_status和idx_status_city在两字段联合查询下的表现——它们都能命中因为联合查询同时包含两个字段最左前缀都满足。真正的差异体现在单字段查询上这就是字段顺序的实际影响。第四步做写入代价测试。清空数据后分别在有 1 个索引和 5 个索引的情况下批量插入 5000 条用console.time包住insertMany计时console.time(insert_with_indexes); db.orders.insertMany(bulk); console.timeEnd(insert_with_indexes);我实测下来5 个索引比 1 个索引的批量插入慢 30% 到 50%数据量越大差距越明显。这个数字不是让你害怕索引而是让你在“加索引”这个动作前多犹豫三秒。4.2 explain 输出里真正该看的四个指标很多人跑 explain 只看一眼stage就关了其实executionStats里有四个指标决定了索引的实际效果。totalKeysExamined是扫描的索引条目数越接近nReturned越好totalDocsExamined是回表次数理想情况等于返回文档数nReturned是最终返回的文档数executionTimeMillis是总耗时。判断一个索引是否高效核心看totalDocsExamined / nReturned这个比值比值接近 1 说明索引覆盖得好比值很大说明回表太多可能需要调整索引字段或考虑覆盖索引。覆盖索引是实验里一个加分项如果查询只返回索引字段MongoDB 可以完全不回表totalDocsExamined会是 0。比如db.orders.find({ city: 北京 }, { city: 1, _id: 0 })在idx_city存在时就是覆盖查询。这个技巧在只查少量字段的列表页场景里非常实用能省掉大量回表 IO。4.3 索引的维护重建、删除与后台构建实验最后会让你删索引语法是db.orders.dropIndex(索引名)或dropIndexes()删全部。这里有个实操细节删除不存在的索引会报错脚本里最好先getIndexes()判断一下。另外生产环境建索引要用{ background: true }4.2 之前版本避免锁住整个集合4.2 之后建索引默认就是增量式的不再需要这个参数但老版本兼容代码里还能见到。索引重建的场景不多但有一种情况必须做索引字段的数据分布发生剧变比如某个字段从“几乎全空”变成“大部分有值”原来的稀疏索引就不合适了。重建就是先 drop 再 create期间查询会退化所以要在低峰期做。这个经验实验里不会讲但实际运维中很关键。5. 常见问题与排查技巧实录5.1 索引建了但查询还是慢五个排查方向这是实验里出现频率最高的问题我整理成一张速查表按可能性从高到低排现象可能原因排查方法解决stage 仍是 COLLSCAN字段名大小写/拼写不一致对比 getIndexes 和查询条件修正字段名stage 是 IXSCAN 但慢索引选择性低看 totalKeysExamined换高选择性字段或加复合字段复合索引不命中字段顺序不匹配对照最左前缀原则重建索引调整顺序查询用了 $or索引合并效率低看 explain 的 SORT/MERGE 阶段改写查询或建对应复合索引排序慢排序字段不在索引中看 explain 是否有 SORT 阶段把排序字段加入索引末尾其中“排序字段不在索引中”是最隐蔽的。MongoDB 如果不能在索引里完成排序会在内存里做SORT数据量大时直接触发 32MB 内存限制报错。解决办法是把排序字段追加到复合索引末尾比如{ city: 1, createdAt: -1 }这样等值匹配 city 之后结果天然按 createdAt 倒序排列排序阶段就消失了。5.2 唯一索引冲突的定位与处理插入时报E11000错误信息里会带上冲突的索引名和字段值照着查就能定位。但有一种情况容易懵批量insertMany时默认遇到第一条冲突就中断后面的数据都没插进去。如果业务上希望“跳过冲突继续插”要加{ ordered: false }参数db.orders.insertMany(bulk, { ordered: false });这样冲突的文档会报错但不冲突的会正常插入最后返回一个包含错误列表的结果。这个参数在数据迁移和去重导入时特别有用实验里可能不会强调但实际用起来能省很多事。5.3 索引占用空间过大怎么办跑完实验用db.orders.stats()看indexSizes你会发现几个索引加起来可能比数据本身还大。压缩空间的手段有三个删掉没被查询用到的索引用$indexStats看每个索引的accesses.ops长期为 0 的就是死索引、用稀疏索引避开缺失字段、用部分索引partialFilterExpression只索引满足条件的文档。部分索引是稀疏索引的升级版比如只给status: active的订单建索引索引体积能砍掉一大半适合“只查活跃数据”的业务场景。注意$indexStats的统计从 MongoDB 重启后清零所以判断死索引要看一段时间的累计值别刚重启就下结论。5.4 实验里最容易踩的三个坑第一个坑是在已有数据的集合上建唯一索引失败。如果集合里已经有重复值createIndex({ unique: true })会直接报错。正确做法是先查重清理再建索引。第二个坑是用$where或正则前缀模糊查询这类查询基本用不上索引实验里如果出现“明明建了索引还是 COLLSCAN”先检查查询语句里有没有这类操作符。第三个坑是忘记_id默认索引每个集合都有一个_id索引统计索引大小时别把它算漏了也别试图删它。6. 从实验到实战索引设计的判断框架实验做完真正要带走的是一个判断框架而不是几条语法。我的经验是把索引设计拆成三步先列出高频查询模式从慢查询日志或业务代码里捞再对每个查询分析字段的选择性和等值/范围属性最后按“等值在前、范围在后、排序垫底”的原则组合成复合索引能合并的合并不能合并的才单独建。一个复合索引往往能服务多个查询关键是找到查询模式的交集。举个实验里的延伸例子如果同时存在{ city, status }、{ city, createdAt 排序 }、{ city, status, createdAt 排序 }三种查询最优解不是建三个索引而是建一个{ city: 1, status: 1, createdAt: -1 }它能覆盖前两个查询的大部分场景第一个查询走前两字段第二个查询走 city 前缀加排序。这种“一索引多用途”的设计能力是实验想让你通过反复对比 explain 慢慢养成的。最后分享一个我自己的习惯每次给集合加索引前先在测试环境用生产数据的采样跑一遍 explain把totalDocsExamined和nReturned的比值记下来比值超过 10 的索引我会重新考虑字段组合。这个阈值不是标准是我踩过几次“索引建了反而更慢”的坑之后给自己定的经验线。索引这件事说到底是用空间和写入换查询账算清楚了再动手比建完再删要省心得多。
阅读完成 · 觉得有帮助?
咨询建站