🎯 この章で学ぶこと

  • インデックスの正体 — 本の索引と同じ「引くための別冊」であること
  • 10万行のテーブルで効果を実測し、EXPLAIN QUERY PLANで実行計画を読む
  • インデックスが効かない3大パターン(列への関数適用・中間一致LIKE・型の不一致)
  • インデックスのコスト — 書き込みが遅くなる理由と、複合インデックスの列順
  • 遅いクエリへの正しい手順 — 計測→実行計画→改善。N+1問題の紹介

7.1 インデックスとは何か — 本の索引のたとえ

分厚い技術書で「トランザクション」の説明ページを探すとき、あなたは1ページ目から順にめくりません。巻末の索引で「と」の欄を見て「トランザクション …… 152ページ」を見つけ、そのページに直行するはずです。

データベースのインデックスはまさにこの索引です。インデックスがない列での検索は、DBMSがテーブルの全行を先頭から順に調べるしかありません。これをフルスキャンと呼びます。6行のcompany.dbなら一瞬ですが、100万行の顧客テーブルで毎回全ページをめくっていたら、システムは使いものになりません。

インデックスの実体は、多くのRDBMSでB木(Bツリー)という木構造です。「値の大小で枝分かれする案内板」を数段たどるだけで目的の行にたどり着けます。

salaryのインデックス(イメージ)

              [ 300000 | 360000 ]           ← 案内板: どの枝に進むか
             /         |         \
   [280000, 295000] [300000, 320000, 350000] [360000, 380000, 400000]
        │                  │                       │
     行の場所           行の場所                行の場所

「salary = 380000 の行は?」→ 案内板で右の枝へ → 2回の分岐で発見
フルスキャンなら10万行を全部確認、B木なら数段の分岐で到達

行数が10倍・100倍になっても、たどる段数はわずかしか増えない——これがB木の強みです。10万行から1行を探すのに、フルスキャンは10万回の確認、B木なら十数回で済みます。

💡 主キーには最初から索引がある

PRIMARY KEY の列は、一意性のチェックを高速に行う必要があるため、DBMSが自動的にインデックス相当の仕組みを用意します。WHERE id = 5 がいつも速いのはこのおかげです。問題になるのは、それ以外の列——名前・日付・金額など——での検索です。

7.2 効果を測ってみる — 10万行とEXPLAIN QUERY PLAN

体感するには大量データが必要です。sqlite3だけで完結する WITH RECURSIVE(自分自身を参照して行を生成する書き方。ここでは「10万行を作る呪文」と思ってください)で、10万人の社員テーブルを作ります。

$ sqlite3 perf.db
sqlite> CREATE TABLE big_employees (
   ...>   id INTEGER PRIMARY KEY,
   ...>   name TEXT NOT NULL,
   ...>   salary INTEGER NOT NULL
   ...> );
sqlite> WITH RECURSIVE seq(n) AS (
   ...>   SELECT 1
   ...>   UNION ALL
   ...>   SELECT n + 1 FROM seq WHERE n < 100000
   ...> )
   ...> INSERT INTO big_employees (id, name, salary)
   ...> SELECT n, 'emp' || n, 200000 + (n * 7) % 300000 FROM seq;
sqlite> SELECT count(*) FROM big_employees;
100000

次に、DBMSが「どんな作戦でこのクエリを実行するつもりか」を教えてくれる EXPLAIN QUERY PLAN を使います。実行計画と呼ばれるこの情報こそ、性能チューニングの羅針盤です。

sqlite> .timer on
sqlite> EXPLAIN QUERY PLAN SELECT * FROM big_employees WHERE salary = 350000;
QUERY PLAN
`--SCAN big_employees
sqlite> SELECT count(*) FROM big_employees WHERE salary = 350000;
1
Run Time: real 0.013 ...

SCAN という表示が「全行を順に調べます」というフルスキャン宣言です。ではsalary列にインデックスを作ってみます。CREATE INDEX 索引名 ON テーブル(列) です。

sqlite> CREATE INDEX idx_big_employees_salary ON big_employees(salary);
sqlite> EXPLAIN QUERY PLAN SELECT * FROM big_employees WHERE salary = 350000;
QUERY PLAN
`--SEARCH big_employees USING INDEX idx_big_employees_salary (salary=?)
sqlite> SELECT count(*) FROM big_employees WHERE salary = 350000;
1
Run Time: real 0.000 ...

表示が SCAN から SEARCH ... USING INDEX に変わりました。「全部めくる」から「索引で直行する」への作戦変更です。実行時間も1桁以上縮んでいます。10万行でこの差なら、1000万行では文字どおり千倍級の差になります。SCANかSEARCHか——実行計画を読むときは、まずこの一語を見てください。

✅ 範囲検索や並べ替えにも効く

