404 Not Found

404 Not Found


nginx

Laravelパフォーマンス最適化の実践

パフォーマンスはSaaSの命綱です—3秒待たされるとユーザーは離れ, スロークエリは1日で数百ドルのデータベース費用を消費します。

1. 学ぶこと


2. 急成長するSaaS企業の実話

(1) 痛み:ShopMetricsのレスポンス時間が200ミリ秒から5秒に急増

ShopMetricsのユーザー数が1,000人から50,000人に増え, 注文データが10万件から500万件に増加しました。Aliceはダッシュボードの読み込みに5秒かかることに気づき, Bobは月次データベース費用が50ドルから500ドルに跳ね上がったことを確認しました—すべてスロークエリが予算を食いつぶしているのが原因でした。Charlieの分析により, N+1クエリ, インデックスの欠落, キャッシュの不在が3つの主犯であることが判明しました。

(2) 体系的な最適化手法

クエリ, キャッシュ, インデックス, OPcacheの4層の最適化を通じて, 各層でレスポンス時間を半減させ, 最終的に5秒から200ミリ秒に短縮しました。

TEXT
最適化前:  5000ms  (N+1クエリ, キャッシュなし, インデックスなし)
ステップ1:  2500ms  (eager loading, N+1修正)
ステップ2:   500ms  (インデックス追加 + クエリ最適化)
ステップ3:   200ms  (Redisキャッシュ + OPcache)
ステップ4:   100ms  (CDN + リードレプリカ)

(3) 成果

Bobの最適化後, データベース費用は500ドル/月から150ドル/月に低下し, ユーザーの定着率は70%から90%に上昇しました。


3. Eloquentクエリの最適化

(1) N+1クエリ問題

PHP
// ❌ N+1:テナント1クエリ + ショップNクエリ
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
    echo $tenant->shops->count(); // テナントごとに1クエリ追加!
}
// 合計:1 + Nクエリ

// ✅ Eager loading:合計2クエリ
$tenants = Tenant::with('shops')->get();
foreach ($tenants as $tenant) {
    echo $tenant->shops->count(); // 追加クエリなし
}
// 合計:2クエリ

(2) クエリ最適化手法の比較

手法 用途 メモリ使用量 説明
all() 少量データ すべて一度にメモリにロード
chunk() 大データの反復 Nレコードずつブロック処理
chunkById() 大データの反復 (より安定) IDでチャンク, データ変更の影響なし
cursor() ストリームベースの読み込み 非常に低 ジェネレーターベースの1行ずつ読み込み
lazy() 大データ, 集計操作が必要 オンデマンドでチャンクをロード
each() 反復 + 処理 chunk + コールバック

(1) ▶ サンプル:ShopMetrics注文分析のN+1修正

PHP
// ❌ 修正前:ダッシュボードの読み込みに5秒
// コントローラー:テナントあたり1 + 3*Nクエリ
public function dashboard(Tenant $tenant)
{
    $orders = $tenant->orders()
        ->whereMonth('created_at', now()->month)
        ->get();                          // クエリ1

    $total = $orders->sum('total');
    $byShop = $orders->groupBy('shop_id');  // ショップを遅延ロード:Nクエリ
    $topProducts = $orders->flatMap->items; // アイテムを遅延ロード:Nクエリ
    $customers = $orders->pluck('customer'); // 顧客を遅延ロード:Nクエリ
}

