第3話では検知について取り上げました。システムが何かが壊れたことをどのように検知するのかという内容でした。第4話はその次のステップです:検知が「何かおかしい」と教えてくれた。では次に何をするのか?


土曜日、第4ラウンド

👦 甥: おじさん、言われた通りにタイムアウトとログを設定したよ。昨日、決済ゲートウェイの呼び出しが実際にタイムアウトした。検知して、ログに残して、そのまま進んだ。

👨‍🦳 おじさん: それで?

👦 甥: それだけだよ。他に何も起こらなかった。

👨‍🦳 おじさん: それはアナリティクスの呼び出しだった?それとも課金だった?

👦 甥: …課金だった。

👨‍🦳 おじさん: なら「ログに残して次に進む」だけじゃ対応とは言えない。ただ見ていただけだ。

👦 甥: つまり検知だけでは足りないんだね。

👨‍🦳 おじさん: 検知は「何かおかしい」と教えてくれる。対応は実際に何をするかを決める。今日のツールは6つだ。

1. リトライ
2. 指数バックオフ
3. フォールバック
4. キュー
5. サーキットブレーカー(予告 — 詳細は後日)
6. グレースフルデグラデーション
7. デッドレターキュー

Enter fullscreen mode Exit fullscreen mode


パート1 — リトライ

👨‍🦳 おじさん: リストの中で一番シンプルな考え方だ。呼び出しが失敗したら、もう一度試すだけ。どこが問題なんだ?

👦 甥: 問題ないんじゃない?失敗したらやり直せばいい。

👨‍🦳 おじさん: いつも?

👦 甥: …なんか罠にハメられてる気がする。

👨‍🦳 おじさん: 昨日の決済ゲートウェイの呼び出し — タイムアウトしたやつだ。すぐにリトライしたら、ゲートウェイ側では実際に何が起きていたと思う?

👦 甥: タイムアウトしたから…失敗した?

👨‍🦳 おじさん: 本当に?それともが応答を時間内に受け取れなかっただけ?

👦 甥: 待って。それって違うことだよね。

Uncle: 全く違う。タイムアウトは、課金が実際に実行されたかどうかを何も教えてくれない。

あなた                    決済ゲートウェイ
 | -- 課金リクエスト --> |
 |                       | (正常に処理)
 |  <-- X 応答が消失 --| (ネットワークで応答が落ちた)
 | -- タイムアウト、リトライ-->|
 |                       | (もう一度課金!)

Enter fullscreen mode Exit fullscreen mode

👦 甥: つまり顧客に2回課金する可能性があったってことか。そしてその「対応」が原因になる。

Uncle: それが罠だ。だから — 実際に安全にリトライできるものは何だろう?

👦 甥: …2回実行しても何も変わらないもの?

👨‍🦳 おじさん: その通りだ。その性質には名前がある — 冪等性で、Module 3で独自のパターンとして扱う。今日はとりあえず覚えておいてほしい:安全なものだけをリトライし、金銭・在庫・状態に触れるものは特に警戒しろ。


パート2 — 指数バックオフ

👨‍🦳 おじさん: ここではリトライが安全だとしよう。では、すぐに3回連続でリトライすべきか?

👦 甥: なぜダメなんだ?復旧が早くなるよ。

👨‍🦳 おじさん: 10,000台のクライアントが同時に苦しんでいるサービスにアクセスしていて、全員が即座にリトライしたら、そのサービスはどうなる?

👦 甥: …さらに負荷がかかる。俺が状況を悪化させていることになる。

👨‍🦳 おじさん: その通り。だから失敗のたびに少しずつ待機時間を長くして、サービスに余裕を与えるんだ。

1回目失敗 → 約2秒待機
2回目失敗 → 約4秒待機
3回目失敗 → 約8秒待機
4回目失敗 → 約16秒待機
5回目失敗 → 諦めてエラーを返す

Enter fullscreen mode Exit fullscreen mode

👦 甥: でも10,000台のクライアントが同じ瞬間に失敗したら、全員が全く同じスケジュールでバックオフするんじゃない?結局また同時にリトライすることになる?

👨‍🦳 おじさん: そこでjitter(ジッター)が必要になる — 小さなランダムな遅延を加えることで、リトライが一斉にではなく少しずつ分散して到達するようにする。

ジッターなし:  1,000台のクライアントが全く同じミリ秒にリトライ
ジッターあり:  1,000台のクライアントが小さなランダムな時間窓に分散してリトライ

Enter fullscreen mode Exit fullscreen mode


パート3 — フォールバック

👨‍🦳 おじさん: 時には「もう一度試す」のではなく、「代わりに別のことをする」のが正しい対応だ。

👦 甥: 例えば?

👨‍🦳 おじさん: レコメンデーションサービスがダウンした。ユーザーは何を見るべきだろう?

👦 甥: エラーかな?「レコメンデーションは利用できません。」

👨‍🦳 おじさん: エラーを見るのと、一般的な「人気商品」リストを見るの、どちらがいい?

