目次
この記事はヌーラボブログリレー2026 Tech 夏の2日目として投稿しています。
Backlogの課題まわりの機能を主に開発していますmadokanです。突然ですがクイズです。次の3つのSQLはどれが一番速いと思いますか?
-- 1
SELECT id FROM tasks WHERE project_id = 1234 LIMIT 10;
-- 2
SELECT id FROM tasks WHERE project_id = 1234 AND status IN ('open', 'in_progress', 'resolved') LIMIT 10;
-- 3
SELECT id FROM tasks WHERE project_id = 1234 AND status IN ('closed') LIMIT 10;
1のSQLはプロジェクトの課題から10個のidを取得するように見えます。2はやや複雑でWHEREでプロジェクトのIDと複数の状態で絞り込んで10個を取得しているようです。3は2と似ていますが完了した課題のみを絞って10個を取得しています。
新人研修時代にときを戻して自分ならどう答えるかを考えてみると、私なら「どれもLIMIT 10で件数が同じなので変わらない」、もしくは、「project_idとstatusの両方で絞っている2か3が速そう」と答えたと思います。ただ、現在にときを戻して、いまある情報だけだと「場合による」が正解だと思います。
目次
何がパフォーマンスを決めるのか?
私はSQLを書くときに「インデックス」「データの偏り」をまず考えます。
この話をする前に、簡単にRDBの内部構造をおさらいしておきます。今回はMySQL(InnoDB)を前提として解説しますが、多くの主要RDBでも概念やレイヤー構造として類似した仕組みが備わっています(※詳細な内部実装は各RDBの挙動をご確認ください)。

