TL;DR
個人開発の中古バイク検索サイト MotoHub(https://motohub.jp / Laravel 12 / PHP 8.3 / MySQL / Redis)で、車種詳細ページのコールド表示が 約16秒 かかっていた。
犯人は「render path の中で同期的に叩いていた外部API」だった。具体的には楽天・Google News・YouTube の3つ。これらを 「読む(render)」と「取りに行く(refresh)」に分離 して render path から完全に剥がしたら、
コールド 16秒 → 0.35秒 になった。
設計の考え方と、最後に時間を溶かした opcache のハマりどころまで記録しておく。個人開発だと誰も「このページ16秒だよ」とは言ってくれない。気づいたのは、自分でキャッシュウォーマーを回した夜だった。
症状:ウォーマーを回したら全ページ16〜18秒
全車種ページのキャッシュを事前に温めるウォーマーを回したら、ログがこうなっていた。
[1/5011] OK /bikes/honda/ape (16915ms)
[2/5011] OK /bikes/honda/ape-dx (18561ms)
1ページ16〜18秒。5,000ページあるので終わるわけがないし、外部APIのレート制限・quotaも焼く。ウォーマー以前に「コールドのユーザーが16秒待たされている」のが本質的な問題だった。正直、よく今まで離脱されながら気づかなかったなと思う。
調査:DBは速い。犯人は render path の中の外部API
「キャッシュしてるのに遅い」の意味が最初は分からず、しばらく見当違いの場所(インデックスやN+1)を疑っていた。プロファイルしてみると、DBからページデータを組み立てる部分は 約0.9秒。DBは犯人ではない。
犯人は、ページデータを組み立てる過程で 同期的に呼んでいた外部API だった。
// before:キャッシュの「組み立てクロージャ」の中で外部APIを直に叩いていた
$data = Cache::remember($model->modelDetailCacheKey(), now()->addDays(7), function () use ($model) {
return [
// ...DBから組み立てる処理(ここは ~0.9s)...
'relatedParts' => $this->parts->fetchForModel($model), // ← 楽天APIを8カテゴリ直列。最大48秒
'news' => $this->news->fetchForModel($model), // ← Google News RSS
'videos' => $this->youtube->fetchForModel($model), // ← DB動画が無い車種でYouTube APIにフォールバック
];
});
特に楽天パーツが最悪で、8カテゴリを直列に timeout(5) + sleep(1) で回していたので、遅いと 8〜48秒。
ポイントは「キャッシュしてるのに遅い」ことではなく、キャッシュミスの瞬間(コールド)に外部待ちが全部リクエストに乗る こと。新着車種・ロングテール車種・キャッシュ失効後の最初の1人が、毎回16秒の人柱になっていた。
設計:「読む(render)」と「取りに行く(refresh)」を分ける
原則はシンプルにした。
render path は絶対に外部APIを叩かない。キャッシュを読むだけ。取得は裏方のバッチに追い出す。
各サービスを2つのメソッドに割る。
class BikePartsService
{
private const TTL = 60 * 60 * 24 * 7; // 7日
public static function cacheKey(int $modelId): string
{
return "parts:bike_model:{$modelId}:v2";
}
/** render path はこれだけ呼ぶ。ミスなら空配列を即返し、絶対に外部を叩かない */
public function getForModel(BikeModel $model): array
{
return Cache::get(self::cacheKey($model->id), []);
}
/** 裏方バッチだけが呼ぶ。ここで初めて楽天を叩いてキャッシュに書く */
public function refreshForModel(BikeModel $model): void
{
Cache::put(self::cacheKey($model->id), $this->fetchFromRakuten($model), self::TTL);
}
}
getForModel がキャッシュミス時に潔く [] を返す。これが肝。コールドでも外部待ちがゼロになる。
キャッシュblobの「外」で注入する(凍結回避+デプロイ時の cache:clear 不要)
もう一つの工夫。外部データを model_detail のキャッシュblobの中に入れない。blobはDBデータ(7日)だけにして、外部データはblobの 外 で毎リクエスト read-only 注入する。
// model_detail の blob は DB のみ(~0.9s で組み立て、7日キャッシュ)
$data = Cache::remember(
$model->modelDetailCacheKey(),
now()->addDays(7),
fn () => $this->buildModelDetailData($model) // DBのみ・外部呼び出しなし
);
// blob の「外」で外部データを read-only 注入(毎リクエスト・キャッシュを読むだけなので速い)
$data['relatedParts'] = $this->parts->getForModel($model);
$data['news'] = $this->news->getForModel($model);
$data['videos'] = $this->youtube->getForModel($model); // DB→cache→[]
これで嬉しいことが2つ。
- 凍結回避:外部データを7日blobに焼き込まないので、refreshバッチがキャッシュを更新すれば、再ウォーム無しで次のリクエストから最新が出る。
-
デプロイ時
cache:clear不要:blob外で値を上書きするので、デプロイ前の古いblobに外部データが残っていても無効化される。個人開発でcache:clearを打つのは毎回ちょっと怖いので、これは地味に効く。
実装:parts → news → youtube の3段階
同じパターンを3サービスに横展開し、取得は専用のArtisanコマンドに追い出した。コマンド本体はこれだけ。
class RefreshBikeParts extends Command
{
protected $signature = 'parts:refresh {--limit=800}';
public function handle(BikePartsService $svc): int
{
// 在庫車種を人気順で、失効分(キャッシュが無い分)だけ上限まで
BikeModel::inStock()->orderByPopularity()->get()
->reject(fn ($m) => Cache::has(BikePartsService::cacheKey($m->id)))
->take((int) $this->option('limit'))
->each(fn ($m) => $svc->refreshForModel($m)); // ← 外部APIはここだけ
return self::SUCCESS;
}
}
// routes/console.php — 深夜に分散してローリング実行
Schedule::command('parts:refresh')->dailyAt('02:00')->withoutOverlapping()->runInBackground();
Schedule::command('news:refresh')->dailyAt('02:30')->withoutOverlapping()->runInBackground();
Schedule::command('youtube:refresh')->dailyAt('03:00')->withoutOverlapping()->runInBackground();
ローリングrefreshの設計
在庫車種が4,000件以上あり、楽天は1件あたり約8秒。全件1パスで約9.5時間かかる。なので「全部を毎晩やる」のではなく、
- TTLを7日に延ばす
-
Cache::hasで 失効分のみ を人気順に拾う - 1日あたりの上限(例 800件)で区切る → 7日で全車種を一周&自然にローテーション
- 空結果は短いTTL(24h)で翌日再試行 → 一時的な楽天障害に強い
「失効した分だけを少しずつ」回すのがコツ。
YouTube quota 対策
YouTube Data API は search.list が100ユニット/1日10,000ユニットなので、1日100リクエストが上限。VPS1台の個人開発で quota を溶かすと一発で詰むので、ここは慎重にした。
// youtube:refresh の中。quota超過(403)が来たら静かに止める
try {
$videos = $this->fetchFromYouTube($model); // search.list = 100 units
} catch (YouTubeQuotaException $e) {
$this->warn('quota exceeded — stopping for today.');
return self::SUCCESS; // 保存せず graceful stop
}
Cache::put(self::cacheKey($model->id), $videos, self::TTL);
加えて、1日80件にcap/DBに動画が無い車種だけを人気順に対象化(うちは在庫4,279件中171件だけが該当だった)/動画ゼロは長めのマーカーで再試行抑制。render path から YouTube API を完全に外したので、ウォーマーやクローラーが quota を焼く事故もこれで消えた。
結果
強制コールド(parts/news/youtube/model_detail の全キャッシュをforget)で実測:
| before | after | |
|---|---|---|
| コールド TTFB | 約16秒 | 約0.35秒 |
| render path の外部呼び出し | 3種(楽天/RSS/YouTube) | 0 |
DBのみの組み立てが残るので0.35秒。外部待ちが完全に消えた。
おまけで、ウォーマーも安全に最適化できるようになった。render が外部ゼロになったので、ウォーマーのページGETも0.35秒で済み、全件ウォームを外部レート制限・quotaを気にせず回せる。
ハマりどころ:デプロイしたのに本番が16秒のまま(opcacheの罠)
ここからが本題(?)。ローカルで強制コールド0.35秒を確認して「勝った」と思った15分後の話である。
本番にデプロイして、満を持してウォーマーを回したら——
[1/5011] OK /bikes/honda/ape (16915ms) ← !?
[2/5011] OK /bikes/honda/ape-dx (18561ms)
16秒のまま。 深夜、本番のログだけが16秒を吐き続けるのを見て、静かに血の気が引いた。
切り分けはこうした。
# ① コンテナ内に新コードはあるか?
docker compose exec -T app grep -c "getForModel" app/Http/Controllers/Bike/BikeController.php
# → 新コードは入っている
# ② 本番で強制コールド計測(app を restart した後)
docker compose exec -T app php artisan tinker --execute='Cache::forget(\App\Models\BikeModel::modelDetailCacheKey("honda","ape"));'
curl -s -o /dev/null -w "cold=%{time_starttransfer}s\n" "https://motohub.jp/bikes/honda/ape"
# → cold=0.45s
①でコードは入っている。②は restart 後に計ったら0.45秒。つまり原因は——
opcache が古いオペコードを掴んだままで、本番が古いコードを実行し続けていた。
デプロイ手順の restart が opcache をリサイクルしきれておらず(ログが restart 0/2 で切れていた)、PHP-FPM が古い opcode を持ち続けていた。docker compose restart app でプロセスを綺麗に落とし直したら、新コードが効いてコールドが0.45秒になった。
そして一番の反省は 検証方法 だった。デプロイ後の動作確認に「ウォームなページの200チェック」を使っていたが、
ウォームなページはどっちのコードでも速いので、デプロイの成否を判定できない。
新旧どちらのコードでもキャッシュヒットすれば速い。だから「200だしOK」で通してしまい、反映漏れに気づけなかった。初めて本番をコールドで叩いた(ウォーマー)瞬間に、真実が露呈した。 ウォーム200で「デプロイOK」にしていた過去の自分を、正座させて説教したい。
教訓:
- デプロイ後は必ず 「強制コールド計測」 で検証する(
Cache::forget→time_starttransfer)。 - ウォームな200は嘘をつく。速度改善のデプロイ確認には使えない。
-
restartが本当にプロセスを落とし直したか(opcacheが飛んだか)を確認する。
まとめ
- 一覧/詳細ページが遅いとき、DBより先に 「render path の中で同期的に叩いている外部呼び出し」 を疑う。
- 外部データは「読む(read-only・キャッシュを読むだけ)」と「取りに行く(バッチ)」に分け、render path からは取得を完全に剥がす。
- キャッシュblobの外で read-only 注入すると、凍結回避&デプロイ時の
cache:clear不要という副産物が付いてくる。 - 速度改善の検証は コールドで。ウォーム200に騙されない。
レビュアーもQAもいない個人開発では、自分の「検証の手抜き」が唯一にして最大のバグ源になる。今回それを骨身に染みて理解した。同じ「気づいたら外部API待ちがリクエストに乗っていた」系の沼にハマっている人の役に立てば。