effidevFlutter・Cloudflareエッジ・クラウドコスト最適化

Durable Objects の overloaded エラーを完全攻略:秒間1,000リクエストのソフトリミットとシングルスレッドのボトルネックを回避する

Durable Objects の overloaded エラーを完全攻略:秒間1,000リクエストのソフトリミットとシングルスレッドのボトルネックを回避する

Durable Objects(DO)は「グローバルに唯一のインスタンスが強い一貫性を保証する」という魅力的な約束をしてくれます。チャットルーム、ゲームセッション、決済ステートマシンのように順序が重要なドメインにぴったりのモデルです。問題は、この約束が成り立っている理由——DOインスタンス1つは物理的に単一スレッドで動作しているという事実を忘れて設計したときに表面化します。トラフィックが増えると突然502とともにoverloadedエラーが噴出し、遅い外部APIを1つ呼んでいるだけで同じルームを使う他のユーザーまで遅延し、alarm()の再試行ロジックが決済を二重に課金してしまう、といった事故につながります。

本稿では、この3つの落とし穴を公式ドキュメントの数値とともに、再現可能なコードで検証していきます。

要点まとめ

  • DOインスタンス1つには秒間1,000リクエストのソフトリミットがあり、同じオブジェクトに10秒のウィンドウ内でリクエストが集中するToo many requests for the same object within a 10 second windowエラーが発生します。
  • await fetch()はinput gateを開き、他のリクエストとのインターリーブを許可しますが、await storage.*系の操作とblockConcurrencyWhile()完全に直列化され、本当の意味でのヘッドオブラインブロッキングを引き起こします。
  • alarm()は2秒から始まる指数バックオフで最大6回までしか自動再試行しません。再試行中に実行されたside effect(決済、Webhook)が冪等でなければ、重複実行が発生します。
  • SQLiteベースのDOはオブジェクト1つあたりFree 1GB / Paid 10GBのストレージ上限があり、超過すると書き込みがSQLITE_FULLで失敗します。2026年7月20日から、ダッシュボードにネームスペース単位のTotal storageチャートが追加されました。

DOはなぜシングルスレッドなのか、そして順次処理が生む遅延

Durable Objectの中核となる契約は、「同じIDで生成されたオブジェクトは、世界中でただ1つの物理的な場所でのみ実行され、そこへ向かうすべてのリクエストはそのインスタンスが順番に処理する」というものです。Cloudflareの公式ドキュメントは、この特性を予約システムの例で説明しています。

“all booking requests for a venue must be serialized to prevent double-booking”

ロック(lock)や分散トランザクションなしに二重予約を防げるのは、まさにこの直列化のおかげです。しかし、これはタダではありません。同じオブジェクトへ向かうすべての同期実行は、単一のJSスレッドのタイムライン上で順番待ちをしなければならないのです。ごく単純なカウンターDOを見てみましょう。

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  async fetch(req) {
    let count = (await this.ctx.storage.get("count")) ?? 0;
    count += 1;
    await this.ctx.storage.put("count", count);
    return Response.json({ count });
  }
}

このコード自体は無害に見えますが、同じidに対して秒間数百件のリクエストが集中すると話は変わってきます。Cloudflareの実践ガイドは、オブジェクト1つあたりのスループットについてこう明言しています。

単一のDurable Objectは、演算の複雑さに応じておおよそ秒間500〜1,000リクエスト程度を処理できる。

つまり「DOは無限にスケールする」というのは半分だけ正しい話です。オブジェクトの数はいくらでも増やせますが、オブジェクト1つあたりのスループットはCPUコア1つの限界を超えられません。 チャットアプリでルームごとにDOを1つ割り当てる設計は、この限界の範囲内では優れた選択ですが、「グローバルカウンター1個」「グローバルレートリミッター1個」のようにすべてのトラフィックをオブジェクト1つに集約した瞬間、そこがボトルネックになります。

