MySQL | SQLite経験者向け、30日で習得するMySQL:パフォーマンスと設計 - Day18 クエリチューニング①

SQL MySQL
スポンサーリンク

Day18 後半のゴール

「“なんとなく速くなった”ではなく“なぜ速くなったか”を説明できるようになる」

前半で「遅いSQLに出会ったときの考え方」と「EXPLAINでの見える化」をやりました。
後半では、実際に「どう書き換えるか」「どんなインデックスを足すか」を、具体例で踏み込みます。

ここでのゴールは、次の状態です。

この変更で何が変わり、なぜ速くなったのかを、自分の言葉で説明できる

これができると、チューニングが「勘」ではなく「再現可能なスキル」になります。


例題の続き:orders × users の遅いクエリを本気で直す

「インデックス追加 → EXPLAIN 再確認、までを一気に見る」

前半で扱った「アクティブユーザーの注文履歴一覧」をもう一度出します。

SELECT
  o.id,
  u.name,
  o.total,
  o.created_at
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'active'
ORDER BY o.created_at DESC
LIMIT 20;
Python

インデックスがない状態の EXPLAIN は、こういう“重い顔”でした。

users は type = ALL, key = NULL, rows = 100000
orders は type = ALL, key = NULL, rows = 500000, Extra に Using filesort

ここから、改善の一手を実際に打っていきます。

orders.user_id にインデックスを貼る

「JOIN の片側をまず軽くする」

最初の一手として、JOIN に使う orders.user_id にインデックスを貼ります。

CREATE INDEX idx_orders_user_id
ON orders (user_id);
Python

これで EXPLAIN を取り直すと、だいたいこう変わります。

users は相変わらず type = ALL, key = NULL
orders は type = ref, key = idx_orders_user_id, rows = 5000

つまり、

users はまだ全件スキャンしている
orders は、users から来た user_id に対してインデックス検索できるようになった

という状態です。

ここでのポイントは、「一手で全部を直そうとしない」ことです。
JOIN の片側だけでもインデックスが効けば、orders のフルスキャンは避けられます。

users.status にインデックスを貼るかどうか

「値の偏りを意識して“貼る価値”を考える」

次に候補になるのが users.status です。

CREATE INDEX idx_users_status
ON users (status);
Python

これを貼ると、EXPLAIN はこう変わる可能性があります。

users が type = ref, key = idx_users_status, rows = 30000 などになる

ただし、ここで効き方はデータ分布に依存します。

active が全体の 90% なら、インデックスを使っても結局ほとんどの行を読むので、
オプティマイザが「インデックスを使わない(ALL のまま)」と判断することもあります。

active が全体の 10% なら、インデックスでかなり絞れるので、
type = ref / rows が小さめ、という“良い顔”になります。

ここで大事なのは、

インデックスを貼るかどうかは、「よく使うか」だけでなく「どれくらい絞れるか」も見る

という視点です。

複合インデックス (user_id, created_at) を検討する

「JOIN と ORDER BY を一気に助ける」

さらに一歩進めると、orders 側に複合インデックスを貼る案が出てきます。

CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at);
Python

このインデックスは、

JOIN のための user_id
ORDER BY のための created_at

をまとめてカバーできます。

EXPLAIN がうまくハマると、orders の行はこうなります。

type = ref
key = idx_orders_user_created
rows がかなり小さくなる
Extra から Using filesort が消える

つまり、

アクティブユーザーの user_id に対して
そのユーザーの注文をインデックスで取り
その中で created_at の降順がすでにインデックス順になっている

という状態です。

ここでの重要ポイントは、「複合インデックスの並び順」です。
このクエリでは、

まず user_id で絞りたい
その中で created_at で並べたい

ので、(user_id, created_at) の順が理にかなっています。


クエリを書き換えて速くするパターン

「同じ意味を、インデックスが効きやすい形で書き直す」

インデックスだけでなく、「書き方の工夫」で速くなることも多いです。

不要な列を SELECT しない

「“とりあえず SELECT *”を卒業する」

例えば、さっきのクエリをアプリ側でこう使っていたとします。

画面に表示するのは、注文ID・ユーザー名・合計金額・注文日時だけ

それなのに、SQLはこう書いている。

SELECT *
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'active'
ORDER BY o.created_at DESC
LIMIT 20;
Python

これを、必要な列だけに絞ります。

SELECT
  o.id,
  u.name,
  o.total,
  o.created_at
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'active'
ORDER BY o.created_at DESC
LIMIT 20;
Python

