php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【実務完全ガイド】Laravelのパフォーマンス最適化と可観測性――キャッシュ・DB設計・ジョブ分割・Octane・メトリクス・アクセシブルな読み込み表示

この記事で学べること(要点)

  • キャッシュ(Config/Route/View/Query)とHTTPキャッシュの設計
  • EloquentのN+1解消、インデックス設計、集約・ページネーション最適化
  • ジョブ分割とスロットリング、重複排除、冪等処理
  • Octane(Swoole/RoadRunner)やOPcache、静的アセットの最適配信
  • Horizon・Telescope・ログ整形・メトリクス(遅延・エラー率)での監視
  • アクセシブルなローディング表示、プログレッシブエンハンスメント、色に依存しない進捗表現

想定読者(だれが得をする?)

  • Laravel 初〜中級エンジニア:速度と安定性を段階的に高めたい方
  • テックリード/アーキテクト:可観測性を備えた運用に強い設計を標準化したい方
  • SRE/QA:指標に基づくパフォーマンス検証と劣化検知を回したい方
  • デザイナー/ライター:読み込み中のアクセシブルな表示や骨組みUI(スケルトン)を整えたい方

1. 基本方針:まずは“無駄を減らす”、次に“仕組みで速くする”

最適化の順序を誤ると、複雑さだけが増します。

  1. 測る:遅いリクエスト/クエリ/ジョブを特定。
  2. 減らす:N+1・不要なクエリ・ムダなレンダリング。
  3. キャッシュ:データ・テンプレート・HTTP。
  4. 非同期化:重い処理はジョブへ。
  5. 基盤強化:OPcache・Octane・CDN。
  6. 監視:劣化を検知し、ロールバック可能な運用に。

以降は、施策を安全に積み上げるための具体策を示します。


2. キャッシュ設計:ヒット面積を広げ、無効化を制御

2.1 まずはビルトインキャッシュ

php artisan config:cache
php artisan route:cache
php artisan view:cache
  • 構成・ルート・Blade のビルド済みキャッシュで起動コストを削減。
  • 本番は OPcache 有効化が前提。コンテナ再起動やデプロイで自動反映される運用に。

2.2 データキャッシュ(Repository層の例)

class CategoryRepo {
  public function all(): Collection {
    return Cache::remember('categories:all', now()->addHours(6), function () {
      return Category::query()->orderBy('rank')->get(['id','name','slug']);
    });
  }
}
  • キー命名:リソース:条件:version を意識。
  • 局所性:一覧や辞書を対象に。ユーザー固有は短命か別レイヤへ。

2.3 クエリキャッシュ(短命)

$top = Cache::remember("posts:top:{$page}", 60, fn() =>
  Post::with('author')
      ->published()
      ->orderByDesc('score')
      ->paginate(20)
);
  • ページング+with()を含む重い一覧を短命キャッシュ。
  • 無効化はイベント駆動(作成/更新/削除時に Cache::forget)。

2.4 HTTPキャッシュ(ETag/Last-Modified)

$etag = sha1($post->updated_at.$post->id);
if (request()->header('If-None-Match') === $etag) {
  return response()->noContent(304);
}
return response()->view('post.show', compact('post'))
  ->header('ETag', $etag)
  ->header('Cache-Control', 'public, max-age=120');
  • CDNと組み合わせて帯域を削減。
  • 認証済みページは private と短命の戦略。

3. Eloquent 最適化:N+1解消・インデックス・集約

3.1 N+1 の検出と撲滅

// 例:著者とタグをまとめて取得
$posts = Post::with(['author:id,name','tags:id,name'])
  ->latest()->paginate(20);
  • with() で関連一括取得、列は最小に。
  • 集約表示はカウンタキャッシュ事前集計テーブルを検討。

3.2 インデックス設計

  • WHERE/ORDER BY/JOIN に現れる列に複合インデックス
  • created_at DESC の並びは(created_at, id)などで安定化。
  • LIKE 検索は前方一致で(column LIKE 'abc%')、全文検索系は別ストアも検討。

3.3 検索・フィルタのホワイトリスト

$sort = $req->enum('sort', ['-created_at','created_at','-score','score']) ?? '-created_at';
$dir  = str_starts_with($sort,'-') ? 'desc' : 'asc';
$col  = ltrim($sort,'-');
$query->orderBy($col, $dir);
  • 任意列の orderBy を禁止し、列挙制御で安全に最適化。

3.4 集約の分離

  • ダッシュボードの重い集計は定期ジョブで素材化し、画面は軽量参照
  • リアルタイム性が不要な指標は非同期更新へ。

4. ビュー最適化:Bladeとフロントの軽量化

4.1 条件分岐とコンポーネント

  • 大量ループ内での@includeを削減し、コレクションの塊でレンダリング。
  • 再計算の多いヘルパはViewModelAccessorで事前整形。

