EXPLAIN ANALYZEでLaravelの遅いクエリを実測!loopsの読み方とtoBase()の落とし穴
🙌 結論から
遅いクエリを調べるときは、EXPLAIN だけで終わらせず EXPLAIN ANALYZE で「実際に何ミリ秒かかったか」まで見たほうが早いです。
EXPLAIN が出すのは、オプティマイザの推定だけなんですよね。
EXPLAIN ANALYZE は MySQL 8.0.18 から使える構文で、クエリを本当に実行して、各ステップの実測時間・行数・ループ回数を出してくれます!
ただ、Laravel 側には少し落とし穴があります(・_・;)
標準の explain() では ANALYZE が付けられないので、私は toBase() を挟んでから自作のマクロで流すようにしています👍
もう当たり前感あるかもですが、loops の数字を掛け算して読むのがいちばん大事なところでした👀
💡 EXPLAINの推定だけ見て安心していた
以前の私は、遅い画面があると EXPLAIN を流して、type と key を見て終わりにしていました。
type が ALL じゃない、key にインデックス名が入っている、よし大丈夫・・・という見方です笑
でも、インデックスはちゃんと使われているのに遅い、というケースがけっこうあるんですよね(´・ω・`)
EXPLAIN の rows は「たぶんこのくらい読むだろう」という見積もりなので、統計情報がずれていると実際の件数とかなり離れます。
EXPLAIN ANALYZE の出力は、ステップごとに次の2つのカッコが並ぶツリー形式です。
(cost=… rows=…): オプティマイザの推定(actual time=… rows=… loops=…): 実際に実行して測った値
この「推定」と「実測」が同じ行に並ぶのがマジでありがたいです!
推定の rows と実測の rows が桁で違っていたら、統計が古い可能性があります。
そういうときは ANALYZE TABLE orders; で統計情報を取り直してから、もう一度流すようにしています💪
同じクエリなのに実行計画ごと変わることもあるので、ここは本当に重要です🔥
👀 loopsを掛け忘れると犯人を見落とします
最初に読み違えたのがここでした。
actual time=0.5..210 の2つの数字は、最初の1行を返すまでの時間と全部の行を返し終わるまでの時間(ミリ秒)です。
そして、MySQL 公式ブログの解説によると、actual time と rows はループ1回あたりの平均値です。
つまり、loops が大きいステップは、表示されている時間に loops を掛けないと本当のコストが見えません。
説明用に簡略化した例ですが、注文一覧で「支払い済みの注文を新しい順に20件、ユーザー名つきで」取るクエリだと、こんな出力になります⇩
-> Limit: 20 row(s) (actual time=1520..1520 rows=20 loops=1)
-> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk (actual time=1520..1520 rows=20 loops=1)
-> Nested loop inner join (cost=… rows=5000) (actual time=0.9..1480 rows=48000 loops=1)
-> Filter: (orders.status = 'paid') (cost=… rows=5000) (actual time=0.5..210 rows=48000 loops=1)
-> Table scan on orders (actual time=0.4..180 rows=120000 loops=1)
-> Single-row index lookup on users using PRIMARY (id=orders.user_id) (cost=0.25 rows=1) (actual time=0.02..0.02 rows=1 loops=48000)
(数値は読み方を説明するための例です。cost の値は省略しています)
一番下の users の主キー検索は、1回あたり 0.02 ミリ秒なので一見すごく速そうです。
でも loops=48000 なので、合計すると 0.02 × 48000 = 960 ミリ秒くらいになります!
「速そうに見える1行」が、実は全体の半分以上を食べているというのが、loops を掛けて初めて分かるんですよね(;_;)
もう一つ見てほしいのが Filter の行です。
推定は rows=5000 なのに、実測は rows=48000 で、10倍近くずれています(・∀・)
この例なら、私は次の順で手を入れます。
ANALYZE TABLE orders;で統計を取り直す(status, created_at)の複合インデックスを検討して、テーブルスキャンとソートを避ける- ユーザー名の JOIN は、20件に絞ったあとで取る形にできないか考える
読む順番は、一番深いところ(インデントが一番右)から上へです。
上から読むと Limit や Sort ばかり目に入って、どこが重いのか分かりにくいので、この順番は覚えておくとかなり楽です✨
🔧 私はLaravelのクエリをこうやって流しています
ここが一番真似してほしいところです!
まず知っておきたいのは、Laravel のクエリビルダーにある explain() の中身です。
フレームワークのソース(Illuminate\Database\Concerns\ExplainsQueries)を見ると、toSql() の前に 'EXPLAIN ' を足して select() しているだけでした。
explain() で見られるのは、普通の EXPLAIN(推定)までということですね。
私は AppServiceProvider の boot() に、EXPLAIN ANALYZE 用のマクロを1つ足しています⇩
use Illuminate\Database\Query\Builder;
public function boot(): void
{
if (! $this->app->environment(['local', 'staging'])) {
return;
}
Builder::macro('explainAnalyze', function (): string {
/** @var Builder $this */
$rows = $this->getConnection()->select(
'EXPLAIN ANALYZE '.$this->toSql(),
$this->getBindings()
);
return collect($rows)->pluck('EXPLAIN')->implode("\n");
});
}
toSql() だけだとバインド値が ? のままなので、getBindings() も一緒に渡すのがポイントです!
使うときは php artisan tinker でこう呼んでいます。
echo Order::query()
->where('status', 'paid')
->join('users', 'users.id', '=', 'orders.user_id')
->select('orders.*', 'users.name')
->latest('orders.created_at')
->limit(20)
->toBase()
->explainAnalyze();
ここで toBase() を挟んでいるのがハマりどころの回避策です。
Eloquent のビルダーは、SoftDeletes の deleted_at is null のようなグローバルスコープを、get() や toBase() のタイミングでまとめて付けます。
標準の explain() や toSql() は、Eloquent 側の $passthru に入っているので、内部で toBase() を通ってスコープ付きの SQL になります。
ところが自作のマクロは $passthru に入っていないので、スコープを付ける前のクエリビルダーにそのまま転送されます。
つまり toBase() を忘れると、本番で実際に流れる SQL より条件が1つ少ない SQL を測ることになるんですよね・・・
私はこれに気づくまで、本番のスロークエリと数字が合わなくて首をかしげていました笑
もう一つ、with() で書いたイーガーロードは別のクエリとして流れるので、この結果には入りません。
イーガーロード側も気になるときは、DB::listen() やデバッグツールで SQL を拾って、1本ずつ EXPLAIN ANALYZE に通しています。
SQL をそのまま DB クライアントに貼りたいときは、ddRawSql() でバインド済みの SQL を出して、頭に EXPLAIN ANALYZE を付けて流すのも手軽です(^^)
🤔 使う前に押さえておきたいこと
推しておいてなんですが、気をつけたい点もあります。
一番大事なのは、EXPLAIN ANALYZE はクエリを本当に実行することです。
10秒かかるクエリなら、EXPLAIN ANALYZE もちゃんと10秒かかります😴
本番のプライマリで重いクエリを何度も流すのは避けて、私はステージングか、本番に近いデータを入れたローカルで測るようにしています。
途中で止めたくなったら、KILL QUERY か Ctrl+C で止められます。
公式マニュアルによると、対象は SELECT・TABLE 文と、複数テーブルの UPDATE・DELETE です。
更新系も実際に書き換わるので、試すならトランザクションの中で流してロールバックしたほうが安心です( .. )
バージョンまわりも一応押さえておきます。
EXPLAIN ANALYZEは MySQL 8.0.18 以降。5.7 の環境では使えません- MySQL 8.0 は 2026年4月でサポートが終わっていて、今のLTSは 8.4 と、2026年4月21日に出た 9.7
- 9.7 のマニュアルでは、
EXPLAIN ANALYZEの出力形式はFORMAT=TREE(標準)とFORMAT=JSON。JSON はexplain_json_format_versionが2のときだけ使えます FORMAT=JSON INTO @変数で結果を変数に入れる書き方もありますが、SELECTだけが対象です
JSON で変数に入れられると、JSON_EXTRACT で使ったインデックス名を取り出して自動チェックに回す、みたいな使い方もできそうです🤔
まだ私はそこまでやっていませんが、CI でスロークエリの劣化を見張りたい人には面白い機能かも・・・!
🔗 関連記事と使い分け
同じ「遅いクエリを直す」話を何本か書いているので、どれをいつ使うかを整理しておきます。
- Laravelのpaginate()が遅い原因はCOUNTクエリ!simplePaginate()で劇的に速くした話: スロークエリログで「どの SQL が遅いか」を見つけた話。見つけた SQL の中身を掘るのが今回の
EXPLAIN ANALYZEです - LaravelのN+1問題を解消したらAPIが速くなった話!with()以外の対策も: クエリの「本数」が多すぎる問題。1本が重いなら今回の記事、本数が多いならこちら
- MySQLの使っていないインデックス、いきなり消さずに「不可視」で2週間様子を見てから消すようにした話: インデックスを消す前に、
EXPLAIN ANALYZEで計画がどう変わるか見ておくと安心です
私の流れは、スロークエリログで見つける → EXPLAIN ANALYZE で中身を測る → インデックスやクエリを直す → もう一度測る、の順番です👍
パパ系Webエンジニア〜愛用ガジェット一覧 🛍️
この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌
👍 まとめ
遅いクエリは、EXPLAIN の推定で止めずに EXPLAIN ANALYZE で実測すると、犯人の見つかり方がかなり変わります。
読むときは、一番深いステップから上へ、actual time と rows は loops を掛けて合計で見る、この2つだけ覚えておけば大丈夫です。
Laravel では explain() が普通の EXPLAIN しか流さないので、toBase() を挟んでマクロで EXPLAIN ANALYZE を付けるのが私のやり方でした(^o^)/
正直、最初は「EXPLAIN で十分でしょ・・・」と思っていましたが、loops=48000 みたいな行を一度見ると戻れなくなります😎
スロークエリで悩んでいる人は、次に遅い画面が出たときに一度流してみてください🙌
※ バージョン番号・料金・機能は今後も変わる可能性があります。最新情報は MySQL 公式マニュアル をご確認ください👀