サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

【現場完全ガイド】Laravelのキュー設計と非同期処理――Jobs/Queues/Horizon、再試行・冪等性、遅延・優先度、失敗隔離、外部API連携、ユーザー通知、アクセシブルな進捗UI

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

【現場完全ガイド】Laravelのキュー設計と非同期処理――Jobs/Queues/Horizon、再試行・冪等性、遅延・優先度、失敗隔離、外部API連携、ユーザー通知、アクセシブルな進捗UI

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

  • 「キュー化すべき処理」と「同期のままで良い処理」の見極め方
  • Laravel Jobs/Queues の基本構成と、Redis・DBドライバの選び方
  • 再試行(tries/backoff)、タイムアウト、失敗隔離(dead letter)的な運用
  • 冪等性(idempotency)で二重実行を防ぎ、外部API連携を安全にする方法
  • 優先度/キュー分割、遅延実行、バッチ、ジョブ連鎖(chain)と現場の使い分け
  • Horizonでの監視、アラート、ワーカー運用(再起動・スケール)
  • 非同期処理の“待ち時間”をユーザーに伝えるアクセシブルなUI(role="status"aria-live、色非依存、再試行導線)

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

  • Laravel 初〜中級エンジニア:メール送信やエクスポートをキュー化したいが、失敗や二重実行が怖い方
  • テックリード/運用担当:ジョブ遅延や失敗が見えず、障害対応が後手になるのを避けたい方
  • PM/CS:非同期処理の進捗・完了通知を整え、問い合わせや不満を減らしたい方
  • デザイナー/QA/アクセシビリティ担当:待ち状態や完了/失敗の案内を「だれでも理解できる」形に統一したい方

アクセシビリティレベル:★★★★★

非同期処理は“待つ”が発生するため、アクセシビリティの影響が大きい領域です。進捗や結果をテキストで伝え、読み上げ・キーボード操作で完遂できる導線を、具体例で標準化します。


1. はじめに:キューは「速くする」だけでなく「壊れにくくする」ためにあります

Laravelでキューを導入する理由は、単に画面を速くするためだけではありません。本質は、重い処理や不安定な処理をリクエストから切り離し、失敗しても再試行できる形にすることで、アプリ全体を壊れにくくすることです。

メール送信、PDF生成、CSVエクスポート、外部API連携、検索インデックス更新、集計の素材化などは、同期でやるほど不安定になります。ユーザーを待たせ、タイムアウトし、失敗の原因も見えにくいからです。キュー設計を整えると、成功率が上がり、障害時の切り分けも速くなり、運用が落ち着きます。


2. キュー化すべき処理の判断基準(迷ったらここを見る)

キュー化を検討する目安は、次のいずれかに当てはまるかどうかです。

  • 1回の処理が重い(数秒以上、CPU/メモリを食う)
  • 外部APIやメールなど、ネットワークに依存して失敗しやすい
  • まとめ処理(大量データのエクスポート、バッチ更新)がある
  • 多少遅れてもよい(数十秒〜数分後に完了してもUXが成立する)
  • リトライや失敗隔離が必要(再実行できる設計にしたい)

逆に、同期のままが良いものもあります。

  • 画面遷移に必要な最低限の保存(例:注文の作成そのもの)
  • ユーザーが即座に結果を必要とする操作(ただし“裏で追加処理”はキュー化しやすい)
    この場合は「核となる保存は同期」「通知や集計や連携は非同期」という分解が現実的です。

3. キューの土台:ドライバ選定と基本設定

3.1 ドライバの考え方

  • Redis:高速で一般的。Horizonとの相性が良い
  • Database:導入は簡単だが、高負荷では伸びにくいことがある
  • SQSなどクラウド:運用が楽になるが、設計・コスト・可観測性の検討が必要

小〜中規模のSaaSなら、Redis+Horizonが扱いやすいことが多いです。最初の導入障壁が低く、ジョブ遅延が見える化しやすいからです。