’Overloaded’エラー、正確な発生条件と再現方法

公式のソフトリミットと4種類のエラーメッセージ

Cloudflareの公式limitsドキュメントはこう明記しています。

“An individual Object has a soft limit of 1,000 requests per second.” “A Durable Object that receives too many requests will, after attempting to queue them, return an overloaded error to the caller.”

「ソフトリミット」という表現が重要です。1,000 req/sを超えた瞬間に即座に遮断されるハードリミットではなく、超えた時点からキューイングを試み、キュー自体が処理しきれなくなったときにエラーを投げる仕組みです。実際にトラブルシューティングのドキュメントを見ると、overloadedエラーは原因に応じて4種類のメッセージに細分化されています。

最後のメッセージが、本稿のタイトルで言及した「10秒ウィンドウ」の条件です。ドキュメントは、このメッセージは「他のoverloadedメッセージを置き換えるものではなく、より極端な過負荷状況でのみ返される」と説明しています。つまり、このメッセージを目にしたなら、すでにかなり深刻なレベルのトラフィック集中が発生しているということです。

もう一つ見落としやすい原因は、リクエスト数そのものではなくI/O遅延です。エラーハンドリングのドキュメントはこう書いています。

インフラ例外の原因には、「単一のDurable Objectに集中する過剰なリクエスト」だけでなく、「遅い、あるいは過大なI/Oによってリクエストがキューに溜まること」も含まれる。

つまり、秒間リクエスト数が1,000件よりずっと少なくても、各リクエストが遅い外部APIを待って長くかかれば、キューが積み上がりoverloadedが発生し得るということです。

再現してみる

本番環境に実際に負荷をかけるのは危険なので、ステージング環境で再現するためのコードを用意しました。ポイントは、同じDO idに対して短時間でリクエストを集中させることです。

// worker.js — 부하 발생용 엔드포인트 (스테이징 전용)
export default {
  async fetch(req, env) {
    const url = new URL(req.url);
    if (url.pathname === "/hammer") {
      const id = env.COUNTER.idFromName("shared-hot-object");
      const stub = env.COUNTER.get(id);

      const batch = Array.from({ length: 500 }, () =>
        stub.fetch("https://do/increment").catch((e) => e)
      );
      const results = await Promise.allSettled(batch);
      const overloaded = results.filter(
        (r) => r.status === "fulfilled" && r.value?.status === 500
      );
      return Response.json({ sent: batch.length, overloaded: overloaded.length });
    }
    return new Response("ok");
  },
};

ここで注意すべき点があります。Workersのsubrequest上限はFreeプランがリクエストあたり50個、Paidプランがリクエストあたり10,000個であり、レスポンスヘッダーを待っている間に同時に開けるアウトバウンド接続数はプランに関係なく6個に制限されています。つまりPromise.allで数千個を一気に発射しているように見えても、実際には6個ずつ順番に処理されており、Freeプランではそもそもリクエスト1つあたり50個以上送ることもできません。本当に1,000 req/sを作り出したいなら、/hammerエンドポイントをautocannonheyのような外部負荷テストツールで複数のWorker呼び出しを同時に叩く方法の方がはるかに現実的です。

# 외부에서 동시성 100으로 10초간 /hammer 호출
npx autocannon -c 100 -d 10 https://your-worker.workers.dev/hammer

こうすることで、各Worker呼び出しが500個ずつ同じDOへfetchを送り、同時呼び出しが重なることで実際にToo many requests for the same object within a 10 second windowエラーを観察できます。エラーオブジェクトには.overloadedプロパティが付与されているため、クライアント側では次のように区別する必要があります。

try {
  const resp = await stub.fetch(req);
  return resp;
} catch (e: any) {
  if (e.overloaded) {
    // 재시도하면 과부하가 더 심해진다 — 재시도 금지, 즉시 실패 처리
    return new Response("busy", { status: 503 });
  }
  if (e.retryable) {
    // 멱등 요청이라면 지수 백오프로 재시도 가능
  }
  throw e;
}