クライアントは実質Webアプリケーションと捉えてしまってよく、たいていネットワークを通してSQLを発行します。
オプティマイザー、エグゼキューター、ストレージエンジンはMySQL内部のコンポーネントです。ざっくり、それぞれの役割を確認しておくと、SQLを見てどのインデックスを使うかを決める(オプティマイザー)、データを加工する(エグゼキューター)、データを取りだす・保存する(ストレージエンジン)と区別します。
それで、セクションタイトルの「何がパフォーマンスを決めるのか?」の回収ですが、たいていの場合「③ データの要求」「④ データの取得」がボトルネックになっており、さらにの原因がオプティマイザーが適切なインデックスを選べていないというのが多いです。
インデックス
さて、さきほどの3つのSQLのどれが速いかは「場合による」と言いました。まず、インデックスという観点でみたときにどのような場合に速く、どのような場合に遅くなりうるのかを検討してみましょう。
まず、tasksテーブルにあるインデックスが「project_idの単体インデックス」のみの場合、間違いなく1が高速です。
これは「project_idの単体インデックス」を使ってストレージエンジンで10個を絞り込めるからです。一方で、2、3は追加でstatusでの絞り込みがあるためこのインデックスだけで10個を確定できません。つまり、インデックスによるLIMITの最適化ができず、project_idのみで絞った結果が順番にストレージエンジンからエグゼキューターに渡されます。エグゼキューターはそこからstatusで絞り込んでゆき、10件揃った時点でクライアントに10件を返します(JavaのStreamや各種言語のイテレーターのようなイメージです)。
クライアントからすると、結果として受け取る件数は同じなのにパフォーマンスに差が出てしまうということは直感的に理解しづらいと思います。ですが、インデックスを使ってストレージエンジンでtasks10個を絞っているか、もしくは、エグゼキューターがtasksを順番に評価するかで扱うデータ量に大きな差が出る場合があります(これについては後述する「データの偏り」にもつながってきます)。この差が明らかなパフォーマンスの差として現れることがあります。
では、データ量に大きな差が出るときにどのようにしたらパフォーマンスをあげられるか、という話になるのですが、答えは複合インデックスです。
データの偏り
このときに有効なのは「project_idとstatusの複合インデックス」で、このインデックスがある場合は、「project_idの単体インデックス」を使った1のSQLと同様にインデックスを使ってストレージエンジンで10個を決定することができます。よって、1、2、3はほぼ同じパフォーマンスと捉えることができます。
次にデータの偏りという観点で検討をしてみましょう。前提の説明をしていませんでしたが、tasksテーブルはBacklogの課題のようなものを想定しています。課題の状態遷移を考えると「未対応」から始まり最終的に「完了」に収束します。
- 「未対応」 -> 「処理中」 -> 「処理済み」 -> 「完了」
このようなモデルでは、一度「完了」された課題の状態はほとんど変わることがないため、「未対応」「処理中」「処理済み」 の割合に比べ「完了」の割合が圧倒的に多くなります。参考情報として、私が開発でメインにかかわっている社内プロジェクトのBacklog画面を確認したところ、この記事執筆時点で「完了」の割合が96%になっていました。
伏線として置いている、「project_idの単体インデックス」のみの場合、3の「完了」のみのSQLだと10件が見つかる確率が高いのに対して、2の「未対応」「処理中」「処理済み」のSQLは10件を見つけるために多くの行をスキャンすることになるためパフォーマンスの差が出てきます。
そもそも、クエリを書くときに、データの偏りを意識する必要があるのはオプティマイザーがインデックスを選ぶときにこれを使うためというのが大きいです。
前に示した2、3の両方のSQLでは、「project_idとstatusの複合インデックス」がある場合、これを使う一択になると思います。一方で例えば「project_idとstatusの複合インデックス」と「project_idと担当者の複合インデックス」が同時にある場合、「任意のプロジェクトの完了されていて、かつ、自分が担当の課題」で絞り込むとき、オプティマイザーは「project_idと担当者の複合インデックス」を選択する可能性が高いと考えられます。
前提として、オプティマイザーはtasksテーブルがビジネス上どのように使われるのか知りません。あくまで、統計情報を使って効率のよいインデックスを機械的に選んでいるにすぎませんが、これにより大抵の場合パフォーマンスがよいものが選ばれるようになっています。ただ、稀にですが、オプティマイザーが効率の悪いインデックスを選んでしまいパフォーマンスが悪くなってしまうこともあります。このときは、私たちのようなサービス提供者側がインデックスヒントやオプティマイザーヒントを指定して、オプティマイザーに対してインデックスの使用を強制することも検討する必要があります。
B-TreeインデックスとEXPLAINの話
2、3のクエリについて少し細かな話をします。tasksテーブルの要件に「作成日でソートしたい」という要件が出てくることは想像しやすいかと思います。このようなときは先程示したSQLにORDER BY created_on DESCを追加します。
-- 2.1
SELECT id FROM tasks WHERE project_id = 1234 AND status IN ('open', 'in_progress', 'resolved') ORDER BY created_on DESC LIMIT 10;
-- 3.1
SELECT id FROM tasks WHERE project_id = 1234 AND status IN ('closed') ORDER BY created_on DESC LIMIT 10;
この場合、project_id、status、created_onの複合インデックスが必要になります。何故必要なのかと疑問に思われた方は、ぜひMySQLのORDER BY最適化を読んでみてください。
ここで強調しておきたいのは、B-Treeインデックスの構造によってデータの処理が異なり、これがEXPLAINの結果に現れるということです。
project_id、status、created_onの複合インデックスはB-Treeインデックス上、次のようなソート済みのデータとして表現されます。
... 1233, 'resolved', '2026-06-02' 1234, 'closed', '2026-06-01' 1234, 'closed', '2026-06-02' 1234, 'in_progress', '2026-06-01' 1234, 'in_progress', '2026-06-02' 1234, 'open', '2026-06-01' 1234, 'open', '2026-06-02' 1234, 'resolved', '2026-06-01' 1234, 'resolved', '2026-06-02' 1235, 'closed', '2026-06-01' ...
3.1のクエリの場合、WHERE句の条件で絞ったときに該当する行は以下の2行です。
1234, 'closed', '2026-06-01' 1234, 'closed', '2026-06-02'
この場合はcreated_onでソート済みのため、ORDER BYする必要がなく、結果としてそのままクライアントに返すことができます。これはEXPLAIN ANALYZE上はCovering index lookupとして出るので、tasksテーブルのように一部のデータ(「完了」)への偏りが大きくても十分にパフォーマンスが出せることは明らかです。
次に2.1だとどうなるでしょうか?
1234, 'in_progress', '2026-06-01' 1234, 'in_progress', '2026-06-02' 1234, 'open', '2026-06-01' 1234, 'open', '2026-06-02' 1234, 'resolved', '2026-06-01' 1234, 'resolved', '2026-06-02'
絞り込みはできていますが、created_onが日付順になっていません。この場合、created_onでのORDER BYが必要になるのでEXPLAINのExtraにUsing filesortが出力されるようです(Amazon Aurora MySQL 3.0.4で確認)。
たまにUsing filesortはよくないという情報を目にしたり、私自身の経験として、新人研修でそういう言葉を聞いた記憶が朧げにあります。それゆえ、未だにUsing filesortをみると不安に感じてしまうのですが、今回のようにインデックスが使えていて、かつ、WHERE句で十分に絞り込めている場合はUsing filesortがでていてもパフォーマンス上大きな差はないと捉えています。
まとめ
もともと、MySQLのチューニングの話題はすでに十分な情報が世に出ているはずなので改めて記事を書く意味もないように思っていました。
ただ、過去に覚えた「Using temporaryやUsing filesortがでているクエリは危ない」というような知識はどうしても消すことができずに残っていて、しばしば混乱させられてしまうことに気づきました。この記事を書いた動機のひとつ目は「status in ('open', 'in_progress', 'resolved')とstatus in ('closed')でEXPLAINした結果に違いがあるのは何故か?」という趣旨の質問を受けたときに、即答できなかったという少し悲しいエピソードがあり、もう一度自分の言葉で整理しておきたいという思いが生まれたためです。
また、現在は、生成AIを使って開発・運用プロセスを回すことが当たり前になってきています。これはとても助かっている反面、生成AIを利用する側の私たちがプロンプトを上手くつくれなかったために、不正確な情報が生成され、それに混乱させられてしまうことが稀にあります。これはMySQLの文脈に限るならば、information_schemaやperformance_schemaなどの情報を活用してプロンプトをつくれば、すぐに解決できる問題なのかもしれません(というかすでに解決されていそうなことも想像できます)。ただ、サービス提供者としては、提供するサービスについて深く理解した上で生成AIとうまく共生し、価値を創出していくことがより一層求められてきていると感じています。生成AIとうまく共生してゆくためために、私自身、Backlogのデータの特性や構造について理解を深める必要性を強く認識いたしました。これがこの記事を執筆するに至ったもうひとつの動機です。