3.2 基本設定(例)

  • .envQUEUE_CONNECTION=redis
  • config/queue.php:キュー名、リトライ秒、失敗ジョブの保持方針などを明示
  • 失敗ジョブ用のテーブル(failed_jobs)の用意(必要な構成で)

4. Jobの最小実装:扱いやすい粒度に分割する

まずは「1ジョブ=1目的」に近い形で作ると、リトライや失敗の意味が読みやすくなります。

// app/Jobs/SendWelcomeMail.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
use App\Models\User;
use App\Mail\WelcomeMail;

class SendWelcomeMail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $userId) {}

    public $tries = 5;
    public $timeout = 60;

    public function backoff(): array
    {
        return [10, 30, 60, 120, 300];
    }

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);
        Mail::to($user->email)->send(new WelcomeMail($user));
    }
}

ポイント

  • モデルを丸ごと渡すより、id を渡す方が安全です(シリアライズや状態変化に強いです)。
  • tries/timeout/backoff を明示すると、運用が急に落ち着きます。
  • 例外が出たら失敗として扱い、監視で気づけるようにします(握りつぶさないのが基本です)。

5. 再試行設計:失敗の種類を分けると強いです

「失敗」は全部同じではありません。現場では次の2種類に分けると判断が速くなります。

  • 一時的失敗(ネットワーク、外部APIの短期不調)
    • リトライで成功しやすい
  • 恒久的失敗(入力データ不正、対象が削除済み、権限不足)
    • 何度やっても失敗するので、隔離して人が見るべき

5.1 失敗を早く諦める(fail fast)

例えば外部APIの400系が返るなら、リトライしても無駄なことが多いです。その場合は例外の種類やHTTPステータスで分岐し、早めに fail() 相当の扱いに寄せるのが現実的です。

5.2 タイムアウトの考え方

  • 短すぎると成功率が落ちる
  • 長すぎるとワーカーが詰まり、遅延が連鎖します

「通常のp95の倍くらい」を起点に、遅延状況を見ながら調整すると安全です。


6. 冪等性(idempotency):二重実行を“仕組みで防ぐ”

キューは、同じジョブが複数回実行され得ます。

  • 再試行
  • ワーカー再起動
  • タイムアウト後の再投入
  • ネットワークの揺れ
    この前提で設計しないと、「メールが二通届く」「決済が二重に走る」「ポイントが二重付与される」などの事故が起きます。

6.1 代表的な冪等性の作り方

  • ジョブ固有の idempotency_key を持つ
  • 処理の開始時に Cache::lock() やDBの一意制約で二重実行を防ぐ
  • すでに完了済みなら“何もしない”で終了する

例:同じ請求書メールを二重送信しない

public function handle(): void
{
    $key = "invoice_mail:{$this->invoiceId}";
    $lock = cache()->lock($key, 120);

    if (!$lock->get()) {
        return; // すでに実行中(または直近に実行)
    }

    try {
        $invoice = Invoice::findOrFail($this->invoiceId);

        if ($invoice->mail_sent_at) {
            return; // 送信済みなら何もしない(冪等)
        }

        // 送信処理
        Mail::to($invoice->user->email)->send(new InvoiceMail($invoice));

        $invoice->forceFill(['mail_sent_at' => now()])->save();
    } finally {
        $lock->release();
    }
}

ポイント

  • 「送信済みフラグ」をDBに残すと、リトライや再実行に強くなります。
  • キャッシュロックは便利ですが、最終的にはDBの状態で完了確認できる形が安心です。

7. キュー分割と優先度:遅延の連鎖を止める

キューを1本にすると、重いジョブが詰まったときに、軽いジョブまで遅れます。現場では、用途ごとにキューを分けると安定します。

  • high:ユーザー操作に近い(通知、軽い連携、即時性が高い)
  • default:通常
  • low:集計、検索インデックス、エクスポートなど時間がかかる
SendWelcomeMail::dispatch($user->id)->onQueue('high');
RecalcDailyUsage::dispatch($tenantId)->onQueue('low');

