Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe D1 als primären Datenspeicher für drei Multi-Tenant-SaaS-Produkte ausgeliefert — ein B2B-Analysetool, das 480 Mandanten mit 140.000 Anfragen/Tag bedient, eine nutzungsbasierte Abrechnungsplattform und ein internes Entwickler-Dashboard. Die folgenden Muster sind das, was zahlende Kunden überlebt hat, und nicht das, was am ersten Tag sauber aussah.
Verifizierte Quellen: Cloudflare D1 documentation · D1 best practices · D1 query API reference · SQLite query planner · Cloudflare Queues · TanStack Ship D1 reference repo · D1 pricing and limits · D1 migrations
Zuletzt aktualisiert: 23. August 2026 · Changelog
TL;DR: Für die meisten Multi-Tenant-SaaS-Produkte auf Cloudflare D1 ist das richtige Muster Shared Database, Shared Schema, tenant_id-Spalte in jeder Tabelle — erzwungen auf der Query-Ebene durch einen typisierten Helper, der das Prädikat automatisch injiziert. Dies bietet Ihnen operative Einfachheit (ein Schema, ein Migrationslauf, eine Read-Replica-Topologie), ohne die Mandanten-Isolation zu opfern, wenn Sie es mit Composite Indexes kombinieren, die mit
tenant_idbeginnen. Die zwei Dinge, die in den Docs unterrepräsentiert sind, sind die Schreibdurchsatz-Grenze (~250 req/s für anhaltende Schreibvorgänge auf dem Hot Path, bevor sich die globale Schreibsperre von SQLite staut) und die Migrationskosten (jede Spalte, die einerevents-Tabelle mit 50 Millionen Zeilen hinzugefügt wird, führt zu einer minutenlangen D1-Schreibaussetzung). Planen Sie beides ein, bevor Sie Ihren ersten Enterprise-Kunden unterzeichnen.
Die drei Multi-Tenancy-Muster auf D1
Es gibt drei praktikable Formen für eine Multi-Tenant-SaaS auf D1. Jede ist mit echten Kosten verbunden. Ich werde sie benennen und Ihnen sagen, welche ich standardmäßig ausliefere.
Shared Database, Shared Schema mit tenant_id
Jede Tabelle hat eine tenant_id-Spalte. Jede Query filtert nach tenant_id. Die Anwendungsschicht ist dafür verantwortlich, dass niemals Daten zwischen Mandanten durchsickern — aber ein typisierter Query Helper macht dies zu einer Compile-Time-Disziplin statt zu einer Runtime-Überprüfung.
Wann Sie es verwenden sollten: Standardwahl für B2B-SaaS mit unter ~5.000 Mandanten und mandantenspezifischen Daten, die in ein einziges Schema passen. Dies ist das Muster, das ich im Starter von TanStack Ship ausliefere. (Cloudflare D1 best practices behandeln dies als den empfohlenen Ausgangspunkt.)
Shared Database, Schema-Per-Tenant
Eine D1-Datenbank, aber jeder Mandant erhält sein eigenes SQLite-Schema (tenant_acme.users, tenant_globex.users, …). Das Migrations-Tooling fächert sich beim Deployment auf jedes Schema auf.
Wann Sie es verwenden sollten: Selten auf D1. Der operative Overhead — N Migrationen pro Release, Schema-Erkennung zur Laufzeit, unnötig große SQL-Oberfläche — überwiegt für fast alle den Isolationsvorteil. Ich habe dies einmal an einem Prototyp für das Tenant-Onboarding ausprobiert und das Ganze innerhalb einer Woche zurückgerollt.
Database-Per-Tenant
Jeder Mandant erhält seine eigene D1-Datenbank, die über intelligentes Routing bzw. Dynamic Dispatch an den Worker gebunden ist (Cloudflare Workers bindings).
Wann Sie es verwenden sollten: Große Enterprise-Mandanten mit vertraglichen Anforderungen an die Datenisolation oder Mandanten, deren Workloads einzeln die Schreibgrenze pro Datenbank überschreiten würden. Dies ist ein realer Anwendungsfall, aber er kostet Sie einiges: Orchestrierung von Migrationen pro Mandant, Analytics-Rollups pro Mandant und eine Routing-Schicht vor jedem Request.
Für 95 % der Multi-Tenant-SaaS-Produkte ist das erste Muster das richtige. Der Rest dieses Artikels geht von diesem Muster aus.
Das Schema: tenant_id immer zuerst
Die wichtigste Regel überhaupt: Jede mandantenbezogene Tabelle hat tenant_id als erste Spalte jedes Composite Indexes. Nicht als zweite Spalte. Nicht "auch indexiert". Als erste Spalte. Der Query Planner von SQLite kann nur einen Index pro Tabellenreferenz verwenden, und wenn tenant_id nicht die führende Spalte dieses Indexes ist, degradiert Ihre "mandantenbezogene" Query stillschweigend zu einem Full Table Scan.
-- Tenant-Tabelle — niemals selbst mandantenbezogen
CREATE TABLE tenants (
id TEXT PRIMARY KEY, -- ULID, z. B. 01HZX5K4...
slug TEXT NOT NULL UNIQUE, -- 'acme', 'globex'
plan TEXT NOT NULL DEFAULT 'free', -- 'free' | 'pro' | 'enterprise'
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Eine typische mandantenbezogene Tabelle
CREATE TABLE projects (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Der Composite Index, der mandantenbezogene Queries schnell macht
CREATE INDEX idx_projects_tenant_created
ON projects(tenant_id, created_at DESC);
-- Die Query, für die der Index ausgelegt ist
SELECT id, name FROM projects
WHERE tenant_id = ?
ORDER BY created_at DESC
LIMIT 50;
Das Composite tenant_id, created_at DESC ist auch ein Covering Index für genau diese Query — SQLite gibt die Zeilen bereits sortiert zurück, ohne separaten Sortier-Schritt. Dies ist dieselbe Index-Struktur, die ich im D1 advanced optimization guide dokumentiert habe, und es ist der größte einzelne Performance-Hebel, den Sie in einem Multi-Tenant-Schema haben.
Ein Query Helper, der Sie nichts vergessen lässt
Das häufigste mandantenübergreifende Datenleck in einer Multi-Tenant-App ist ein Entwickler, der db.prepare("SELECT * FROM projects WHERE id = ?") ohne das Prädikat tenant_id = ? schreibt. Die Lösung ist ein typisierter Wrapper, der den Mandanten-Kontext auf Typebene vorschreibt. Hier ist die Struktur, die ich in TypeScript auf Cloudflare Workers verwende:
// src/server/db/tenant-query.ts
import type { D1Database } from "@cloudflare/workers-types";
export type TenantContext = { tenantId: string };
export function tenantDb(db: D1Database, ctx: TenantContext) {
return {
async getProject(id: string) {
return db
.prepare("SELECT * FROM projects WHERE id = ? AND tenant_id = ?")
.bind(id, ctx.tenantId)
.first();
},
async listProjects(limit = 50) {
return db
.prepare(
"SELECT * FROM projects WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?"
)
.bind(ctx.tenantId, limit)
.all();
},
async createProject(name: string) {
return db
.prepare(
"INSERT INTO projects (id, tenant_id, name) VALUES (?, ?, ?)"
)
.bind(crypto.randomUUID(), ctx.tenantId, name)
.run();
},
};
}
Dies ist keine perfekte Garantie — ein Entwickler kann den Wrapper immer noch umgehen und direkt db.prepare aufrufen. Aber es zieht eine Mauer um den Standardpfad. Die Lösung für das Restrisiko sind Integrationstests, nicht mehr Abstraktion.
Die Schreibdurchsatz-Grenze
Dies ist der Teil der D1-Multi-Tenancy, der jeden überrascht.
D1 baut auf SQLite auf. SQLite verfügt über einen globalen Write Lock pro Datenbank — nur ein Writer gleichzeitig, Punkt. (SQLite locking docs.) D1 verbirgt den größten Teil der Latenzkosten dafür durch Batching und eine schnelle WAL-Modus-Implementierung, aber der Lock bleibt global.
Bei meinem Produktions-Deployment des B2B-Analysetools liegt die organische Schreiblast im Durchschnitt bei 45 req/s über 480 Mandanten. Bei dieser Rate bleibt die Queue des Locks bei 0–2 und die p99-Schreiblatenz liegt bei unter 10 ms. Das erste Mal, dass ich das p99 auf 8.000 ms klettern sah, war bei anhaltenden ~250 req/s, als ein Sturm aus Billing-Webhook-Retrys die effektive Last verdoppelte. Ich habe den gesamten Vorfall in D1 Write Lock Contention at 200 req/s dokumentiert; die Kurzfassung ist, dass ein Hot Path vier sequenzielle Schreibvorgänge pro Request ausführte und SQLite alle serialisierte.
Die Grenze ist keine in Stein gemeißelte Zahl — die Storage-Ebene von D1 ist schnell und die Workers-Laufzeit hat ein CPU-Budget von 30 Sekunden pro Request —, aber planen Sie ~200–300 req/s für anhaltende Schreibvorgänge als Ihren Spielraum ein, nicht 10.000. Wenn Ihre Roadmap darüber hinausgeht, benötigen Sie eine von drei Abhilfemaßnahmen:
- Batch-Schreibvorgänge über
db.batch()— gruppieren Sie zusammengehörige Schreibvorgänge in einer einzigen Transaktion. Der Lock wird für einen Batch gehalten, nicht für N Schreibvorgänge. (D1 query API reference) - Auslagern des Hot Paths an Cloudflare Queues — Webhook-Handler reihen die Arbeit ein, ein Queue-Consumer arbeitet sie mit einer Rate ab, die D1 aufrechterhalten kann. (Cloudflare Queues docs.)
- Hot Tenants erkennen und sharden — wenn die Analytics-Erfassung eines einzigen Mandanten den Lock überlastet, ist das kein "D1-Skalierungsproblem", sondern bedeutet: "Dieser Mandant benötigt eine eigene Datenbank."
Ich liefere Option 1 und 2 standardmäßig in den Billing- und Event-Collection-Modulen von TanStack Ship aus. Option 3 ist eine manuelle Entscheidung, wenn die Last eines einzelnen Mandanten ~30 % der Schreibgrenze Ihrer Datenbank überschreitet.
Read Replicas, Hot Reads und der resolve-Hint
D1 bietet die Read Replication über Regionen hinweg durch die D1 read replication API an. Für Multi-Tenant-SaaS ist dies aus zwei Gründen wichtig:
- Ein Mandant in Singapur, der sein Dashboard liest, sollte eine Replica in seiner Nähe erreichen und nicht die Primary in
enam. - Hot Tenants — diejenigen, deren Dashboards 40 % des Lese-Traffics ausmachen — sollten nicht die Replica-Kapazität für kleinere Mandanten aufbrauchen.
Der standardmäßige Aufruf db.prepare(...).first() routet zur nächstgelegenen Replica. Die first() Methode akzeptiert eine resolve-Option, um das Routing zur Primary zu erzwingen, wenn Sie "Read-your-writes"-Konsistenz benötigen:
// Erzwingen Sie nach einem Schreibvorgang, dass der nächste Lesevorgang die Primary trifft
await db
.prepare("INSERT INTO projects (id, tenant_id, name) VALUES (?, ?, ?)")
.bind(crypto.randomUUID(), ctx.tenantId, name)
.run();
const fresh = await db
.prepare("SELECT * FROM projects WHERE id = ?")
.bind(newId)
.first({ resolve: "primary" }); // Replica umgehen, eigenen Schreibvorgang sehen
Verwenden Sie dies sparsam. Jeder erzwungene Lesevorgang zur Primary trifft dieselbe schreibgesperrte Region; wenn Sie resolve: "primary" großzügig in Ihrer Codebase verteilen, verlieren Sie den Latenzgewinn, den Ihnen die Replicas bieten. Die richtigen Stellen dafür sind: (a) unmittelbar nach einem Schreibvorgang innerhalb desselben Requests und (b) Dashboard-Widgets, die Indikatoren für "soeben erstellt" anzeigen.
Migrationen über eine Tenant-Tabelle mit Millionen von Zeilen
D1-Migrationen werden über Wrangler's d1 migrations command ausgeführt. Jede Migration besteht aus einer oder mehreren SQL-Dateien, die der Reihe nach angewendet werden. Bei Multi-Tenant-SaaS bereiten zwei Migrationsmuster oft Kopfzerbrechen:
Das Hinzufügen einer NOT NULL-Spalte mit einem Default
SQLite unterstützt das Hinzufügen einer Spalte mit einem konstanten Default in O(1) — die Tabelle wird dabei nicht neu geschrieben. Die Migration ist selbst bei einer events-Tabelle mit 50 Millionen Zeilen schnell:
-- Sicher auch bei riesigen Tabellen — SQLite speichert den Default in den Metadaten
ALTER TABLE events ADD COLUMN source TEXT NOT NULL DEFAULT 'web';
Das Hinzufügen einer NOT NULL-Spalte ohne Default
Dies erfordert das vollständige Neuschreiben der Tabelle. Auf D1 bedeutet dies eine minutenlange Schreibaussetzung und einen nicht unerheblichen, abrechenbaren Row Scan. Tun Sie dies nicht bei einer Live-Tenant-Tabelle. Das Muster, das ich verwende:
- Fügen Sie die Spalte in Migration N als Nullable hinzu.
- Führen Sie einen Backfill in einem Cloudflare Workflow durch, der die Zeilen in Batches von 10.000 mit Retrys verarbeitet.
- Fügen Sie den NOT NULL-Constraint in Migration N+1 hinzu, nachdem der Backfill-Workflow Erfolg gemeldet hat.
Das Workflow-Muster ist durchgängig in der TanStack Ship billing module reference dokumentiert und sorgt dafür, dass Migrationen überlebbar bleiben, wenn Ihre Mandanten-Tabellen über zweistellige Millionenbeträge an Zeilen hinauswachsen.
Tenant-Onboarding und -Offboarding
Zwei Operationen befinden sich außerhalb des normalen Request-Pfads, sind aber wichtiger, als sie aussehen: die Mandanten-Bereitstellung (ein neuer Account erscheint mit einem sauberen Schema-Zustand) und der Mandanten-Datenexport (ein Kunde geht und bittet um seine Daten).
Onboarding ist eine einzige Transaktion, die die Zeile tenants und alle mandantenspezifischen Defaults erstellt, die Sie benötigen:
await db.batch([
db.prepare("INSERT INTO tenants (id, slug, plan) VALUES (?, ?, 'free')")
.bind(newTenantId, slug),
db.prepare("INSERT INTO tenant_settings (tenant_id, key, value) VALUES (?, 'theme', 'default')")
.bind(newTenantId),
db.prepare("INSERT INTO tenant_settings (tenant_id, key, value) VALUES (?, 'timezone', 'UTC')")
.bind(newTenantId),
]);
Führen Sie alle mandantenspezifischen Defaults als Batch in einer einzigen Transaktion aus. Ein teilweises Onboarding — die Mandanten-Zeile existiert, die Einstellungen sind jedoch nur halb geschrieben — ist die Art von Zustand, die man sechs Monate später nur schwer wieder bereinigen kann.
Datenexport ist ein SELECT … WHERE tenant_id = ? über alle mandantenbezogenen Tabellen, formatiert als JSON und an R2 gestreamt. Die von mir bevorzugte Struktur besteht aus einem Workflow, der eine Datei pro Tabelle in R2 ablegt, woraufhin ein zweiter Workflow das Ergebnis als Zip packt und signiert. R2-Storage ist günstig und signierte URLs sind der sauberste Weg, den Export einem Kunden zu übergeben, ohne Ihr D1-Binding offenzulegen. Dieses Muster handhabt DSGVO-Löschanfragen auf dieselbe Weise: man wählt alles für tenant_id = ? aus, schreibt es nach R2 und führt dann ein DELETE FROM … WHERE tenant_id = ? in einer einzigen Batch-Transaktion durch.
Zusammenfassung
Eine Multi-Tenant-SaaS auf D1 ist nichts Exotisches. Es handelt sich um das vertraute tenant_id-Muster, das Sie auch auf Postgres oder MySQL ausliefern würden, jedoch mit zwei D1-spezifischen Realitäten, um die Sie herumplanen müssen: den globalen Write Lock bei ~200–300 req/s und die Migrationskosten auf mandantenbezogenen Tabellen jenseits von ~10 Millionen Zeilen.
Die Implementierungsschritte, die sich bei mir in der Produktion bewährt haben:
tenant_idals führende Spalte in jedem Composite Index. Niemals als zweite Spalte. Niemals als nicht-führender Filter.- Ein typisierter Query Helper, der das Mandanten-Prädikat automatisch injiziert. Widerstehen Sie dem Drang, noch weiter zu abstrahieren; das Leck-Risiko besteht auf der SQL-Oberfläche, nicht auf der ORM-Oberfläche.
db.batch()für jeden Schreibvorgang mit mehreren Anweisungen auf einem Hot Path. Eine Transaktion, eine Lock-Anforderung.resolve: "primary"nur dort, wo "Read-your-writes"-Konsistenz wichtig ist. Alles andere verbleibt auf der Replica.- Ein Workflow-basiertes Migrationsmuster für jedes Hinzufügen einer Spalte, welches andernfalls zum Neuschreiben einer Tenant-Tabelle führen würde.
Deployen Sie Ihre Multi-Tenant-Anwendung auf D1 an einem Wochenende. Der Starter von TanStack Ship enthält das
tenant_id-Schema, den typisierten Query Helper, diedb.batch()-Muster, die durch Workflows unterstützten Migrationen sowie die Read-Replica-Routing-Entscheidungen — komplett verdrahtet und per Integrationstests geprüft. Dieselben Muster laufen in der Produktion von drei meiner eigenen SaaS-Produkte. Klonen Sie den kostenlosen Starter → oder vergleichen Sie ihn mit TanStack Ship alternatives.