ジョブキューで二重課金を止める:リトライ・冪等性・DLQ実装
「キュー作って」では本番で事故る。BullMQ前提の再試行・冪等性・DLQを、依存なしNode.jsの動くコードで僕が検証した実装メモ。
決済の再請求メールが、同じお客さんに3通飛びました。
カード会社のAPIが一瞬詰まっただけ。リトライ処理は「ちゃんと」動いていて、3回とも健気に再請求をかけ、3回ともメールを送った。コードは正しかったんです。設計が間違っていただけで。
あの日の僕は「キューに積めば非同期になって速くなる」くらいの理解でジョブキューを入れていました。でも、キューの本当の仕事は速さじゃない。失敗をどう扱うかでした。今日はその話を、外部サービスゼロ・コピペで動くNode.jsだけで書きます。
この記事の要点
- ジョブキューは「速くする道具」ではなく、失敗を隔離し・再試行を制御し・二重処理を防ぐための本番基盤。
- キューは「少なくとも1回」届く前提。だから冪等性ガードがないと、メール二重送信・二重課金は必ず起きる。
- 何度やっても失敗する毒メッセージ(poison message)は、有限回リトライ→**DLQ(隔離場所)**へ。無限リトライは復旧直後のサービスを殺す。
- Claude Codeに頼むときは「キュー作って」では足りない。payload設計・retry・冪等性・DLQ・監視まで指示に入れる。
- この記事のコードは3本とも僕が手元で実行確認済み。本番でBullMQやSQSを選ぶ前の「型」として使える。
まず、なぜ同期処理だと詰むのか
Claude CodeでWebサービスを組むと、最初はAPIの中で全部やりたくなります。問い合わせを受けたらその場でメールを送り、画像が来たらその場でリサイズし、決済が失敗したらその場で再請求メールを出す。検証段階ならこれで動きます。
崩れるのは本番に出した瞬間です。外部APIが遅延する。タイムアウトする。ユーザーが送信ボタンを連打する。プロバイダーが勝手に再送してくる。障害から復旧した瞬間に処理が殺到する。同期処理は、このどれか一つで巻き添えになります。メール送信に8秒かかっただけで、ユーザーの画面は8秒固まるわけです。
ジョブキューは「あとで確実に処理したい仕事」を一度箱に入れ、別のワーカー(処理係)が順番に取り出して片付ける仕組みです。APIは「受け付けました」だけ即返す。重い処理は裏に回す。これだけで体感速度も安定性も別物になります。
ただ、Claude Codeに「キューを作って」とだけ頼むと、だいたい薄いサンプルが出てきます。仕事を入れる側(producer)、取り出す側(consumer)、処理に必要なデータ(message payload)、処理中の一時ロック時間(visibility timeout)、再試行(retry)、失敗した仕事の隔離場所(dead-letter queue=DLQ)、同じ仕事が2回来ても結果を二重にしない性質(idempotency=冪等性)。ここまで言葉にして渡さないと、本番では使えません。
キューが守っている5つの仕事
キューは速くするためだけの道具ではない、と何度も言うのは、ここを誤解すると設計を全部間違えるからです。実際にキューが担っているのは、ざっくり次の役割です。
- APIレスポンスを即返す(重い処理を裏に回す)
- 外部サービスの障害を吸収する(落ちている間は溜めておく)
- 失敗した仕事をあとから調査できる状態にする(消さずに隔離する)
- 処理量を制限してDBやメール配信を守る(同時実行数を絞る=backpressure)
- 二重処理を防ぐ(冪等性ガードで結果を一度きりにする)
全体像を図にするとこうです。仕事は入口から入り、処理に成功すれば外部サービスへ、一時的に失敗すればキューへ戻り、何度やっても失敗すればDLQへ隔離される。横で常にメトリクスが流れている。
flowchart LR
A["Producer<br/>API, cron, webhook"] --> B["Queue<br/>message payload"]
B --> C["Consumer<br/>worker process"]
C --> D["External service<br/>mail, image, billing"]
C -- "retryable failure" --> B
C -- "poison message" --> E["DLQ<br/>manual review"]
C --> F["Metrics<br/>logs and alerts"]
用語が並ぶと身構えますが、実務の言葉に置き換えると大したことは言っていません。下の表は、僕がそのままClaude Codeへの依頼文に貼り付けているものです。これを渡すだけで、出てくる設計の質がはっきり変わります。
| 用語 | かんたんな意味 | 実装で決めること |
|---|---|---|
| Producer | 仕事をキューに入れるAPIやバッチ | payloadの形式、重複防止キー、優先度 |
| Consumer | キューから仕事を取るワーカー | 同時実行数、タイムアウト、失敗時の扱い |
| Message payload | ワーカーが読む仕事の中身 | ID、種別、必要最小限のデータ、schema version |
| Visibility timeout | 処理中の仕事を他ワーカーから隠す時間 | p95処理時間より少し長くする |
| Retry | 一時的な失敗をやり直すこと | 最大回数、backoff、失敗理由の記録 |
| DLQ | 何度やっても失敗した仕事の退避先 | 誰が見るか、再投入条件、通知先 |
| Idempotency | 同じ仕事を再実行しても結果が二重にならない性質 | 業務IDの一意制約、処理済みテーブル |
| Backpressure | 処理しきれない量を抑える仕組み | concurrency、rate limit、受付停止条件 |
| Monitoring | 何が詰まっているか見える状態 | キュー深さ、最古ジョブ年齢、失敗率、DLQ件数 |
どんな場面でキューにすべきか
僕が「これはキュー行き」と判断している典型を4つ挙げます。
1つ目はメール送信。 問い合わせ完了メール、パスワードリセット、請求失敗通知、ステップメール。どれもAPIレスポンスの中で送ると、SMTPが詰まった瞬間に画面ごと巻き添えです。送信そのものの設計はClaude Codeでメール自動化とSendGridメール実装に書きました。鉄則は、メール本文やAPIキーをpayloadに入れず、templateId・userId・deliveryIdのような参照IDだけ入れること。
2つ目は画像・動画処理。 アップロード直後にサムネイル生成、WebP変換、ウイルススキャン、字幕生成を同期でやると、ユーザーは延々待たされます。キューにすれば先に「受付完了」を返し、結果はポーリングやWebhookで返せます。ただしCPUを食い尽くしやすいので、同時実行数を無制限にしないのが肝です。
3つ目は請求リトライ。 冒頭で僕がやらかしたやつです。カード決済、請求書発行、サブスク更新は、外部決済APIの一時障害で普通に失敗します。ここで無限リトライをすると、同じ顧客に何度も請求し、プロバイダーのrate limitにも引っかかる。有限回数・指数backoff・DLQ・手動確認の導線をセットで用意します。
4つ目はリードエンリッチメントやレポート生成。 問い合わせ後にCRMへ登録し、会社情報を補完し、営業向けレポートを作る。ユーザーの画面表示とは切り離せる処理です。イベント設計全体はイベント駆動アーキテクチャ、詰まりの見つけ方はログ・モニタリング、機密をpayloadに入れない設計はセキュリティベストプラクティスへつなげて考えると運用がラクになります。
実装1:依存なしのインメモリキュー
説明より動かしたほうが早いです。まずはproducer・consumer・visibility timeout・backpressureを1ファイルで再現します。保存してnode queue-basic-demo.mjsで動きます。プロセスをまたいだ永続化はありませんが、キューの基本動作を体で理解するには十分です。
ポイントは、仕事を「受付済み(ready)」「誰かが処理中(inFlight)」「時間切れで戻す(requeueExpired)」の3状態で分けて扱っているところ。これがキューの背骨です。
// queue-basic-demo.mjs
let nextJobId = 1;
class InMemoryQueue {
constructor({ visibilityTimeoutMs = 800, maxInFlight = 2 } = {}) {
this.visibilityTimeoutMs = visibilityTimeoutMs;
this.maxInFlight = maxInFlight;
this.ready = [];
this.inFlight = new Map();
}
enqueue(type, payload) {
const job = {
id: `job-${nextJobId++}`,
type,
payload,
attempts: 0,
visibleAt: 0,
lockedBy: null,
};
this.ready.push(job);
return job.id;
}
receive(workerId) {
this.requeueExpired();
// backpressure: 処理中が上限なら新しい仕事を渡さない
if (this.inFlight.size >= this.maxInFlight) {
return null;
}
const job = this.ready.shift();
if (!job) return null;
job.attempts += 1;
job.lockedBy = workerId;
job.visibleAt = Date.now() + this.visibilityTimeoutMs;
this.inFlight.set(job.id, job);
return {
id: job.id,
type: job.type,
payload: job.payload,
attempts: job.attempts,
};
}
ack(jobId) {
this.inFlight.delete(jobId);
}
requeueExpired(now = Date.now()) {
// 時間切れの仕事をreadyに戻す(ワーカーが落ちても仕事が消えない)
for (const [jobId, job] of this.inFlight.entries()) {
if (job.visibleAt <= now) {
this.inFlight.delete(jobId);
job.lockedBy = null;
this.ready.push(job);
}
}
}
stats() {
this.requeueExpired();
return {
ready: this.ready.length,
inFlight: this.inFlight.size,
};
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function produce(queue) {
queue.enqueue("email.send", {
deliveryId: "mail-1001",
templateId: "welcome",
userId: "user-42",
});
queue.enqueue("image.resize", {
assetId: "asset-9001",
sizes: [320, 768, 1280],
});
queue.enqueue("report.generate", {
reportId: "weekly-2026-06-02",
accountId: "acct-7",
});
}
async function consume(queue, workerId) {
for (let step = 0; step < 8; step += 1) {
const job = queue.receive(workerId);
if (!job) {
console.log(`${workerId}: no job or backpressure`, queue.stats());
await sleep(120);
continue;
}
console.log(`${workerId}: started ${job.id}`, job.payload);
await sleep(job.type === "image.resize" ? 300 : 90);
queue.ack(job.id);
console.log(`${workerId}: acked ${job.id}`, queue.stats());
}
}
async function main() {
const queue = new InMemoryQueue({
visibilityTimeoutMs: 500,
maxInFlight: 2,
});
produce(queue);
await Promise.all([consume(queue, "worker-a"), consume(queue, "worker-b")]);
console.log("final stats", queue.stats());
}
void main();
実務ではこのready配列が、SQS・RabbitMQ・Redisといった永続ストアに置き換わるだけです。場所が変わっても、「受け付けた仕事」「処理中の仕事」「時間切れで戻す仕事」を分ける考え方は変わりません。
実装2:ワーカーの冪等性ガード
ここが冒頭の事故の本丸です。キューは「少なくとも1回」処理される前提で設計します。裏を返せば、同じジョブが2回来ることがある。メールなら二重送信、請求なら二重課金、ポイントなら二重付与。次のコードは、idempotencyKey(業務上の一意キー)で「処理済み」を記録して二重実行を止める最小パターンです。
肝は3つ。①成功したときだけ”done”にする ②処理中は”processing”でロックする ③失敗したらロックを解除して再試行を許す。この順番を守らないと、ロックが残って二度と処理されない、という別の事故になります。
// idempotent-worker-demo.mjs
const idempotencyStore = new Map();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function withIdempotency(key, work) {
const current = idempotencyStore.get(key);
// すでに完了済みなら、結果を返すだけで再実行しない
if (current?.status === "done") {
return { skipped: true, result: current.result };
}
// 別ワーカーが処理中なら、二重に走らせない
if (current?.status === "processing") {
return { skipped: true, reason: "already processing" };
}
idempotencyStore.set(key, { status: "processing" });
try {
const result = await work();
idempotencyStore.set(key, { status: "done", result });
return { skipped: false, result };
} catch (error) {
// 失敗したらロックを外し、次のリトライを許す
idempotencyStore.delete(key);
throw error;
}
}
async function fakeSendEmail(payload) {
await sleep(50);
return {
providerMessageId: `sg_${payload.deliveryId}`,
sentToUserId: payload.userId,
};
}
async function handleEmailJob(job) {
const key = job.payload.idempotencyKey;
if (!key) throw new Error("missing idempotencyKey");
return withIdempotency(key, () => fakeSendEmail(job.payload));
}
async function main() {
const original = {
id: "job-1",
payload: {
idempotencyKey: "email:welcome:user-42",
deliveryId: "mail-1001",
userId: "user-42",
},
};
// 1回目は送信、2回目(再配送)はskippedになる
console.log(await handleEmailJob(original));
console.log(await handleEmailJob({ ...original, id: "job-1-redelivery" }));
}
void main();
本番ではMapではなく、DBの一意制約、RedisのSETNX、決済プロバイダーが用意しているidempotency keyを使います。Claude Codeに頼むときは「成功時だけ処理済みにする」「処理中ロックを置く」「失敗時はロックを解除する」「payloadにAPIキーやメール本文を入れない」と、この4点を必ず明示してください。言わないと、だいたい成功パスしか書いてくれません。
実装3:retryとDLQ(毒メッセージの隔離)
一時的なネットワーク障害は、リトライで回復します。でも、schemaが壊れたpayload、存在しないユーザーID、権限のない操作は、何度やっても成功しません。この**poison message(毒メッセージ)**を通常キューに戻し続けると、ワーカーは永遠に同じ失敗を繰り返し、後ろの仕事が一生詰まります。
だから「有限回だけリトライ→ダメならDLQへ隔離」という出口が要ります。次のコードは、指数backoff(待ち時間を倍々にする)でリトライし、上限を超えたらdeadへ送ります。normalは一発成功、flakyは2回目で成功、poisonは最後までDLQ送り、という3パターンを再現しています。
// retry-dlq-demo.mjs
let nextRetryJobId = 1;
class RetryQueue {
constructor({ maxAttempts = 3 } = {}) {
this.maxAttempts = maxAttempts;
this.ready = [];
this.delayed = [];
this.dead = [];
this.completed = [];
}
enqueue(payload) {
this.ready.push({
id: `retry-job-${nextRetryJobId++}`,
payload,
attempts: 0,
runAt: Date.now(),
lastError: null,
});
}
moveReadyJobs(now = Date.now()) {
// backoff待ちが明けた仕事をreadyへ戻す
const stillDelayed = [];
for (const job of this.delayed) {
if (job.runAt <= now) {
this.ready.push(job);
} else {
stillDelayed.push(job);
}
}
this.delayed = stillDelayed;
}
retryOrDeadLetter(job, error) {
job.lastError = error.message;
// 上限を超えたらDLQへ隔離
if (job.attempts >= this.maxAttempts) {
this.dead.push(job);
return;
}
// 指数backoff: 50ms, 100ms, 200ms...
const delayMs = 50 * 2 ** (job.attempts - 1);
job.runAt = Date.now() + delayMs;
this.delayed.push(job);
}
async drain(handler) {
let idleRounds = 0;
while (this.ready.length > 0 || this.delayed.length > 0) {
this.moveReadyJobs();
const job = this.ready.shift();
if (!job) {
idleRounds += 1;
if (idleRounds > 100) throw new Error("drain timeout");
await sleep(20);
continue;
}
idleRounds = 0;
job.attempts += 1;
try {
const result = await handler(job);
this.completed.push({ id: job.id, result });
} catch (error) {
this.retryOrDeadLetter(job, error);
}
}
return {
completed: this.completed.length,
dead: this.dead.length,
};
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function handler(job) {
// poison: schemaが壊れていて何度やっても失敗する
if (job.payload.kind === "poison") {
throw new Error("invalid payload schema");
}
// flaky: 一時障害。2回目で成功する
if (job.payload.kind === "flaky" && job.attempts < 2) {
throw new Error("temporary provider timeout");
}
return `processed ${job.payload.kind}`;
}
async function main() {
const queue = new RetryQueue({ maxAttempts: 3 });
queue.enqueue({ kind: "normal" });
queue.enqueue({ kind: "flaky" });
queue.enqueue({ kind: "poison" });
console.log(await queue.drain(handler));
console.log(
"dead letters",
queue.dead.map((job) => ({
id: job.id,
attempts: job.attempts,
lastError: job.lastError,
payload: job.payload,
}))
);
}
void main();
リトライは「根性で何度も回す」仕組みではありません。成功しそうな一時失敗だけを有限回試し、ダメならDLQへ送る仕組みです。そしてDLQは、入れて終わりだと意味がない。アラート→調査→修正→再投入の流れまで決めて、初めて機能します。僕は最初これを決めずに運用して、DLQが「誰も見ない墓場」になっていました。
本番に出す前のチェックリスト
ここは僕が事故るたびに増やしてきたリストです。PRテンプレートに貼って使っています。
- payloadには
jobId、type、schemaVersion、業務ID、idempotency keyを入れる - payloadにAPIキー、アクセストークン、メール本文、カード情報、長文の個人情報を入れない
- producer側でpayloadを検証し、壊れた仕事をキューに入れない
- visibility timeoutをp95処理時間より長くし、長すぎる処理は進捗更新や分割を検討する
- retry回数、backoff、jitter、DLQ移動条件を明文化する
- consumerのconcurrencyをDB接続数、外部API rate limit、CPU使用率から決める
- キュー深さ、最古ジョブ年齢、active件数、失敗率、DLQ件数、処理時間p95を監視する
- DLQを誰が何分以内に見るか、再投入してよい条件は何かをrunbookに書く
- 二重配送を前提に、メール・請求・ポイント付与・CRM登録に冪等性ガードを入れる
- Claude Codeの差分レビューでは、成功パスだけでなく失敗パスと運用ログを見る
特にやられやすいのが、visibility timeoutの設定ミスです。これ、目立たないんですよ。短すぎると、まだ処理中の仕事が別ワーカーに再配送されて二重処理になる。長すぎると、ワーカーが落ちたとき仕事がなかなか戻らず、ユーザーから見ると処理が止まる。まず実測のp95を見て余裕を持たせ、長時間ジョブは分割する。これが現実的な落とし所です。レート制限まわりで詰まる場合はAPIレート制限の実装も合わせてどうぞ。
Claude Codeへの依頼テンプレート
Claude Codeに頼むときは、実装対象だけじゃなく「失敗したときの契約」を一緒に渡します。僕が実際に投げている文がこれです。
このリポジトリにメール送信キューを追加してください。APIは送信要求をDBへ保存し、キューには
deliveryIdとtemplateIdだけを入れます。ワーカーはidempotency keyで二重送信を防ぎ、最大3回だけ指数backoffでretryし、失敗が続いたらDLQテーブルへ移します。payloadにAPIキー、メール本文、個人情報を入れないでください。キュー深さ、最古ジョブ年齢、失敗率、DLQ件数をログまたはmetricsで見えるようにし、テストでは重複配送、poison message、visibility timeoutを確認してください。
この粒度で渡すと、Claude Codeは「動くだけのサンプル」ではなく、レビューできる設計に寄せてくれます。Webhook受信からキューに積む構成ならWebhook実装の署名検証・冪等性の話とつながります。
本番で何を選ぶか(SQS / RabbitMQ / BullMQ)
どのキューを選ぶかは、結局あなたの既存インフラと運用チームで決まります。
- AWS中心ならまずAmazon SQS。標準キューは高スループット、FIFOキューは順序や重複排除を扱いやすい。
- ルーティングやpub/sub、オンプレ/Kubernetes運用を重視するならRabbitMQ。
- Node.jsでRedisを既に運用していて、ジョブ管理・遅延実行・repeatable jobs・ダッシュボードを素早く作りたいならBullMQ。公式も「ベストエフォートでexactly once、最悪ケースはat-least-once」と書いていて、つまり結局あなた側の冪等性ガードは必須です。
ただ、ライブラリ名から決めるのは順番が逆です。先にpayload・冪等性・retry・DLQ・監視・権限・費用・チームの運用経験を固め、その制約に合うサービスを選ぶ。この記事のコードを依存なしにしたのは、SQSやBullMQを触る前に「失敗時の考え方」だけを切り出して身につけるためです。
よくある質問
Q. 結局、最初に入れるべき1個はどれですか? A. 冪等性ガードです。速さやDLQは後からでも足せますが、二重送信・二重課金は一度起きると顧客対応のコストが跳ね上がります。業務IDの一意制約を1本入れるところから始めてください。
Q. リトライは何回が正解ですか? A. 「正解の回数」より「指数backoff+上限+DLQ」のセットが正解です。目安は3〜5回。回数より、上限を超えたら必ずDLQへ逃がす出口があるかどうかが効きます。
Q. visibility timeoutはどう決めればいい? A. 勘で決めず、実測のp95処理時間より少し長く。短いと二重処理、長いと復旧遅延。長時間ジョブはタイムアウトを延ばすより、仕事を分割するほうが安全です。
Q. payloadにメール本文やAPIキーを入れたら何がまずい? A. キューはログ・DLQ・管理画面・ダンプ・サポート調査と、あちこちに中身が残ります。漏えい時の被害が大きい。参照ID(templateId・deliveryId)だけ入れ、本文や鍵はワーカーが権限のある場所から取り直してください。
Q. 小規模サービスでもキューは要りますか? A. メール・決済・外部API連携があるなら要ります。逆に外部依存がなくレスポンスが速いだけの処理なら、無理に入れる必要はありません。判断軸は「外部の遅延・失敗に巻き込まれるか」です。
実際に試した結果
この3本のコードは、僕が手元のNode.jsで全部実行確認しました。外部サービスはゼロ。それでも、visibility timeout切れで仕事が戻る挙動、毒メッセージがDLQに落ちる挙動、flakyが2回目で成功する挙動まで、数秒で再現できます。
いちばん効いたのは実装2の冪等性ガードでした。同じメールジョブを再配送しても、2回目がちゃんとskipped: trueになってログに残る。冒頭の「再請求3通事件」が、たったあのMapの分岐ひとつで起きなくなるわけです。賢いリトライを書く前に、二度実行されても平気な体を作る。順番はいつもこっちが先だ、というのが今の僕の結論です。
キューはコード量より運用設計で差が出ます。チームで非同期処理を本番導入するなら、既存リポジトリを題材にCLAUDE.mdの禁止事項・payload設計・DLQ runbook・監視メトリクスまで一緒に詰められます。詳しくは研修・導入相談からどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。