// ✅ 修正後:ダッシュボードの読み込みが500ms
// コントローラー:合計4クエリ
public function dashboard(Tenant $tenant)
{
    $orders = $tenant->orders()
        ->with(['shop:id,name', 'items.product:id,name,price', 'customer:id,name'])
        ->whereMonth('created_at', now()->month)
        ->select(['id', 'shop_id', 'customer_id', 'total', 'created_at'])
        ->get();

    $total = $orders->sum('total');
    $byShop = $orders->groupBy('shop.name');
    $topProducts = $orders->flatMap->items->take(10);
    $customers = $orders->pluck('customer.name');
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:大規模データセットでのchunkとcursorの使用

PHP
// 500万件の注文をメモリオーバーフローなしで処理
// オプション1:chunk (500件ずつ処理)
Order::where('tenant_id', $tenant->id)
    ->chunk(500, function ($orders) {
        foreach ($orders as $order) {
            $this->calculateOrderMetrics($order);
        }
    });

// オプション2:chunkById (同時書き込み時により安定)
Order::where('tenant_id', $tenant->id)
    ->select(['id', 'total', 'status'])
    ->chunkById(500, function ($orders) {
        $metrics = $orders->groupBy('status')->map->sum('total');
        Cache::put("metrics:{$tenant->id}", $metrics, 3600);
    });

// オプション3:cursor (ストリーミング, 最低メモリ)
$total = 0;
foreach (Order::where('tenant_id', $tenant->id)->cursor() as $order) {
    $total += $order->total;
}

// オプション4:lazy (コレクションメソッドが利用可能)
$highValue = Order::where('tenant_id', $tenant->id)
    ->lazy(500)
    ->filter(fn ($o) => $o->total > 1000)
    ->count();

出力:

TEXT
// 実行成功

4. キャッシュ戦略

(1) Laravelキャッシュの階層

100%
flowchart TD
    A[リクエスト] --> B{ルートキャッシュ?}
    B -->|ヒット| C[キャッシュ済みルート]
    B -->|ミス| D[ルート解析]
    D --> E{設定キャッシュ?}
    C --> E
    E -->|ヒット| F[キャッシュ済み設定]
    E -->|ミス| G[.env + 設定ファイルのロード]
    G --> H{データキャッシュ?}
    F --> H
    H -->|ヒット| I[キャッシュデータを返却]
    H -->|ミス| J[DBクエリ + 結果をキャッシュ]
    I --> K[レスポンス]
    J --> K

(2) キャッシュタイプと用途

キャッシュタイプ コマンド/メソッド 用途 永続性
設定キャッシュ config:cache 本番環境の設定 長期 (クリアまで)
ルートキャッシュ route:cache 多数ルート, クロージャ少ない 長期 (クリアまで)
ビューキャッシュ view:cache Bladeビューのコンパイル 長期 (クリアまで)
データキャッシュ Cache::put() クエリ結果/レポートデータ TTL期限切れ
クエリキャッシュ remember() 頻繁なクエリ TTL期限切れ
OPcache php.ini PHPバイトコード 再起動まで有効

(1) ▶ サンプル:ShopMetrics Redisキャッシュ戦略

PHP
// app/Services/DashboardService.php
class DashboardService
{
    public function getOverview(Tenant $tenant): array
    {
        return Cache::remember(
            "dashboard:{$tenant->id}:overview",
            now()->addMinutes(15),
            fn () => $this->calculateOverview($tenant)
        );
    }

    public function getTopProducts(Tenant $tenant, string $period = '7d'): Collection
    {
        return Cache::remember(
            "dashboard:{$tenant->id}:top-products:{$period}",
            now()->addHours(1),
            fn () => $this->calculateTopProducts($tenant, $period)
        );
    }

    public function getRevenueChart(Tenant $tenant, string $granularity = 'daily'): array
    {
        return Cache::remember(
            "dashboard:{$tenant->id}:revenue:{$granularity}",
            now()->addMinutes(30),
            fn () => $this->calculateRevenueChart($tenant, $granularity)
        );
    }

    public function invalidateTenantCache(Tenant $tenant): void
    {
        $prefix = "dashboard:{$tenant->id}";
        // Redis KEYSは大規模データセットで遅い。タグまたはプレフィックススキャンを使用
        Cache::getStore()->getConnection()->del(
            ...Cache::getStore()->getConnection()->keys("{$prefix}:*")
        );
    }
}

// コントローラー
class DashboardController extends Controller
{
    public function __construct(
        private DashboardService $dashboard
    ) {}

    public function index(Tenant $tenant)
    {
        return response()->json([
            'overview' => $this->dashboard->getOverview($tenant),
            'top_products' => $this->dashboard->getTopProducts($tenant),
            'revenue_chart' => $this->dashboard->getRevenueChart($tenant),
        ]);
    }
}

出力:

TEXT
// 実行成功

(3) キャッシュタグと一括期限切れ

PHP
// タグ付きキャッシュ (Redis/databaseドライバーのみ)
Cache::tags(['dashboard', "tenant:{$tenant->id}"])->put(
    'overview',
    $data,
    3600
);

// テナントの全ダッシュボードキャッシュを無効化
Cache::tags(["tenant:{$tenant->id}"])->flush();

// またはキャッシュキーの規約にプレフィックススキャンを使用
Cache::forget("dashboard:{$tenant->id}:overview");
Cache::forget("dashboard:{$tenant->id}:top-products:7d");
Cache::forget("dashboard:{$tenant->id}:revenue:daily");

5. データベース最適化

(1) インデックス戦略

PHP
// マイグレーション:ShopMetricsクエリ用のインデックスを追加
Schema::table('orders', function (Blueprint $table) {
    // 単一カラムインデックス
    $table->index('tenant_id', 'idx_orders_tenant');
    $table->index('created_at', 'idx_orders_date');
    $table->index('status', 'idx_orders_status');

    // 複合インデックス (複数条件クエリで最も有用)
    $table->index(['tenant_id', 'created_at'], 'idx_orders_tenant_date');
    $table->index(['tenant_id', 'status', 'created_at'], 'idx_orders_tenant_status_date');

    // ユニークインデックス
    $table->unique(['tenant_id', 'external_id'], 'uniq_orders_tenant_external');
});

(2) インデックスタイプの比較

インデックスタイプ 構文 用途 説明
Index $table->index(col) WHERE/ORDER BY 標準Bツリー
Unique $table->unique(col) 一意制約 Bツリー + 重複排除
複合 $table->index([c1,c2]) 複数条件クエリ 最左プレフィックスに従う
FullText $table->fullText(col) テキスト検索 MySQL 8.0+
Spatial $table->spatialIndex(col) 地理クエリ POINT/LINESTRING

(3) EXPLAIN:クエリの分析

SQL
-- スロークエリの分析
EXPLAIN SELECT * FROM orders 
WHERE tenant_id = 1 AND status = 'completed' 
ORDER BY created_at DESC LIMIT 20;

-- 確認ポイント:
-- type: ALL (フルスキャン)→ インデックスが必要
-- key: NULL (インデックス未使用)→ インデックスが必要
-- rows: 大きな数 → 最適化が必要
-- Extra: Using filesort → 複合インデックスが必要

(1) ▶ サンプル:ShopMetricsスロークエリの診断と最適化

SQL
-- ❌ 修正前:500万行のフルテーブルスキャン (3000ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ALL, rows: 5,000,000, key: NULL

-- インデックスの追加
ALTER TABLE orders ADD INDEX idx_orders_tenant (tenant_id);

-- ✅ 修正後:インデックススキャン (5ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ref, rows: 50,000, key: idx_orders_tenant

-- ❌ まだ遅い:インデックスなしのソート (500ms)
EXPLAIN SELECT * FROM orders 
WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;
-- Extra: Using filesort

-- ✅ 複合インデックス (3ms)
ALTER TABLE orders ADD INDEX idx_orders_tenant_date (tenant_id, created_at DESC);

出力:

TEXT
CREATE TABLE

(4) 読み書き分離

PHP
// config/database.php
'mysql' => [
    'read' => [
        'host' => env('DB_READ_HOST', 'read-replica.shopmetrics.internal'),
    ],
    'write' => [
        'host' => env('DB_WRITE_HOST', 'primary.shopmetrics.internal'),
    ],
    'sticky' => true,  // 同一リクエスト内で書き込み後に書き込みホストから読み取り
    'driver' => 'mysql',
    'database' => env('DB_DATABASE', 'shopmetrics'),
    // ...
],

6. OPcacheとPHP-FPMの最適化

(1) OPcache設定

INI
; php.ini or php.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256        ; バイトコードキャッシュに256MB
opcache.interned_strings_buffer=32    ; 文字列に32MB
opcache.max_accelerated_files=40000   ; Laravelは約12000ファイル
opcache.validate_timestamps=0         ; 本番ではOFF (手動リセット)
opcache.save_comments=1               ; Laravelアノテーションに必要
opcache.jit=1255                      ; PHP 8.2 JIT:トレーシングモード
opcache.jit_buffer_size=128M          ; 128MB JITバッファ

(2) PHP-FPMチューニング

INI
; php-fpm.d/www.conf
pm = dynamic
pm.max_children = 50          ; = (RAM - OS) / avg_per_process
pm.start_servers = 10         ; = min_spare + (max - min) / 2
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000        ; メモリリーク防止

(3) OPcacheのリセット

BASH
# デプロイ後, OPcacheをリセット
php artisan optimize:clear
# またはルート経由
Route::get('/opcache-clear', function () {
    if (app()->environment('production')) {
        opcache_reset();
    }
    return 'OPcache cleared';
})->middleware('auth:api');

(1) ▶ サンプル:ShopMetrics本番キャッシュワンクリックスクリプト

BASH
#!/bin/bash
# deploy-optimize.sh — デプロイ後に毎回実行

set -e

echo "→ すべてのキャッシュをクリア..."
php artisan optimize:clear

echo "→ 設定をキャッシュ..."
php artisan config:cache

echo "→ ルートをキャッシュ..."
php artisan route:cache

echo "→ ビューをキャッシュ..."
php artisan view:cache

echo "→ マイグレーションを実行..."
php artisan migrate --force

echo "→ キューワーカーを再起動..."
php artisan queue:restart

echo "→ OPcacheをクリア..."
php artisan optimize

echo "✓ 最適化完了!"

出力:

TEXT
# コマンド実行成功

7. 負荷テスト

(1) ツールの比較

ツール 言語 利点 用途
Apache Bench (ab) C シンプル, 依存なし 単一APIエンドポイントの迅速なストレステスト
k6 JS 柔軟なスクリプト, CIに最適 複雑なシナリオ + CI
wrk C 高同時接続, 低リソース 極端な同時テスト
JMeter Java 豊富なGUIとプラグイン 複雑なエンタープライズレベルテスト

(2) k6負荷テストスクリプト

JAVASCRIPT
// k6-scripts/dashboard-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 20 },   // 20ユーザーまで増加
    { duration: '2m', target: 20 },    // 20を維持
    { duration: '30s', target: 50 },   // 50まで増加
    { duration: '2m', target: 50 },    // 50を維持
    { duration: '30s', target: 0 },    // 減少
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95%のリクエスト < 500ms
    http_req_failed: ['rate<0.01'],    // エラー率 < 1%
  },
};

const BASE_URL = 'https://api.shopmetrics.io';
const TOKEN = __ENV.API_TOKEN;

export default function () {
  const params = {
    headers: { Authorization: `Bearer ${TOKEN}` },
  };

  // ダッシュボードAPIのテスト
  const res = http.get(`${BASE_URL}/api/v1/dashboard`, params);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'has overview data': (r) => JSON.parse(r.body).overview !== null,
  });

  sleep(1);
}

