1. 为什么出行巨头都绕不开索引这道坎第一次接触 MongoDB 的人往往会被它“无模式、随手就能存”的爽快感惯坏。文档往集合里一扔find()一查就出结果感觉数据库不过如此。可等到集合里攒到几十万、上百万条文档某天线上突然冒出个慢查询页面转圈转到用户想砸手机你才会真正意识到索引才是数据库从玩具走向生产的分水岭。滴滴、摩拜这类出行平台订单、轨迹、车辆状态动辄千万级甚至亿级文档如果没有索引兜底每一次按手机号查订单、按地理范围捞附近车辆都会演变成一次全集合扫描CPU 直接被打满。这篇内容我想聊的就是 MongoDB 索引这件事围绕头歌实验里那套经典练习展开但不会只停留在“照着实验步骤敲一遍”的层面。我会把索引背后的原理、创建时的取舍、执行计划怎么看、踩过的坑怎么绕都掰开揉碎讲清楚。适合刚学完 MongoDB 基本增删改查、准备往实战方向走的朋友也适合那些平时写查询不假思索、直到被慢查询教育过的开发者。核心关键词就三个MongoDB 索引、复合索引、执行计划。读完你至少能做到两件事一是看到一条查询能判断它该不该走索引、走哪个索引二是自己动手建索引时知道字段顺序怎么排、什么时候该放弃索引。先说清楚一个基本认知索引不是越多越好它本质上是用写入时的额外开销换读取时的速度。每建一个索引MongoDB 就要在插入、更新、删除时同步维护这棵额外的数据结构。所以真正的高手不是“见查询就加索引”而是先搞清楚数据长什么样、查询长什么样再决定加不加、加在哪。这个思路会贯穿全文也是我觉得比实验步骤本身更值钱的东西。2. 索引的底层逻辑与方案选型拆解2.1 B树结构索引为什么能快MongoDB 的索引底层用的是 B 树更准确地说是 B-树变体这个结构你可以类比成一本字典的目录体系。假设一本字典有十万个词条没有目录你得从第一页翻到最后一页有了按拼音排序的目录你翻两三次就能定位到目标区间。B 树干的就是这件事它把索引键按顺序组织成多层节点每次查找都像走楼梯一样逐层缩小范围最终定位到具体文档的存储位置。这里有个关键点很多人忽略B 树是有序的。有序意味着它不仅能做等值匹配status: paid还能高效支持范围查询amount 100、排序sort({createTime: -1})。这也是为什么索引字段的选择要考虑查询模式——如果你的查询经常带范围条件那这个字段建索引的收益就很高如果只是偶尔查一次全表那索引反而是负担。我用一个生活化的类比帮你记住索引就像快递柜的编号系统。快递员放件时按编号归位写入维护成本取件人报编号就能秒取读取收益。如果整个小区只有三个快递搞一套编号系统纯属浪费但如果有三千个快递没有编号系统你找一天都找不完。数据量就是那个“快递数量”它决定了索引值不值得建。2.2 单字段索引与复合索引的取舍单字段索引最好理解给一个字段建索引查询条件命中这个字段就能用上。但真实业务里查询条件往往不止一个。比如订单查询经常是“某个用户 某个时间范围 某个状态”这时候单字段索引就有点力不从心了。复合索引是把多个字段按顺序组合成一个索引键。它的核心规则叫最左前缀原则索引{userId: 1, createTime: -1}能支持userId单独查询也能支持userId createTime组合查询但不能单独支持createTime查询。这就像电话簿按“姓名”排序你能快速找到“张”开头的人也能找“张三”但没法直接跳到所有叫“三”的人。字段顺序怎么排我的经验是三条等值条件字段放前面范围条件字段放后面排序字段跟着范围字段走。原因在于等值匹配能快速把 B 树的搜索范围收窄到一个点范围查询再在这个点附近做区间扫描效率最高。如果反过来把范围字段放前面等值字段就没法发挥精确裁剪的作用了。2.3 为什么出行场景对索引如此敏感出行平台的数据有个鲜明特点写多读也多且查询维度极其分散。一笔订单要按用户查、按司机查、按时间查、按状态查一辆车的位置要按地理范围查、按车辆编号查。这种多维度查询如果每个维度都建单字段索引索引数量会爆炸写入性能被拖垮。所以实际工程里更常见的是精心设计的复合索引用少量索引覆盖大量查询模式。另一个敏感点是地理索引。摩拜这类共享单车场景核心查询是“找出我附近 500 米内的可用车辆”这靠普通 B 树索引做不了得用 2dsphere 地理索引。它的原理是把经纬度编码成一种特殊结构支持范围检索和距离计算。头歌实验里可能没深入这块但你要知道它是出行场景索引体系里不可或缺的一环理解了这个背景你再看那些“为什么这么建索引”的设计就通透多了。3. 核心实操从建索引到看执行计划3.1 实验环境与数据准备头歌实验通常会给一个现成的集合但为了讲清楚原理我建议你自己造一批数据来练手。思路很简单造一个订单集合字段包括用户编号、订单状态、金额、创建时间然后灌入十万条左右的模拟数据。数据量太小看不出索引效果太大又拖慢实验节奏十万是个比较舒服的量级。// 切换到实验数据库 use index_lab // 造十万条模拟订单数据 for (let i 0; i 100000; i) { db.orders.insertOne({ userId: u (i % 5000), status: [paid, unpaid, cancelled][i % 3], amount: Math.floor(Math.random() * 1000), createTime: new Date(Date.now() - i * 60000) }) }灌数据的时候有个小技巧批量插入比逐条插入快得多用insertMany分批比如每批一千条能把准备时间从几分钟压到十几秒。另外注意造数据阶段先别建索引等数据灌完再建这样建索引是一次性批量构建比边插边维护索引快很多。这是实操里很实在的一条经验——大批量导入数据前先删索引导入完再重建能省下大量时间。3.2 建索引的几种写法与参数含义建索引的基本命令是createIndex第一个参数是索引键文档第二个参数是选项。键文档里1表示升序-1表示降序。这里有个常见误区很多人以为升序降序对查询有影响其实对于单字段索引和等值查询升降序无所谓只有在排序场景下索引的排序方向才需要和查询的排序方向匹配或者完全相反。// 单字段索引 db.orders.createIndex({ userId: 1 }) // 复合索引等值字段在前范围/排序字段在后 db.orders.createIndex({ userId: 1, createTime: -1 }) // 查看集合上所有索引 db.orders.getIndexes()选项里最值得关注的是background老版本用于后台建索引新版本行为有变化和unique唯一索引。唯一索引除了加速查询还承担了数据约束的职责比如用户手机号、订单号这类天然唯一的字段建唯一索引能防止重复写入。但要注意如果集合里已有重复数据建唯一索引会直接失败得先清理重复。3.3 用 explain 看懂查询到底走没走索引建完索引不代表查询就一定用得上这是新手最容易踩的坑。判断方法是explain(executionStats)重点看三个指标stage、totalDocsExamined、totalKeysExamined。db.orders.find({ userId: u100 }).explain(executionStats)stage是执行阶段COLLSCAN表示全集合扫描没走索引IXSCAN表示走了索引扫描FETCH表示根据索引回表取文档。totalKeysExamined是扫描的索引条目数totalDocsExamined是实际读取的文档数。理想情况下这两个数字应该接近返回的结果条数。如果totalDocsExamined远大于返回条数说明索引选择性不好扫了很多才筛出少量结果。我拿实验数据实测过一组对比不建索引时查userId: u100stage是COLLSCANtotalDocsExamined是十万建了{userId: 1}索引后stage变成IXSCANtotalDocsExamined降到二十左右。这个差距就是索引价值的直观体现。你可以自己动手跑一遍亲眼看到数字从十万掉到二十比看十遍理论都管用。3.4 复合索引的命中验证与字段顺序实验复合索引的验证更有意思。建{userId: 1, status: 1}之后分别跑三种查询只查userId、只查status、两个都查。你会发现前两个里只有第一个走索引第二个依然是COLLSCAN。这就是最左前缀原则的实证。db.orders.createIndex({ userId: 1, status: 1 }) // 走索引 db.orders.find({ userId: u100 }).explain(executionStats) // 走索引 db.orders.find({ userId: u100, status: paid }).explain(executionStats) // 不走索引COLLSCAN db.orders.find({ status: paid }).explain(executionStats)更进一步你可以试试把字段顺序反过来建{status: 1, userId: 1}再跑同样的查询观察命中情况的变化。这个实验能让你彻底记住复合索引的字段顺序不是随便排的它直接决定了哪些查询能用上它。头歌实验里这一步通常是重点考察项因为它是理解复合索引的钥匙。4. 索引设计中的常见坑与排查实录4.1 索引没生效的几种典型原因明明建了索引查询却还是慢这种情况我遇到过太多次。排查下来无非几类原因。第一类是查询条件类型不匹配比如索引字段是字符串你传了个数字进去MongoDB 类型敏感匹配不上自然不走索引。第二类是用了函数或正则前缀不固定比如find({ userId: /100/ })这种非锚定正则索引基本用不上。第三类是复合索引字段顺序不对查询没命中最左前缀。还有一类比较隐蔽索引选择性太差。比如给status这种只有三个取值的字段单独建索引查询status: paid会命中三分之一的文档优化器一算走索引回表的成本比全表扫描还高干脆放弃索引。这种情况不是索引坏了而是这个索引本身就不该建。判断标准很简单一个字段的取值种类越接近文档总数选择性越好越值得建索引。4.2 覆盖查询让查询完全不回表有个能显著提升性能的技巧叫覆盖查询。如果查询需要的所有字段都包含在索引里MongoDB 就不用回表取文档直接从索引返回结果。执行计划里stage会显示PROJECTION_COVEREDtotalDocsExamined为 0。// 建一个包含查询所需全部字段的索引 db.orders.createIndex({ userId: 1, status: 1, amount: 1 }) // 查询只取索引里有的字段且不返回 _id db.orders.find( { userId: u100, status: paid }, { _id: 0, userId: 1, status: 1, amount: 1 } ).explain(executionStats)注意那个_id: 0因为_id默认不在你建的索引里如果查询结果要返回_id就还得回表覆盖查询就失效了。这个细节很多人不知道实验里也容易忽略。覆盖查询在出行场景里特别有用比如查“某用户最近订单的金额列表”只取金额字段完全可以在索引里搞定。4.3 索引维护的代价与删除策略前面反复强调索引有写入成本这里给个量化感受每多一个索引每次插入都要多维护一棵 B 树。十万条数据、五个索引插入耗时可能是无索引时的好几倍。所以定期清理无用索引是必要的运维动作。判断一个索引有没有用可以看db.orders.aggregate([{ $indexStats: {} }])它会返回每个索引被使用的次数accesses.ops。长期为 0 的索引基本可以考虑删掉。删除索引用dropIndex注意复合索引要按名字或完整键文档删不能只删其中一个字段。另外主键_id的索引删不掉这是 MongoDB 的硬性规定别在这上面浪费时间。常见问题现象排查方向解决方式查询慢但已建索引stage 仍为 COLLSCAN检查字段类型、复合索引顺序调整查询或重建索引索引选择性差totalKeysExamined 接近总数看字段取值分布考虑删除或改复合索引覆盖查询失效totalDocsExamined 不为 0检查是否返回了 _id投影里排除 _id写入变慢插入耗时明显上升统计索引数量清理低使用率索引4.4 一个容易被忽略的实操心得最后分享一个我在实验和实际项目里都验证过的经验建索引前先用 explain 跑一遍原始查询记下基线数据建完再跑一遍对比。这个习惯能让你对每个索引的实际收益心里有数而不是凭感觉“应该有用”。头歌实验的评分往往看结果对不对但真正学到东西的是这个对比过程——你会亲眼看到哪些索引立竿见影哪些纯属心理安慰。数据不会骗人执行计划里的数字就是最诚实的反馈。