1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

車種ページのコールド16秒を0.35秒にした話|renderから外部APIを剥がす3段リファクタと、opcacheに溶かした時間

1
Posted at

TL;DR

個人開発の中古バイク検索サイト MotoHubhttps://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つ。

  1. 凍結回避:外部データを7日blobに焼き込まないので、refreshバッチがキャッシュを更新すれば、再ウォーム無しで次のリクエストから最新が出る。
  2. デプロイ時 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::forgettime_starttransfer)。
  • ウォームな200は嘘をつく。速度改善のデプロイ確認には使えない。
  • restart が本当にプロセスを落とし直したか(opcacheが飛んだか)を確認する。

まとめ

  • 一覧/詳細ページが遅いとき、DBより先に 「render path の中で同期的に叩いている外部呼び出し」 を疑う。
  • 外部データは「読む(read-only・キャッシュを読むだけ)」と「取りに行く(バッチ)」に分け、render path からは取得を完全に剥がす。
  • キャッシュblobの外で read-only 注入すると、凍結回避&デプロイ時の cache:clear 不要という副産物が付いてくる。
  • 速度改善の検証は コールドで。ウォーム200に騙されない。

レビュアーもQAもいない個人開発では、自分の「検証の手抜き」が唯一にして最大のバグ源になる。今回それを骨身に染みて理解した。同じ「気づいたら外部API待ちがリクエストに乗っていた」系の沼にハマっている人の役に立てば。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?