インデックスは値の順に並んでいるので、WHERE salary >= 400000 のような範囲検索や、ORDER BY salary の並べ替えにも効きます。「=だけのための仕組み」ではありません。一方、WHERE name = 'emp500' のようにインデックスのない列の検索は、相変わらずSCANのままです。試して確かめてみてください。

7.3 インデックスが効かないパターン

せっかく貼ったインデックスが使われない、という落とし穴が3つあります。共通する原因は「索引に載っている形のまま比較していない」ことです。書名の索引があっても「書名を逆から読んだ結果」では引けないのと同じです。

パターン効かない書き方効く書き方への修正
① 列に関数を適用WHERE substr(hire_date, 1, 4) = '2024'WHERE hire_date >= '2024-01-01' AND hire_date < '2025-01-01'
② 中間一致LIKEWHERE name LIKE '%花子%'(先頭が不定)前方一致 LIKE '佐藤%' なら効く。中間一致が本当に必要なら全文検索の仕組みを検討
③ 型の不一致文字列の列に WHERE emp_code = 123(数値で比較)WHERE emp_code = '123' と列の型に合わせる

①は特に頻出です。インデックスに載っているのは hire_date の値そのものであって、「substrを通した後の値」ではありません。列を関数で加工した瞬間、DBMSは全行に関数を適用して確かめるしかなくなり、SCANに転落します。加工するなら比較する側(右辺)を加工し、インデックス列は裸で使う——これが合言葉です。

②の中間一致は、索引が「先頭の文字から順に並んでいる」ことを思い出せば納得できます。「『花子』を含む名前」は、五十音順の索引では探しようがないのです。③は比較のために全行で型変換が走るのが原因で、①の変種と言えます。

⚠️ 「インデックスを貼ったのに遅い」の第一容疑者はWHERE句の書き方

貼ったはずなのに遅いときは、思い込みで悩む前に EXPLAIN QUERY PLAN を実行してください。SCANのままなら、上の3パターンのどれかに当てはまっていないかWHERE句を点検します。「貼ってあるか」ではなく「使われているか」を確認するのが正しい順序です。

7.4 インデックスのコスト — タダではない

「そんなに速くなるなら全列に貼ればいい」と思うかもしれません。しかしインデックスは書き込みのたびに更新される別冊です。本の本文に1ページ足したら索引も直すように、INSERT・UPDATE・DELETEのたびに、その表に貼られたすべてのインデックスの木構造を更新する作業が発生します。

つまりインデックスは「読み取りを速くするために、書き込みの速度と容量を差し出す」交換です。貼る基準は「WHERE・JOIN・ORDER BYで頻繁に使う列」に絞ること。ログのように書き込みが主役のテーブルでは、特に慎重に選びます。

複合インデックスと列順の初歩

2列以上をまとめた複合インデックスも作れます。このとき列の順番が重要です。

CREATE INDEX idx_emp_dept_salary ON big_employees(dept_id, salary);

これは「部署ごとに給与順で並んだ索引」です。電話帳が「姓→名」の順に並んでいるのと同じで、WHERE dept_id = 20 AND salary >= 300000 には抜群に効きますが、WHERE salary >= 300000 だけ(2列目だけ)の検索には効きません。姓が分からないまま名だけで電話帳は引けないからです。複合インデックスは「先頭の列から順に使われる」——初歩としてこれだけ覚えてください。

7.5 遅いクエリとの付き合い方

実務で「画面が遅い」と言われたとき、腕の見せどころは修正の速さではなく手順の正しさです。

  1. 計測する — どのクエリが何秒かかっているかを数字で特定する(.timer on、本番ではDBMSのスロークエリログ)。「たぶんこれが遅い」で直し始めない。
  2. 実行計画を見る — 遅いと分かったクエリに EXPLAIN QUERY PLAN。SCANになっていないか、7.3節のパターンを踏んでいないか。
  3. 改善して再計測する — インデックス追加やWHERE句の書き直しをして、①と同じ方法で効果を数字で確認する。

推測で直さない、計測で直す。当てずっぽうの変更は、効かないだけでなく「複合インデックスの列順を壊す」「書き込みを遅くする」など状況を悪化させることさえあります。

N+1問題 — クエリは速いのにシステムが遅い

最後に、1本1本のクエリは速いのに全体が遅くなる有名なパターンを紹介します。「社員100人の一覧に部署名を添えて表示する」処理を、プログラム側のループでこう書いてしまうケースです。

-- 社員一覧を1回取得(1本)
SELECT id, name, dept_id FROM employees;
-- その後、ループで社員ごとに部署名を1件ずつ取得(100本)
SELECT name FROM departments WHERE id = 10;
SELECT name FROM departments WHERE id = 20;
-- ... これが人数分続く

