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

SQL MySQL
スポンサーリンク

Day19 前半のゴール

「JOIN が遅いときに“どこが悪いか”を冷静に切り分けられるようになる」

Day18 では「遅いSQL全般」のチューニングをやりました。
Day19 はその中でも、実務で一番ハマりやすいテーマ――JOIN の改善にフォーカスします。

前半のゴールはこうです。

JOIN が遅いときに、原因候補を言葉で列挙できる
EXPLAIN を見て「どのテーブル側の JOIN が重いか」を特定できる
JOIN を書くときに“やってはいけないパターン”を避けられる

ここまで行けば、後半で「インデックスとJOIN順の最適化」に踏み込むときに、かなり楽になります。


そもそも JOIN が重くなりやすい理由

「“掛け算”になるから、雑にやると一気に爆発する」

JOIN が遅くなりやすいのは、構造的な理由があります。

1つのテーブルだけなら、「行数 × 1回のアクセスコスト」で済みます。
でも、JOIN は「テーブル同士の組み合わせ」を作る処理です。

イメージとしてはこうです。

users が 10万行
orders が 50万行

この2つを、

users.id = orders.user_id

で JOIN するとき、
もしインデックスがなくて、両方フルスキャンしていたら、

users の1行ごとに orders を全部なめる
つまり 10万 × 50万 の組み合わせを“検討”する

という、ほぼ地獄みたいなことになります(実際には最適化されますが、感覚として)。

だからこそ、JOIN は「どのテーブルから先に読むか」「どのカラムにインデックスがあるか」で、
体感速度が桁違いに変わります。


例題:典型的な3テーブルJOIN

「users × orders × products の“よくある画面”」

次のようなテーブルを考えます。

CREATE TABLE users (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(50)  NOT NULL
) ENGINE=InnoDB;

CREATE TABLE orders (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  user_id    INT          NOT NULL,
  created_at DATETIME     NOT NULL
) ENGINE=InnoDB;

CREATE TABLE order_items (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  order_id   INT          NOT NULL,
  product_id INT          NOT NULL,
  quantity   INT          NOT NULL
) ENGINE=InnoDB;

CREATE TABLE products (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  price      INT          NOT NULL
) ENGINE=InnoDB;
Python

ここで、こんな画面を作りたいとします。

「特定ユーザーの注文履歴(商品名・数量・金額込み)を新しい順に表示」

それに対応するSQLは、だいたいこうなります。

SELECT
  o.id          AS order_id,
  o.created_at  AS ordered_at,
  p.name        AS product_name,
  oi.quantity,
  p.price,
  (oi.quantity * p.price) AS amount
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p     ON oi.product_id = p.id
JOIN users u        ON o.user_id = u.id
WHERE u.id = 123
ORDER BY o.created_at DESC;
Python

実務でめちゃくちゃよく見る形です。
そして、インデックスが弱いと、だいたい「なんか遅い」です。


JOIN が遅いときにまず見るべきポイント

「JOIN 条件のカラムにインデックスがあるか?」

JOIN が遅いとき、最初に疑うべきはここです。

JOIN の ON 句に出てくるカラムに、インデックスが貼られているか

さっきのクエリで言えば、

o.user_id
oi.order_id
oi.product_id
p.id(主キーなのでOK)
u.id(主キーなのでOK)

です。

もし、orders.user_id にインデックスがなければ、

「ユーザーIDで絞りたいのに、orders を全件なめる」

ことになります。

もし、order_items.order_id にインデックスがなければ、

「注文1件ごとに、order_items を全件なめる」

ことになります。

JOIN が遅いときの“あるある”は、

WHERE のカラムにはインデックスを貼っているのに
JOIN のカラムにはインデックスを貼り忘れている

というパターンです。

JOIN は「テーブル同士をつなぐカラム」が命なので、
ON 句に出てくるカラムは、インデックス候補として最優先で見るべきです。


EXPLAIN で JOIN のどこが重いかを特定する

「テーブルごとの type / key / rows を上から順に追う」

JOIN が絡むクエリを EXPLAIN すると、
テーブルごとに1行ずつ、実行計画が出てきます。

さっきのクエリを、インデックスほぼ無しで EXPLAIN したと仮定すると、
ざっくりこんな“悪い顔”になります。

users は type = const(WHERE u.id = 123 なので1行)
orders は type = ALL, key = NULL, rows = 100000
order_items は type = ALL, key = NULL, rows = 500000
products は type = ALL, key = NULL, rows = 10000