(3) クイック負荷テストコマンド

BASH
# Apache Bench:100同時ユーザー, 合計1000リクエスト
ab -n 1000 -c 100 -H "Authorization: Bearer $TOKEN" \
   https://api.shopmetrics.io/api/v1/dashboard

# wrk:50接続で30秒間
wrk -t4 -c50 -d30s -H "Authorization: Bearer $TOKEN" \
    https://api.shopmetrics.io/api/v1/dashboard

# k6:閾値チェック付きで負荷テストを実行
k6 run --env API_TOKEN=$TOKEN k6-scripts/dashboard-load.js

8. 総合サンプル:ShopMetrics最適化のビフォーアフター比較

PHP
// ============================================
// 総合:ダッシュボードAPIの最適化
// すべての手法を適用したビフォーアフター比較
// ============================================

// ❌ ビフォー:遅いダッシュボード (5秒, 200+クエリ)
class DashboardController extends Controller
{
    public function index(Request $request)
    {
        $tenant = $request->user()->tenant;

        $orders = $tenant->orders()
            ->whereMonth('created_at', now()->month)
            ->get();  // 全カラムをロード, eager loadingなし

        return response()->json([
            'total_revenue' => $orders->sum('total'),
            'order_count' => $orders->count(),
            'top_shops' => $orders->groupBy('shop_id')  // shopでN+1
                ->map->sum('total')
                ->sortDesc()
                ->take(5),
            'recent_orders' => $orders->sortByDesc('created_at')
                ->take(10)
                ->map(fn ($o) => [
                    'id' => $o->id,
                    'customer' => $o->customer->name,  // customerでN+1
                    'total' => $o->total,
                ]),
        ]);
    }
}

