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:
- Die p95-Latenz für
INSERT/UPDATEschnellt während Spitzenzeiten auf das 3- bis 5-Fache des Normalwerts hoch, obwohl bereits alle nötigen Indizes gesetzt sind. - Während der Batch-Job eines einzelnen (meist großen) Tenants läuft, werden auch die API-Antworten anderer Tenants langsamer.
- Die mit
wrangler d1 infoermittelte DB-Größe liegt über 8–9 GB und wächst weiter. - Bulk-Insert-Operationen liefern Fehler wie
too many SQL variablesoder Query-Timeouts.
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:
- Liegt die größte Datenbank bei 70 % oder mehr des 10-GB-Limits, ohne dass sich das Wachstum abflacht?
- Liegt die Schreib-p95-Latenz während Spitzenzeiten mindestens doppelt so hoch wie im Normalfall? (Unbedingt darauf achten, dass es sich um die Schreib-Latenz handelt, nicht um die Lese-Latenz.)
- Bleiben die beiden oben genannten Symptome bestehen, obwohl Indizes, Batch-Writes und das Archivieren kalter Daten bereits angewendet wurden?
- Ist die Streuung im Datenvolumen zwischen Tenants so groß (z. B. die oberen 5 % machen 60 % des Gesamtvolumens aus), dass eine Isolierung einzelner Tenants tatsächlich etwas bringt?
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:
- 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. - 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.
- 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:
- 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.
- Status auf MIGRATING setzen: In
shard_mapwird derstatusdes betreffenden Tenants aufMIGRATINGgesetzt undtarget_shard_idauf 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). - Bulk-Kopie: Die dem Tenant gehörenden Zeilen werden per paginiertem
SELECTaus dem Ursprungs-Shard gelesen und mitdb.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 mitwrangler d1 export --output=tenant.sql, filtern und anschließend mitwrangler d1 executein den Ziel-Shard importieren (wobei das Datei-Import-Limit von 5 GB zu beachten ist). - 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.
- 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. - Cutover: Nach bestandener Prüfung wird
shard_map.statusin einer einzigen Transaktion aufDONEgesetzt undshard_idauf den Ziel-Shard aktualisiert. Ab diesem Moment gehen neue Schreibvorgänge ausschließlich an den Ziel-Shard. - 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.
- 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.
- Mit
EXPLAIN QUERY PLANzuerst prüfen, ob Indizes tatsächlich genutzt werden. ErscheintSCAN TABLE, findet ein Full Scan statt. Covering Indexes für die Spaltenkombinationen bauen, die gemeinsam inWHEREundORDER BYvorkommen. - Statt
SELECT *nur die benötigten Spalten abfragen. Zeilen dürfen zwar bis zu 2 MB groß sein, aber unnötig große Spalten (etwa JSON-Blobs) bei jeder Query mitzulesen, treibt sowohl Query-Zeit als auch Netzwerk-Payload nach oben. - Mehrzeilige Schreibvorgänge statt einzelner
INSERTs mitdb.batch()bündeln. Allein die Reduzierung der Roundtrips verbessert den gefühlten Durchsatz bei einer Single-Writer-Architektur deutlich. Dabei die Batch-Größe so wählen, dass die Limits von 100 gebundenen Parametern und 100 KB pro SQL-Statement nicht überschritten werden. - Hochfrequente Einzelzeilen-Zähler und Rate-Limits aus D1 auslagern. Ein Zähler, der mehrmals pro Sekunde direkt per
UPDATEin D1 aktualisiert wird, ist selbst der Auslöser für den Single-Writer-Engpass. Eine Verlagerung zu Durable Objects oder KV entlastet D1s Schreib-Warteschlange. - Kalte Daten archivieren, um die DB-Größe im Griff zu behalten. Selten abgefragte Daten – alte Logs, abgeschlossene Bestellungen – regelmäßig in einen separaten Speicher wie R2 exportieren und aus D1 löschen, um wieder Spielraum gegenüber dem 10-GB-Limit zu schaffen.
- Bei lesebasierten Engpässen zuerst Read-Replicas (Sessions API) und KV/Cache-API-Caching einsetzen, statt zu shardieren. Wie bereits erläutert, steigern Read-Replicas nur den Lesedurchsatz – wenn das Problem aber ohnehin bei den Lesezugriffen liegt, reicht das in vielen Fällen völlig aus.
- Auf N+1-Query-Muster prüfen. Werden in einer Schleife einzelne Queries abgesetzt, stößt man zuerst an das Query-Limit pro Worker-Aufruf (50 bei Free / 1.000 bei Paid). Vorrang hat, das mit
JOINoder einerIN (...)-Klausel zusammenzufassen.
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.