これを日本語にすると、

ユーザー1人はすぐ特定できている
でも、そのユーザーの注文を取るために orders を全件スキャンしている
さらに、各注文に対して order_items を全件スキャンしている
さらに、各 order_items に対して products を全件スキャンしている

という、なかなかの地獄です。

ここでのポイントは、

JOIN 全体が遅い、ではなく
どのテーブルの行で type = ALL / key = NULL / rows が大きいか

を特定することです。

「JOIN が遅い」は抽象的すぎます。
「orders と order_items の JOIN が、インデックスなしで回っている」
くらいまで具体化できると、打ち手が見えてきます。


JOIN を遅くする“やってはいけない書き方”

「ON 句に関数をかける・型が合っていない・条件をWHEREに押し出し忘れる」

インデックスだけでなく、JOIN の書き方自体が原因で遅くなることもあります。
前半では、特にやりがちな3つを押さえておきます。

ON 句のカラムに関数をかける

例えば、こういう書き方です。

JOIN users u
  ON DATE(o.created_at) = u.some_date
Python

orders.created_at に関数 DATE() をかけてしまうと、
created_at にインデックスがあっても効きにくくなります。

JOIN 条件に出てくるカラムには、基本的に関数をかけない。
どうしても必要なら、別カラムに事前計算して持つ、などを検討します。

型が合っていない(暗黙の型変換)

例えば、

orders.user_id が INT
users.id が VARCHAR

のように、型がズレている状態で JOIN すると、
内部的に型変換が走り、インデックスが効かなくなることがあります。

JOIN するカラムは、型も揃える。
これはパフォーマンスだけでなく、バグ防止の意味でも超重要です。

JOIN 条件に書くべきものを WHERE に押し出し忘れる

例えば、こういう書き方です。

FROM orders o
JOIN users u ON o.user_id = u.id AND u.deleted_at IS NULL
Python

と書くべきところを、

FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.deleted_at IS NULL
Python

と書いてしまうケースがあります。

MySQL はある程度最適化してくれますが、
JOIN の段階で絞れる条件は、ON 句に書いた方が意図が明確です。

「JOIN してから WHERE で落とす」のか、
「JOIN するときにそもそも対象外にする」のか、
意識して書き分けられると、EXPLAIN の読み方もクリアになります。


JOIN の順番は自分で変えられるのか

「基本はオプティマイザ任せ、でも“ヒント”を出すこともある」

「JOIN の順番を変えたら速くなった」という話を聞いたことがあるかもしれません。

実際、MySQL のオプティマイザは、
FROM 句に書いた順番とは別に「どのテーブルから先に読むか」を決めます。

つまり、SQL の見た目の順番と、実行順は必ずしも一致しません。

ただし、オプティマイザも万能ではなく、
統計情報やインデックス状況によっては「イマイチな順番」を選ぶこともあります。

Day19 前半では、まだ「ヒント句」までは踏み込みません。
ここで押さえておきたいのは、

JOIN の順番をいじる前に、まずインデックスを整える
EXPLAIN の id / table の順番を見て、「どのテーブルから読んでいるか」を把握する

という2点です。

順番をいじるのは、「インデックスも整えたのにまだおかしい」と感じた、もう一段先の話です。


Day19 前半のまとめ

JOIN が遅くなる根本理由は「テーブル同士の組み合わせ=掛け算」になるからであり、インデックスがない状態で大きなテーブル同士をJOINすると、実質的に「片方の行ごとにもう片方を全件なめる」ような動きになって爆発するため、まずは EXPLAIN で各テーブル行の type / key / rows を見て「どのテーブルの JOIN がフルスキャンになっているか」を特定することが出発点になる。
JOIN が遅いときに最優先で確認すべきなのは、ON 句に出てくるカラム(user_id, order_id, product_id など)にインデックスが貼られているかどうかであり、WHERE のカラムだけインデックスを貼って JOIN カラムを忘れる、ON 句のカラムに関数をかけてインデックスを殺す、JOIN するカラムの型を揃えず暗黙の型変換を発生させる、といった“やってはいけない書き方”を避けるだけでも、JOIN のパフォーマンスは大きく変わる。
JOIN の順番自体は基本的にオプティマイザが決めるが、EXPLAIN の id / table の順番を見て「どのテーブルから読み始めているか」を把握しつつ、まずはインデックス設計とJOIN条件の書き方を整えることが、Day19 後半で扱う「JOIN順・複合インデックス・ヒント句」などの一段深いチューニングに進むための土台になる。

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