// ✅ アフター:高速ダッシュボード (100ms, 3クエリ, キャッシュ付き)
class DashboardController extends Controller
{
    public function index(Request $request)
    {
        $tenant = $request->user()->tenant;

        $data = Cache::remember(
            "dashboard:{$tenant->id}:overview",
            now()->addMinutes(15),
            fn () => $this->buildDashboardData($tenant)
        );

        return response()->json($data);
    }

    private function buildDashboardData(Tenant $tenant): array
    {
        // 集計を含む単一の最適化クエリ
        $stats = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->whereMonth('created_at', now()->month)
            ->select([
                DB::raw('SUM(total) as total_revenue'),
                DB::raw('COUNT(*) as order_count'),
            ])
            ->first();

        // トップショップ:1クエリ
        $topShops = DB::table('orders')
            ->join('shops', 'orders.shop_id', '=', 'shops.id')
            ->where('orders.tenant_id', $tenant->id)
            ->whereMonth('orders.created_at', now()->month)
            ->groupBy('shops.id', 'shops.name')
            ->orderByDesc('revenue')
            ->limit(5)
            ->select('shops.name', DB::raw('SUM(orders.total) as revenue'))
            ->get();

        // Eager loading付きの最近の注文:1クエリ
        $recentOrders = Order::with('customer:id,name')
            ->where('tenant_id', $tenant->id)
            ->select(['id', 'customer_id', 'total', 'created_at'])
            ->latest()
            ->limit(10)
            ->get()
            ->map(fn ($o) => [
                'id' => $o->id,
                'customer' => $o->customer->name,
                'total' => $o->total,
            ]);

        return [
            'total_revenue' => (float) $stats->total_revenue,
            'order_count' => (int) $stats->order_count,
            'top_shops' => $topShops,
            'recent_orders' => $recentOrders,
        ];
    }
}
100%
flowchart LR
    subgraph "ビフォー:5000ms"
        A1[200+ SQLクエリ] --> A2[キャッシュなし]
        A2 --> A3[インデックスなし]
        A3 --> A4[N+1ロード]
    end
    subgraph "アフター:100ms"
        B1[3 SQLクエリ] --> B2[Redisキャッシュ 15分]
        B2 --> B3[複合インデックス]
        B3 --> B4[Eager Loading]
    end
    A4 -->|最適化| B1