一覧取得の1本+行数N本で「N+1問題」と呼びます。1本あたり数ミリ秒でも、往復100回ぶんの通信と処理が積み重なって画面全体は重くなります。解決はシンプルで、JOIN(第4章)を使って1本のSQLにまとめることです。

SELECT e.id, e.name, d.name AS dept_name
FROM employees e LEFT JOIN departments d ON e.dept_id = d.id;

N+1はプログラムからDBを呼ぶときに無意識に書いてしまいがちで、Python連携を学ぶ第8章でループの中にSELECTを書きそうになる場面として再登場します。「ループの中のSELECTは黄色信号」と覚えておいてください。

🛡️ セキュリティ・運用の視点 — 性能問題は可用性(CIAのA)の問題

遅いクエリを「使い勝手の問題」と軽く見てはいけません。フルスキャンを繰り返す重いクエリはCPUとディスクを占有し、他の全ユーザーの処理まで巻き添えにして、実質的なサービス停止=可用性(CIAのA)の侵害を引き起こします。さらに攻撃者の視点では、重いクエリを誘発できる画面——たとえば中間一致LIKEで全文検索する検索フォーム——は、安価にサービスを麻痺させられるDoS攻撃の入口です。検索条件の上限設定(期間指定の必須化、結果件数のLIMIT)、実行時間の上限(タイムアウト)、スロークエリの常時監視は、性能対策であると同時にセキュリティ対策でもあります。「速いシステムは守りも固い」のです。

まとめ

練習問題

問題 7-1

7.2節の perf.db(big_employeesテーブル)で、name列を条件にした SELECT * FROM big_employees WHERE name = 'emp77777'; の実行計画を確認してください。次にname列へインデックスを作成し、実行計画がどう変わるかを確認してください。

解答を見る
sqlite> EXPLAIN QUERY PLAN
   ...> SELECT * FROM big_employees WHERE name = 'emp77777';
QUERY PLAN
`--SCAN big_employees
sqlite> CREATE INDEX idx_big_employees_name ON big_employees(name);
sqlite> EXPLAIN QUERY PLAN
   ...> SELECT * FROM big_employees WHERE name = 'emp77777';
QUERY PLAN
`--SEARCH big_employees USING INDEX idx_big_employees_name (name=?)

インデックス作成前はSCAN(10万行の全件調査)、作成後はSEARCH ... USING INDEXに変わります。.timer on を付けて前後の実行時間を比べると、効果を数字でも確認できます。なお SELECT count(*) FROM big_employees WHERE name LIKE '%777%'; はインデックスを作った後でもSCANのままです(中間一致のため)。あわせて確認してみてください。

問題 7-2

company.dbのemployeesテーブルに CREATE INDEX idx_employees_hire_date ON employees(hire_date); を作成済みとします。次のSQLはこのインデックスが使われません。理由を説明し、インデックスが効く形に書き直してください。
SELECT * FROM employees WHERE substr(hire_date, 1, 4) = '2020';

解答を見る

理由: インデックスに載っているのは hire_date の生の値(例: '2020-04-01')であり、substr() を通した加工後の値('2020')ではないからです。列を関数で加工すると、DBMSは全行に関数を適用して比較するしかなくなり、フルスキャンになります。

書き直し: 「2020年に入社した」を範囲条件で表現し、インデックス列を裸のまま使います。

SELECT * FROM employees
WHERE hire_date >= '2020-01-01' AND hire_date < '2021-01-01';
sqlite> SELECT id, name, hire_date FROM employees
   ...> WHERE hire_date >= '2020-01-01' AND hire_date < '2021-01-01';
4|高橋 美咲|2020-04-01

結果は同じでも、実行計画はSCANからSEARCHに変わります(EXPLAIN QUERY PLANで確認できます)。上限を < '2021-01-01' とし、<= '2020-12-31' にしていない点にも注目してください。時刻付きデータ('2020-12-31 23:59:59'など)が混ざっても取りこぼさない、日付範囲の定石の書き方です。

問題 7-3

「毎秒大量のINSERTがある一方、検索は月次レポートでしか行わないログテーブルの全列にインデックスを貼る」という提案があります。この提案の問題点を、インデックスのコストの観点から説明してください。

解答を見る

インデックスはINSERTのたびに更新される「別冊の索引」なので、全列に貼ると1回のINSERTごとに列数ぶんの索引更新が発生し、このテーブルの主業務である書き込みが大幅に遅くなります。ディスク容量も索引のぶんだけ余計に消費します。一方、検索は月1回しか行われないため、支払うコストに対して得られる利益がほとんどありません。

対案: 月次レポートのWHERE句・GROUP BYで実際に使う列(たとえば日時列)に限定して貼る、あるいはレポート実行前にインデックスを作成しレポート後に削除する、レポート用の集計テーブルを別に持つ(第6章の非正規化パターン)といった方法が考えられます。いずれにせよ「計測して、使われ方に合わせて絞る」のが原則です。