公式ドキュメントも明確に警告しています。.overloadedtrueのエラーを再試行すると、「過負荷を悪化させ、全体のエラー率を高めてしまう」というのです。

遅い外部API呼び出しが生むヘッドオブラインブロッキング

Input GateとOutput Gateが実際にしていること

ここからが最も誤解されやすい部分です。「DOはシングルスレッドなのだから、await fetch()で外部APIを呼び出すと、そのオブジェクトへ向かう他のすべてのリクエストが止まる」と考えがちですが、これは正確ではありません。Cloudflareはinput gate / output gateという仕組みでこれを細かく制御しています。

つまり、単に外部APIを1つawaitしただけでオブジェクト全体が止まるわけではありません。他のリクエストまで巻き込む本当の意味でのヘッドオブラインブロッキングは、次の3つのパターンで発生します。

本当に危険なパターン3つ

1) blockConcurrencyWhile()の中に遅い外部呼び出しを入れてしまう場合。 このメソッドは、コールバックが完了するまで、コールバック自身が生成したイベントを除くすべてのイベントをキューに閉じ込めます。よくある間違いは、コンストラクタで「キャッシュを事前に温めておこう」と外部APIを呼び出すパターンです。

export class RoomState extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.ctx.blockConcurrencyWhile(async () => {
      // 안티패턴: 느린 외부 API를 초기화 블록 안에서 기다림
      this.config = await fetch("https://config-service.example.com/room").then((r) => r.json());
    });
  }
}

このconfigサービスが遅くなったり一時的に落ちたりすると、このオブジェクトへ向かうすべてのリクエストがコールバックの完了まで待たされます。公式ドキュメントは、blockConcurrencyWhileには30秒のタイムアウトがあり、これを超えると「Durable Objectがリセットされる」と明記しています。コールバックが例外を投げた場合も同様に、オブジェクトは終了・リセットされます。推奨事項は明確です——「コールバックはできる限り少ない作業を行うべきで、それが全体のリクエストスループットを良くする」。SQLiteベースのストレージは操作がアトミックであるため、通常のリクエスト処理ではblockConcurrencyWhileはほとんど必要なく、主な用途はコンストラクタでのスキーマ移行程度に限定しておくのが安全です。

2) ストレージ操作が集中する場合。 await fetch()とは異なり、await this.ctx.storage.get(...)のような呼び出しはinput gateを開きません。つまり、ストレージI/Oが原因であれば、リクエストは本当に列を作って待たされます。複数のキーを個別にget()する代わりにバッチでまとめるのが、ここでの実質的な処方箋です。

// 느림: N번의 개별 왕복, 그동안 input gate가 계속 닫힘
for (const key of keys) {
  await this.ctx.storage.get(key);
}

// 빠름: 한 번의 왕복
const values = await this.ctx.storage.get(keys); // keys: Array<string>

3) 同期的なCPU演算。 大きなJSONをパースしたり重いループを回している間は、そもそもawaitするポイントがないため、input gateとは無関係にイベントループ自体がブロックされます。DOの中で大容量ペイロードを直接処理するロジックは、Worker側に移すかチャンク単位に分割するのが定石です。

解決パターン:リクエストを複数のDOインスタンスに分散するシャーディング

これまでの2つのセクションの結論は1つに集約されます——オブジェクト1つの容量は増やせないのだから、オブジェクトの数を増やすしかない。 公式ガイドは「すべてのチャットルームを処理するグローバルなDurable Object1個」を明確なアンチパターンとして挙げ、必要なオブジェクト数を次のように定式化しています。

必要なDOの数 = (秒間の総リクエスト数) / (DO1つが処理可能なリクエスト数)

