バックグラウンドジョブキューの設計 は何をしますか?
バックグラウンドジョブシステムには、初日から設計しなければならない3つの障害モードがあります。それは、ジョブ喪失、重複実行、そしてリトライ暴走です。間違ったプリミティブを選ぶと、その後1年間、これら3つの問題の修正に追われることになります。BullMQ、Sidekiq、SQS、Temporalのドキュメントに共通する業界のコンセンサスは、at-least-once(最低1回)が唯一正直な配送セマンティクスであるということであり、すべてのハンドラーを冪等に設計し、exactly-once(厳密1回)を追い求めるのはやめるべきです。(Caduh — Queues 101)
お困りの内容からジャンプ:
- リトライ時にジョブが2回実行される → 冪等性
- ワーカーがジョブ処理中にクラッシュし、ジョブが消失した → 信頼性のある取得 / 可視性タイムアウト
- DLQが一杯になり、アラートが発報している → エラー分類 + DLQ
- バックログ中にRedisがOOMになった → メモリ衛生
- 「これってTemporalを使うべき?」 → キュー vs ワークフロー
- リトライストームが下流に影響を与えている → バックオフ + ジッター
- 並行実行がレート制限を超過した → 並行性 + リミッター
使用するタイミング
- Node、Ruby、Python、Goで新しいバックグラウンドジョブシステムを設計する場合。
setTimeout/cron/ 自作キューから本格的なブローカーに移行する場合。- ワーカークラッシュがジョブを喪失している場合。
- 本番環境でのリトライが重複した副作用を引き起こしている場合。
- BullMQ (Node + Redis)、Sidekiq (Ruby + Redis)、RQ / Celery / Dramatiq (Python)、Asynq (Go + Redis)、SQS (マネージド)、Temporal (永続実行) のいずれかを選択する場合。
中核機能
作業内容に合ったプリミティブを選ぶ
| あなたの状況 | 選択肢 | 理由 |
|---|---|---|
| Nodeで短時間の副作用ジョブ(メール送信、画像リサイズ) | BullMQ | Redisネイティブ、成熟したDLQ、フローパターン、通常1ノードあたり〜5k jobs/sec |
| Rubyで同様のジョブ | Sidekiq (信頼性向上にはPro) | 同様の構成。Proのsuper_fetchはLMOVEを使用した信頼性のある取得を追加 (Sidekiq wiki) |
| 外部APIへのファンアウトを含むマルチステップワークフロー、時間〜日単位の期間 | Temporal | 永続実行。イベント履歴からリプレイ (Temporal docs) |
| AWSネイティブ、シンプルなファンアウト、Redisを運用したくない | SQS + Lambda | マネージドat-least-once。可視性タイムアウト + DLQが第一級 (AWS SQS DLQ) |
| クロスリージョン、マルチテナント分離 | Temporal または SQS | 単一Redisではマルチリージョンをうまく処理できない |
| DB + メール + 支払いでexactly-onceが必要 | なし — at-least-once + 冪等ハンドラーを設計する | ブローカー内の「exactly-once」はシステム間では拡張されない (Caduh) |
隠れた判断基準:リカバリーストーリーの複雑さ。 「領収書を送る」ような単純なジョブにはBullMQで十分です。しかし、24時間ウェブフックを待機する12ステップの購入ワークフローは、12個のチェーンされたキューイングジョブではなく、Temporalワークフローが適しています。
冪等性
必須のプロパティです。人気のあるキューのドキュメントはすべてこれを最初に説明しています。BullMQの冪等ジョブページから:「ジョブが最初の試行で正常に完了するか、最初は失敗してリトライで成功するかで、システムの最終状態に違いがあってはなりません。」 (BullMQ idempotent-jobs)
実践的なパターン:
// 1. 冪等性キーはビジネスイベントから派生させ、キューからは派生させない。
// 上流の event_id があればそれを使用する(Stripeのevent.id、Shopifyのフックidなど)。
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQはキュー内のjobIdで重複排除を行う。
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. ハンドラー内部でも、副作用サイトで重複排除を行う。
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// すでに送信済みなら何もしない。
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
jobIdは、重複がまだキューにある間の追加を重複排除します。DBのユニーク制約は、ワーカークラッシュ、リトライ、リプレイ全体での重複排除を行います。両方が必要です。jobIdだけでは不十分です。なぜならBullMQは完了したジョブを削除し、重複排除ウィンドウが消えてしまうからです。
信頼性のある取得と可視性タイムアウト
ワーカーがジョブを取得してクラッシュした場合、何が起こるでしょうか?
| システム | デフォルトの動作 | リカバリー |
|---|---|---|
| BullMQ | ジョブにはlockDuration(デフォルト30秒)がある。ストールジョブチェッカーが期限切れ後に再キューイングする (BullMQ production) | ワーカーはロックを延長するか、lockDuration内に処理を完了する必要がある。さもなければジョブが2回実行される |
| Sidekiq OSS | Popは非アトミック。ワーカーがpopとprocessの間に死ぬと、ジョブは喪失される | Sidekiq Pro super_fetchにアップグレード(Redis 6.2+ LMOVEをプライベートな作業キューに対して使用) |
| Sidekiq Pro super_fetch | プロセスごとの作業リストにLMOVE。ハートビートは60秒で期限切れ。孤立チェックがスイープして再エンキュー (Sidekiq wiki) | 組み込み |
| SQS | 可視性タイムアウト(デフォルト30秒、最大12時間)。タイムアウト後に削除されていない場合、メッセージが再表示される | タイムアウトをp99ハンドラー遅延より長く設定。長時間ジョブの場合はChangeMessageVisibilityで延長 |
| Temporal | アクティビティにはハートビート + リトライポリシーがある。ワークフロー履歴はワーカーの死亡に耐える | 履歴からリプレイ。これについて考える必要はない |
落とし穴: lockDuration / 可視性タイムアウトがハンドラーのp99より短い → ジョブが2回実行される。許容可能なリカバリー時間より長い → クラッシュした作業が長時間待たされる。まずp99を測定し、タイムアウトをその約3倍に設定し、本当に長時間の作業に対してはハンドラーでハートビート/ロック延長を行う。
エラー分類とDLQ
すべてのエラーが永遠にリトライされるべきではありません。SQSのドキュメントより:「最大受信数を設定し...しきい値を超えた場合にメッセージをDLQに送信する再配信ポリシーを設定します。」 (AWS SQS DLQ)
// スローする前に分類する。永続的なエラーはリトライすべきではない。
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → 初回でDLQへ
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// すぐにDLQへ移動 — リトライしても無駄。
job.discard(); // BullMQ: リトライを防止
throw e;
}
throw e; // ネットワーク/タイムアウト → attemptsポリシーに従いリトライ
}
}
DLQには以下が必要です:
- 増加数に対するアラート(ゼロ以外だけではない — 「DLQに3つ永久にある」のは問題ないが、「DLQが毎分100増加」は火事)。
- リプレイツール(CLIまたは管理UI)。人間が上流のバグを修正し、デッドレターされたジョブをリプレイし、DLQをクリアできるようにする。
- ダッシュボード(
grafana-dashboard-builderを参照)。
バックオフとジッター
指数バックオフだけでは不十分です。12:00:00に下流サービスが瞬間的にダウンしたために1000のジョブがすべて失敗した場合、単純な指数バックオフでは1000のジョブすべてが12:00:01、12:00:03、12:00:07に正確にリトライされ — 下流サービスをダウンさせ続ける可能性のある殺到する群衆(thundering herd)が発生します。フルジッターを追加します:
// AWSスタイルの「フルジッター」: delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
BullMQ推奨のベースラインについては、going-to-productionドキュメントではretryStrategyを使用して1秒から20秒の間のリトライ間隔を推奨しています。(BullMQ production)
並行性とリミッター
ワーカーごとの並行性は、1つのプロセスからN個のジョブを並列実行します。複数のワーカーがそれを増幅します。キュー全体のリミッターがなければ、下流への総呼び出し数を制限できません:
// BullMQ — このキュー上のすべてのワーカーにわたるグローバルレート制限。
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // フリート全体で100 emails/秒
});
正しい構成:多数の同時ワーカー + 下流契約制限のためのキューリミッター。Sidekiqにも同等のレートリミッターがあります。SQSはLambdaの同時実行数またはメッセージあたりのレートを使用します。
メモリ衛生
BullMQの本番稼働で最も強調されている注意点は、Redisインスタンスの**maxmemory-policy noeviction**です。これ以外(allkeys-lru、volatile-lru)は、プレッシャー下でキューイングされたジョブを黙ってエビクトします。(BullMQ production)
さらに:
removeOnCompleteを積極的に設定(例:{ age: 3600, count: 1000 })。removeOnFailはDLQパターンに移行した後はさらに積極的に。- RedisのAOF永続化を有効にする —
appendfsync everysecがBullMQのドキュメントで推奨されるスイートスポットです。(BullMQ production) - ワーカーの
maxRetriesPerRequest: nullは、一時的な切断中にioredisが例外をスローするのを防ぎます。(BullMQ production) - キュー側:
enableOfflineQueue: falseにすると、プロデューサーはバッファリングせずに迅速に失敗します。
グレースフルシャットダウン
process.on('SIGTERM', async () => {
await worker.close(); // 新しいジョブの受け入れを停止。実行中のジョブは完了させる。
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* 同様 */ });
BullMQのプロダクションガイドは明示的に、プロセスを停止する前にワーカーを閉じることを述べています。デフォルトのストーリングタイムアウトは約30秒です。(BullMQ production)
キューvsワークフローの判断
Temporalのドキュメントからの有用なフレーミング:「メッセージキューがサービス間でデータを移動するのに対し、Temporalはプロセス全体をオーケストレーションします...ワークフローのどこにいるか、何が完了し、何が未完了で、何をリトライする必要があるかを把握しています。」 (Temporal blog)
flowchart TD
A[バックグラウンド処理] --> B{単一の副作用か、パイプラインか?}
B -->|単一| C[BullMQ / Sidekiq / SQS]
B -->|マルチステップ| D{ステップは秒単位か、時間以上か?}
D -->|秒単位| E[BullMQ flows / Sidekiq batches]
D -->|時間以上または人間の介入あり| F[Temporal / DBOS / Restate]
C --> G{クロスリージョンまたはマルチテナント分離が必要か?}
G -->|はい| H[SQS または Temporal — 単一Redisクラスターでは不十分]
G -->|いいえ| I[Redisクラスター + AOF + noeviction で運用]
アンチパターン
Redis SET-NXのみに依存した冪等性
症状: Redisの瞬断や再起動時に、同じジョブが2回実行され、重複した副作用(課金、メール、チケット)が発生する。
診断: Redisはキャッシュであり、エビクト、再起動、パーティション分割の可能性がある。Redisのみに存在するSET-NXによる重複排除はベストエフォートである。
修正: 副作用レコード(例: email_sends.idempotency_key)にDBのユニーク制約を持つ冪等性キーを使用する。副作用を作成するDBトランザクション自体が重複排除のプリミティブとなる。
lockDurationがハンドラーp99より短い
症状: ストールジョブログエントリ。同じジョブが2つのワーカーで処理される。顧客からクレーム。
診断: デフォルトのlockDurationは30秒。ハンドラーが時々45秒かかる。2番目のワーカーがジョブをピックアップする。
修正: p99を測定し、lockDurationをp99の約3倍に設定し、長時間ハンドラー内でjob.extendLock()を呼び出す。または作業を分割する。
単純な指数バックオフ(ジッターなし)
症状: 下流の瞬断が下流の障害に発展。10kのジョブがリカバリー中のサービスを一斉に攻撃する。 診断: ジッターがないため、リトライが連動する。 修正: フルジッターバックオフ。最大遅延を制限してDLQ到達時間を限定する。
Redisでのmaxmemory-policy allkeys-lru
症状: 負荷がかかるとキューが不可解にジョブを喪失する。エラーはなし。(BullMQ production)
診断: Redisが容量を確保するためにジョブキーをエビクトした。
修正: CONFIG SET maxmemory-policy noeviction を実行し、起動時にこれを確認する。設定されていない場合、ワーカーは起動を拒否すべき。
システム全体でのexactly-onceを偽装する
症状: 顧客に2回課金。同じメールを2通送信。エンジニアは「ブローカーがexactly-onceだ」と確信している。 診断: FIFO/exactly-onceブローカーでも、その保証はあなたのDB、メールベンダー、Stripe、元帳には拡張されない。 修正: キューをat-least-onceとして扱う。すべての副作用境界で冪等性を確保する。
リプレイツールのないDLQ
症状: 「DLQに3,200のジョブがある。中身がわからない。」 DLQは監視されない墓場になる。 診断: DLQは構築したが、運用ツールは構築しなかった。 修正: IDによるリプレイ、時間範囲によるリプレイ、確認付きドレイン。CLIまたは管理UI。テスト済みであること。
イベントループをブロックする長時間作業
症状: BullMQワーカープロセスがハング。ハートビートが停止。ロックが期限切れ。ジョブが再実行される。
診断: CPUバウンドの作業がキューイングシステムのハートビートと同じプロセスで実行されている。イベントループがlockDuration以上ブロックされる。
修正: CPUバウンドの作業は子プロセスまたはワーカースレッドで実行する。またはサンドボックス化されたプロセッサを使用する(BullMQはジョブごとのプロセスサンドボックスをサポート)。
品質ゲート
- テスト: カオステスト — ジョブ処理中にワーカーを強制終了。ジョブが再実行され、副作用が重複しないことを確認。
- テスト: リトライストームテスト — 下流を60秒間停止。バックオフとジッターがリトライを分散させることを確認(メトリクスに殺到する群衆がないこと)。
- すべてのハンドラーは冪等であり、ビジネスイベントから派生した安定した冪等性キーを使用している。
- DBレベルのユニーク制約が、Redis内の重複排除(jobId、SET-NX)をバックアップしている。
-
lockDuration/ 可視性タイムアウト ≥ 測定されたp99ハンドラー遅延の3倍、かつ長時間ハンドラーはロックを延長している。 - 永続的エラー(
PermanentErrorクラスまたは同等)はリトライをバイパスし、直接DLQに送られる。 - DLQアラート:カウント > 0 ではなく、増加速度が毎分Nを超えた場合にページング。
- DLQリプレイツールが存在し、テスト済みである(単一 + 範囲)。
- Redis:
maxmemory-policy=noeviction、AOF有効(appendfsync everysec)、ワーカー起動時に確認。(BullMQ production) -
removeOnCompleteとremoveOnFailが設定済み。ジョブカウントメトリクスを経時でグラフ化。