ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

索引“卡死”?3个explain技巧让你的MongoDB查询快10倍

索引“卡死”?3个explain技巧让你的MongoDB查询快10倍 1. 一次线上告警把我拉回 explain 的怀抱上周三凌晨两点监控群里弹出一条告警订单列表接口 P99 从 80ms 飙到 3.2s。我第一反应是流量涨了结果一看 QPS 跟平时没差。登录服务器连上 MongoDB手动跑了一遍那个查询db.orders.find({userId: u_8823, status: paid}).sort({createTime: -1}).limit(20)光标转了快 4 秒才吐数据。问题出在哪我当时的排查路径很典型先看慢查询日志确认是这个语句再看集合文档数1200 万最后才想起来用explain。但第一次我犯了个低级错误——直接db.orders.find({...}).explain()返回的只有 queryPlanner 阶段的计划树压根看不到执行耗时和扫描行数。这就是很多人踩的第一个坑默认 explain 模式不告诉你查询到底跑了多久、扫了多少文档。MongoDB 的explain()有三种模式对应三种信息粒度。queryPlanner 只回答“优化器选了哪个计划”executionStats 回答“这个计划实际执行时扫了多少、返回多少、花了多久”allPlansExecution 则把被淘汰的计划也一并展示用来对比为什么没选另一个。慢查询排查场景下executionStats 是性价比最高的入口它输出的executionStats子文档里藏着索引命中、扫描行数、执行阶段三类关键信号。这篇文章就围绕这三类信号展开。我会先讲清楚怎么用 TaoToken 快速搭一个可复现的 MongoDB 实验环境如果你本地已经有实例可以直接跳到第 3 节然后给出可复制的 explain 配置片段再通过加索引前后的 executionStats 字段对比给出一份判断索引是否生效的检查清单。目标很明确让你下次遇到查询卡死时能在 5 分钟内定位到根因而不是靠猜。2. 用 TaoToken 准备一个可复现的排查环境排查慢查询最怕的是“环境不一致”——本地跑得快线上跑得慢最后发现是数据量差异。为了让你能跟着文章一步步复现我建议用一个独立的 MongoDB 实例来跑实验。如果你手头没有现成的可以通过 TaoToken 的 API 快速接入模型能力让 AI 帮你生成测试数据集和排查脚本省去手写 mock 数据的时间。TaoToken 的接入方式很直接。先到官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册账号然后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite API Key 管理页面在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。拿到 Key 之后API 端点用 https://taotoken.net/api 即可注意这个地址不带 UTM 参数是纯接口地址。我实测下来用模型对话功能让它生成 10 万条订单测试数据比手写脚本快很多。模型对话入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 你可以直接描述需求“生成一段 Node.js 脚本向 MongoDB 的 orders 集合插入 10 万条文档字段包括 userId、status、createTime、amount其中 status 随机取 paid/pending/cancelled”。它会给出可直接运行的代码。如果你更习惯在编辑器里完成这些事TaoToken 也提供了 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 适合长期做查询优化、写排查脚本的场景。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言 SDK 的调用示例。环境准备好之后确认你的 MongoDB 版本在 4.4 以上executionStats 字段在不同版本略有差异本文以 6.0 为准。连接命令mongosh mongodb://localhost:27017/taotoken_lab建一个实验集合插入测试数据。下面这段脚本可以直接在 mongosh 里跑// 切换到实验库 use taotoken_lab; // 清空旧数据 db.orders.drop(); // 构造 10 万条测试文档 const statuses [paid, pending, cancelled]; const bulk []; for (let i 0; i 100000; i) { bulk.push({ userId: u_ (i % 5000), status: statuses[i % 3], createTime: new Date(Date.now() - i * 1000), amount: Math.round(Math.random() * 10000) / 100 }); } db.orders.insertMany(bulk); print(插入完成文档数 db.orders.countDocuments());跑完之后确认文档数是 100000。这时候先不建任何索引我们故意制造一个“全集合扫描”的慢查询场景方便后面用 explain 观察。3. 可复制的 explain 配置片段与三类信号解读3.1 三种 explain 模式怎么选很多人只知道.explain()不知道它还能传参。三种模式的调用方式如下// 模式一queryPlanner默认只看计划不看执行 db.orders.explain(queryPlanner).find({ userId: u_8823, status: paid }).sort({ createTime: -1 }).limit(20); // 模式二executionStats推荐看执行统计 db.orders.explain(executionStats).find({ userId: u_8823, status: paid }).sort({ createTime: -1 }).limit(20); // 模式三allPlansExecution看所有候选计划的执行情况 db.orders.explain(allPlansExecution).find({ userId: u_8823, status: paid }).sort({ createTime: -1 }).limit(20);排查慢查询时直接用 executionStats。queryPlanner 只适合确认“优化器有没有选中我期望的索引”但它不告诉你实际扫了多少文档。allPlansExecution 适合在多个索引候选之间做取舍输出量大日常排查用不上。3.2 executionStats 里的三类关键信号执行executionStats模式后返回的 JSON 里有一个executionStats子文档重点看这几个字段{ executionStats: { executionSuccess: true, nReturned: 20, // 实际返回文档数 executionTimeMillis: 3820, // 执行耗时毫秒 totalKeysExamined: 0, // 扫描的索引键数 totalDocsExamined: 100000, // 扫描的文档数 executionStages: { stage: COLLSCAN, // 执行阶段 direction: forward, docsExamined: 100000, nReturned: 20 } } }信号一索引命中情况。看executionStages.stage。如果是COLLSCAN说明走了全集合扫描索引完全没生效如果是IXSCAN说明命中了索引如果是FETCH套在IXSCAN外面说明索引命中后还需要回表取文档。理想情况是IXSCAN直接返回或者PROJECTION_COVERED覆盖索引。信号二扫描行数与返回行数的比值。看totalDocsExamined和nReturned。上面这个例子扫了 10 万文档只返回 20 条比值 5000:1索引效率极低。健康的值应该接近 1:1最多不超过 10:1。如果totalKeysExamined远大于nReturned说明索引选择性差比如在 status 这种只有三个值的字段上建单列索引。信号三执行阶段树。executionStages是一棵树从叶子往根读。常见的阶段有COLLSCAN全表扫、IXSCAN索引扫、FETCH回表、SORT内存排序、LIMIT截断。如果看到SORT阶段且memLimit接近 32MB说明排序没走索引数据量大时会直接报错。如果看到SORT_KEY_GENERATOR也是内存排序的信号。3.3 加索引前后的对比实验现在给{userId: 1, status: 1, createTime: -1}建一个复合索引再跑同样的 explain// 建复合索引 db.orders.createIndex({ userId: 1, status: 1, createTime: -1 }); // 再次执行 executionStats db.orders.explain(executionStats).find({ userId: u_8823, status: paid }).sort({ createTime: -1 }).limit(20);对比两次输出的关键字段字段加索引前加索引后说明stageCOLLSCANIXSCAN FETCH从全表扫变为索引扫totalDocsExamined10000020扫描文档数降为 1/5000totalKeysExamined020索引键扫描数nReturned2020返回数不变executionTimeMillis38203耗时降为 1/1273这个对比就是判断索引是否生效的核心依据。只要totalDocsExamined接近nReturned且 stage 里出现 IXSCAN就说明索引起作用了。如果建了索引但 stage 还是 COLLSCAN常见原因是索引字段顺序与查询不匹配、查询条件用了$ne/$nin导致无法走索引、或者排序方向与索引方向不一致。4. 验证请求用真实查询确认优化效果光看 explain 输出还不够得用真实查询验证。下面这段脚本会分别跑加索引前后的查询并打印耗时// 删除索引测原始耗时 db.orders.dropIndex({ userId: 1, status: 1, createTime: -1 }); const start1 Date.now(); const before db.orders.find({ userId: u_8823, status: paid }).sort({ createTime: -1 }).limit(20).toArray(); const cost1 Date.now() - start1; print(加索引前耗时 cost1 ms返回 before.length 条); // 重建索引测优化后耗时 db.orders.createIndex({ userId: 1, status: 1, createTime: -1 }); const start2 Date.now(); const after db.orders.find({ userId: u_8823, status: paid }).sort({ createTime: -1 }).limit(20).toArray(); const cost2 Date.now() - start2; print(加索引后耗时 cost2 ms返回 after.length 条);我实测的结果是加索引前 3800ms 左右加索引后 2-5ms。这个差距在 10 万文档级别已经很明显到了千万级就是“卡死”和“秒回”的区别。如果你想进一步确认索引被真正使用可以用$indexStats聚合db.orders.aggregate([ { $indexStats: {} } ]).forEach(idx { print(idx.name 被使用次数 idx.accesses.ops); });跑几次查询后再看这个统计对应索引的accesses.ops会增加说明查询确实走了这个索引。5. 本篇常见错排查清单排查过程中我踩过的坑基本都集中在下面这几类。你可以对照自己的 explain 输出逐条检查。错误一explain 模式没指定看到的是假象。直接.explain()默认走 queryPlanner输出里没有executionStats字段。很多人看到winningPlan里有 IXSCAN 就以为索引生效了但实际执行时可能因为数据分布、缓存等原因退化成 COLLSCAN。排查慢查询必须用explain(executionStats)。错误二索引字段顺序与查询不匹配。复合索引遵循最左前缀原则。如果你建的是{status: 1, userId: 1}而查询条件是{userId: u_8823, status: paid}索引可能无法高效使用。正确做法是把选择性高的字段放前面或者按照查询中字段的出现顺序建索引。用explain看indexBounds字段能确认索引边界是否被正确裁剪。错误三排序字段没进索引导致内存排序。查询里有.sort({createTime: -1})但索引是{userId: 1, status: 1}没有包含 createTime。这时 explain 输出里会出现SORT阶段executionStats里totalDocsExamined可能不大但executionTimeMillis偏高。解决办法是把排序字段加进复合索引且方向一致。错误四索引选择性太差优化器主动放弃。在 status 这种只有三个值的字段上建单列索引优化器可能判断走索引还不如全表扫。explain 输出里会显示rejectedPlans里面能看到被拒绝的原因。这时候应该建复合索引用高选择性字段打头。错误五$ne、$nin、$regex导致索引失效。这些操作符无法有效利用索引边界。如果查询里必须用考虑用$in替代$nin或者把正则改成前缀匹配。explain 的indexBounds字段会显示[MinKey, MaxKey]说明索引边界没被裁剪等于全索引扫描。错误六忽略了nReturned与totalDocsExamined的比值。有些人看到 stage 是 IXSCAN 就放心了但totalDocsExamined是 50000、nReturned是 10说明索引命中后回表了大量文档。这种情况要检查是不是索引没有覆盖查询所需字段考虑建覆盖索引。6. 把 explain 变成日常习惯排查慢查询这件事工具本身不复杂难的是形成条件反射。我现在遇到查询变慢第一反应不是去加索引而是先跑一遍explain(executionStats)看三个数totalDocsExamined、nReturned、executionTimeMillis。这三个数一出来问题基本就定位了。如果你在团队里做技术分享可以把第 3 节那张对比表打印出来贴在工位上。加索引前扫 10 万文档、加索引后扫 20 文档这种直观的数字比任何理论都有说服力。另外提一句排查过程中如果需要快速生成测试数据、写验证脚本或者让 AI 帮你解读一段复杂的 explain 输出可以用 TaoToken 的模型对话功能把 explain 的 JSON 贴进去让它帮你分析哪个阶段是瓶颈。接入文档里有完整的 API 调用示例Coding Plan 则适合把这类排查脚本沉淀成团队工具。工具地址我放在前面了有需要的可以自取。最后留一个检查清单下次遇到查询卡死时按顺序过一遍用explain(executionStats)而不是默认模式看executionStages.stage是不是 COLLSCAN算totalDocsExamined / nReturned的比值大于 10 就要警惕检查executionStages树里有没有 SORT 阶段看indexBounds是否被正确裁剪用$indexStats确认索引真的被用上了这六步走完根因基本跑不掉。
返回列表