例えば、ゲームセッションサービスが秒間50万リクエストを受け、オブジェクト1つが秒間500〜1,000件を処理できるとすると、グローバルコーディネーター1個ではなく、500〜1,000個のセッション単位のDOが必要になるということです。グローバルレートリミッターをDO1個で実装するのも同じ理由で危険です——すべてのトラフィックが1点に集中する「chokepoint」になってしまい、スケールしません。

実践では、ユーザーID、ルームID、テナントIDのような自然なパーティションキーでシャーディングを行います。

// 안티패턴: 전역 싱글턴
const id = env.RATE_LIMITER.idFromName("global");

// 개선: 사용자 단위로 샤딩 — 사용자마다 독립된 오브젝트
const id = env.RATE_LIMITER.idFromName(`user:${userId}`);

// 더 세밀하게: 해시로 N개 버킷에 분산 (전역 카운터를 근사치로 집계할 때)
function shardId(key, shardCount = 64) {
  let hash = 0;
  for (let i = 0; i < key.length; i++) {
    hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
  }
  return `shard:${hash % shardCount}`;
}
const id = env.COUNTER.idFromName(shardId(userId));

もちろんトレードオフはあります。シャーディングすると、「正確なグローバル合計」が必要なロジック(例:全体の同時接続者数)は、もはやオブジェクト1つから即座に読み取ることができなくなります。このような場合には、各シャードがalarmで自身のカウントを定期的に上位集計用のDOへ報告するファンイン(fan-in)パターンで折り合いをつけるのが一般的です——リアルタイムの正確性の代わりに、数秒単位の近似値を受け入れるわけです。

alarm()の指数バックオフ再試行と、冪等性のないコードが生む重複実行バグ

「最大6回リトライ」が落とし穴になる理由

alarm()は、DOの中で信頼できるスケジュール実行を提供するAPIです。公式ドキュメントは、その信頼性の条件をこう規定しています。

“The alarm() handler has guaranteed at-least-once execution and will be retried upon failure using exponential backoff, starting at 2 second delays for up to 6 retries.”

つまり、2秒→4秒→8秒→16秒→32秒→64秒の間隔で最大6回しか自動的に再試行されません。ここで、もう一つ本当に重要な一文があります。

“If an unexpected error terminates the Durable Object, the alarm() handler may be re-instantiated on another machine. Following a short delay, the alarm() handler will run from the beginning on the other machine.”

この一文が、重複実行バグの核心です。alarm()は例外を投げたときだけ再試行されるのではなく、DOプロセス自体が(インフラ障害、リソース超過、予期しない終了など)クラッシュした場合には最初から再実行されます。つまり、alarm()ハンドラの途中で「決済呼び出しは成功したが、その次の状態保存の直前にプロセスが落ちる」というシナリオが実際に起こり得るのです。この場合、再実行されたalarm()はすでに成功している決済呼び出しをもう一度行ってしまいます。

// 위험한 패턴 — 멱등성 없음
async alarm() {
  const order = await this.ctx.storage.get("pendingOrder");
  if (!order) return;

  // ① 이 fetch가 성공한 직후, ②로 가기 전에 프로세스가 죽으면?
  await fetch("https://payments.example.com/charge", {
    method: "POST",
    body: JSON.stringify(order),
  });

  // ② 여기 도달하지 못하면 다음 실행에서 ①이 다시 일어난다
  await this.ctx.storage.delete("pendingOrder");
}

ドキュメントは、alarm()ハンドラの中でdeleteAlarm()を呼び出しても、「best-effortで再試行を止められる可能性はあるが、保証はされない」と明記しています。つまり、deleteAlarm()を冪等性の対策として使うことはできません。

冪等性を保証するパターン

最も実用的な方法は、外部呼び出し自体を冪等にすることと、呼び出し前に状態を先にコミットしておくことを組み合わせて使うことです。

