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

Cloudflare D1シャーディング実践ガイド: 10GBの壁と単一Writerボトルネックを突破する

Cloudflare D1シャーディング実践ガイド: 10GBの壁と単一Writerボトルネックを突破する

マルチテナントSaaSやイベントロギングのように書き込みが集中するサービスをCloudflare D1上に構築したことがあるなら、いつかこの壁にぶつかる。データベース1つあたり最大10GB、そしてすべての書き込みは単一writerを経由して逐次処理されるという構造的な制約だ。PostgresやMySQLであればread replicaを増やしたりパーティショニング用の拡張機能で誤魔化せる問題だが、D1はそもそも「SQLiteをエッジに複数分散配置したもの」であり、アプローチ自体が異なる。

本記事では、インデックス最適化だけで十分なケースと実際にシャーディングが必要なケースを見分ける基準から、テナントIDベースのルーティングをWorker + KVで実装するコード、ダウンタイムなしにシャードをリバランスする手順、クロスシャード集計を回避する戦略まで、実際に運用へ落とし込めるレベルで扱う。

要点まとめ

  • D1はデータベースごとに最大10GB(Paid)/500MB(Free)、アカウント全体では1TB(Paid)/5GB(Free)という上限があり、各データベースは単一writerが書き込みを逐次処理する。
  • 読み取り中心のボトルネックはシャーディングではなく、まずSessions APIベースのread replicaで解決すべきだ — read replicaは書き込みスループットを一切増やさない。
  • シャーディングは(1) DBサイズが10GBに近づいている、または(2) 同時書き込み競合でレイテンシが増大している場合にのみ検討する。その前にインデックス、バッチ書き込み、コールドデータのアーカイブから見直すべきだ。
  • シャードマッピングはハッシュベースよりもKV + 制御DBベースのディレクトリマッピングの方がリバランスに圧倒的に有利だ。
  • クロスシャードのJOIN・トランザクションはサポートされないため、集計はファンアウトクエリか専用のロールアップストアで回避する必要がある。

D1の10GBハードリミットと単一writerアーキテクチャが生む実際のボトルネック

D1の制約は大きく2つの層に分かれる。個々のデータベースのサイズ上限アカウント全体のストレージ容量上限だ。Cloudflare公式ドキュメント記載の数値は次の通り。

項目 Freeプラン Paidプラン
データベース1つの最大サイズ 500MB 10GB
アカウント全体のストレージ容量 5GB 1TB
Worker呼び出しあたりのクエリ数 50 1,000
SQL文の最大長 100,000 bytes (100KB) 同じ
クエリあたりのバインドパラメータ数 100個 同じ
クエリの最大実行時間 30秒 同じ
行(row)の最大サイズ 2,000,000 bytes (2MB) 同じ
テーブルあたりの最大カラム数 100個 同じ
ファイルインポートの最大サイズ 5GB 同じ

ここで重要なのは**「アカウント全体の容量には余裕があるのに、個々のDBはすぐに埋まる」という非対称性だ。例えばPaidプランではアカウント全体で1TBまで使えるが、テナント100件を1つのDBに詰め込んでいれば、そのDB自体は依然として10GBで頭打ちになる。つまりアカウントの上限ではなく「DB1つあたり10GB」という壁が実際のボトルネック**なのだ。イベントログや監査(audit)テーブルのように蓄積し続けるテーブルを持つサービスは、この壁に思ったより早く到達する。

2つ目は単一writer構造だ。D1はSQLiteエンジン上で動作しており、ドキュメントにも明記されている通り「各データベースは単一スレッドでクエリを逐次処理」する。1ms程度の短いクエリを基準にすると秒間約1,000クエリが理論上の上限として言及されるが、実際のプロダクションクエリはインデックススキャン・JOIN・書き込みトランザクションが入り混じっているため、これよりもはるかに低い水準でレイテンシが跳ね上がり始める。問題は、これがスループットの問題だけでなく並行性(concurrency)の問題でもあるという点だ。テナントAの重い書き込みトランザクションが実行されている間、同じDBを使うテナントBのリクエストはキューで待たされる。マルチテナント構成では、これは**あるテナントのトラフィックスパイクが他のすべてのテナントのレイテンシを押し上げるノイジーネイバー(noisy neighbor)**問題に直結する。

実運用でこのボトルネックが顕在化する典型的なシグナルは次の通りだ。

