インデックスは順序と範囲で設計する
一言でいうと
インデックスは「どのフィールドに張るか」ではなく、どの順序で、何を含めるかという問題です。同じ3つのフィールドでも、順序によって 走査するキーが数千倍変わり、含める範囲(部分・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
複合インデックスは、先頭のフィールドから値でソートされた1つのリストです。そこから順序のルールが導かれます。
| 順序 | フィールド | 理由 |
|---|---|---|
| E | 等価(status: "shipped") |
1つの区間に絞り込みます。その区間の中は次のフィールドの順序でソートされています |
| S | ソート(createdAt: -1) |
等価の区間の中ですでにソートされた順序で進めば、ソートは無料です |
| R | 範囲(amount >= …) |
進みながらキーですぐに絞り込みます。20件が埋まったら止まります |
{status, createdAt, amount}で作ると、計画はLIMIT>FETCH>IXSCANになり、走査するキーは20件を埋めるのに必要な分まで減ります。
範囲フィールドの後ろに置いたフィールドは、探索ではなく絞り込みにだけ使われます — これがERSとESRの違いのすべてです。
さらに3つあります。
- カバードクエリ: 返すフィールドがすべてインデックスの中にあれば、ドキュメントをまったく開きません(
totalDocsExamined: 0、PROJECTION_COVERED)。射影で_idを外し忘れると、_idを取りにドキュメントを開きます。 - 部分インデックス:
partialFilterExpressionに合うドキュメントだけを含みます。未処理の注文3%だけを探す画面なら、インデックスは30分の1になります。 その代わり、クエリの条件がその式を含んでいないとプランナーは使えません。条件からstatusを外すと、そのインデックスはないのと同じです。 - TTLインデックス: 日付フィールドに
expireAfterSecondsを指定すると、バックグラウンドの監視プロセスが60秒ごとに期限切れのドキュメントを削除します。 日付型だけを見ます — 文字列で書かれた時刻はいつまでも削除されません。
計画を読む順序
実行計画は木構造です。外側(winningPlan)が最後に行う処理で、inputStageをたどって下りると最初に行う処理が出てきます。
LIMIT>FETCH>IXSCANは「インデックスを走査して → ドキュメントを取得して → 20件で切る」で、SORT>COLLSCANは「すべて走査して → メモリで
ソートする」です。読むときは3つの数字を一緒に見ます。
| 比較 | 意味 |
|---|---|
| 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・正規表現の 接頭辞を順に測ります。最後に、3つの画面が遅くなったインシデントを受け取り、同じ症状の裏にある異なる原因を切り分けます。