👦 甥: …明らかに人気商品リストだよ。そんな小さなことでエラーを見せたくない。

👨‍🦳 おじさん: それがフォールバックだ — 完璧な機能を継続可能な機能と交換する。

理想のパス:    ユーザー → パーソナライズド・レコメンデーション   (最高の体験)
障害時のパス:  ユーザー → 人気商品フォールバック          (十分な体験)
最悪の場合:    ユーザー → エラーページ                      (可能な限り避ける)

Enter fullscreen mode Exit fullscreen mode

👦 甥: でも全部にフォールバックが必要なわけじゃないよね?

👨‍🦳 おじさん: 決済が失敗した場合にフォールバックが欲しいか — 「課金を確認できなかったので、ランダムな金額を請求しました」みたいなのは?

👦 甥: 絶対に嫌だ。

👨‍🦳 おじさん: その通り。フォールバックは「不完全でも何もないよりはマシ」な場面で使う。一部の障害は、ただ正しく・大声で失敗させる必要がある。


パート4 — キュー

👨‍🦳 おじさん: サインアップの流れで、ウェルカムメールをリクエスト内で同期的に送信している。メールサービスが5分間ダウンしたら、サインアップはどうなる?

👦 甥: 失敗する?それともメールサービスを待ってハングする?

👨‍🦳 おじさん: サインアップにメール送信の完了を待つ必要があるか?

👦 甥: …いや、必要ない。すでにアカウントは作成されている。

👨‍🦳 おじさん: なら待たせるな。メールジョブを安全な場所に押し込んで、すぐにユーザーに応答し、メールサービスが復旧したらワーカーが送信するようにする。

キューを使わない場合:
サインアップリクエスト → メールサービスを待つ → 応答    (遅く、メールサービスがダウンすると失敗)

キューを使う場合:
サインアップリクエスト → ジョブをキューに投入 → 即時応答
                        |
                   ワーカーがメールサービスが復旧したタイミングで処理

Enter fullscreen mode Exit fullscreen mode

👦 甥: つまりキューは「今すぐ成功しなければならない」を「いずれ成功する」に変えるんだね。

👨‍🦳 おじさん: その通り — この変化だけで、障害の種類を丸ごと排除できる。これがロードマップにあったBullMQ + Redisの組み合わせだ — BullMQがキューとリトライロジックを扱い、Redisがジョブデータを保持する。


パート5 — サーキットブレーカー(予告)

👨‍🦳 おじさん: もう一つ、クイックプレビュー — 詳細はModule 3で後日。下流のサービスが完全に死んでいる。それでも毎回リクエストを投げて、待って、タイムアウトして、失敗している。この絵に何が問題ある?

👦 甥: 毎回、すべてのリクエストで待機時間を無駄にしている?

👨‍🦳 おじさん: その通り。だから連続で何度か失敗したら、システムはしばらくの間、試すのをやめる。

最初の数リクエスト → 試す、失敗、失敗 → ブレーカー「トリップ」(OPEN)
        |
以降のリクエスト → 即座に失敗、待機時間の無駄なし
        |
クールダウン後 → ブレーカーが1回だけテストリクエストを通す
        |
成功したら → ブレーカーをクローズ、通常トラフィック再開
失敗したら → ブレーカーはオープンしたまま、さらに待機

Enter fullscreen mode Exit fullscreen mode

👦 甥: つまり無限に期待し続けるのではなく、意図的に一時的に諦めるんだね?

👨‍🦳 おじさん: 速くて安い諦めは、すべてのリクエストで遅くて高い失敗を繰り返すよりも優れている。状態と実装の詳細 — これは独立したエピソードで扱う。


パート6 — グレースフルデグラデーション

👨‍🦳 おじさん: これは新しいツールというよりは、直前の3つの基盤にある考え方だ。何かが失敗したとき、失ってもいい最小限のものは何か?

フルシステム:
[ コアチェックアウト ] [ レコメンデーション ] [ レビュー ] [ ライブチャットサポート ]

レコメンデーションサービスが失敗した場合:
[ コアチェックアウト ] [ (フォールバック:スキップ) ] [ レビュー ] [ ライブチャットサポート ]
   ↑ まだ動作し、ユーザーは購入可能

Enter fullscreen mode Exit fullscreen mode

👦 甥: つまりフォールバックの「なぜ」だね — 重要な80%を生き残らせ、あったらいいなの20%が壊れても問題ないようにする。

👨‍🦳 おじさん: その通り。フォールバック、タイムアウト、サーキットブレーカー — これらはすべて「この部品が壊れたら、システムが動き続けるための最も被害の少ない方法は何か?」という一つの問いに対する道具だ。


パート7 — デッドレターキュー

👨‍🦳 おじさん: キューに戻ろう。ジョブが失敗した。1回、2回、3回リトライした。それでも失敗する。今どうなる?

👦 甥: …永遠に試し続ける?

👨‍🦳 おじさん: そうしたいか?

👦 甥: いや — 明らかに成功しないものにリソースを永遠に無駄遣いしたくない。

