【現場完全ガイド】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 基本設定(例)
.env:QUEUE_CONNECTION=redisconfig/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」のどれか一つを、丁寧にキュー化してみてくださいね。
参考リンク
- Laravel 公式
- 信頼性・運用の考え方
- アクセシビリティ
