effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

Cloudflare D1 Sharding in der Praxis: Das 10-GB-Limit und den Single-Writer-Flaschenhals überwinden

Cloudflare D1 Sharding in der Praxis: Das 10-GB-Limit und den Single-Writer-Flaschenhals überwinden

Wer schon einmal eine Multi-Tenant-SaaS-Anwendung oder einen schreiblastigen Event-Logging-Dienst auf Cloudflare D1 betrieben hat, stößt früher oder später an diese Wand: maximal 10 GB pro Datenbank, und jeder Schreibvorgang läuft sequenziell über einen einzigen Writer. Bei Postgres oder MySQL würde man das Problem einfach mit zusätzlichen Read-Replicas oder einer Partitionierungs-Extension lösen – D1 ist von Grund auf anders gebaut, weil es im Kern “viele über das Edge-Netzwerk verteilte SQLite-Instanzen” sind, weshalb der gesamte Lösungsansatz ein anderer sein muss.

Dieser Beitrag deckt alles ab, was man braucht, um Sharding tatsächlich produktiv einzusetzen: wie man erkennt, ob Indexoptimierung ausreicht oder ob man wirklich shardieren muss, Code für Tenant-ID-basiertes Routing auf Basis von Worker + KV, ein Verfahren zum Rebalancing der Shards ohne Downtime sowie Strategien, um Cross-Shard-Aggregation zu umgehen – alles auf einem Niveau, das sich direkt in den Betrieb übernehmen lässt.

Das Wichtigste in Kürze

  • D1 begrenzt jede Datenbank auf 10 GB (Paid) / 500 MB (Free), mit einer kontoweiten Obergrenze von 1 TB (Paid) / 5 GB (Free), und jede Datenbank hat einen einzigen Writer, der Schreibvorgänge sequenziell abarbeitet.
  • Lesebasierte Engpässe sollte man zuerst mit Sessions-API-basierten Read-Replicas lösen, nicht mit Sharding – Read-Replicas bringen für den Schreibdurchsatz überhaupt nichts.
  • Sharding kommt erst infrage, wenn (1) die DB-Größe sich der 10-GB-Grenze nähert oder (2) gleichzeitige Schreibkonkurrenz die Latenz spürbar erhöht. Davor sollte man zuerst Indizes, Batch-Writes und das Archivieren kalter Daten prüfen.
  • Für das Shard-Mapping ist eine Verzeichnis-Zuordnung über KV + Control-DB beim Rebalancing deutlich praktikabler als ein Hash-basiertes Schema.
  • Cross-Shard-JOINs und -Transaktionen werden nicht unterstützt, daher muss Aggregation über Fan-out-Queries oder einen separaten Rollup-Speicher umgangen werden.

Wo die eigentlichen Engpässe entstehen: D1s hartes 10-GB-Limit und die Single-Writer-Architektur

D1s Einschränkungen lassen sich grob in zwei Ebenen unterteilen: das Größenlimit der einzelnen Datenbank und das Speicherlimit für das gesamte Konto. Die Zahlen laut offizieller Cloudflare-Dokumentation sehen so aus:

Kategorie Free-Plan Paid-Plan
Maximale Größe pro Datenbank 500 MB 10 GB
Speicherplatz pro Konto 5 GB 1 TB
Queries pro Worker-Aufruf 50 1.000
Maximale Länge eines SQL-Statements 100.000 Bytes (100 KB) Gleich
Gebundene Parameter pro Query 100 Gleich
Maximale Ausführungszeit einer Query 30 Sekunden Gleich
Maximale Zeilengröße 2.000.000 Bytes (2 MB) Gleich
Maximale Spaltenzahl pro Tabelle 100 Gleich
Maximale Größe beim Datei-Import 5 GB Gleich

Entscheidend ist hier die Asymmetrie: “das Konto insgesamt hat großzügig Platz, aber eine einzelne Datenbank ist schnell voll.” Im Paid-Plan lassen sich zwar bis zu 1 TB pro Konto nutzen, aber wenn 100 Tenants in einer einzigen Datenbank zusammengepackt sind, stößt genau diese Datenbank trotzdem an die 10-GB-Grenze. Der eigentliche Engpass ist also nicht das Kontolimit, sondern die Mauer bei “10 GB pro Datenbank”. Dienste mit Tabellen, die immer weiter anwachsen – etwa Event-Logs oder Audit-Tabellen –, stoßen an diese Grenze oft schneller, als man denkt.