シャーディングが必要なタイミングの判断基準: 読み取り中心 vs 書き込み中心ワークロードの見分け方

シャーディングはコストが大きい。 ルーティングレイヤー、リバランス手順、クロスシャード集計の回避ロジックまで全部作り込む必要がある作業なので、「とりあえず分けておけば安全」というアプローチではオーバーエンジニアリングになる。実務ではまずワークロードの性質から診断すべきだ。

読み取り中心のボトルネックならシャーディングは答えではない

読み取りトラフィックが集中して遅くなっている場合、D1はSessions APIベースのread replica機能を提供している。リクエストを地理的に近い読み取りレプリカに分散させることでレイテンシを減らし、複数のレプリカが同時に読み取りを処理するため読み取りスループットも向上する。ただし公式ドキュメントに明記されている通り、**「すべての書き込みクエリは依然としてprimaryデータベースにのみ転送」**され、read replicaは書き込みスループットには一切改善をもたらさない。つまり読み取り中心のワークロードでシャーディングから検討するのは順序が違う — read replica、KV/Cache APIによるキャッシュ、クエリ最適化を先に使い切るべきだ。

書き込み中心のボトルネックならシャーディング以外に有効な代替策はない

逆に問題が同時書き込み競合単一DBの容量が10GBに近づいていることである場合は話が別だ。read replicaはこの2つの問題のどちらにも役立たない — 依然として1つのprimaryがすべての書き込みを逐次処理し、依然として1つのファイルが10GBの壁にぶつかる。このケースでは実質的にテナントまたはエンティティ単位の手動シャーディングが唯一の水平スケーリング経路となる。

判断のためのチェックリスト:

4つ中3つ以上に該当するなら、シャーディングを設計段階に乗せる時期だ。逆にほとんどが「いいえ」であれば、後述する最終セクションのインデックスチェックリストだけであと数ヶ月は持ちこたえられる。

テナントIDベースのルーティング設計: Worker + KVによるシャードマッピングテーブルの実装

シャードキーにはハッシュベースディレクトリ(マッピングテーブル)ベースの2つの方式がある。ハッシュベース(shard = hash(tenant_id) % N)は実装がシンプルだが、シャード数を変更したり特定のテナント1つだけを別のシャードに隔離移動したりするには大規模な再ハッシュが必要になる。一方、ディレクトリベースのマッピングは「このテナントは今shard-3にいる」という事実を別テーブルに記録しておくため、テナント1つだけをピンポイントで別シャードに移す部分的なリバランスが可能だ。マルチテナント環境ではほぼ常にディレクトリベースが推奨される。

アーキテクチャは3層で構成する。

  1. 制御DB(control DB): シャードマッピングの単一の信頼できる情報源(source of truth)。専用の小さなD1データベースにshard_mapテーブルを1つだけ置く。
  2. KVキャッシュ: リクエストのたびに制御DBを参照するとD1自体がボトルネックになるため、マッピング結果をWorkers KVにread-throughキャッシュとして置く。
  3. シャードDB群: 実際のテナントデータが格納されているN個のD1データベース。
# wrangler.toml
name = "multitenant-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"

[[d1_databases]]
binding = "CONTROL_DB"
database_name = "control-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

[[d1_databases]]
binding = "SHARD_0"
database_name = "tenant-shard-0"
database_id = "xxxxxxxx-0000-xxxx-xxxx-xxxxxxxxxxxx"

[[d1_databases]]
binding = "SHARD_1"
database_name = "tenant-shard-1"
database_id = "xxxxxxxx-1111-xxxx-xxxx-xxxxxxxxxxxx"

[[kv_namespaces]]
binding = "SHARD_MAP_KV"
id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"

制御DBのスキーマ:

CREATE TABLE shard_map (
  tenant_id   TEXT PRIMARY KEY,
  shard_id    TEXT NOT NULL,       -- 'SHARD_0', 'SHARD_1' ...
  status      TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | MIGRATING | DONE
  target_shard_id TEXT,            -- 리밸런싱 중일 때만 채워짐
  updated_at  INTEGER NOT NULL
);
CREATE INDEX idx_shard_map_status ON shard_map(status);

ルーティングロジックは次のように、まずKVを確認し、キャッシュミスのときだけ制御DBを参照してwrite-throughでキャッシュする。

