Laravelの paginate() をやめて simplePaginate() にしたら劇的に速度改善した話
商品一覧の遅さを調べる中で、Laravel の paginate() が発行する COUNT クエリが原因候補だと判明しました。simplePaginate() に切り替えた結果と考え方をまとめます。
🙌 結論から
商品一覧のように、もともとの一覧取得 SQL が重い画面では、Laravel の paginate() をそのまま使うと想像以上に遅くなることがあります。
理由は、一覧取得とは別に 総件数を数えるための COUNT クエリを追加で発行する ためです。そのため、メインの取得SQL自体が重いと、それをベースにカウントクエリを発行するので単純に2倍分時間と負荷がかかることになります。
今回もまさにそれで、商品一覧の取得クエリに加えて、そのクエリ全体をサブクエリ化して SELECT COUNT(*) する重い SQL が走っていました。
実際に仕事で使ってみて感じたのは、ページネーションは便利でも、「全件数が本当に必要か」は別問題 だということです。
試しに paginate() をやめて simplePaginate() に変えたところ、CPU 使用率は大きく変わらなかったものの、レスポンスは 8 秒から 3 秒まで短縮 しました。(実際のプログラム上で計測はしておらず、ブラウザ上でHTMLがサーバーから返ってくる時間で測ってます。クライアントへ共有するためこの数字の方がわかりやすいので)
正直、ここまで変わるのか・・・という感想でした。
💡 何を調べていたのか
商品一覧画面が遅かったため、原因の切り分けとしてスロークエリを確認しました。
その際に、以下の設定でスロークエリログを有効化しています。
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 3;
一覧画面なので、商品一覧を取る本体クエリが重いのはある程度想定していました。
ただ、実際に仕事で使ってみて困っていたのは、一覧を開くたびに待ち時間が長く、操作感がかなり重たかったこと です。
そのため、単に「重い」で終わらせず、どの SQL が何本走っているのか まで見にいきました。
👀 スロークエリで見えた違和感
ヒットした重いクエリは 2 本でした。
1 本目は商品一覧の取得クエリです。
これは想定通りでした。
気になったのは 2 本目です。
内容を見ると、商品一覧の取得クエリを丸ごとサブクエリにして、その結果に対して SELECT COUNT しているクエリ でした。
最初に見たときは、なぜここまで重いカウントが別で必要なのか、かなり違和感がありました。
一覧表示そのものより、件数確認のためにもう一度重い処理をしている ように見えたからです。
この時点で、アプリケーション側のページネーション実装を疑う流れになりました。
✨ 原因は paginate() の COUNT クエリだった
ソースを追って確認したところ、使っていたのは Laravel の標準的な paginate() でした。
これは LengthAwarePaginator を使う都合上、総ページ数や総件数を出すための COUNT クエリを別途発行 します。
ソースを読みにいくと、paginate() はざっくり次のような流れでその総件数を取りにいきます。この途中で COUNT 用 SQL が必ず走るので、一覧本体が重い画面では「もう一本の重いクエリ」の犯人になりやすい です。今回スロークエリに 2 本目として出ていたのも、この経路です。
Model::query()->paginate(...)
→ Illuminate\Database\Eloquent\Builder::paginate(...)
→ toBase()->getCountForPagination(...) 相当の流れ
→ Illuminate\Database\Query\Builder::getCountForPagination(...)
→ runPaginationCountQuery(...)
→ COUNT 用 SQL 発行
→ その total を LengthAwarePaginator に渡す
その先、runPaginationCountQuery() を開くと実装はだいたい次の形です(Laravel の Illuminate\Database\Query\Builder 付近。groups や havings があると、一覧用クエリを toSql() で括ってサブクエリ化し、その上で COUNT する 分岐が入る)。スロークエリに出ていた「本体を丸ごと包んだ SELECT COUNT」と、そのまんま対応がつくので、ここで犯人確定 です。
protected function runPaginationCountQuery($columns = ['*'])
{
if ($this->groups || $this->havings) {
$clone = $this->cloneForPaginationCount();
if (is_null($clone->columns) && ! empty($this->joins)) {
$clone->select($this->from.'.*');
}
return $this->newQuery()
->from(new Expression('('.$clone->toSql().') as '.$this->grammar->wrap('aggregate_table')))
->mergeBindings($clone)
->setAggregate('count', $this->withoutSelectAliases($columns))
->get()->all();
}
$without = $this->unions ? ['unionOrders', 'unionLimit', 'unionOffset'] : ['columns', 'orders', 'limit', 'offset'];
return $this->cloneWithout($without)
->cloneWithoutBindings($this->unions ? ['unionOrder'] : ['select', 'order'])
->setAggregate('count', $this->withoutSelectAliases($columns))
->get()->all();
}
単純なテーブルなら問題になりにくいですが、結合が多い一覧や条件が複雑な一覧では、この COUNT 側まで重くなります。
今回の商品一覧もまさにその形でした。
本体クエリだけでも十分重いのに、それをベースにさらに件数計算まで走るので、遅くなるのも納得です。
調査してみると、paginate() が余計なカウントクエリを発行し、速度低下につながるという話は既存記事でも触れられています。
参考までに、考え方の整理に近かった記事は次のとおりです。
コントローラ側では、概ね次のような呼び出しが paginate() の典型形です。
$orders = Order::query()
->with(/* ... */)
->where(/* ... */)
->orderBy(/* ... */)
->paginate(20);
simplePaginate() に切り替える場合は、メソッド名だけ変えるイメージです(取得件数は同様に指定します)。
$orders = Order::query()
->with(/* ... */)
->where(/* ... */)
->orderBy(/* ... */)
->simplePaginate(20);
🎯 simplePaginate に変えてどうなったか
そこで paginate() をやめて、総件数を取りにいかない simplePaginate() に変更しました。
この変更で、本体の一覧取得クエリだけに処理が絞られます。
結果として、CPU はそこまで変わらない一方で、画面表示の時間は 8 秒から 3 秒へ短縮 しました。(半分以下)
ユーザー体験としても別物です。
もちろん、「全 xx 件」や最終ページ番号が必要な画面では paginate() が必要 です。
ただ、前へ・次へで十分な一覧なら、simplePaginate() のほうが設計として自然なことも多いです。
私が作ってるシステムでは、ページネーションの下部や「全 xx 件」などはカスタマイズしてるので、初期表示だけは simplePaginate() で表示。ページ表示後、ページネーションの下部や「全 xx 件」は、非同期で件数などを取りに行ってJS側でレンダリングしてます。
「件数を取得中です・・・」みたいな感じにしておいて、取得できたらレンダリング、みたいな。
実際に仕事で使ってみて、ページネーションは 「Laravel の標準だからそのまま使う」ではなく、画面要件に合わせて選ぶべき だと強く感じました。
👍 まとめ
複雑な一覧画面で遅さを感じたら、まず本体 SQL だけでなく、ページネーションが発行している COUNT クエリまで疑ったほうがよいです。
今回のように、遅い原因が一覧取得そのものではなく、paginate() による総件数取得 にあるケースは十分あります。
私としては、スロークエリを見たことで「何となく遅い」が「なぜ遅いのか」に変わったのが大きかったです。
そして、simplePaginate() への切り替えは、実際に仕事で使ってみてかなり変化がありました。
商品一覧のような重い画面で、総件数が必須ではないなら、一度試してみる価値はかなりあると思います。
※ Laravel のバージョンやページネーションの挙動は更新で変わることがあります。
最新は Laravel 公式ドキュメント をご確認ください。