❓ よくある質問

Q キャッシュはいつ使うべきですか?
A 頻繁に読み取られるが書き込みが少ないデータのみキャッシュしてください。レポート, ダッシュボード, 統計データはキャッシュに適しています (15〜60分のTTL)。リアルタイムデータはキャッシュしないでください。キャッシュには必ず有効期限ポリシーを含め, データ変更時にプロアクティブにクリアする必要があります。
Q Eager loadingとLazy loadingのどちらを選ぶべきですか?
A デフォルトではEager loading (with())を使用し, 関連データにアクセスしないことが確実な場合のみLazy loadingを使用してください。Laravel Debugbarでクエリ数を監視してください—N+1問題がすぐに分かります。
Q chunkとcursorのどちらが良いですか?
A cursor()はメモリ使用量が最も少ないですが, 1行ずつ処理するしかなく, 行のスキップや同時書き込みの安全性がありません。chunkById()はより安定していて本番環境のバッチ処理に適しています。ほとんどのシナリオではchunkById()を使用します。
Q インデックスを追加してもクエリがまだ遅い場合はどうすればよいですか?
A EXPLAINでインデックスが実際に使用されているか確認します (keyカラム)。インデックスカラムの順序が最左プレフィックス原則に従っているか確認します。大規模な結果セットでソートが発生していないか確認します (filesort)。カバリングインデックスでテーブルスキャンを減らすことも検討してください。
Q 開発環境でOPcacheを有効にすべきですか?
A 開発環境では有効にしないか, opcache.validate_timestamps=1に設定してください。そうしないとコード変更が反映されず, デバッグ時に混乱します。本番環境では必ず有効にし, validate_timestamps=0に設定してください。
Q 負荷テストでは何人の同時ユーザーを使うべきですか?
A 想定ピークの2〜5倍から始めます。例えば, 1日のアクティブユーザーが1,000人でピーク同時接続約100人の場合, 200〜500同時ユーザーでテストします。平均ではなくp95とp99のレイテンシ値に注目してください。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetricsでN+1クエリを特定し, with()で修正し, 最適化前後のクエリ数を比較してください (DB::enableQueryLog()で記録)。

  2. 応用問題 (⭐⭐):ShopMetricsダッシュボードAPIにRedisキャッシュ (15分TTL)を追加し, データ変更時の自動キャッシュクリアを実装してください。k6スクリプトを書いてキャッシュヒット率をテストしてください。

  3. チャレンジ (⭐⭐⭐):ShopMetrics注文一覧APIを完全に最適化してください—複合インデックス (tenant_id + status + created_at)の追加, Collection操作をDB::table集計クエリに置き換え, キャッシュを実装し, EXPLAINでパフォーマンスを検証してください。目標p95 < 200ミリ秒。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%