async alarm(alarmInfo) {
  const order = await this.ctx.storage.get("pendingOrder");
  if (!order || order.status === "charged") return;

  if (alarmInfo?.isRetry) {
    console.log(`retry #${alarmInfo.retryCount}, resuming order ${order.id}`);
  }

  // 1. 외부 호출 전에 "처리 중" 상태를 먼저 스토리지에 커밋
  //    (order.idempotencyKey는 최초 생성 시 한 번만 발급)
  await fetch("https://payments.example.com/charge", {
    method: "POST",
    headers: { "Idempotency-Key": order.idempotencyKey },
    body: JSON.stringify(order),
  });

  // 2. 성공 후에만 완료 상태로 전이
  order.status = "charged";
  await this.ctx.storage.put("pendingOrder", order);
  await this.ctx.storage.delete("pendingOrder"); // 정리
}

ポイントは3つです。

SQLiteベースDOのストレージ容量上限とモニタリング

Free/Paidの上限

Durable Objectsは現在、デフォルトでSQLiteベースのストレージを使用します。公式limitsドキュメントに基づく上限は次の通りです。

上限を超えるとどうなるのでしょうか。公式ドキュメントは正確にこう説明しています。

オブジェクトが最大ストレージ上限(Paid 10GB、Free 1GB)に達すると、INSERTUPDATEput()sql.exec()のような書き込み操作はdatabase or disk is full: SQLITE_FULLエラーで失敗するSELECTget()list()のような読み取り操作とDELETEは引き続き動作するため、空き容量を確保できるようになっている。

つまり、上限超過はサービス全体の停止ではなく、「書き込みだけが止まり、削除によって復旧可能」な形です。処理コードは次のように書いておくのが安全です。

try {
  this.ctx.storage.sql.exec(
    "INSERT INTO my_table (key, value) VALUES (?, ?)",
    key,
    value,
  );
} catch (e) {
  if (e.message.includes("SQLITE_FULL")) {
    // 저장 한도 도달 — 읽기/삭제는 여전히 가능
    // 오래된 데이터를 정리하거나 호출자에게 의미 있는 에러를 반환
  }
  throw e;
}

容量を事前に確認する方法

上限に達してから対処するより、達する前にアラートで検知しておく方がはるかに良い方法です。SQLiteストレージAPIは、現在のDBサイズをバイト単位でそのまま読み取れるプロパティを提供しています。

async fetch(req) {
  const sizeBytes = this.ctx.storage.sql.databaseSize;
  const limitBytes = 10 * 1024 * 1024 * 1024; // Paid 플랜 10GB
  if (sizeBytes > limitBytes * 0.8) {
    console.warn(`storage at ${(sizeBytes / limitBytes * 100).toFixed(1)}% of limit`);
    // 알림 전송, 오래된 로우 정리 alarm 예약 등
  }
  // ...
}

アカウント全体を俯瞰したい場合、Cloudflareは2026年7月20日にDurable Objectsダッシュボードへネームスペース単位の「Total storage」チャートを追加しました。1時間ごとに報告された最大ストレージ量を表示するチャートで、増加傾向を確認したり、データ整理が実際に効果があったか、あるいは予想外の使用量急増がなかったかを確認する用途に使えます。ただし、このチャートはSQLiteベースのネームスペースにのみ適用され、個々のオブジェクト単位(ID/name別)の追跡にはまだ対応していないため、特定のオブジェクトが上限に近づいているかどうかは、上記のdatabaseSizeのコードのようにオブジェクトの中で直接確認する必要があります。

まとめ:プロダクションチェックリスト

まとめると、DOを本番環境にデプロイする前に、最低限次の項目をチェックすることをお勧めします。

Durable Objectsは「強い一貫性」と「無限のスケーラビリティ」を同時には与えてくれません。一貫性はオブジェクト1つの単一スレッド直列化から生まれ、スケーラビリティはそのオブジェクトをどれだけうまく分割できるかから生まれます。この設計上のトレードオフを最初から認識していれば、ここまで取り上げてきた落とし穴のほとんどは事前に回避できるものです。