Die zweite Einschränkung ist die Single-Writer-Architektur. D1 läuft auf der SQLite-Engine, und laut Dokumentation “verarbeitet jede Datenbank Queries sequenziell auf einem einzigen Thread”. Als theoretische Obergrenze werden oft rund 1.000 Queries pro Sekunde bei kurzen (~1 ms) Abfragen genannt, aber echte Produktions-Queries mischen Indexscans, Joins und Schreibtransaktionen, sodass die Latenz in der Praxis schon deutlich früher ansteigt. Das eigentliche Problem ist, dass es sich um ein Concurrency-Problem handelt und nicht nur um ein Durchsatzproblem. Während Tenant As schwere Schreibtransaktion läuft, warten die Anfragen von Tenant B – die dieselbe Datenbank nutzen – in der Warteschlange. In einer Multi-Tenant-Umgebung führt das direkt zum Noisy-Neighbor-Problem: Der Traffic-Spike eines einzelnen Tenants zieht die Latenz aller anderen Tenants mit nach oben.

Die typischen Anzeichen, an denen sich dieser Engpass in der Praxis zeigt:

Wann Sharding wirklich nötig ist: Lesebasierte vs. schreibbasierte Workloads unterscheiden

Sharding hat einen hohen Preis. Man muss eine komplette Routing-Schicht, ein Rebalancing-Verfahren und Workarounds für Cross-Shard-Aggregation bauen – wer nach dem Motto “sicherheitshalber schon mal aufteilen” vorgeht, betreibt Over-Engineering. In der Praxis sollte man zuerst die Natur der eigenen Workload diagnostizieren.

Bei lesebasierten Engpässen ist Sharding nicht die Antwort

Wenn die Verlangsamung von einem Ansturm an Lesezugriffen kommt, bietet D1 Sessions-API-basierte Read-Replicas an. Anfragen werden auf geografisch nahe Read-Replicas verteilt, was die Latenz senkt, und da mehrere Replicas gleichzeitig Lesezugriffe bedienen können, steigt auch der Lesedurchsatz. Laut offizieller Dokumentation gilt jedoch: “Alle Schreib-Queries werden weiterhin ausschließlich an die Primärdatenbank weitergeleitet” – Read-Replicas bringen für den Schreibdurchsatz überhaupt keine Verbesserung. Bei einer lesebasierten Workload zuerst an Sharding zu denken, ist also die falsche Reihenfolge – zuerst sollten Read-Replicas, KV/Cache-API-Caching und Query-Optimierung ausgeschöpft werden.

Bei schreibbasierten Engpässen gibt es kaum Alternativen zu Sharding

Anders sieht es aus, wenn das Problem gleichzeitige Schreibkonkurrenz oder eine einzelne Datenbank nahe der 10-GB-Grenze ist. Read-Replicas helfen bei keinem der beiden Probleme – nach wie vor verarbeitet eine einzige Primärdatenbank alle Schreibvorgänge sequenziell, und nach wie vor stößt eine einzige Datei an die 10-GB-Wand. In diesem Fall ist manuelles Sharding nach Tenant oder Entität praktisch der einzige Weg zur horizontalen Skalierung.

Eine Checkliste für die Entscheidung:

Wenn drei oder mehr der vier Punkte zutreffen, ist es Zeit, Sharding in die Design-Phase zu heben. Wenn die meisten Antworten “Nein” lauten, reicht die Indexierungs-Checkliste aus dem letzten Abschnitt unten dagegen noch für einige weitere Monate.

Tenant-ID-basiertes Routing entwerfen: eine Shard-Mapping-Tabelle mit Worker + KV umsetzen

Für Shard-Keys gibt es zwei Ansätze: hash-basiert und verzeichnisbasiert (Mapping-Tabelle). Der hash-basierte Ansatz (shard = hash(tenant_id) % N) ist einfach zu implementieren, erfordert aber massives Rehashing, sobald man die Anzahl der Shards ändert oder auch nur einen einzelnen Tenant isoliert verschieben will. Ein verzeichnisbasiertes Mapping hält dagegen Fakten wie “dieser Tenant liegt gerade auf shard-3” in einer separaten Tabelle fest, wodurch partielles Rebalancing möglich wird – genau einen Tenant gezielt auf einen anderen Shard verschieben, ohne alles andere anzufassen. In Multi-Tenant-Umgebungen ist das verzeichnisbasierte Mapping fast immer die empfohlene Wahl.

