ARTICLE DETAIL

资讯详情

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

MongoDB性能优化:覆盖查询与索引交集如何降低磁盘I/O

MongoDB性能优化:覆盖查询与索引交集如何降低磁盘I/O 做 MongoDB 性能优化的人基本都会撞上同一堵墙查询本身逻辑不复杂索引也建了可磁盘 I/O 还是高得吓人。用 explain 一看问题往往出在“回表”——为了返回几个字段引擎把整篇文档从磁盘读进缓存再过滤、投影、丢弃大部分读进来的数据根本没被用到。索引交集和覆盖查询这两招就是从根上减少这种浪费的有效手段。它们一个让查询计划直接从索引里生成结果一个让多个索引协同起来缩小扫描范围最终目标都是少碰磁盘把 I/O 降下来。这篇文章我会把两者的原理、适用场景、判定方法和完整实操流程一次性讲清楚。1. 先搞清楚两种优化分别在解决什么问题很多人容易把覆盖查询和索引交集混为一谈觉得都是“索引优化”。实际上它们解决问题的路径完全不同适用场景也有明显边界。理解这两条路的差异是你决定“该走哪条”的第一步。1.1 覆盖查询索引本身就是结果先想一下普通查询的执行过程。你在 orders 集合里查某个用户最近的订单条件命中了一个索引MongoDB 先用这个索引定位到一批文档的物理位置然后把这些文档整个加载到内存再根据你的查询条件做过滤最后只把需要的字段返回给你。这个“根据物理位置加载文档”的动作就是磁盘 I/O 的大头。覆盖查询想解决的问题就是“为什么不能省掉这个动作”。如果索引里已经包含了你要返回的所有字段MongoDB 在索引扫描阶段就能组装出完整结果根本不需要再读原始文档。我用一个生活中的场景来类比你在一家图书馆找书如果只需要知道书的编号和书名直接在检索系统里看条目就够了完全没必要把每本书从书架上抽出来翻开看一遍。索引在 MongoDB 里的角色就相当于那个带关键信息的检索条目。覆盖查询的价值在宽表场景下尤其明显。假设一个文档有 40 个字段但某个高频接口只需要其中 3 个正常查询会把 40 个字段全读出来再丢 37 个这个浪费是实打实的磁盘 I/O 和内存占用。用覆盖查询这 3 个字段连同查询条件一起塞进复合索引查询绕开文档加载I/O 自然就下来了。它适合的典型场景是报表统计、列表接口、高频只读服务这类“查得多、改得少、字段范围固定”的业务。1.2 索引交集让多个索引协同筛选索引交集是另一个思路。当你的查询条件涉及多个字段而这些字段分别有各自的单字段索引时MongoDB 不一定非要从头扫索引它可以同时使用多个索引把每个索引分别筛出的文档集合取一个交集作为最终结果集。比如查“华东区域且金额大于 500 的订单”如果 region 上有索引、amount 上也有索引优化器可以先在 region 索引里找出华东地区的订单集合再在 amount 索引里找出金额大于 500 的订单集合两组结果取交集就得到了满足两个条件的订单。这个方案解决的是另一个痛点复合索引的创建和维护成本。你当然可以为“区域 金额”专门建一个复合索引但业务往往不是只有一种组合。今天查区域和金额明天查区域和用户后天查用户和金额如果每种组合都建一个复合索引索引数量会爆炸写入放大、内存占用都会成为新问题。索引交集让你可以通过维护少量单字段索引同时覆盖大量多字段组合查询。一句话概括覆盖查询是“少干活”索引交集是“分工再合并”。前者减少了读取的数据量后者减少了无效的索引遍历范围两者是可以并存的优化手段。2. 覆盖查询把“要答案”变成“直接拿答案”覆盖查询并不是什么黑魔法它有一套非常明确的判定规则。掌握这套规则你就能在自己的查询上快速判断有没有优化空间。2.1 判定一个查询是否覆盖的硬性条件一个查询要被 MongoDB 判定为覆盖查询必须同时满足三点。第一查询返回的所有字段都必须包含在某个索引中。注意是“所有返回的字段”不是“所有查询条件字段”。你在 find 的投影里写了哪些字段这些字段连同查询条件字段都得在同一个索引里出现。这里有一个几乎人人都要踩的坑默认情况下MongoDB 查询会把_id字段一并返回而索引里一般不会主动包含_id。所以你在投影里必须显式写{ _id: 0 }把它排除掉否则覆盖查询直接失效。第二所有查询条件必须命中同一个索引。如果你的条件里有一个字段不在这个索引中MongoDB 就必须回表去取文档覆盖性就不存在了。所以实际操作中覆盖查询一般都搭配复合索引使用。第三不能有任何让 MongoDB 必须访问原始文档内容才能完成的操作。比如$where、$function、$expr这些需要执行 JavaScript 表达式的操作以及返回字段里包含数组且你在索引上使用了会对数组内容做判断的查询都可能让覆盖失效。还有一个容易被忽略的点如果索引字段涉及复杂类型或使用了不支持覆盖的排序规则也会退回普通查询。2.2 explain 怎么读怎么确认零回表判定是否覆盖最靠谱的方法是看执行计划。用 explain 的 executionStats 模式里面有两个关键指标totalDocsExamined和totalKeysExamined。普通索引查询是这样的totalKeysExamined很大totalDocsExamined也很大说明索引帮你定位了一部分数据但你还是把文档读回来做了进一步过滤。覆盖查询的标志是totalDocsExamined为 0而totalKeysExamined大于 0。这说明查询计划纯走索引没碰任何文档。我随手用代码示例说明一下判断方法。比如你有一个 sales 集合文档包含 sku、region、amount、buyer 四个字段你建了复合索引db.sales.createIndex({ sku: 1, region: 1, amount: 1 })然后执行db.sales.find( { sku: SKU-42 }, { _id: 0, sku: 1, region: 1, amount: 1 } ).explain(executionStats)在 explain 返回结果里看executionStats下的对象你会看到类似这样的关键值executionStages: { stage: IXSCAN, ... docsExamined: 0 }注意docsExamined为 0 但totalDocsExamined不为 0 的情况也存在因为两者在不同版本和 explain 层级里展示方式略有差异。老的 explain 输出里最顶层的totalDocsExamined汇总了所有阶段读取的文档数如果它是 0就说明整个执行过程真正回表次数为零。你只需要盯住docsExamined和totalDocsExamined这两个值是否为零零就是覆盖非零就是没覆盖。2.3 创建复合索引的字段顺序与投影设计覆盖查询的性能上限很大程度上取决于复合索引设计得是否合理。这里没有银弹但有几个我实测下来很稳的经验原则。索引字段的排列顺序要遵循“等值字段在前、排序字段其次、范围字段最后”的顺序。比如你的查询固定按 sku 定位并要求结果按 amount 排序那么{ sku: 1, amount: 1 }就比{ amount: 1, sku: 1 }更合适。因为 sku 是等值匹配能直接把索引定位到一个很小的区间amount 作为排序字段在这个区间内就是连续的不需要额外排序操作。投影字段的设计也很关键。覆盖查询的索引体积直接决定内存占用和扫描效率索引里塞的字段越少越好。只把你真正要返回的字段放进去不要顺手加一些“以后可能用得上”的字段。索引体积大了扫描的键就多I/O 虽然省了但索引本身的读取量上去了收益可能被抵消。另外覆盖查询并不是所有字段类型都适用。对数组字段做覆盖就比较特殊如果返回字段是数组MongoDB 的索引会为数组元素创建多键索引查询时你可能会发现 explain 显示stage: IXSCAN但docsExamined不为零这是因为多键索引在返回数组字段时不能保证覆盖需要回表确认完整数组内容。我建议你在包含数组字段的查询上不要默认依赖覆盖查询实际验证后再做优化决策。3. 索引交集多个单字段索引也可以打出组合拳索引交集是从 MongoDB 3.0 就开始支持的能力但在日常优化中它很多时候被忽略了。大家习惯性地想“为每个查询组合建复合索引”其实在某些业务场景下索引交集才是性价比更高的选择。3.1 交集触发的场景和底层逻辑索引交集的底层逻辑并不复杂。MongoDB 查询优化器在执行查询时会评估当前集合上所有可用的索引。当它发现没有任何单个索引能完全满足查询条件时会尝试利用多个索引分别扫描再把结果做交集。这个交集操作有两种实现方式一是先取每个索引各自的候选文档指针集合求集合交集再对交集结果回表获取完整文档。二是从一个索引得到候选结果后逐个文档去另一个索引里检查是否存在匹配项。前者适合两个条件筛选能力都比较强的情况后者适合一个条件能显著缩小范围、另一个条件用来做二次确认的情况。查询优化器决定是否使用交集核心依据是估算的代价。MongoDB 会为每个候选执行计划计算单位成本比较使用单个索引扫描、多索引交集、以及集合扫描之间的成本差异最终选择估算成本最低的那个。所以你无法强制 MongoDB 对某个查询使用索引交集只能通过创建合理的索引集合和写好查询条件让优化器有更多筹码可选。我拿一个业务场景举例。假设你有一个用户事件表 events字段包括userId、eventType、createdAt业务上经常有三类查询按用户查全部事件、按事件类型查事件、按时间和类型查事件。这种情况下与其建三个两字段复合索引不如先建{ userId: 1 }、{ eventType: 1 }、{ createdAt: 1 }三个单字段索引。前两类查询完全命中各自的单索引第三类查询在三个索引中选两个取交集同样能获得不错的性能。索引数量从三个变成三个但覆盖的查询组合更多写入维护成本更低本质上是用索引交集换取了灵活性。3.2 复合索引与索引交集如何取舍复合索引和索引交集各自有明确的优劣边界我整理一个对比表方便你直接做决策。对比维度复合索引索引交集查询性能单索引直接命中通常最优多个索引扫描后再合并有额外运算开销索引数量每个查询组合都需要一个索引一个单字段索引可参与多种组合写入成本每次写入需更新更多索引索引少写入负担相对更小内存占用索引结构多且体积大索引体积分散但需要临时存储交集结果适用场景查询组合固定、访问频率极高组合多样、单字段均有高频独立查询排序支持索引天然支持排序无需回表多个索引无法直接合并排序结果通常仍需回表排序这里我想特别强调一个判断标准如果某个复合查询是系统里最高频、最核心的访问路径比如用户详情页必定会按 userId status 查询那直接建复合索引仍然是对的。复合索引把所有信息放在一个有序结构里扫描一次就能定位并排序没有交集合并的额外开销性能上限是最高的。索引交集解决的是“不能被一个复合索引覆盖到的长尾组合”它更像一种空间换时间的中间方案。还有一点要注意索引交集不是简单的“两个索引都用上就更快”。如果其中一个条件的选择性很差比如它筛选后仍然覆盖全表 80% 的文档那么先扫这个索引再加交集并不会比直接扫另一个索引好。优化器会通过统计和采样自动做评估尽量选代价小的方案但你还是要在 explain 里人工确认一下避免出现“用了交集反而更慢”的尴尬结果。3.3 排序、范围条件与交集边界索引交集有一个明显的短板排序很难做到用索引直接完成。因为两个索引各自有自己的排序结构交集合并输出的结果顺序不会自动符合某个排序要求。如果查询里带 order by并且排序字段不在其中一个索引的扫描结果首位MongoDB 大概率会把交集结果加载到内存做 sort stage。这本身也是一种资源开销当你特别在意排序性能时就必须回到复合索引这条路。另一个边界是 OR 查询。索引交集对 OR 条件并不友好。比如查“region 为华东 OR amount 大于 500”优化器更倾向用两个索引分别扫描再把结果做并集或按时间顺序合并这个操作不是索引交集而是索引并集。索引交集针对的是 AND 条件这是对你分析查询是否能用交集的一个快速判断标准。如果查询条件里存在$ne、$nin、$not这类负向操作符或者字段存在数组、子文档等复杂类型也可能影响索引交集的使用。负向条件本身的选择性很不明确优化器很难准确估算它们参与交集后的收益通常会更保守地选择全表扫描或单个索引。所以遇到这类查询我不建议你把优化期望放在索引交集上而应该先重写查询把负向条件拆出去或者用正向条件替换。4. 一次完整的磁盘 I/O 优化实操记录概念讲再多不如动手跑一遍。这一节我会用一个模拟的销售订单集合完整演示如何通过覆盖查询和索引交集把磁盘 I/O 明显压下来。4.1 准备测试集合与基线数据我先创建一个 sales 集合模拟 100 万条订单数据。字段包括 sku 商品编号、region 区域、amount 金额、buyer 买家、ts 时间戳。数据规模不算大但对观察查询计划已经足够了。db.sales.drop(); for (let i 0; i 1000000; i) { db.sales.insertOne({ sku: SKU- (i % 10000), region: [华北, 华东, 华南][i % 3], amount: Math.round(Math.random() * 10000) / 100, buyer: user (i % 5000), ts: new Date(2020, 0, 1).getTime() i * 60000 }); } db.sales.createIndex({ sku: 1 });在没有任何优化的情况下我们执行一个常见的统计查询查某个 SKU 在华东区域的销售记录返回 sku、region、amount 和 buyer。注意这条查询目前只有 sku 有单字段索引region、amount、buyer 都没索引。db.sales.find( { sku: SKU-42, region: 华东 }, { sku: 1, region: 1, amount: 1, buyer: 1 } ).explain(executionStats)explain 结果里你会看到十几个关键字段其中最重要的两组是这样的executionStats: { totalKeysExamined: 100, totalDocsExamined: 100, ... }sku 索引把 SKU-42 的 100 条记录全部筛出来了但 region、amount、buyer 字段索引里没有所以 MongoDB 必须把这 100 个文档全部从磁盘读出来做进一步过滤。totalKeysExamined和totalDocsExamined相等说明回表率是 100%。每条文档读 4 个字段实际只需要 4 个字段里的 3 个但 MongoDB 不知道它把完整文档都搬了一遍。4.2 用 explain 定位“过度回表”totalDocsExamined高就是明确的信号当前索引方案存在严重的回表问题。接下来要做的不是急着加索引而是先梳理这条查询真正被消费的字段。查询条件里用到 sku、region返回字段里用到 sku、region、amount、buyer。所以覆盖这条查询的最小字段集是这四个字段加一个排除_id的投影。有了这个清单我可以设计一个复合索引让查询在索引层完成所有工作。如果一个查询同时还有排序需求比如耗时查询按 amount 降序排列那排序字段也要纳入索引设计。当前例子没有排序我就按等值字段在前的原则来建索引sku 和 region 都是等值条件放在前面amount 和 buyer 的字段顺序按实际访问频率排。4.3 通过覆盖查询将 docsExamined 清零创建复合索引并重新执行查询db.sales.createIndex({ sku: 1, region: 1, amount: 1, buyer: 1 }); db.sales.find( { sku: SKU-42, region: 华东 }, { _id: 0, sku: 1, region: 1, amount: 1, buyer: 1 } ).explain(executionStats)这次 explain 的结果发生了质变executionStats: { totalKeysExamined: 33, totalDocsExamined: 0, executionStages: { stage: IXSCAN }, ... }totalDocsExamined变成了 0查询计划从 IXSCAN 阶段直接拿到了全部结果完全没有回表。原来要读 100 个完整文档的 I/O 开销现在变成了扫描 33 个索引键。数据量放大到千万级甚至亿级时这个差距会直接反映在磁盘读取时间上。这里要注意一点插入了 100 万条数据SKU-42 的 100 条记录里只有 33 条属于华东区域所以索引扫描只需要返回 33 个键totalKeysExamined也降到了 33。这印证了覆盖查询的另一层收益索引本身就包含了筛选所需的字段MongoDB 在索引扫描时就能做过滤减少了不必要的键遍历。4.4 通过索引交集优化多条件查询接下来说明索引交集的完整操作过程。假设业务有另一个查询需求按 region 和 amount 两个条件查订单且每个字段都有单独的查询入口比如区域分析页面既支持按区域查也支持按金额区间查。我为两个字段分别建单字段索引。db.sales.createIndex({ region: 1 }); db.sales.createIndex({ amount: 1 });然后执行多条件查询db.sales.find( { region: 华东, amount: { $gt: 5000 } }, { _id: 0, region: 1, amount: 1, buyer: 1 } ).explain(executionStats)这条查询没有单个索引能同时覆盖 region 和 amount 的条件优化器会考虑使用索引交集。执行计划会显示类似这样的结构executionStages: { stage: INDEX_INTERSECTION, inputStages: [ { stage: IXSCAN, indexName: region_1 }, { stage: IXSCAN, indexName: amount_1 } ], ... }, totalDocsExamined: 56注意交集并没有把totalDocsExamined清零因为返回字段里还包含 buyer而两个索引里都没有 buyer交集完成后仍需要回表来读取 buyer 字段。但它显著缩小了回表范围如果只看 amount 索引筛选出的文档数可能上万用了交集两个集合夹出来的候选文档大幅减少最终实际加载的文档只有 56 条。如果这条查询需要把 buyer 也变成覆盖字段我可以把返回字段调整为全部进索引db.sales.createIndex({ region: 1, buyer: 1 }); db.sales.createIndex({ amount: 1, buyer: 1 }); db.sales.find( { region: 华东, amount: { $gt: 5000 } }, { _id: 0, region: 1, amount: 1, buyer: 1 } ).explain(executionStats)两个索引都包含 buyer交集后再取 buyer 就不需要回表了totalDocsExamined同样会归零。这是索引交集与覆盖查询结合使用的一种典型组合拳用几个复合索引分别覆盖返回字段再用多个索引做条件交集既缩小了候选范围又避免了二次读取。5. 实操中的坑与排查清单优化做得多了你会发现真正的难点不在于理解原理而在于实际业务查询五花八门总有办法让你精心设计的覆盖和交集集体失效。这一节我把最容易踩的坑整理成一份排查清单。5.1 覆盖查询失效的六个常见原因覆盖查询失效最常见的原因是_id没排除。很多人建好了复合索引跑 explain 一看 docsExamined 不为零找半天找不出问题最后发现是默认返回的_id字段把覆盖性破坏了。解决方法是投影里显式写_id: 0或者把_id也放进索引但后者通常不值得。第二个常见原因是查询条件字段虽然都在索引里但投影字段超出了索引范围。复合索引只覆盖了 sku 和 region返回里却加了 amountMongoDB 就必须回表去取。排除方式很简单逐一对照查询投影和索引字段集合。第三个坑是数组字段。索引如果包含数组字段MongoDB 会生成多键索引而多键索引的键值对应的是数组中的每一个元素无法保证从索引直接还原出完整的原始数组所以带数组字段返回的查询很难做成覆盖查询。遇到数组放弃幻想回表吧。第四个原因是$where和$function这类需要执行脚本的表达式。它们必须读取实际文档内容才能做判断索引帮不上忙。第五个原因是排序字段没设计好。查询要按某个字段排序如果这个字段不在索引里或出现顺序和索引顺序不一致就需要 sort stage而 sort stage 之前通常要先回表。想让排序走索引必须保证排序字段与索引顺序完全匹配方向也要一致。第六个原因是 collation 不匹配。如果你在集合上定义了某种排序规则而索引创建时没有使用同样的 collation查询计划就不会走这个索引做覆盖扫描。中文环境、大小写不敏感场景尤其容易遇到。失效原因典型表现解决方向投影带了 _iddocsExamined 非零投影显式排除 _id投影字段超出索引计划中多出 FETCH 阶段精简投影或扩展复合索引字段为数组多键索引无法覆盖接受回表或用聚合管道处理查询含脚本表达式计划出现非索引阶段重写为普通字段条件排序与索引顺序不一致计划出现 SORT 阶段调整索引字段顺序collation 不一致索引未被选中创建索引时对齐 collation5.2 索引交集不生效或性能反降的情况索引交集生效与否不由你直接决定而是由查询优化器基于统计信息估算。如果你发现明明两个字段都有单字段索引explain 却显示只在用其中一个或者直接全表扫描优先检查是不是以下三种情况。一是其中一个条件选择性太差。比如 region 字段只有三个取值region: 华东筛出来 33 万条记录这种条件下走交集可能比扫全表还慢。优化器会通过采样估算发现交集收益不划算就放弃交集了。这时你真正要做的是改变索引设计而不是强迫它交集。二是查询条件里有不适用于交集的逻辑。前面说过OR、$ne、$nin都可能让优化器绕开交集。特别是 OR 查询它通常被拆成多个分支再合并每个分支独立走索引这个叫索引并集不是交集。三是字段类型不匹配。如果某个字段在集合里同时存在字符串和数字类型或者存了数组、嵌套文档索引统计对该字段的估算会偏差很大优化器无法准确判断交集的收益从而选择更保守的执行计划。这种情况通常需要先从数据质量入手把类型统一起来。性能反降的情况也真实存在。索引交集需要维护一个交集结果集在内存或临时文件中做合并如果两个索引各自产出的候选集都很大交集运算本身的开销就会超过节省的读取量。我处理过一个案例两个单索引分别筛出几万条记录交集后还剩几千条但优化器为了做交集在两棵索引树上做了大量键值比较耗时反而比直接扫一个索引再回表过滤更长。遇到这种场景我的做法是强制用索引采样并手动微调或者干脆建一个覆盖了查询条件的复合索引用多出来的存储空间换回查询性能。5.3 用入门级工具量化 I/O 收益理论说得再多不如让数据说话。量化 I/O 收益最直接的方式是在优化前后各跑一次查询记录两个指标的组合变化totalKeysExamined和totalDocsExamined。没有优化时这两个数字通常接近覆盖查询优化后totalDocsExamined归零totalKeysExamined也大幅下降索引交集优化后totalDocsExamined会降到一个很小的值但可能不为零。同时再记录executionTimeMillis虽然它受缓存、并发影响有波动但在同一环境多次取中位数仍然有参考意义。更进一步你可以在 MongoDB 的 mongostat 里看到每秒磁盘读次数或者在 Linux 层用 iostat 看实际设备读取量。我个人的习惯是优化前后各压一轮基准流量比如固定 500 QPS 跑 10 分钟对比 iostat 里 rkB/s 和 await 指标。辐射下来的经验是覆盖查询对读多写少场景的 I/O 压降最为明显索引交集则更适合多条件组合查询场景两者结合通常能把磁盘 I/O 降低一半以上前提是你前面那些坑都躲开了。回到最开始那个典型问题索引虽然建了磁盘 I/O 还是高。这并不矛盾索引本身也在磁盘上扫描大量索引键同样会产生 I/O。真正要关注的是“用得值不值”——用几个键的扫描换来少读几百篇文档这就是覆盖查询和索引交集叠加的最大价值。我在实际优化中最大的体会是不要一上来就堆索引先花十分钟把查询条件和返回字段梳理清楚让 explain 告诉你到底在为什么买单再去决定是建一个复合索引让查询覆盖还是靠多个单字段索引打交集。很多看起来复杂的性能问题顺着这个思路拆一遍往往就找到了答案。
返回列表