一見、パフォーマンスへの影響は小さそうに見えますが、
実は「インデックスだけで完結できるかどうか」に効くことがあります。

例えば、users.name も orders.total もインデックスに含まれているような設計なら、
Extra に「Using index」が出て、テーブル本体を読まずに済むケースも出てきます。

Day18 の段階では、

SELECT * は「とりあえず」ではなく「最後の手段」くらいに思っておく

くらいの意識を持っておくと、後々効いてきます。

LIMIT と WHERE の順番を意識する

「“まず絞ってから LIMIT”が基本」

次のようなクエリを考えます。

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20;
Python

これは「全注文の中から新しい20件」です。
これ自体は悪くありません。

しかし、もし本当の要件が、

アクティブユーザーの注文の中から新しい20件

なら、本来はこう書くべきです。

SELECT o.*
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'active'
ORDER BY o.created_at DESC
LIMIT 20;
Python

「LIMIT 20 だから軽いでしょ」と思いがちですが、
WHERE で絞る前に大量の行を並べ替えてから LIMIT していると、
内部的にはかなり重い処理になっていることがあります。

EXPLAIN の Extra に「Using filesort」が出ているときは、

本当に必要な行数まで、先に WHERE で絞れているか?

を疑ってみるとよいです。


集計クエリのチューニングの入り口

「COUNT(*) や GROUP BY が重いときの最初の視点」

一覧だけでなく、集計系のクエリもよく「遅い」と言われます。

例えば、次のようなクエリです。

SELECT user_id, COUNT(*)
FROM orders
GROUP BY user_id
ORDER BY COUNT(*) DESC
LIMIT 10;
Python

「注文数が多いユーザーTOP10」を出すクエリです。

EXPLAIN すると、こうなりがちです。

table: orders
type: ALL
key: NULL
rows: 500000
Extra: Using temporary; Using filesort

つまり、

orders を全件スキャン
一時テーブルを作って GROUP BY
さらに filesort で並べ替え

という、なかなか重い動きです。

ここでの最初の一手はシンプルです。

GROUP BY に使っている user_id にインデックスを貼る

CREATE INDEX idx_orders_user_id
ON orders (user_id);
Python

これで EXPLAIN を取り直すと、

type = index
key = idx_orders_user_id
rows は同じでも、Extra から Using temporary が消えることがある

など、少なくとも「一時テーブルを作らない」方向に寄せられます。

集計クエリのチューニングは奥が深いですが、
Day18 では、

GROUP BY / ORDER BY に出てくるカラムにもインデックスを意識する

という入口だけ押さえておけば十分です。


「改善前/改善後」を必ず EXPLAIN で比べる

「“速くなった気がする”を卒業する」

最後に、チューニングの一番大事な習慣をはっきりさせておきます。

クエリを変えたら、必ず EXPLAIN を取り直す

これを徹底してください。

改善前の EXPLAIN と、改善後の EXPLAIN を並べて見て、

type が ALL → ref / range / index などに変わったか
key が NULL → インデックス名に変わったか
rows が明らかに減っているか
Extra から Using filesort / Using temporary が消えたか

を確認します。

これができると、

このインデックス追加で、orders のフルスキャンがなくなった
この書き換えで、filesort が消えてソートコストが減った

といった説明ができるようになります。

それはそのまま、「次のプロジェクトでも再現できるスキル」になります。


Day18 後半のまとめ

遅いSQLを本気で改善するときは、まず EXPLAIN で「どのテーブルがフルスキャンか」「どこで filesort / temporary が発生しているか」を特定し、JOIN・WHERE・ORDER BY に出てくるカラムからインデックス候補を洗い出し、例えば orders.user_id に単一インデックスを貼る→必要に応じて (user_id, created_at) の複合インデックスを貼る、といった形で「JOIN と ORDER BY を同時に助ける」打ち手を順番に試し、そのたびに EXPLAIN を取り直して type / key / rows / Extra の変化を確認する。
同時に、SELECT * をやめて必要な列だけに絞ることで「インデックスだけで完結する(Using index)」余地を作ったり、WHERE user_id IN (SELECT ...) を JOIN に書き換えてインデックスが効きやすい形に変えたり、GROUP BY / ORDER BY に使うカラムにもインデックスを意識することで、Using temporary / Using filesort を減らしていくなど、「インデックス追加」と「クエリの書き方の工夫」をセットで考えるのが、Day18 で身につけたいクエリチューニングの核心になる。

タイトルとURLをコピーしました