4.2 画像・静的アセット

  • <img>幅・高さを付与し、CLS を抑える。
  • <picture>srcsetloading="lazy" で転送量を削減。
  • CSS/JS はHTTP/2 まとめ、不要コードをビルド時にツリーシェイク

4.3 読み込み中の表示(アクセシブル)

<div role="status" aria-live="polite" class="mb-2">
  データを読み込み中です…
</div>
<ul aria-busy="true" aria-describedby="loading-desc">
  <li class="skeleton h-6 w-full"></li>
  <li class="skeleton h-6 w-5/6 mt-2"></li>
</ul>
<p id="loading-desc" class="sr-only">読み込みが完了するとリストが表示されます。</p>
  • 骨組みUIはテキスト説明も併記。
  • 完了後は aria-busy="false" へ更新し、リスト先頭へフォーカス移動で文脈を回復。

5. 非同期化:ジョブ分割・重複排除・スロットリング

5.1 重い処理はジョブへ

class ExportOrders implements ShouldQueue {
  public $tries = 3;
  public $timeout = 1800; // 30分
  public function handle(ExportService $svc) { $svc->run(); }
}
  • 冪等性(同じ入力で同じ出力)を保ち、再試行で壊れないように。

5.2 重複排除(ユニークジョブ)

$lock = Cache::lock("export:{$userId}", 600);
if ($lock->get()) {
  ExportOrders::dispatch($userId)->onQueue('exports');
  // ジョブ終了時に $lock->release();
}
  • 同一ユーザーの同種処理を直列化し、資源を守る。

5.3 レート制限ミドルウェア

public function middleware(): array {
  return [ new \Illuminate\Queue\Middleware\RateLimited('exports') ];
}
  • 外部APIやメール送信でスロットリングを付与。

5.4 フロントのフォールバック

  • リアルタイム通知がなくてもポーリングで状況を把握。
  • 読み込み中は role="status"、失敗時は短文の原因と次の手段を提示。

6. Octane/OPcache/プロセス最適化

6.1 OPcache 基本

  • 本番で opcache.enable=1opcache.validate_timestamps=0(イミュータブルビルド時)
  • デプロイ時にプロセス再起動でキャッシュ更新。

6.2 Laravel Octane の狙いどころ

  • 同期I/Oのオーバーヘッドを削減。
  • セッション/キャッシュ/キュー等は外部ストアで共有。
  • ステートフルなシングルトンリクエスト間の汚染に注意し、Octane::tick() などで周期的に再初期化。

6.3 画像/静的配信はCDNへ

  • アプリから配らないものは原則CDN
  • 署名付きURLで期限付きアクセスを提供。

7. 可観測性:ログ・トレース・メトリクス

7.1 構造化ログ

Log::info('order.created', [
  'order_id' => $order->id,
  'user_id' => $order->user_id,
  'amount' => $order->amount,
  'request_id' => request()->header('X-Request-Id')
]);
  • 検索キー(ユーザーID、注文ID、リクエストID)を必ず記録。
  • PII はマスク、トークンは記録禁止

7.2 遅延・エラー率・スループット

  • パーセンタイル(p50/p95/p99)で体感を把握。
  • 「遅い=悪」ではなく、目標値(SLO)を決めて逸脱を検知。
  • 指標はデプロイごとに比較し、劣化時はロールバック判断。

7.3 Telescope/Horizon の活用

  • Telescope:リクエスト/クエリ/例外/ジョブの可視化。
  • Horizon:キューの待ち行列・失敗ジョブ・処理時間のダッシュボード。
  • 本番ではアクセス制限し、保全目的で短期間のみ有効化。

8. ページネーションと段階読み込み

8.1 simplePaginate と無限スクロール

$items = Item::orderByDesc('id')->simplePaginate(50);
  • 総件数の集計コストを回避。
  • 無限スクロール時は手動でも到達可能な導線(「さらに読み込む」ボタン)を併置。

8.2 アクセシブルな読み込み

<button id="more" class="btn" aria-controls="list" aria-describedby="load-hint">
  さらに読み込む
</button>
<p id="load-hint" class="sr-only">追加の結果を下部に読み込みます。</p>
<div id="alist" role="feed" aria-busy="false"></div>
  • 自動読み込みができない環境でも確実に操作可能

9. 失敗を無害化:タイムアウト・再試行・サーキットブレーカー

  • HTTPクライアントは接続/応答タイムアウトを明示。
  • 一時的障害は指数バックオフで再試行。
  • 恒久的障害はサーキットブレーカーで一時遮断し、UI には代替文言を出す。
  • 失敗の影響範囲をスコープで限定(トランザクション/部分更新)。

