索引设计 — 顺序、覆盖、部分、TTL
目标
在 60,000 条订单上,把同一个列表查询分别在无索引、按字段出现顺序(ERS)、相等→排序→范围(ESR)三种情况下测量并用数字比较, 再用执行计划中的数字确认覆盖查询、部分索引、TTL 和正则前缀。最后区分三个页面变慢的事件。
为什么重要
索引问题通常不是“没有”,而是“不匹配”。一个顺序就能让扫描的键数相差数千倍,一个正则表达式就能让
显示为 IXSCAN 的计划扫描全部键。只看阶段名称会漏掉问题,看 totalKeysExamined、totalDocsExamined、nReturned
这三个数字以及有没有 SORT 阶段,问题就会显现。本实验让你亲手测量这些数字,评分器也会重新测量同样的数字来核对。
步骤
- 加载素材与基线(无索引)
- 按字段出现顺序建立的索引(ERS)
- 相等→排序→范围索引(ESR)与规划器的选择
- 不打开文档的查询(覆盖)
- 部分索引
- TTL 与字符串时间
- 正则前缀
- 事件——三个页面的查询变慢了
连接与素材
python3 /opt/lab/fixtures/mg/mgkit.py load-index # idx.orders 60,000건(다시 돌리면 인덱스까지 처음 상태로)
mongosh idx
列表页面的查询(第 1–3 步):db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)
参考
- 结果文件放在
/root/work/mgidx/下(mkdir -p /root/work/mgidx)。 - 想用特定索引测量就加
.hint({<keys>})(填入索引的键),想不用索引测量就加.hint({$natural: 1})。 - 常见错误:只看阶段名称(IXSCAN)就放心;投影中没有去掉
_id,导致覆盖失效。 - 事件工具:用
incident list查看可触发的事件及一行症状,用incident show <name>重新查看工单。再次触发同一事件时,会以新的随机素材回到初始故障状态(之前写下的答案将不再适用)。 - 报告是
/root/incident/<name>/report.txt。每个目标写一行<target>: <cause-code>,最后附上evidence: <line>。证据是修复之前看到的输出中原样的一行(20 个字符以上)——评分器会把它与事件发生时保存的观察记录整行比对。评分器先检查修复后的实际状态,报告用来确认你的判断。 - 实验 Pod 没有卷。会话结束后,
/root下的成果会消失。
加载素材并测量基线
用 python3 /opt/lab/fixtures/mg/mgkit.py load-index 向 idx.orders 加载 60,000 条订单。在 idx 数据库中,不使用索引,用 explain('executionStats') 读取列表页面的查询 db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20),并在 /root/work/mgidx/01-baseline.txt 中写下 plan:、keysExamined:、docsExamined:、nReturned: 四行。plan 是从最外层阶段(winningPlan)开始顺着 inputStage 把阶段名称用 > 连起来的结果。
方向:修改之前先测量,才能与修改之后比较。这个查询先过滤出 status 相等、amount 在 150000 以上的文档,再按 createdAt 排序,只用 20 条——看看没有索引时它扫描了什么、扫描了多少。 工具:explain('executionStats')、queryPlanner.winningPlan 及其中的 inputStage、executionStats 中的三个数字。 具体:计划树的最外层是最后做的事。如果有排序阶段,就往下看看它里面是什么。
idx.orders 60,000 条保持不变;01-baseline.txt 的四个值与无索引时的计划一致
按查询中出现的顺序建立索引
建立 {status: 1, amount: 1, createdAt: -1} 索引,用该索引(hint)测量同一个列表查询,在 /root/work/mgidx/02-ers.txt 中写下同样的四行。看看与基线相比哪些变了、哪些没变。
方向:用上了索引并不代表结束——要看排序阶段是否还在、扫描了多少个键。 工具:createIndex、cursor.hint({<keys>})、explain('executionStats')。 具体:按范围(amount)扫描出的键并不是按 createdAt 排序的。所以计划中的某处会另外加上排序——请写下这个阶段的位置。
存在 {status: 1, amount: 1, createdAt: -1} 索引,用该索引测得的 02-ers.txt 的四个值一致
按相等 → 排序 → 范围的顺序建立
建立 {status: 1, createdAt: -1, amount: 1} 索引,用该索引测量同一个列表查询,在 /root/work/mgidx/03-esr.txt 中写下同样的四行。再加一行 winner: <indexName>,写出不带 hint 查询时规划器选中的索引名称。
方向:如果相等区间内部已经按 createdAt 排好序,就可以按这个顺序遍历、一边过滤 amount,一边在凑满 20 条时停下。 工具:createIndex、hint、不带 hint 的 explain() 中 winningPlan 里的 indexName,落选的计划在 rejectedPlans。 具体:把 keysExamined 和 plan 与第 2 步并排比较。规划器会让候选计划各自短暂试跑,选出先给出结果的那个。
用 {status: 1, createdAt: -1, amount: 1} 索引测得的 03-esr.txt 的四个值和 winner 与当前计划一致
不打开文档就给出答案
某个列表页面只显示 status 为 paid、且 createdAt 在 2026-10-01T00:00:00Z 之后(含)的订单的 createdAt 和 amount。把这个页面的查询以 {"filter": …, "projection": …} 的形式写入 /root/work/mgidx/04-covered.json(日期用 Extended JSON {"$date": "…"}),并让它一个文档也不打开(totalDocsExamined: 0)就给出答案。需要的话可以再加索引。
方向:如果要返回的字段全部在索引键中,就没有理由打开文档。 工具:投影、第 3 步的 ESR 索引、explain('executionStats') 中的 totalDocsExamined 与 PROJECTION_COVERED,确认日期用 EJSON.stringify。 具体:有一个字段即使不写在投影里也会默认跟着返回——如果它不在索引中,就会因为它而打开文档。
04-covered.json 的查询与该页面的结果相同,并且 totalDocsExamined 为 0
只收录所需文档的索引
为按客户查找未处理订单(status: "pending")的页面,在 customerId 上建立只收录 status 为 pending 的文档的部分索引。在 /root/work/mgidx/05-partial.txt 中写下三行:该索引中实际收录的键数 partialKeys: <n>、客户 3728 的 {customerId: 3728, status: "pending"} 查询计划 withStatus: <plan>、{customerId: 3728} 查询计划 withoutStatus: <plan>(plan 与第 1 步一样,是用 > 连接的阶段名称)。
方向:部分索引小而便宜,但只有查询条件包含该索引的条件时才能使用。 工具:createIndex 的 partialFilterExpression,键数用 db.orders.validate().keysPerIndex,计划用 explain()。 具体:如果去掉 status 再查询,规划器会认为该索引没有收录所有文档,因而不能使用——请写下两个计划有何不同。
存在 customerId 部分索引,05-partial.txt 的键数和两个查询的计划与当前一致
用 TTL 删除过期会话
用 python3 /opt/lab/fixtures/mg/mgkit.py load-sessions 向 idx.sessions 加载 300 条记录(再次运行会回到初始状态)。让数据库自行删除最后访问时间(lastSeen)之后已超过 1 小时(3600 秒)的会话。旧应用写入的部分会话中 lastSeen 是字符串——也要让它们能被删除。不要删除应当存活的会话,时间值要原样转换。
方向:TTL 索引由后台监视器每 60 秒检查日期字段并删除。不是日期的值会被直接跳过。 工具:createIndex(…, {expireAfterSeconds: …})、用 $type 统计字符串数量、更新管道中的 $toDate。 具体:把字符串转换成日期后,等大约 1 分钟再统计剩余条数。如果手动删除,下一批过期的会话仍会留下。
lastSeen 上有 3600 秒的 TTL;字符串时间为 0 条;过期会话由 TTL 删除,存活会话保留
正则表达式只有在前缀形式下才会缩小范围
在 orders 的 sku 上建立索引,测量三个查询 {sku: /^TN-123/}、{sku: /TN-123/}、{sku: /^tn-123/i},在 /root/work/mgidx/07-prefix.txt 中分 anchored:、unanchored:、ignoreCase: 三行写下 keysExamined=<n> nReturned=<n>。
方向:三个查询的计划中都显示 IXSCAN。差别不在阶段名称,而在扫描了多少个键。 工具:createIndex、explain('executionStats') 中的 totalKeysExamined 与 nReturned、indexBounds。 具体:同时看看 indexBounds——只有开头固定的正则表达式区间才窄,其余的都是从头到尾。
查询使用 sku 索引,07-prefix.txt 中三个查询扫描的键数和返回数与当前一致
事件——三个页面的查询变慢了
运行 incident mg-slow-endpoints 就会发生事件(会新建一个与前面步骤无关的 inc 数据库)。页面 ep1、ep2、ep3 的查询(/root/slowapp/queries/<ep>.json)三个都触发了慢查询告警。请用 /root/slowapp/slowapp run|explain|indexes <ep> 运行查看,找出每个页面的原因并修复:返回的结果保持不变,不做全表扫描(COLLSCAN)和内存排序(SORT),只扫描需要的部分。邮箱页面是不区分大小写查找的页面。不要删除或修改数据。在 /root/incident/mg-slow-endpoints/report.txt 中写下 ep1: <code>、ep2: <code>、ep3: <code> 三行,以及 evidence: <line>(修复前看到的 slowapp explain 或 slowapp indexes 输出中原样的一行)。每个页面的代码是以下之一:collation-mismatch(查询与索引的 collation 不同,用不上索引)、hidden-index(合适的索引被隐藏了)、index-missing(根本没有合适的索引)、sort-after-range(索引把排序字段放在范围之后,导致内存排序)、unanchored-regex(正则表达式开头不固定,扫描全部键)。
方向:对每个页面,把“计划(explain)”和“该集合的索引列表”并排来看。如果在全表扫描,而列表中有合适的索引,原因就在于规划器为什么用不上它。如果用了索引却仍扫描大量键或有 SORT,原因就在于索引的形状或查询的形状。 工具:slowapp explain、slowapp indexes,mongosh 的 unhideIndex、createIndex、dropIndex,查询文件中的 collation 与 $regex。 具体:每个页面要修改的地方不同——请区分是在索引一侧(恢复可见、建立调整顺序后的新索引)还是在查询一侧(加入 collation、固定正则开头)。修复后用 slowapp explain 查看 keys 和 docs 是否接近 returned,并确认 slowapp run 的条数保持不变。
三个页面的结果都不变,不用 COLLSCAN 和内存 SORT,只扫描需要的部分;写有原因代码和证据行