ワーカーもキュー別に並行稼働させると、遅延が局所化して読みやすくなります。


8. 遅延実行・ジョブ連鎖・バッチ:実務での使い分け

8.1 遅延実行(delay)

「5分後に再試行」や「一定時間後にフォローアップ通知」などに便利です。

SendFollowUpMail::dispatch($user->id)->delay(now()->addMinutes(10));

8.2 連鎖(chain)

順番が重要な処理(例:ファイル生成→アップロード→通知)に向きます。

Bus::chain([
  new GenerateReport($reportId),
  new UploadReport($reportId),
  new NotifyReportReady($reportId),
])->dispatch();

8.3 バッチ(batch)

大量ジョブをまとめて扱い、進捗や失敗を集計したい場合に向きます。CSVエクスポートや一括更新で特に便利です。


9. 外部API連携の型:タイムアウト、再試行、フォールバック

外部APIは落ちます。だからこそ、キュー化と相性が良いです。

  • timeout を短めに明示する
  • リトライ回数・間隔を決める
  • 失敗したら隔離して手動復旧できる導線を用意
  • 可能ならフォールバック(キャッシュ値、後で再実行)

LaravelのHTTPクライアントも、ジョブの中で使うと扱いやすいです。

$res = \Illuminate\Support\Facades\Http::timeout(10)
  ->retry(3, 200)
  ->post($url, $payload);

if ($res->failed()) {
  throw new \RuntimeException('external api failed');
}

「失敗したら例外」で良いのは、ジョブが再試行/失敗隔離を担ってくれるからです。同期処理よりも、設計がシンプルになります。


10. 失敗ジョブの運用:隔離して“見える化”する

キューは「失敗をゼロにする」より、「失敗しても回復できる」に寄せるのが現実的です。そこで重要なのが次です。

  • 失敗ジョブが増えたら気づける(通知/アラート)
  • 失敗理由が追える(例外、ジョブ名、対象ID、trace_id)
  • リトライ手順が決まっている(再投入の条件、手動修正の条件)
  • 恒久失敗はデータ修正やUI案内に繋げる(“直すべき根本原因”が見える)

ジョブ失敗のログには、次の情報を入れると調査が速いです。

  • 対象ID(userId、orderIdなど)
  • テナントID(マルチテナントなら)
  • trace_id(リクエスト由来の処理なら)
  • 外部APIのレスポンス概要(機微情報はマスク)

11. Horizonでの監視:遅延・失敗が見えるだけで安心が増えます

Horizon(Redis前提)を使うと、

  • キューごとの処理数
  • 失敗数
  • 待ち(遅延)
  • ワーカーの状態
    が見えます。

運用で特に見たいのは次の指標です。

  • queue_wait_time(待ち時間が増えるとユーザー影響が出やすい)
  • 失敗率(急増は外部障害やデプロイ起因の可能性)
  • ジョブの処理時間の増加(重くなって詰まり始める兆候)

アラートは最初は少なく、

  • 失敗急増
  • 待ち時間が一定閾値超え
  • ワーカー停止
    から始めると運用が回りやすいです。

12. ユーザー通知とアクセシブルな進捗UI:非同期の“分かりにくさ”をなくす

非同期処理は、ユーザーから見ると「押したのに何も起きない」に見えやすいです。ここを丁寧にすると、体験も問い合わせも改善します。

12.1 最低限の3段階

  • 開始:受け付けたこと
  • 進行:処理中であること(必要なら)
  • 完了/失敗:結果と次の行動

12.2 画面の標準例(開始〜完了の通知)

@if(session('status'))
  <div role="status" aria-live="polite" class="border p-3 mb-4">
    {{ session('status') }}
  </div>
@endif

@if(session('error'))
  <div role="alert" class="border p-3 mb-4">
    {{ session('error') }}
  </div>
@endif

開始時の案内(例:エクスポート)

  • 「エクスポートを開始しました。完了すると通知します。」
  • 可能なら「通常は数分以内に完了します。」のような期待値も添えます(時間は断定しすぎないのが安心です)

