Laravelパフォーマンス最適化の実践
パフォーマンスはSaaSの命綱です—3秒待たされるとユーザーは離れ, スロークエリは1日で数百ドルのデータベース費用を消費します。
1. 学ぶこと
- Eloquentクエリ最適化:Eager Loading / Chunk / Cursor / Lazy
- キャッシュ戦略:Route/Config/Viewキャッシュ + Redisデータキャッシュ
- データベース最適化:インデックス戦略 / EXPLAIN分析 / 読み書き分離
- OPcacheとPHP-FPMチューニング
- 負荷テスト:k6 / Apache Bench負荷テストとボトルネック分析
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ミリ秒に短縮しました。
最適化前: 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クエリ問題
// ❌ 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修正
// ❌ 修正前:ダッシュボードの読み込みに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');
}
出力:
// 実行成功
(2) ▶ サンプル:大規模データセットでのchunkとcursorの使用
// 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();
出力:
// 実行成功
4. キャッシュ戦略
(1) Laravelキャッシュの階層
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キャッシュ戦略
// 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),
]);
}
}
出力:
// 実行成功
(3) キャッシュタグと一括期限切れ
// タグ付きキャッシュ (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) インデックス戦略
// マイグレーション: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:クエリの分析
-- スロークエリの分析
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スロークエリの診断と最適化
-- ❌ 修正前: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);
出力:
CREATE TABLE
(4) 読み書き分離
// 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設定
; 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チューニング
; 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のリセット
# デプロイ後, OPcacheをリセット
php artisan optimize:clear
# またはルート経由
Route::get('/opcache-clear', function () {
if (app()->environment('production')) {
opcache_reset();
}
return 'OPcache cleared';
})->middleware('auth:api');
(1) ▶ サンプル:ShopMetrics本番キャッシュワンクリックスクリプト
#!/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 "✓ 最適化完了!"
出力:
# コマンド実行成功
7. 負荷テスト
(1) ツールの比較
| ツール | 言語 | 利点 | 用途 |
|---|---|---|---|
| Apache Bench (ab) | C | シンプル, 依存なし | 単一APIエンドポイントの迅速なストレステスト |
| k6 | JS | 柔軟なスクリプト, CIに最適 | 複雑なシナリオ + CI |
| wrk | C | 高同時接続, 低リソース | 極端な同時テスト |
| JMeter | Java | 豊富なGUIとプラグイン | 複雑なエンタープライズレベルテスト |
(2) k6負荷テストスクリプト
// 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) クイック負荷テストコマンド
# 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最適化のビフォーアフター比較
// ============================================
// 総合:ダッシュボード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,
];
}
}
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
❓ よくある質問
with())を使用し, 関連データにアクセスしないことが確実な場合のみLazy loadingを使用してください。Laravel Debugbarでクエリ数を監視してください—N+1問題がすぐに分かります。cursor()はメモリ使用量が最も少ないですが, 1行ずつ処理するしかなく, 行のスキップや同時書き込みの安全性がありません。chunkById()はより安定していて本番環境のバッチ処理に適しています。ほとんどのシナリオではchunkById()を使用します。EXPLAINでインデックスが実際に使用されているか確認します (keyカラム)。インデックスカラムの順序が最左プレフィックス原則に従っているか確認します。大規模な結果セットでソートが発生していないか確認します (filesort)。カバリングインデックスでテーブルスキャンを減らすことも検討してください。opcache.validate_timestamps=1に設定してください。そうしないとコード変更が反映されず, デバッグ時に混乱します。本番環境では必ず有効にし, validate_timestamps=0に設定してください。📖 まとめ
- N+1クエリはパフォーマンスキラーです。Eager loadingで1行で解決します
- 大規模データセットには
chunkByIdまたはcursorを使用してメモリオーバーフローを防止します - Redisでクエリ結果をキャッシュし, TTLと手動期限切れの二重安全策を使用します
- 複合インデックスは最左プレフィックス原則に従い, EXPLAINで検証します
- OPcache + PHP-FPMの最適化で本番環境を高速化します
- 負荷テストで最適化の効果を検証し, 目標はp95 < 500ミリ秒
📝 練習問題
-
基本問題 (⭐):ShopMetricsでN+1クエリを特定し,
with()で修正し, 最適化前後のクエリ数を比較してください (DB::enableQueryLog()で記録)。 -
応用問題 (⭐⭐):ShopMetricsダッシュボードAPIにRedisキャッシュ (15分TTL)を追加し, データ変更時の自動キャッシュクリアを実装してください。k6スクリプトを書いてキャッシュヒット率をテストしてください。
-
チャレンジ (⭐⭐⭐):ShopMetrics注文一覧APIを完全に最適化してください—複合インデックス (
tenant_id + status + created_at)の追加, Collection操作をDB::table集計クエリに置き換え, キャッシュを実装し,EXPLAINでパフォーマンスを検証してください。目標p95 < 200ミリ秒。



