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应该是 1,totalKeysExamined也应该是 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/5),totalDocsExamined也是 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,把数据填进下面这张对比表。这张表是实验报告的核心,也是你判断索引是否值得建的依据:
| 索引配置 | 查询条件 | stage | totalKeysExamined | totalDocsExamined | 耗时(ms) |
|---|---|---|---|---|---|
| 无索引 | city+status | COLLSCAN | 0 | 5000 | 较高 |
| idx_city | city | IXSCAN | ~1000 | ~1000 | 中等 |
| idx_city_status | city+status | IXSCAN | ~250 | ~250 | 低 |
| idx_status_city | status | IXSCAN | ~1250 | ~1250 | 中等 |
| idx_status_city | city+status | IXSCAN | ~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 的索引我会重新考虑字段组合。这个阈值不是标准,是我踩过几次“索引建了反而更慢”的坑之后给自己定的经验线。索引这件事,说到底是用空间和写入换查询,账算清楚了再动手,比建完再删要省心得多。