EC-CUBEのサイトが重くなったとき、サーバーのスペックを上げる前に確認すべきことがあります。データ量が増えてから急に重くなったのなら、原因はほぼプログラムの書き方です。特に多いのが、1ページの表示で何百回もSQLが実行されている状態です。まず現状を計測して、どこで時間を使っているかを特定するところから始めます。
EC-CUBEの速度改善は何をすればいい?(結論)
具体的な改善の順番は「計測 → N+1解消 → 取得データ削減 → キャッシュ → サーバー増強」です。
- Webプロファイラでクエリ数を計測する — 推測で対策しない。1ページで数百クエリ実行されていたらN+1問題が原因
- addSelect + leftJoin でN+1問題を解消する — データ量が増えてから急に重くなったサイトは、ほぼここで直る
- 一覧画面で使わないカラムまで取得しない — メモリ消費と転送量を削減する
- 更新頻度の低いマスタ系データはクエリキャッシュを使う
- ここまでやってまだ遅い場合に初めてサーバー増強を検討する
サーバー増強を最後にしているのは、原因の大半がプログラム側(SQLの発行回数)にあり、先にスペックを上げても「増強したサーバーで同じ非効率なクエリを実行するだけ」になるからです。以下、それぞれの手順を説明します。
推測せずWebプロファイラで実行クエリ数を見る
最初にやるのは計測です。EC-CUBEはSymfonyベースなので、開発環境でデバッグモードにするとWebプロファイラが使えます。
.envを編集します。
APP_ENV=dev
APP_DEBUG=1
キャッシュをクリアすると、画面下部にツールバーが表示されます。
php bin/console cache:clear --no-warmup
ツールバーのデータベースのアイコンに、そのページで実行されたクエリ数と合計時間が出ます。ここをクリックすると、実行されたSQLが全件並びます。
見るべきは合計時間よりクエリ数です。商品一覧やマイページで数百件が並んでいたら、後述するN+1問題が起きています。1クエリが1ミリ秒でも、500回実行されれば0.5秒かかります。
なお、本番環境でデバッグモードを有効にしてはいけません。内部情報が表示されるうえ、動作も重くなります。確認が終わったらAPP_ENV=prodに戻してキャッシュをクリアしてください。
N+1問題を解消する
クエリ数が異常に多い場合、ほぼこれが原因です。
商品一覧を表示するとき、まず商品を10件取得します。そのあとテンプレートで各商品のカテゴリを参照すると、Doctrineは参照のたびにSQLを発行します。商品10件なら、最初の1回+10回で11回。これがN+1問題です。100件表示なら101回になります。
解決するには、最初のクエリで関連データもまとめて取得します。createQueryBuilderでaddSelectとleftJoinを組み合わせます。
// 商品とカテゴリを1回のクエリで取得する
$qb = $this->createQueryBuilder('p')
->addSelect('pc', 'c')
->leftJoin('p.ProductCategories', 'pc')
->leftJoin('pc.Category', 'c');
return $qb->getQuery()->getResult();
ポイントはaddSelectを書くことです。leftJoinだけでは結合条件に使われるだけで、取得したデータは保持されません。結果としてテンプレートで参照した時点で再度SQLが飛び、N+1が解消されません。
修正したら、もう一度プロファイラでクエリ数を確認してください。数百件が数件に減っていれば成功です。
一覧取得で使わないデータまで取らない
クエリ数が減っても重い場合、1回あたりで取得している量が多すぎる可能性があります。
Doctrineはエンティティを取得すると、全カラムをオブジェクトに展開してメモリに保持します。商品名とIDだけあれば足りる一覧画面で、商品説明や各種フラグまで含めた全カラムを数千件ぶん読み込むと、メモリを大量に消費します。
必要なカラムだけを指定すると、オブジェクトではなく配列で返るため軽くなります。
$qb = $this->createQueryBuilder('p')
->select('p.id', 'p.name')
->where('p.Status = 1');
return $qb->getQuery()->getArrayResult();
ただし配列で返るとエンティティのメソッドが使えなくなります。テンプレート側の書き換えも必要になるため、影響範囲を確認してから適用してください。
大量データのバッチ処理ではメモリを解放する
CSV出力や一括更新のように数万件を扱う処理では、別の問題が起きます。
Doctrineは取得したエンティティを内部で管理し続けるため、ループで処理していくとメモリが増え続けます。件数が増えたときにメモリ不足で落ちるのはこのパターンです。
一定件数ごとに管理対象をクリアします。
$i = 0;
foreach ($products as $Product) {
// 何らかの処理
if (++$i % 100 === 0) {
$this->entityManager->flush();
$this->entityManager->clear();
}
}
$this->entityManager->flush();
clear()は管理中のエンティティをすべて切り離します。実行後に古いオブジェクトを参照すると期待どおりに動かないため、ループ内で外部の変数を保持している場合は注意してください。
クエリ結果のキャッシュを使う
マスタ系のように更新頻度が低いデータは、毎回SQLを実行する必要がありません。Doctrineのクエリキャッシュを有効にすると、同じクエリの結果を再利用できます。
設定方法はSQL(Doctrine)のキャッシュ時間を変更してページ高速化にまとめています。
注意点として、キャッシュ期間中はデータを更新しても画面に反映されません。管理画面から更新する運用があるデータに長い期間を設定すると、「変更したのに変わらない」という問い合わせにつながります。
サーバー増強はいつ検討すべきか
プログラム側で改善できる余地があるうちは、サーバーのスペックを上げても費用対効果が見合いません。500回のSQLが400回に減る程度では、体感は変わらないためです。
先にプロファイラで計測して、クエリ数が妥当な水準まで下がったことを確認する。そのうえでまだ遅いなら、そこで初めてサーバー構成やNginxの設定を検討する段階です。Nginx側の設定はNginxでのリライトと高速化設定で扱っています。
順番を逆にすると、増強したサーバーで同じ非効率なクエリを実行するだけになります。
EC-CUBEに関するお問い合わせ
[重要]現在公式にセキュリティサポートが切れていないPHPは8.1以上、MySQLは8.0以上で、対応しているEC-CUBEバージョンは4.2以上です。古いEC-CUBEを使っている方は適切なタイミングでバージョンアップをご検討ください。