// src/shard-router.ts
type Env = {
  CONTROL_DB: D1Database;
  SHARD_MAP_KV: KVNamespace;
  SHARD_0: D1Database;
  SHARD_1: D1Database;
  [key: string]: any;
};

interface ShardEntry {
  shardId: string;
  status: "ACTIVE" | "MIGRATING" | "DONE";
  targetShardId?: string;
}

export async function resolveShard(
  tenantId: string,
  env: Env
): Promise<D1Database> {
  const cacheKey = `shard:${tenantId}`;
  const cached = await env.SHARD_MAP_KV.get<ShardEntry>(cacheKey, "json");

  let entry: ShardEntry | null = cached;

  if (!entry) {
    const row = await env.CONTROL_DB
      .prepare(
        "SELECT shard_id, status, target_shard_id FROM shard_map WHERE tenant_id = ?"
      )
      .bind(tenantId)
      .first<{ shard_id: string; status: string; target_shard_id: string | null }>();

    if (!row) {
      throw new Error(`Unknown tenant: ${tenantId}`);
    }

    entry = {
      shardId: row.shard_id,
      status: row.status as ShardEntry["status"],
      targetShardId: row.target_shard_id ?? undefined,
    };

    // 읽기 전용 캐시. 리밸런싱 중에는 TTL을 짧게 둔다.
    const ttl = entry.status === "MIGRATING" ? 30 : 300;
    await env.SHARD_MAP_KV.put(cacheKey, JSON.stringify(entry), {
      expirationTtl: ttl,
    });
  }

  // MIGRATING 상태면 라이팅은 아직 원본 샤드로 보낸다 (다음 섹션 참고).
  const activeShardId = entry.status === "DONE" && entry.targetShardId
    ? entry.targetShardId
    : entry.shardId;

  const db = env[activeShardId] as D1Database | undefined;
  if (!db) throw new Error(`Shard binding not found: ${activeShardId}`);
  return db;
}

新しいテナントをオンボーディングする際は、現在の各シャードのサイズと負荷を基準に最も余裕のあるシャードを選ぶロジックを入れる。単純にラウンドロビンで割り当てると特定のシャードに大口テナントが集中しかねないため、オンボーディング時点で想定データボリューム(プランのグレードなど)を考慮して割り当てる方が、後々のリバランス頻度を減らせる。

ダウンタイムなしでシャード間のデータを移動・リバランスする手順

D1はデータベース間のネイティブなレプリケーションやオンラインマイグレーションAPIを提供していない。したがってリバランスはアプリケーションレイヤーで直接ステートマシンを動かす必要があるshard_map.statusカラム(ACTIVE → MIGRATING → DONE)を用いた手順は次の通り。

  1. マイグレーション対象の選定: サイズ・負荷モニタリングの結果、特定のシャードが閾値(例: 8GB、または特定のテナントがそのシャードの書き込みの40%以上を占める)を超えたら、移行するテナントを選ぶ。多数のテナントの中から1つだけを選べるのがディレクトリマッピング方式の利点だ。
  2. 状態をMIGRATINGに切り替える: shard_mapで該当テナントのstatusMIGRATINGに、target_shard_idを移行先シャードに更新する。この時点からアプリケーションは書き込みは依然として元のシャードへ行いつつ、同時にCloudflare Queuesへ同じ書き込みイベントを非同期で発行する(デュアルライト)。
  3. 一括コピー: 元のシャードから該当テナント所有の行をSELECTでページネーションしながら読み出し、移行先シャードにdb.batch()で書き込んでいく。Worker呼び出しあたりのクエリ数制限(Free 50 / Paid 1,000)とバインドパラメータ100個の制限を考慮してバッチサイズを決める必要がある。データ量が多くレイテンシにあまりシビアでないなら、wrangler d1 export --output=tenant.sqlでダンプしてからフィルタリングし、wrangler d1 executeで移行先にインポートするオフライン方式の方がシンプルだ(ただしファイルインポートは5GB上限)。
  4. キューの再生(replay): 一括コピーが終わった時点以降にキューに溜まったイベント(マイグレーション中に発生した書き込み)を、順序通りに移行先シャードへ再生して追いつかせる。
  5. 整合性の検証: 元と移行先の行数、そして主要テーブルのチェックサム(例: SELECT COUNT(*), SUM(amount) FROM orders WHERE tenant_id = ?)を比較する。不一致があればステップ3〜4をリトライする。
  6. カットオーバー: 検証を通過したら、shard_map.statusDONEに、shard_idを移行先に更新する単一のトランザクションで切り替える。この瞬間から新規の書き込みは移行先シャードにのみ向かう。
  7. キャッシュの失効を待つ: KVにキャッシュされたルーティングエントリが失効するまで(上のコードでMIGRATING状態のTTLを30秒と短めに設定している理由がこれだ)、元のシャードを読み取り専用のフォールバックとして生かしておく。グローバルなKV伝播遅延を考慮し、最低1〜2分の猶予時間を設ける。
  8. クリーンアップ: 猶予時間が過ぎたら、元のシャードから該当テナントのデータを削除して容量を回収する。