Die Architektur besteht aus drei Schichten:

  1. Control-DB: die einzige Source of Truth für das Shard-Mapping. Eine kleine, dedizierte D1-Datenbank, die nur eine einzige shard_map-Tabelle enthält.
  2. KV-Cache: Würde man die Control-DB bei jeder Anfrage abfragen, würde D1 selbst zum Flaschenhals – deshalb werden die Mapping-Ergebnisse als Read-Through-Cache in Workers KV vorgehalten.
  3. Shard-DBs: die N D1-Datenbanken, die die eigentlichen Tenant-Daten enthalten.
# 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"

Schema der Control-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);

Die Routing-Logik prüft zunächst KV; nur bei einem Cache-Miss wird die Control-DB abgefragt, und das Ergebnis wird anschließend per Write-through in den Cache geschrieben:

// 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;
}

Beim Onboarding neuer Tenants sollte man eine Logik einbauen, die denjenigen Shard auswählt, der aktuell – gemessen an Größe und Last – am meisten Spielraum hat. Eine einfache Round-Robin-Zuweisung kann dazu führen, dass sich große Tenants auf einem Shard häufen; besser ist es, beim Onboarding das erwartete Datenvolumen (z. B. anhand der Plan-Stufe) zu berücksichtigen – das senkt, wie oft später rebalanciert werden muss.

Daten zwischen Shards verschieben und ohne Downtime rebalancieren

D1 bietet weder native Replikation zwischen Datenbanken noch eine Online-Migrations-API. Rebalancing muss deshalb explizit als Zustandsautomat auf der Anwendungsebene gesteuert werden. Das Verfahren über die Spalte shard_map.status (ACTIVE → MIGRATING → DONE) sieht so aus:

  1. Migrationsziel auswählen: Sobald Größen-/Last-Monitoring zeigt, dass ein Shard einen Schwellenwert überschreitet (z. B. 8 GB, oder ein einzelner Tenant macht mehr als 40 % der Schreibvorgänge dieses Shards aus), wählt man einen zu verschiebenden Tenant aus. Dass man aus vielen Tenants gezielt nur einen auswählen kann, ist genau der Vorteil des verzeichnisbasierten Mappings.
  2. Status auf MIGRATING setzen: In shard_map wird der status des betreffenden Tenants auf MIGRATING gesetzt und target_shard_id auf den Ziel-Shard aktualisiert. Ab diesem Zeitpunkt schreibt die Anwendung weiterhin auf den Ursprungs-Shard und veröffentlicht gleichzeitig dieselben Schreib-Events asynchron über Cloudflare Queues (Dual Write).
  3. Bulk-Kopie: Die dem Tenant gehörenden Zeilen werden per paginiertem SELECT aus dem Ursprungs-Shard gelesen und mit db.batch() in den Ziel-Shard geschrieben. Die Batch-Größe muss dabei das Query-Limit pro Worker-Aufruf (50 bei Free / 1.000 bei Paid) sowie das Limit von 100 gebundenen Parametern berücksichtigen. Bei großen Datenmengen und geringerer Latenzempfindlichkeit ist der Offline-Weg einfacher: Dump mit wrangler d1 export --output=tenant.sql, filtern und anschließend mit wrangler d1 execute in den Ziel-Shard importieren (wobei das Datei-Import-Limit von 5 GB zu beachten ist).
  4. Queue abspielen (Replay): Nach Abschluss der Bulk-Kopie werden die in der Zwischenzeit in der Queue angesammelten Events (Schreibvorgänge, die während der Migration passiert sind) der Reihe nach gegen den Ziel-Shard abgespielt, um aufzuholen.
  5. Konsistenz prüfen: Zeilenanzahlen von Ursprungs- und Ziel-Shard sowie Prüfsummen zentraler Tabellen vergleichen (z. B. SELECT COUNT(*), SUM(amount) FROM orders WHERE tenant_id = ?). Bei Abweichungen werden die Schritte 3–4 wiederholt.
  6. Cutover: Nach bestandener Prüfung wird shard_map.status in einer einzigen Transaktion auf DONE gesetzt und shard_id auf den Ziel-Shard aktualisiert. Ab diesem Moment gehen neue Schreibvorgänge ausschließlich an den Ziel-Shard.
  7. Auf Cache-Ablauf warten: Der Ursprungs-Shard bleibt als Read-only-Fallback aktiv, bis der in KV zwischengespeicherte Routing-Eintrag abgelaufen ist (deshalb setzt der Code oben für den Status MIGRATING eine kurze TTL von 30 Sekunden). Angesichts der globalen KV-Propagationsverzögerung sollte man mindestens 1–2 Minuten Kulanzzeit einplanen.
  8. Aufräumen: Nach Ablauf der Kulanzzeit werden die Daten des Tenants aus dem Ursprungs-Shard gelöscht, um den Speicherplatz zurückzugewinnen.