10. 国際化とタイムゾーンのパフォーマンス

  • Carbon のロケール設定はブート時に一度だけ。
  • 多言語の文言はJSON翻訳名前付きキーキャッシュヒットしやすく。
  • 通貨・日付整形はサーバ側で集約し、クライアント側の計算を最小化。

11. セキュリティと速度のバランス

  • レート制限は重要操作ほど厳格に。
  • CSP/HSTS/Referrer-Policy はヘッダーで一括適用、動的 nonce を使う場合はテンプレートの最小化でオーバーヘッドを抑制。
  • 署名付きURLや権限チェックはキャッシュ層を迂回する経路を意識。

12. UX:遅い・失敗・オフライン時の案内

12.1 遅延時の案内

<div id="status" role="status" aria-live="polite">
  3秒以上応答がありません。通信環境をご確認ください。
</div>
  • 時間経過に応じて短文を出し、次の手段(再読み込み、簡易版へ)を提示。

12.2 失敗時の案内

  • 「もう一度試す」ボタンを最前に配置
  • 問い合わせに使えるリクエストIDを表示。
  • 色に依存せず、アイコン+文言で状態を示す。

13. 実装サンプル:ボトルネックの測定から改善まで

13.1 ミドルウェアで応答時間を計測

class RequestTimer {
  public function handle($req, Closure $next) {
    $start = microtime(true);
    $res = $next($req);
    $ms = (int)((microtime(true) - $start) * 1000);
    Log::info('http.timing', [
      'path' => $req->path(),
      'status' => $res->getStatusCode(),
      'ms' => $ms,
      'request_id' => $req->headers->get('X-Request-Id'),
    ]);
    return $res->headers->set('Server-Timing', "app;dur={$ms}");
  }
}
  • ブラウザのNetworkServer-Timing を確認し、遅い画面を特定。

13.2 N+1 の可視化(開発時)

DB::listen(function ($query) {
  if (str_contains($query->sql, 'select') && $query->time > 30) {
    logger()->debug('slow.query', ['sql' => $query->sql, 'ms' => $query->time]);
  }
});
  • しきい値を超えるクエリをログへ。

13.3 クエリの抜本改善

  • withCountselectRaw集約をDB側に寄せる。
  • 履歴や統計は別テーブルで累積(ライトヘビーを避ける)。

14. チェックリスト(配布用)

測定

  • [ ] p50/p95/p99、エラー率、スループット
  • [ ] slow query/slow request ログ
  • [ ] 直近デプロイと比較するダッシュボード

削減

  • [ ] N+1 撲滅(with()/列絞り込み)
  • [ ] 適切なインデックス
  • [ ] 不要な再計算/テンプレート分岐の整理

キャッシュ

  • [ ] Config/Route/View/OPcache
  • [ ] データ短命キャッシュと無効化戦略
  • [ ] HTTP ETag/Last-Modified/CDN

非同期

  • [ ] ジョブ化・再試行・タイムアウト
  • [ ] ユニークジョブ/レート制限
  • [ ] フォールバック(ポーリング/軽量版)

基盤

  • [ ] Octane 適用範囲の検討
  • [ ] 静的アセットのCDN配信
  • [ ] 画像のsrcset/遅延読込/幅高さ指定

可観測性

  • [ ] 構造化ログとリクエストID
  • [ ] Horizon/Telescope の安全運用
  • [ ] アラート閾値とロールバック手順

アクセシビリティ

  • [ ] ローディングの文言と role="status"
  • [ ] 色以外の状態表現、フォーカス復帰
  • [ ] 失敗時の次アクション提示

15. よくある落とし穴と回避策

  • キャッシュ前にN+1が残る → 先に削減してからキャッシュ。
  • 無効化戦略なしの長命キャッシュ → イベント駆動で忘れず破棄。
  • 任意ソート/検索を直渡し → ホワイトリストで最適パスに固定。
  • 非同期化だけで安堵 → 再試行/冪等がないと障害時に積もる。
  • 読み込み中が無言 → 短文の案内操作の余地を提供。
  • 計測なしの最適化 → 効果が不明、測定→改善→再測定のサイクルへ。

16. まとめ

  • 測る→減らす→キャッシュ→非同期→基盤強化→監視の順で前進。
  • Eloquentは with() とインデックス、集約の外出しで根本から軽く
  • ジョブは重複排除・レート制限・冪等で壊れにくく
  • OPcache/Octane/CDN で土台の速度を底上げ。
  • ローディング・失敗・再試行の導線をアクセシブルに
  • 指標をダッシュボード化し、劣化を早期に検知して戻せる運用を。

パフォーマンスは単発の改善ではなく、計測と改善の習慣です。この記事のテンプレートを土台に、チーム全体で“速くてわかりやすい”Laravelを育てましょう。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)