この記事の対象読者
- SQL構文は書ける。インデックスという概念も知っている
- でも 実行計画は「DBが勝手に作ってくれるもの」だと思っていて、見たことがない
- 「性能を考慮した設計・実装ができること」に、答えられるようになりたい
実行計画はDBが勝手に作ってくれる、という認識、実は半分正解です。実行計画は確かにDBが勝手に作ってくれます。ただし 「勝手に作られた計画が良い計画とは限らない」 ので、それを人間が確認する手段が EXPLAIN です。この記事は初学者でも理解できるように記述しました。
前提知識は「SELECT 〜 WHERE の基本構文が読める」「インデックスという言葉を聞いたことがある」の2つだけです。LIKE や JOIN などの構文・関数は、出てきた場所でその都度説明します。
例はMySQLで書きますが、考え方はPostgreSQLでもOracleでも同じです(読み方のコマンドが違うだけで、概念は全DB共通です)。
1. 同じSQLでも、データの「取り方」は1つじゃない
まず具体例から。100万行の users テーブルから1人を探すSQLを考えます。
SELECT * FROM users WHERE email = 'taro@example.com';
このSQLをよく見ると、「emailがこれの人をください」としか書いてありません。「テーブルを頭から読め」とも「索引を使え」とも書いていない。つまり、同じSQLに対して、データの取りに行き方(探し方)は複数ありえます。代表的なのが次の2つです。
取り方A: フルテーブルスキャン(頭から全部読む)
users テーブル(100万行)
┌──────────────────────────────────┐
│ 1行目 → 違う │
│ 2行目 → 違う │
│ 3行目 → 違う │
│ ⋮ (100万行ぜんぶ確認) │
│ 731,204行目 → これだ! │
│ ⋮ (残りも一応最後まで見る) │
└──────────────────────────────────┘
= 本を1ページ目から全ページめくって探すのと同じ
取り方B: インデックススキャン(索引で引く)
インデックスの実体は B-tree(ビーツリー) という「常にソート済みの索引」です。
[ m ] ← 「探す文字はmより前?後?」
/ \
[ e ] [ t ] ← 3回くらい分岐をたどると…
/ \ / \
[a-d] [e-l] [m-s] [t-z] ← 目的の場所に到着
│
└→ taro@example.com → 731,204行目にあるよ
= 本の巻末索引で「た行 → taro → p.731204」と引くのと同じ
100万行でも、B-treeなら3〜4回の分岐で目的の行にたどり着きます。取り方Aとの差は文字通り数十万倍になり得ます。
同じSQL、同じ結果。でも裏側の仕事量はまるで違う——「クエリが速いか遅いか」は、SQLの文面ではなく、どの取り方が選ばれたかで決まるわけです。
2. 取り方を決める係が「オプティマイザ」、決まった手順が「実行計画」
では、取り方AとBのどちらでいくかは誰が決めるのか。あなたではなく、DBの中にいる「オプティマイザ」という部品が自動で決めます。そして、オプティマイザが決めた具体的な手順のことを実行計画と呼びます。
カーナビに例えるのが一番わかりやすいです。
| カーナビ | データベース |
|---|---|
| 目的地を入力する(「東京駅へ」) | SQLを書く(「emailがこれの人をください」) |
| ルートを計算するナビ本体 | オプティマイザ |
| 提案されたルート(「高速で行く」「下道で行く」) | 実行計画(「インデックスで引く」「全件読む」) |
| ルート計算の材料になる地図・渋滞情報 | 統計情報(後述) |
| 出発前にルート画面を確認する | EXPLAIN ← この記事の主役 |
統計情報 = ナビの地図データ
オプティマイザはどうやって「インデックスで引く方が速そう」と判断するのか。実は、DBは普段からテーブルのプロフィールをメモしています。
- このテーブルはだいたい100万行ある
- emailカラムの値はほぼ全部バラバラ(重複が少ない)
- statusカラムは3種類の値しかない
これが統計情報です。オプティマイザはこのメモを見て「emailで絞るならインデックスが効く。100万行のうち1行しか残らないはずだ」と見積もります。
ここで大事な帰結が1つ。地図データが古いカーナビは変なルートを案内します。同じように、統計情報が実態とズレていると(大量データ投入直後など)、オプティマイザは遅い取り方を選んでしまうことがあります。「DBが勝手に選ぶ計画は、いつも正しいとは限らない」——だから人間が確認する手段が用意されているのです。
EXPLAIN = 出発前にルート画面を見ること
カーナビは普段ルートを疑わずに走りますが、「あれ、いつもより明らかに遅いぞ?」というときはルート画面を見て「なんで下道を走ってるんだ」と確認しますよね。DBも同じで、クエリが遅いときに「どんな計画で実行されている(される予定)か」を表示させるコマンドが EXPLAIN です。
3. EXPLAINを使ってみる:頭に EXPLAIN と付けるだけ
使い方は拍子抜けするほど簡単で、調べたいSQLの頭に EXPLAIN と付けて実行するだけです。実際にデータを取りに行かず、計画だけを表示してくれます(出発せずにルート画面だけ見るイメージ)。
ここでは「インデックスを張る前と後で、計画がどう変わるか」のビフォーアフター実験をやってみます。
ステップ①【ビフォー】インデックスがない状態でEXPLAIN
EXPLAIN SELECT * FROM users WHERE email = 'taro@example.com';
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
| 1 | SIMPLE | users | ALL | NULL | NULL | NULL | NULL | 998,432 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
type: ALL、つまり取り方A(フルスキャン)が選ばれる計画です。索引がないので、オプティマイザには全件読む以外の選択肢がありません。
ステップ② インデックスを張る(ここでDBの状態を変える)
CREATE INDEX idx_users_email ON users(email);
この1行が「インデックスを張る」操作の実体です。EXPLAINと違って「見るだけ」ではなく、emailカラムのB-tree索引をこの時点で実際に構築します(カーナビの例えなら「新しい高速道路を建設した」に相当。大きいテーブルでは構築に時間がかかるので、本番DBで気軽に撃つものではありません)。
ステップ③【アフター】まったく同じEXPLAINをもう一度
EXPLAIN SELECT * FROM users WHERE email = 'taro@example.com';
+----+-------------+-------+------+-----------------+-----------------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+-----------------+-----------------+---------+-------+------+-------+
| 1 | SIMPLE | users | ref | idx_users_email | idx_users_email | 1022 | const | 1 | NULL |
+----+-------------+-------+------+-----------------+-----------------+---------+-------+------+-------+
①と③のEXPLAIN文は一字一句同じです。それでも結果が変わったのは、②でテーブル側の状態(索引の有無)が変わったから。オプティマイザが新しい索引に気づき、取り方B(インデックス)を選ぶ計画に切り替えました。実行計画はSQLの文面だけでなく、DBの状態によって決まる——これがこの実験で一番伝えたいことです。
列がたくさんありますが、最初は3列だけ見れば十分です。
見るべき3列
| 列 | 意味 | 上の例での変化 |
|---|---|---|
| type | データの取り方(第1章のA/Bどっちか) |
ALL(フルスキャン=取り方A)→ ref(インデックス=取り方B) |
| rows | 読む予定の行数(見積もり) | 998,432行 → 1行 |
| key | 実際に使われるインデックス |
NULL(使わない)→ idx_users_email
|
「100万行読む計画」が「1行読む計画」に変わったのが、数字ではっきり見えます。これが実行計画を見るということです。
type列 = 「どんな探し方をするか」のラベル
第1章で紹介した「取り方A(全件走査)/ 取り方B(インデックス)」の正式なラベルがこのtype列です。実際には取り方Bが「索引をどう使うか」で細分化されています。速い・遅いの正体は単純で、読む行数が少ない探し方ほど速い、それだけです。
100万人の会員がいる図書館で、名簿(テーブル本体)と五十音索引(インデックス)を使って人を探す例えで、読む量が少ない順に並べます。
const … 会員番号で1人を特定。「No.5の人」→ 即決まる 【読む量: 1行】
ref … 索引で「佐藤さん」を引く。同姓が数人いるかも 【読む量: 該当の数行】
range … 索引で「サ行の人ぜんぶ」をまとめて見る 【読む量: 該当範囲の行】
index … 索引を1ページ目から最後まで全部読む 【読む量: 全員分の索引】
ALL … 名簿本体を1ページ目から全部読む(=取り方A) 【読む量: 全員分の本体】
どんなSQLでどれが出るのか:
| type | どういうとき出るか | SQL例 |
|---|---|---|
const |
主キーやUNIQUEの一致検索。1行に確定 | WHERE id = 5 |
eq_ref |
JOINで、相手テーブルを主キー/UNIQUEで1行ずつ引く | JOIN users u ON u.id = o.user_id |
ref |
普通のインデックスの一致検索。同じ値が複数ありうる |
WHERE email = '...'(emailに索引あり) |
range |
インデックスで範囲検索 | WHERE created_at >= '2026-01-01' |
index |
索引を全部読む(絞り込みに使えていない) | 絞り込み条件なしで索引内の列だけ取る場合など |
ALL |
索引が使えず、テーブル本体を全部読む | 索引がない/効かない書き方をした場合 |
-
constとrefの違いは「1行に確定するか、数行ありうるか」。主キーは重複しないので探した瞬間1行に決まり、普通のインデックスは同じ値が複数ありうるので該当分を読みます -
indexが「注意」扱いなのも読む量で説明できます。行数としては全件走査と同じですが、索引はテーブル本体より小さいぶんALLよりは少しマシ、という位置づけです
実務での見方は2段階だけで、暗記は不要です。
- 大きいテーブルで
ALLかindexが出ていたら「索引が効いていない」サイン → そこが調査対象 -
const/ref/rangeなら索引は効いている → typeは問題なし。それでも遅ければ他(rowsの多さ、後述のN+1など)を疑う
Extra列 = 「探す作業とは別に発生する追加作業」のメモ欄
type列が「行をどう探すか」だったのに対し、Extra列には「探す以外に、こんな追加作業が発生します」というDBの独り言が書かれます。ここに次の2つが出ていたら、行を読むコストとは別のコストが乗っている警告です。
Using filesort = 「取ってきた後に、並べ替え作業をします」
-- INDEX(team_id) だけがある状態で
SELECT * FROM orders WHERE team_id = 1 ORDER BY created_at DESC;
インデックスで team_id=1 の行は見つかるものの、見つけた行は created_at 順には並んでいません。そこでDBが取得後に自力で並べ替えます。これが Using filesort(名前に反して、必ずしもディスクのファイルを使うわけではなく「ソート作業」の歴史的な呼び名です)。
図書館の例えなら「索引で佐藤さんを全員見つけたが、依頼が『年齢順に並べて』だったので、見つけた後に机の上で並べ替えている」状態。10人なら一瞬ですが、10万行なら並べ替えだけで重くなります。
根治策は、索引の並び順をORDER BYに合わせることです。
CREATE INDEX idx ON orders(team_id, created_at);
-- 索引が「team_idごとに、created_at順で」並んでいるので、
-- 取り出した時点ですでにソート済み → filesortが消える
Using temporary = 「集計のための作業台(一時テーブル)を作ります」
SELECT status, COUNT(*) FROM orders GROUP BY status;
「statusごとに数えて」と言われたDBは、集計用のメモ用紙(一時テーブル)を用意して、行を読みながら正の字を書いていく必要があります。これが Using temporary。データが多いと作業台自体が巨大化し、メモリに収まらずディスクに溢れると一気に遅くなります。GROUP BY対象の列への索引で回避できることが多いです。
Extra列の早見表
| 表示 | DBの独り言 | 対処 |
|---|---|---|
Using filesort |
「取った後に並べ替えます」 | 索引の並び順をORDER BYに合わせる |
Using temporary |
「集計用の作業台を作ります」 | GROUP BY対象に索引を検討 |
Using index |
「索引だけで答えられます(本体を読みません)」 | 良い表示。後述のカバリングインデックス |
Using where |
「WHERE条件で絞ってます」 | ただの事実報告。心配不要 |
さきほどのビフォーアフター実験の①にも Using where が出ていましたが、あれは無害な報告です。危険信号は filesort と temporary の2つ、と覚えれば十分です。
補足: ここまでのEXPLAINは「走らせる前の予定表」です。EXPLAIN ANALYZE(MySQL 8.0+ / PostgreSQL)を使うと、実際に走らせた実測タイム付きの結果が見られます。Oracleでは EXPLAIN PLAN FOR + DBMS_XPLAN が同じ役割です。コマンドが違うだけで、見るポイント(探し方と行数)は同じです。
4. インデックスが「あるのに使われない」4大パターン
理解で一番差がつくのがここです。「インデックスを張れば速くなる」で止まらず、張っても効かないケースを知っていると強いです。
パターン1: カラムに関数や計算をかけている
YEAR() はSQL組み込みの関数で、日時から年だけを取り出します(YEAR('2026-03-15 10:23:45') → 2026)。created_at には日時まるごとが入っているので、「2026年の注文が欲しい」を素直に書くとこうなります。
-- NG: 読みやすいし結果も正しい。でも全行に対してYEAR()を
-- 計算しないと比較できない → インデックス無効
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- OK: 同じ意味を「生の値の範囲」に翻訳し直す
-- 「2026年」=「2026-01-01以降、かつ2027-01-01より前」
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
理屈: インデックスは「カラムの生の値」でソートされた索引です。YEAR(created_at) の結果(年だけの値)は索引に載っていないので、全行計算するしかなくなります。「索引が引けるのは、索引に載っている形のままの値だけ」 と覚えてください。
図書館の例えなら、五十音索引があるのに「名前を逆から読んだ結果が『うろたけ』の人」と聞かれた状態。索引は普通の読み順で並んでいるので使えず、全員の名前を1人ずつ逆読みするしかありません。「竹内さんを探して」と聞き方を変えれば索引で一発——条件の書き方を索引の並びに合わせるのがこのパターンの本質です。
NG側は悪意も手抜きもない、むしろ丁寧に見える書き方です。だからこそ誰もが自然に書いてしまう、4パターン中いちばん遭遇率の高い罠です。
パターン2: 前方一致でないLIKE
LIKE は「あいまい検索」の構文で、% は「ここは何が入ってもいい」という記号です。
WHERE email LIKE 'taro%' -- 「taroで始まる」(前方一致)
WHERE email LIKE '%@example.com' -- 「@example.comで終わる」(後方一致)
WHERE email LIKE '%taro%' -- 「taroをどこかに含む」(部分一致)
このうち、インデックスが使えるのは前方一致だけです。
-- OK: 前方一致は索引が使える
WHERE email LIKE 'taro%'
-- NG: 後方一致・部分一致は使えない
WHERE email LIKE '%@example.com'
理屈: インデックスは値を先頭の文字から順に並べた索引です。「taroで始まる」ものは索引の中で taro... のひとかたまりの区間に固まっているので、そこだけ読めば済みます(typeでいうと range)。一方「@example.comで終わる」ものは、先頭文字がバラバラなので索引のあちこちに散らばっており、結局全部見るしかありません。
辞書で「た」から始まる言葉はすぐ引けるのに、「〜んご」で終わる言葉(りんご、だんご…)を探すには全ページめくるしかないのと同じです。
パターン3: 暗黙の型変換
前提の確認から。SQLではクォートで囲むと文字列、囲まないと数値として扱われます。
'09012345678' -- 文字列(文字の並びとして扱われる)
09012345678 -- 数値(9012345678という数として扱われる。先頭の0は消える)
電話番号カラム phone は、数字に見えても**文字列型(VARCHAR)**で作るのが普通です(先頭の0を保持したい、計算に使わない、ハイフンが入ることもある、等の理由)。ところが数字に見えるせいで、ついクォートなしで書いてしまう——これが罠です。
-- NG: 文字列カラムと数値を比較している
WHERE phone = 09012345678
-- OK: 型を合わせて文字列同士で比較する
WHERE phone = '09012345678'
NGの方でもエラーにはならず、DBが気を利かせて「全行のphoneを数値に変換してから比較」してくれます。結果も正しい。でもこれ、パターン1とまったく同じ構図です——索引には文字列のままの値しか載っていないので、「数値に変換した後の値」では索引が引けず、全行変換のフルスキャンになります。
正しく動くのでコードレビューでも見逃されやすく、実際の障害調査でよく見つかる地味な定番です。
パターン4: 複合インデックスの「左端ルール」
複合インデックスとは、INDEX(team_id, created_at) のように複数のカラムをセットにして1本にした索引です(別々に2本張るのとは違います)。よく使う絞り込みの組み合わせ(「このチームの、この期間の」など)を1本でまかなえる強力な道具ですが、左のカラムから順にしか使えないという制約があります。
INDEX (team_id, created_at) の中身のイメージ:
「team_id順に並べて、同じteam_idの中では created_at 順」
team_id=1, 2026-01-05
team_id=1, 2026-03-12 ← team_id を決めれば created_at も並んでいる
team_id=1, 2026-06-01
team_id=2, 2026-01-02 ← でも created_at だけで見ると全体はバラバラ
team_id=2, 2026-05-20
-- OK: 左端の team_id を使っている
WHERE team_id = 1
WHERE team_id = 1 AND created_at >= '2026-01-01'
-- NG: 左端を飛ばして created_at だけ → インデックス使えない
WHERE created_at >= '2026-01-01'
「電話帳は姓→名の順。姓が分からないのに名だけで探せない」 と覚えるのが定番です。
5. アプリ側の定番の罠:N+1問題
性能問題の原因は、SQL単体ではなくアプリのコードの書き方にあることも多いです。代表がN+1問題。
多くのアプリはSQLを手書きせず、ORM(プログラムのコードからSQLを自動生成してくれるライブラリ。JavaならJPA、RailsならActiveRecord、Node.jsならPrismaなど)を使っています。便利なのですが、「チーム一覧と、各チームのメンバー数を表示する」処理をORMで素直に書くと、裏でこういうSQLの流れになりがちです。
1回1回のクエリは高速でも、回数が多いこと自体が遅さの原因になります(クエリごとに通信の往復コストがかかるため)。
対策: ループでクエリを撃たず、1回のSQLにまとめるか、ORMの一括読み込み機能(JPAなら JOIN FETCH、Railsなら includes など)を使う。
「1回のSQLにまとめる」の例がこれです。
-- 101回 → 1回に
SELECT t.id, t.name, COUNT(m.id) AS member_count
FROM teams t
LEFT JOIN members m ON m.team_id = t.id
GROUP BY t.id, t.name;
初見の構文が3つあるので日本語に訳しておきます。
| 構文 | 意味 |
|---|---|
LEFT JOIN members m ON m.team_id = t.id |
teamsの各行に、team_idが一致するmembersの行をくっつける(メンバー0人のチームも消さずに残すのがLEFT) |
GROUP BY t.id, t.name |
くっつけた結果をチームごとの束にまとめる |
COUNT(m.id) |
束ごとにメンバーの数を数える |
つまり「チームとメンバーを突き合わせて、チームごとに人数を数える」を、アプリでループする代わりにDBに1回で全部やらせるわけです。DBはこういう集合処理が得意なので、101回の往復より圧倒的に速くなります。
厄介なのは、EXPLAINでは見つからないこと(1本1本のSQLは健全なので)。発見方法は「アプリのクエリログを見て、同じ形のSQLが大量に流れていないか確認する」です。「開発環境ではデータが10件で気づかず、本番で1万件になって発覚」という典型的な事故パターンでもあります。
6. 一歩進んだ武器:カバリングインデックス
この章の前提を1つ。インデックスの中には、対象カラムの値そのものがコピーとして入っています。INDEX(team_id, created_at) を作ると、DBの中に実際こういうデータ構造ができます。
インデックスの中身(team_id順・created_at順に整列済み)
┌────────────────────────────────────┬──────────────┐
│ team_id=1, created_at=2026-01-05 │ → 本体の行712 │
│ team_id=1, created_at=2026-03-12 │ → 本体の行88 │
│ team_id=1, created_at=2026-06-01 │ → 本体の行501 │
│ team_id=2, created_at=2026-01-02 │ → 本体の行33 │
└────────────────────────────────────┴──────────────┘
ここに値そのものが入っている こっちは場所のメモ
つまり team_id と created_at の値は、テーブル本体と索引の2か所に存在します。ということは——クエリが欲しい列が全部索引の中にあるなら、本体を見に行く必要がそもそもないわけです。
通常: 索引で場所を特定 → テーブル本体へ行って行を読む(2段階)
カバリング: 欲しい列が全部索引の中にある → 本体に行かない(1段階)
図書館の索引カードに「名前と会員番号」が書いてあるとすると:
- 「佐藤さんの会員番号を教えて」→ カードを見るだけで答えられる。本棚まで歩かない(カバリング)
- 「佐藤さんの住所を教えて」→ 住所はカードに載っていないので、本体の名簿を見に行くしかない(通常の2段階)
SQLで書くとこうなります。
-- INDEX(team_id, created_at) がある前提
SELECT * FROM orders WHERE team_id = 1;
-- ↑ statusもamountも要る → 索引に無い列がある → 本体行きが必須(2段階)
SELECT team_id, created_at FROM orders WHERE team_id = 1;
-- ↑ 欲しい列が全部索引の中にある → 本体に行かない(1段階)
-- → EXPLAINのExtraに「Using index」と出たらカバリング成功のサイン
SELECT * は「全カラムください」なので、索引に入っていない列が必ず混ざり、書いた時点でカバリングは成立しません。「必要な列だけ書く習慣」は行儀の問題ではなく、本体アクセスを丸ごと省略できるチャンスを残すための実利です。
よくある疑問3つ(インデックスの実体について)
Q. 上のSELECT文のどこにもインデックスが出てこないけど?
出てこないのが正常です。SQLには「何が欲しいか」しか書かず、索引を使うかどうかは実行のたびにオプティマイザが勝手に判断します(第2章のカーナビ:目的地を入れるだけで、高速を使うかはナビが決める)。SQLの見た目からは索引を使うか絶対に分からない——だからEXPLAINという確認手段があるわけです。
Q. SELECTを発行するとインデックス化されるの?
されません。索引が作られるのは事前に CREATE INDEX した1回だけ(第3章のステップ②)。SELECTは既にある索引を「利用」するだけで、何百万回発行してもDBの状態は変わりません。
Q. 索引用にデータを持つなら、2重管理では?
はい、まさに2重管理です。インデックスとは「検索を速くするために、意図的にデータを2重に持つ」仕組みで、容量コスト(行数に比例して索引の実体が増える)と更新コスト(INSERT/UPDATE/DELETEのたび本体と索引の両方を書き換える)が実在します。ただし本体と索引の同期はDBが自動で完全保証するので、人間の管理の手間はありません。この「読むとき得・書くとき損」のトレードオフが、次章①「インデックスはタダではない」の正体です。
7. 「性能を考慮した設計」= テーブル設計時点での話
ここまでは「書いたSQLを速くする」話でしたが、求人票の「性能を考慮した設計」はテーブル設計の時点の判断も含みます。代表的な観点を3つだけ。
① インデックスはタダではない
インデックスは検索を速くする代わりに、INSERT/UPDATE/DELETEのたびに索引の更新コストがかかります(本の巻末索引は、本文を書き換えるたびに直す必要がある)。「とりあえず全カラムに張る」は逆効果で、実際のWHERE句・JOIN・ORDER BYに合わせて必要な分だけ張るのが設計です。
② 正規化と、あえての非正規化
正規化とは「同じ情報を1か所にだけ持つ」設計の基本ルールです。
正規化した形(基本形) 非正規化した形(あえてコピーを持つ)
orders users orders
┌────┬─────────┐ ┌────┬──────┐ ┌────┬─────────┬───────────┐
│ id │ user_id │──引く──→ │ id │ name │ │ id │ user_id │ user_name │
│ 1 │ 42 │ │ 42 │ 太郎 │ │ 1 │ 42 │ 太郎 │
│ 2 │ 42 │ └────┴──────┘ │ 2 │ 42 │ 太郎 │
└────┴─────────┘ └────┴─────────┴───────────┘
名前はusersに1か所だけ。 注文のたびに名前のコピーを持つ。
改名しても1行直せば済む 表示はJOIN不要で速いが、
改名時は全注文行を直す必要がある
基本は正規化です。しかし「注文一覧を表示するたびに毎回JOINでusersを引くのが重い」なら、あえて右の形にする(非正規化)判断もあります。第6章のQ&Aで見た「インデックスは意図的な2重持ち」と同じ発想で、読む速度のためにコピーを持ち、そのぶん更新の手間と不整合のリスクを引き受けるトレードオフです。この損得を意識して選べることが「設計ができる」ということです。
③ データ量の見積もりとパーティショニング
「このログテーブルは月100万行増える。3年で3,600万行。インデックスがあっても古いデータの削除が重くなる」→ 月ごとにテーブルを内部分割する(パーティショニング)、古い月はまとめて切り離す、といった将来のデータ量から逆算する設計です。
8. まとめ
Q. 実行計画とは何ですか?見たことはありますか?
SQLは「何が欲しいか」だけを書く言語で、実際のデータの取り方(フルスキャンかインデックスか等)はDBのオプティマイザが統計情報をもとに決めます。その「取り方の手順書」が実行計画です。MySQLならSQLの頭にEXPLAINを付けて確認します。主に見るのはアクセスタイプ(typeがALLならフルスキャンなので危険信号)、読む行数の見積もり(rows)、使われるインデックス(key)の3点です。
Q. クエリが遅いとき、どう調査しますか?
まずスロークエリログ(実行に一定時間以上かかったSQLをDBが自動記録するログ)で遅いSQLを特定し、EXPLAINで実行計画を確認します。フルスキャンになっていればインデックスの追加を検討しますが、インデックスがあっても、カラムへの関数適用・前方一致でないLIKE・型の不一致・複合インデックスの左端を使っていない、などの理由で効かないケースがあるので、SQLの書き方も確認します。クエリ単体が健全なら、N+1のようにアプリ側で回数を撃ちすぎていないかをクエリログで確認します。
Q. 性能を考慮した設計とは?
実際の検索条件に合わせたインデックス設計(張りすぎは更新コストになるので必要な分だけ)、参照性能のためのあえての非正規化の判断、将来のデータ量を見積もったパーティショニング等の検討、といったテーブル設計時点での性能配慮のことだと理解しています。
今日からできる練習
- 手元の開発DBで、適当なSELECT文の頭に
EXPLAINを付けて実行してみる -
typeとrowsを見る - WHERE句のカラムにインデックスを張って、もう一度EXPLAINして数字の変化を見る
「インデックスを張ったらrowsが998,432→1になった」を一度自分の目で見ると、この記事の内容が全部つながります。面談でも「実際にEXPLAINで確認しながら開発しています」と実体験として話せるようになるので、ぜひ一度試してみてください。