按顺序和范围设计索引
一句话总结
索引的问题不是“建在哪个字段上”,而是**“以什么顺序、装进什么”**。同样的三个字段,顺序不同, 扫描的键数可能相差数千倍;装入的范围(部分索引、TTL)不同,大小和寿命也不同。判断要依据执行计划中的数字。
为什么需要——明明有索引却很慢
“建了索引为什么还慢”的报告,大多数不是没有索引,而是索引不匹配。来看列表页面上 一个常见的查询。
db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)
如果按查询中出现的顺序建立 {status, amount, createdAt} 索引,就会先用 status 缩小范围,再扫描 amount 的范围。但是范围
扫描出的结果并不是按 createdAt 排序的。于是执行计划中出现 SORT 阶段,把符合条件的数千条记录全部载入内存
排序之后再截取 20 条。limit 20 毫无帮助。
它如何工作——ESR
复合索引是从第一个字段开始按值排序的一张列表。由此得出顺序规则。
| 顺序 | 字段 | 原因 |
|---|---|---|
| E | 相等(status: "shipped") |
缩小到一个区间。区间内部按下一个字段排序 |
| S | 排序(createdAt: -1) |
在相等区间内按已排好的顺序遍历,排序就是免费的 |
| R | 范围(amount >= …) |
一边遍历一边直接用键过滤。凑满 20 条就停止 |
如果建成 {status, createdAt, amount},执行计划会变成 LIMIT>FETCH>IXSCAN,扫描的键数会减少到刚好凑满 20 条的程度。
放在范围字段之后的字段只用于过滤,而不用于查找——这就是 ERS 与 ESR 的全部区别。
还有三点。
- 覆盖查询(covered query):如果要返回的字段全部在索引中,就根本不打开文档(
totalDocsExamined: 0,PROJECTION_COVERED)。如果忘了在投影中去掉_id,就会为了取_id而打开文档。 - 部分索引:只收录符合
partialFilterExpression的文档。如果页面只查找占 3% 的未处理订单,索引会缩小到三十分之一。 但查询条件必须包含这个表达式,规划器才能使用它。条件中去掉 status,这个索引就等于不存在。 - TTL 索引:在日期字段上设置
expireAfterSeconds,后台监视器会每 60 秒删除一次过期文档。 它只看日期类型——以字符串写下的时间永远不会被删除。
阅读执行计划的顺序
执行计划是一棵树。最外层(winningPlan)是最后做的事,顺着 inputStage 往下走,就会看到最先做的事。
LIMIT>FETCH>IXSCAN 是“扫描索引 → 取出文档 → 在 20 条处截断”,SORT>COLLSCAN 是“全部扫描 → 在内存中
排序”。阅读时要把三个数字放在一起看。
| 比较 | 含义 |
|---|---|
| totalKeysExamined ≫ nReturned | 用了索引但区间太宽——怀疑顺序或正则表达式 |
| totalDocsExamined ≫ nReturned | 取出文档后再过滤——用于过滤的字段不在索引中 |
| totalDocsExamined = 0 | 只靠索引就给出了答案(覆盖) |
规划器会让候选计划各自短暂地并行试跑,选出先返回较多结果的那个,并按查询形状缓存这个选择。落选的计划
留在 rejectedPlans 中。因此新建索引会清空缓存,同一个查询的计划可能改变——这就是修改之后要重新
测量的原因。如果想单独测量某个索引的表现,就用 hint 强制使用该索引;想在没有索引的情况下测量,就传入 {$natural: 1}。
在现场会遇到的情形
- 正则表达式搜索只有在以
^开头的前缀形式下才会缩小索引区间。/TN-123/或忽略大小写的/^tn/i即使显示为 IXSCAN, 也会把键全部扫描一遍——这就是要看totalKeysExamined而不是阶段名称的原因。不区分大小写的搜索要用collation(strength 2)索引,而且只有指定了相同 collation 的查询才能使用该索引。 - 删除索引之前,先用
hideIndex把它隐藏起来试试。隐藏的索引仍会持续更新,但规划器看不到它,因此可以在可回退的状态下 测量删除它的影响。反过来,如果忘了自己隐藏过,就会出现“索引明明在列表里却是 COLLSCAN”的事故。 - 调整索引时,不要说“变慢了”,要用数字说话:扫描的键、打开的文档、返回的条数,以及有没有 SORT 阶段。
下一个实验要做的事
在 60,000 条订单上,把同一个列表查询分别在无索引、ERS、ESR 三种情况下测量并比较数字,然后依次测量覆盖查询、部分索引、TTL、正则 前缀。最后处理三个页面变慢的事件,区分同一症状背后各不相同的原因。