12.3 進捗(ポーリング/イベント)を入れる場合の注意

  • 進捗は数値(例:40%)をテキストで示す
  • aria-live="polite" を使い、頻繁すぎる更新は避ける
  • スピナーの色だけで状態を表さない
  • キーボードで「キャンセル」「戻る」ができる導線を残す

例:進捗の読み上げ(概念)

<div id="progress" role="status" aria-live="polite">準備中です。</div>

12.4 失敗時の導線(重要)

失敗したときに「エラーです」だけだと、ユーザーは詰みます。

  • 再試行(ボタン/リンク)
  • 条件を変える提案(ファイルサイズを下げる、期間を短くする)
  • 問い合わせ用のID(trace_id など)
    この3つがあるだけで、ストレスが大きく減ります。

13. テスト:キューはFakeで“仕様”として守る

キューのテストは、実際に走らせるより、まず Queue::fake() で「投げられているか」を固定すると安定します。

use Illuminate\Support\Facades\Queue;

public function test_export_dispatches_job()
{
    Queue::fake();

    $user = User::factory()->create();
    $this->actingAs($user);

    $this->post('/export', ['range' => 'last_30_days'])
        ->assertRedirect()
        ->assertSessionHas('status');

    Queue::assertPushed(\App\Jobs\ExportCsv::class);
}

外部APIが絡むジョブは、HTTPもFakeにして成功/失敗パターンを作ると、リトライ方針の回帰が防げます。


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

  • ジョブが重すぎてワーカーが詰まる
    • 回避:キュー分割、ジョブ粒度の分割、timeout調整、集計は素材化
  • 二重実行でメールや決済が重複する
    • 回避:冪等性キー、送信済みフラグ、ロック
  • 失敗が見えない(気づいた時には大量)
    • 回避:Horizon/アラート、失敗通知、ログの構造化
  • 外部APIが不安定でリトライが暴れる
    • 回避:回数上限、バックオフ、恒久失敗の判定、サーキットブレーカ的な抑制(段階導入)
  • ユーザーが「何が起きたか分からない」
    • 回避:開始/完了/失敗のメッセージ、role="status"、再試行導線

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

設計

  • [ ] キュー化すべき処理が整理されている(重い/不安定/遅れてよい)
  • [ ] 1ジョブ1目的で粒度が適切
  • [ ] 冪等性(送信済み、完了済み判定)がある
  • [ ] キュー分割(high/default/low)で遅延が局所化されている

運用

  • [ ] tries/backoff/timeout を明示
  • [ ] 失敗ジョブの見える化(監視/アラート/ログ)
  • [ ] 再投入手順(条件、責任者)がある
  • [ ] 外部APIはtimeout/retry方針がある

UX/アクセシビリティ

  • [ ] 開始/完了/失敗をテキストで案内
  • [ ] role="status"/aria-live を必要箇所で使用
  • [ ] 失敗時に再試行/代替/問い合わせIDがある
  • [ ] 色だけに依存しない進捗表示

テスト

  • [ ] Queue::fake() でディスパッチを固定
  • [ ] 外部APIは Http::fake() で成功/失敗を再現
  • [ ] 重要ジョブは失敗時の挙動(隔離/通知)もテスト

16. まとめ

Laravelのキューは、非同期化によって「速さ」と「壊れにくさ」を同時に手に入れられる強い仕組みです。ポイントは、再試行だけに頼らず冪等性で二重実行を防ぎ、キュー分割で遅延を局所化し、Horizonやアラートで“見える化”すること。そして、非同期だからこそ、ユーザーには開始/完了/失敗を分かりやすく伝える必要があります。読み上げやキーボード操作でも迷子にならない案内を標準にすると、体験も運用も驚くほど落ち着きます。まずは「エクスポート」「メール」「外部API」のどれか一つを、丁寧にキュー化してみてくださいね。


参考リンク

モバイルバージョンを終了