Der Kern dieses Verfahrens ist nicht “alles anhalten und verschieben”, sondern “Dual Write plus ein schrittweiser Cutover über die Cache-TTL”. Wer ohne Downtime rebalancieren will, muss letztlich in Kauf nehmen, vorübergehend an zwei Stellen gleichzeitig zu schreiben.

Grenzen von Cross-Shard-Queries und Strategien zur Umgehung von Aggregation

Das Problem, auf das man nach dem Sharding am häufigsten stößt, ist Cross-Shard-Aggregation – etwa eine “Summe über alle Tenants”. Da D1-Datenbank-Bindings physisch getrennt sind, werden weder JOINs noch Transaktionen über Shards hinweg unterstützt. Das ist eine harte Einschränkung ohne Workaround und sollte deshalb von Anfang an als Design-Prämisse gesetzt werden.

Fan-out-Queries

Wenn Echtzeit-Ergebnisse gebraucht werden und die Anzahl der Shards überschaubar ist (im niedrigen zweistelligen Bereich), ist der einfachste Ansatz, alle Shards parallel abzufragen und die Ergebnisse im Worker zu summieren.

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);
}

Mit wachsender Shard-Zahl gerät dieser Ansatz zunehmend an das Query-Limit pro Worker-Aufruf und an die Gesamtlatenz der Anfrage (der langsamste Shard bestimmt die gesamte Antwortzeit). Sobald die Shard-Zahl in die Hunderte geht, ist Fan-out für einen Echtzeit-Pfad nicht mehr geeignet.

Rollup-Tabellen + asynchrones ETL

Für Aggregationen, die keine sekundengenaue Aktualität brauchen – etwa Dashboards oder Reporting –, ist es deutlich stabiler, nicht zum Anfragezeitpunkt zu fan-outen, sondern bereits zum Schreibzeitpunkt vorzuaggregieren. Nach Abschluss der Schreibtransaktion in jedem Shard wird über Cloudflare Queues ein “Aggregation muss aktualisiert werden”-Event veröffentlicht; ein separater Consumer-Worker verarbeitet dieses Event und aktualisiert die Rollup-Zeilen in einer dedizierten Analytics-D1-Datenbank (oder in Analytics Engine, wenn die Daten stark zeitreihenartig sind). So muss das Admin-Dashboard die Shards überhaupt nicht anfassen, sondern fragt ausschließlich die eine Rollup-DB ab.

Transaktionen über Shard-Grenzen hinweg: mit dem Saga-Pattern lösen

Für den selteneren Fall, dass eine atomare Transaktion über zwei Shards hinweg nötig ist – etwa eine Ressourcenübertragung zwischen Tenants –, unterstützt D1 keine verteilten Transaktionen, weshalb man auf ein Saga-Pattern auf Basis von Compensating Transactions zurückgreifen muss. Der Vorgang wird dazu in idempotente Schritte zerlegt (“in Shard A abbuchen → bei Erfolg in Shard B gutschreiben → bei Fehler in A zurückbuchen”), und jeder Schritt erhält eine eindeutige, wiederholbare Operations-ID, um doppelte Ausführung zu verhindern.

Checkliste für Indizierung und Query-Optimierung, bevor man shardiert

In der Praxis stellt sich nach dem vollständigen Entwurf einer Sharding-Architektur nicht selten heraus, dass “eigentlich hätte es gereicht, nur die Indizes zu korrigieren”. Es lohnt sich, zuerst die folgende Checkliste durchzugehen.

Wenn diese gesamte Checkliste angewendet wurde und die zuvor genannten Kriterien (DB-Größe nahe 10 GB, stark erhöhte Schreib-p95-Latenz) trotzdem weiterhin zutreffen, ist genau das der Zeitpunkt, an dem es tatsächlich Zeit ist, die in diesem Beitrag beschriebene Sharding-Architektur umzusetzen.