👨‍🦳 おじさん: では静かに削除する?

👦 甥: それも嫌だ。ジョブを失って、何が起きたか永遠にわからない。

👨‍🦳 おじさん: その通り — どちらの選択肢も受け入れられない。そこで特定の場所に行く:デッドレターキュー。削除もせず、無限にリトライもせず — 人間が実際に見るために置いておく。

ジョブ失敗 → リトライ1 → リトライ2 → リトライ3 → まだ失敗
        |
デッドレターキューに移動(削除もせず、静かに落とすでもない)
        |
エンジニアが後で確認:「なぜこの特定のジョブがずっと失敗し続けたのか?」

Enter fullscreen mode Exit fullscreen mode

👦 甥: つまりDLQは、キューの服を着た検知ツールなんだね。

👨‍🦳 おじさん: その通りだ。対応と検知のループを閉じる。


パート8 — 障害に合ったツールを選ぶ

👨‍🦳 おじさん: 最後だ。障害を出すから、ツールを答えてくれ。安全に繰り返せる呼び出しで、1回のネットワークの瞬断。

👦 甥: バックオフ付きリトライ。

👨‍🦳 おじさん: 下流のサービスが完全に、完全にダウンしている。

👦 甥: サーキットブレーカー。死んでいるものを叩き続ける意味がない。

👨‍🦳 おじさん: 重要でない機能 — 例えばレコメンデーション — が失敗した。

👦 甥: フォールバック。あるいはグレースフルデグラデーション、同じ考え方だ。

👨‍🦳 おじさん: 今このミリ秒に処理する必要のない仕事。

👦 甥: キューに入れる。

👨‍🦳 おじさん: 何をやっても失敗し続けるジョブ。

👦 甥: デッドレターキュー — 盲目的にリトライをやめて、人間に見てもらう。

👨‍🦳 おじさん: 君は自分で表を全部作ったな。

状況 適切なツール
安全に繰り返せる呼び出しでの1回のネットワーク瞬断 リトライ + バックオフ
下流のサービスが完全にダウンしている サーキットブレーカー
重要でない機能が失敗した フォールバック / グレースフルデグラデーション
今すぐ処理する必要のない仕事 キュー
何をやっても失敗し続けるジョブ デッドレターキュー
金銭/状態を変更する可能性があり、リトライされる可能性のある操作 冪等性 (Module 3)

👨‍🦳 おじさん: そして今日の教訓を一言で言うと:一番怖いリトライは、失敗し続けるものではなく、静かに2回成功するものだ。


実践的なNode.js実装

今日の6つのツールを、実際のコードとして1箇所にまとめた。

リトライ

async function callPaymentGateway(payload) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await paymentApi.charge(payload);
    } catch (err) {
      if (attempt === 3) throw err;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

指数バックオフ + ジッター

async function retryWithBackoff(fn, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxAttempts) throw err;
      const delay = Math.min(1000 * 2 ** attempt, 30000);
      const jitter = Math.random() * 300;
      await new Promise(r => setTimeout(r, delay + jitter));
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

フォールバック

async function getRecommendations(userId) {
  try {
    return await recommendationService.getFor(userId);
  } catch (err) {
    logger.warn({ userId }, 'Recommendation service down, using fallback');
    return getPopularItems();
  }
}

Enter fullscreen mode Exit fullscreen mode

キュー

const { Queue } = require('bullmq');
const emailQueue = new Queue('emails', { connection: redisConnection });

app.post('/signup', async (req, res) => {
  const user = await createUser(req.body);
  await emailQueue.add('welcome-email', { userId: user.id });
  res.status(201).json(user);
});

Enter fullscreen mode Exit fullscreen mode

デッドレターキュー(BullMQの組み込みfailed状態を利用)

const emailQueue = new Queue('emails', {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 }
  }
});

const failedJobs = await emailQueue.getFailed();

Enter fullscreen mode Exit fullscreen mode


👦 甥: わかった。検知して、今度は対応もできた。

👨‍🦳 おじさん: その通り。

👦 甥: じゃあ残りは?危険は封じ込められた — システムはどのように完全に健全な状態に戻るんだ?

👨‍🦳 おじさん: そこからリカバリーが始まる。

👦 甥: 土曜日?

👨‍🦳 おじさん: 土曜日。


第4話で取り上げた内容

  • リトライ — 決済のような非冪等な操作では強力だが危険
  • 指数バックオフ + ジッター — 過負荷のサービスをさらに悪化させずにリトライする
  • フォールバック — 完璧な機能を継続可能な機能と交換する
  • キュー(BullMQ + Redis) — 「今すぐ成功しなければならない」を「いずれ成功する」に変える
  • サーキットブレーカー — すでにダウンしているサービスへの無駄な呼び出しを止めることの予告
  • グレースフルデグラデーション — フォールバックやサーキットブレーカーの基盤となる設計原則
  • デッドレターキュー — 永遠に失敗し続けるジョブが、消えたり無限にリトライされたりする代わりに送られる場所
  • 障害の種類に合った適切な対応ツールの選択