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 付近。groupshavings があると、一覧用クエリを 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 公式ドキュメント をご確認ください。