この手順の要点は、**「完全に止めて移す」のではなく「デュアルライト + キャッシュTTLを利用した段階的なカットオーバー」**であるという点だ。ダウンタイムなしでリバランスするには、結局のところ一時的に書き込みを2箇所へ重複記録するコストを受け入れる必要がある。

クロスシャードクエリの限界と集計(aggregation)の回避戦略

シャーディング後に最も頻繁にぶつかる問題は、**「全テナント合計」**のようなクロスシャード集計だ。D1はデータベースバインディングが物理的に分離されているため、シャード間のJOINもシャード間のトランザクションもサポートしていない。 これは回避する方法のないハード制約なので、設計段階から前提として組み込んでおく必要がある。

ファンアウト(fan-out)クエリ

リアルタイム性が必要で、かつシャード数が少ない(数十個以下)のであれば、最もシンプルな方法はすべてのシャードに並列でクエリを投げ、Worker側で合算することだ。

async function totalOrdersAcrossShards(env: Env): Promise<number> {
  const shardBindings = ["SHARD_0", "SHARD_1", "SHARD_2"] as const;

  const results = await Promise.all(
    shardBindings.map((key) =>
      (env[key] as D1Database)
        .prepare("SELECT COUNT(*) AS cnt FROM orders")
        .first<{ cnt: number }>()
    )
  );

  return results.reduce((sum, r) => sum + (r?.cnt ?? 0), 0);
}

この方式はシャード数が増えるほど、Worker呼び出しあたりのクエリ数制限やリクエスト全体のレイテンシ(最も遅いシャードが全体のレスポンスタイムを決定する)の影響を受ける。シャードが数百個規模まで大きくなると、ファンアウトはもはやリアルタイム経路には向かなくなる。

ロールアップ(rollup)テーブル + 非同期ETL

ダッシュボードやレポーティングのように秒単位のリアルタイム性が不要な集計は、リクエスト時にファンアウトするのではなく、書き込み時にあらかじめ集計しておく方がはるかに安定する。各シャードの書き込みトランザクションが完了した後にCloudflare Queuesへ「集計更新が必要」というイベントを発行し、別のコンシューマーWorkerがそのイベントを消費して**専用のanalytics D1データベース(時系列的な性格が強ければAnalytics Engine)**のロールアップ行を更新する構造だ。こうすることで、管理者ダッシュボードはシャードに一切触れず、ロールアップDBを1つ照会するだけで済む。

シャードをまたぐトランザクションはサガ(saga)パターンで

テナント間の資源移転のように2つのシャードにまたがる原子的トランザクションが必要になるケース(稀ではあるが発生する)では、D1は分散トランザクションをサポートしないため、補償トランザクション(compensating transaction)ベースのサガパターンで解決する必要がある。つまり「シャードAで減算 → 成功したらシャードBで加算 → 失敗したらAで巻き戻す」という冪等(idempotent)なステップに分割し、各ステップにリトライ可能な一意の操作IDを付与して重複実行を防ぐ。

シャーディングの前にまず試すべきインデックス・クエリ最適化チェックリスト

シャーディングアーキテクチャを一通り設計し終えてから、「実はこの程度ならインデックスを直すだけで済んだ」というケースが実務では意外と多い。以下のチェックリストを先に使い切ることを勧める。

このチェックリストをすべて適用してもなお、先に挙げた判断基準(DBサイズが10GBに近い、書き込みp95レイテンシの急上昇)が残っているなら、そのときこそ本記事で扱ったシャーディングアーキテクチャを実際に導入する時期だ。