Cloudflare D1 Write Lock Contention: Postmortem bei 200 req/s — Was passierte und der Fix

Ein reales Produktions-Postmortem: Wie das SQLite Write Lock von D1 eine Billing-Route bei 200 req/s blockierte und warum eine Queue die Lösung ist.

Huifer
Huifer
21. Juli 202610 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Dieser Vorfall ereignete sich in einem Produktions-Deployment, das ca. 3.200 zahlende Kunden bediente. Ich habe dieses Postmortem 48 Stunden nach der Veröffentlichung des Fixes geschrieben, als die Traces noch frisch waren. Jeder Befehl und jede Metrik in diesem Beitrag stammt aus dem tatsächlichen Vorfall — die Namen der Variablen wurden geändert, die Zahlen jedoch nicht.

Verifizierte Quellen: TanStack Ship GitHub org · Cloudflare D1 docs Zuletzt aktualisiert: 21. Juli 2026 · Changelog


TL;DR: Eine rechenintensive Billing-Route auf Cloudflare Workers + D1 verzeichnete 30% Request-Timeouts, als der Traffic ~200 req/s überschritt. Die Ursache war das SQLite Write Lock von D1, das jede Mutation gegen jede andere Mutation auf derselben Datenbank serialisierte. Der Fix bestand aus einem Cloudflare Queue Producer vor der Route und einem gebatchten Workflow, der die Queue mit zusammengefassten Schreibvorgängen abarbeitet. Das Billing-Modul von TanStack Ship liefert dieses Pattern nun als einzigen verfügbaren Schreibpfad aus — synchrone D1-Schreibvorgänge auf Billing-Routen sind durch die Schema-Validierungsschicht nicht mehr zulässig.


Hintergrund

Das Billing-Modul des betroffenen Deployments läuft auf Cloudflare Workers, mit D1 (SQLite at the edge) als System of Record. Es gibt drei hoch frequentierte Routen:

  • /api/billing/checkout/confirm — läuft bei Stripe-Webhook-Akzeptanz, schreibt eine Subscription-Zeile und eine Credit-Ledger-Zeile in einer einzigen Transaktion.
  • /api/billing/usage/record — läuft bei jedem gemessenen Usage-Event der Anwendung, fügt eine Usage-Zeile hinzu und aktualisiert eine Credit-Ledger-Zeile.
  • /api/billing/cron/settle — läuft über einen Cron Trigger alle 5 Minuten und gleicht ausstehende Credits mit erfolgreichen Stripe-Abrechnungen für denselben Zeitraum ab.

Zum Zeitpunkt des Vorfalls bediente das Deployment ca. 3.200 zahlende Kunden. Die Route /usage/record war die am stärksten ausgelastete — der Spitzen-Traffic lag bei ~200 req/s während einer anhaltenden Marketing-Promotion, die von 09:00 bis 18:00 Uhr Ortszeit lief. Die anderen beiden Routen fügten zusammen weitere ~50 req/s hinzu. Der gesamte Mutationsdurchsatz in der Spitze lag bei ~250 req/s auf einer einzelnen D1-Datenbank.

Das Deployment befand sich im kostenpflichtigen Workers-Plan, mit einer einzigen D1-Datenbank, ohne konfigurierte Read Replicas (wir hatten den Lese-Druck noch nicht gemessen, um sie zu rechtfertigen), und ohne Cloudflare Queue vor den Billing-Routen. Jeder Schreibvorgang war ein synchroner db.insert(...)-Aufruf innerhalb des Request-Handlers. Dies ist die Standardstruktur für ein neues TanStack Ship Billing-Modul, und genau diese Struktur brach zusammen.

Vorfall

Das erste Signal kam um 09:14 Uhr durch eine Support-E-Mail von Stripe — drei Enterprise-Kunden hatten Timeout-Wiederholungen gemeldet, als ihre gemessene Nutzung die API erreichte. Bis 09:18 Uhr zeigte das Workers-Analytics-Dashboard eine Timeout-Spitze von 30% auf der Route /usage/record, wobei die p99-Latenz von einem Basiswert von ~210 ms auf ~8,4 s anstieg. Die Route /cron/settle schlug ebenfalls fehl — fünf aufeinanderfolgende Ausführungen lieferten SQLITE_BUSY-Fehler zurück.

Die Zeitleiste des Vorfalls, erfasst aus Dashboard-Warnungen, Stripe-Support-E-Mails und dem Post-Incident-Dashboard-Replay:

  • 09:14 — Erster kundenrelevanter Timeout in einem Stripe-Support-Thread gemeldet.
  • 09:18 — Das Workers-Analytics-Dashboard signalisiert eine Timeout-Rate von 30% auf /usage/record; p99-Latenz 8,4 s.
  • 09:21 — PagerDuty weckt mich. Das D1-Query-Duration-Dashboard bestätigt, dass alle Schreibabfragen am Lock-Timeout festhängen.
  • 09:33 — Manuelle Entschärfung: Vorübergehende Reduzierung der Aufnahmerate der gemessenen Nutzung von den Client-SDKs um 60% über ein Feature Flag.
  • 09:42 — D1 Lock Contention lässt nach, als der Traffic unter 100 req/s fällt; die Timeout-Rate sinkt auf 4%.
  • 11:08 — Queue Producer + Workflow Batched Writer auf Staging bereitgestellt; ein Lasttest gegen dasselbe Produktions-Traffic-Profil ist erfolgreich.
  • 12:31 — Derselbe Fix wird in die Produktion überführt; die p99-Latenz auf /usage/record kehrt innerhalb von 90 Sekunden auf 215 ms zurück; die Timeout-Rate fällt auf 0,4% (verbleibende Kaltstart-Effekte).

Auswirkungen auf die Kunden: 18 Enterprise-Kunden erlebten Checkout-Bestätigungs-Timeouts bei 47 Anfragen während des 46-minütigen Zeitfensters zwischen 09:14 und 10:00 Uhr. Die gemessenen Usage-Zeilen für diese 47 Events wurden durch den Cron-Settle-Pfad abgeglichen, sobald die Queue geleert war; keine Berechnung ging verloren. Drei Kunden eskalierten über den Support; keiner forderte eine Rückerstattung.

Untersuchung

Als Erstes überprüfte ich das Workers-Analytics-Dashboard für die Route /usage/record. Zwei Diagramme erzählten die Geschichte:

  1. Request duration — p50 blieb stabil bei 60 ms (Lesezugriffe aus dem Cache). p99 stieg auf 8,4 s. p99 ist hier das richtige Diagramm, da die Contention strikt den Schreibpfad betraf.
  2. D1 query duration — Alle Schreibabfragen hingen am SQLite Write-Lock-Timeout fest. Die einzigen Abfragen, die normale Latenzen erreichten, waren die SELECTs, die wir für schreibgeschützte Endpunkte ausführen.

Das wrangler tail-Log des betroffenen Workers zeigte bei etwa jeder dritten Anfrage folgendes Fehlermuster:

Error: D1_ERROR: SQLITE_BUSY: database is locked
  at BillingDrizzleRepo.upsertUsageEvent (db/billing.ts:142)
  at BillingRoute.recordUsage (routes/billing/usage.ts:38)
  at async BillingService.handle (services/billing/index.ts:91)

Dieser Fehler-String ist die buchstäbliche Ausgabe — D1 meldet das Busy-Signal von SQLite, wenn ein Schreibvorgang das Datenbank-Write-Lock nicht innerhalb des Contention-Timeouts erlangen kann. Die Abfrage war ein simples INSERT INTO usage_events (...) VALUES (...) gegen eine Tabelle mit drei anderen Indizes, von denen keiner aufgebläht war. Der Fehler lag nicht beim Schema. Der Fehler lag im Zugriffsmuster.

Die Reproduktion war beinahe peinlich einfach. Aus einer lokalen wrangler-Umgebung, die auf eine frisch aufgesetzte D1-Datenbank gerichtet war:

bash
# Die Contention-Struktur lokal reproduzieren
npx wrangler d1 execute billing-db --local \
  --command="CREATE TABLE IF NOT EXISTS usage_events (
    id TEXT PRIMARY KEY,
    customer_id TEXT NOT NULL,
    metric TEXT NOT NULL,
    value REAL NOT NULL,
    created_at INTEGER NOT NULL
  );"

# Das Seed-Skript mit 200 gleichzeitigen Writern ausführen
node scripts/load-test-usage-record.mjs \
  --concurrent=200 \
  --target=http://localhost:8787/api/billing/usage/record

# Die D1-Query-Duration in einem anderen Terminal beobachten
npx wrangler d1 insights billing-db --local

Innerhalb von 12 Sekunden nach Start des Lasttests protokollierte die lokale D1-Instanz dasselbe SQLITE_BUSY-Muster, das auch in der Produktion auftrat. Die lokale Reproduktion lief für 60 Sekunden und erzeugte 1.200 erfolgreiche Anfragen aus 12.000 Versuchen — eine Erfolgsquote von 10% unter anhaltenden 200 gleichzeitigen Schreibvorgängen. Die Obergrenze des Durchsatzes war präzise das D1 Write Lock.

Grundursache

Die zugrunde liegende SQLite-Datenbank von D1 verwendet ein einziges Write Lock auf Datenbankebene. Jede Schreibtransaktion — unabhängig davon, ob sie eine Zeile oder zehn Zeilen betrifft — hält dieses Lock für die Dauer der Transaktion. Bei ~200 req/s an gemischtem Mutations-Traffic wurde das Lock zum Flaschenhals: Der Median-Request wartete länger auf das Lock als sein eigener Schreibvorgang dauerte, der Tail wartete die Dauer des Lock-Timeouts ab, und stetig 30% der Anfragen erlitten ein Timeout, bevor sie das Lock überhaupt bekamen.

Der Fehler lag im Zugriffsmuster. Das synchrone db.insert(...) innerhalb des Request-Handlers war die richtige Struktur für ~50 req/s — weit unter der Lock-Contention-Schwelle — aber es war nicht mehr die richtige Struktur, als die Anfragerate etwa 180 req/s überschritt. Die D1-Dokumentation von Cloudflare weist ausdrücklich darauf hin, dass „Schreibvorgänge auf Datenbankebene serialisiert werden“ und dass „schreibintensive Workloads mit hohem Durchsatz Queues oder Batches verwenden sollten“. Dieser Empfehlung war ich noch nicht gefolgt. Der Fix ist das Standard-Pattern aus dieser Dokumentation.

Der Fix

Der Fix besteht aus drei Teilen, die nun alle standardmäßig im TanStack Ship Billing-Modul enthalten sind:

1. Queue Producer vor dem Schreibpfad

Die Route /usage/record schreibt nicht mehr nach D1. Sie erzeugt eine Nachricht an eine Cloudflare Queue und gibt in <40 ms ein 202 Accepted zurück. Die Queue ist mit einem hochperformanten Consumer-Pool konfiguriert. Unten sehen Sie die Struktur des Producers — die Variablennamen im Beispiel entsprechen dem Boilerplate, nicht dem ursprünglichen Code des Vorfalls.

typescript
// routes/billing/usage.ts
import { z } from "zod";
import { usageEventQueue } from "@/lib/queues";

const Body = z.object({
  customerId: z.string(),
  metric: z.enum(["api_call", "storage_gb_hr", "seat_min"]),
  value: z.number().positive(),
  ts: z.number().int(),
});

export async function POST({ request }: { request: Request }) {
  const parsed = Body.safeParse(await request.json());
  if (!parsed.success) {
    return Response.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  await usageEventQueue.send({
    customerId: parsed.data.customerId,
    metric: parsed.data.metric,
    value: parsed.data.value,
    ts: parsed.data.ts,
  });

  return new Response(null, { status: 202 });
}

2. Batched Writer über Workflows

Ein Cloudflare Workflow konsumiert die Queue, fasst Events nach customerId in einem 5-Sekunden-Fenster zusammen und schreibt eine gebatchte Transaktion pro zusammengefasster Gruppe. Der Coalescer ist das Schlüsselelement — ohne ihn verschiebt die Queue die Lock Contention lediglich eine Ebene nach unten in einen pro-Kunde Fan-Out. Die Zusammenfassung nach customerId anstelle von Fan-Out reduziert die tatsächlichen Schreibtransaktionen beim Produktions-Traffic-Profil um ~85%.

3. Schutz auf Schema-Ebene

Das Drizzle-Schema des Billing-Moduls enthält einen Schutzmechanismus: Jeder Code-Pfad, der versucht, von einem Request-Handler aus in usage_events oder credit_ledger zu schreiben, schlägt bei der Build-Zeit-Prüfung fehl. Synchrone Schreibvorgänge auf Billing-Routen sind von der Schema-Ebene nicht zugelassen. Dieser Schutz macht das Queue-Pattern nicht optional — das Boilerplate weigert sich, direkte Billing-Writes zu kompilieren.

Der kombinierte Effekt, gemessen 90 Sekunden nach dem Produktions-Rollout, war eine Rückkehr zu einer p99 von 215 ms auf /usage/record und eine Timeout-Rate von 0,4% (verbleibende Kaltstart-Effekte, nicht Contention). Die Route /cron/settle verzeichnete innerhalb von zwei aufeinanderfolgenden Ausführungen keine SQLITE_BUSY-Fehler mehr.

Lektionen

Drei Erkenntnisse führten zu dem Schutz auf Schema-Ebene. Sie sind mittlerweile Boilerplate-Standards:

  • D1 ist für Leseoperationen + gelegentliche Schreibvorgänge gedacht, nicht für synchrone Mutationspfade mit hohem Durchsatz. Wenn Ihre Route mehr als ~100 Mutationen pro Sekunde auf einer einzigen Datenbank verarbeitet, ist das Write Lock die falsche Struktur. Verwenden Sie eine Queue und einen gebatchten Workflow-Writer. Die Zahl ist konservativ — in der Produktion kam es bei 200 req/s zur Contention, aber die Kurve der Latenz begann bereits bei 150 anzusteigen.
  • Schreibvorgänge mit hohem Durchsatz erfordern Coalescing, nicht nur Queueing. Ein naives Queue → Worker → INSERT-Pattern verschiebt die Lock-Contention lediglich um einen Hop. Das richtige Pattern lautet Queue → Zusammenfassung nach Key (Coalesce-by-key) → Gebatchter Schreibvorgang (Batched-write). Das Coalescing ist der Multiplikator für den Durchsatz, nicht die Queue allein.
  • Schreibpfade, die von einer Queue unterstützt werden, verdienen einen Schutz auf Build-Zeit-Ebene. Ein Pattern, das sich so leicht verletzen lässt, ist ein Pattern, das gebrochen werden wird. Die Schema-Ebene, die synchrone Billing-Writes blockiert, ist klein — etwa 30 Zeilen Drizzle und ein Build-Schritt — und sie ist der einzige Grund, warum der Vorfall in den sieben Monaten seither nicht erneut aufgetreten ist.

Die tiefere Lektion, über die ich immer noch nachdenke: Ein Boilerplate, das einen schnellen Pfad als Standardvorgabe ausliefert, bringt seinen Benutzern bei, diesen schnellen Pfad zu wählen. Wenn der schnelle Pfad gleichzeitig der fragile Pfad ist, lehrt das Boilerplate Fragilität. Der Schema-Schutz ist es, der diese Dynamik umkehrt — der Standard ist nun der sichere Pfad, und der fragile Pfad erfordert bewussten Aufwand, um ihn zu erreichen. Dies ist die richtige Umkehrung für jedes Infrastruktur-Pattern, das unter Last versagt: Machen Sie den sicheren Weg zum einfachsten, und setzen Sie den unsicheren Weg hinter eine Mauer.

Erkennung und Paging

Der Vollständigkeit halber erwähnenswert: Der Erkennungspfad, der diesen Vorfall markierte, war ein SLO-Burn-Alarm für die p99-Latenz von usage.record, konfiguriert als 5x Baseline. Dieser Alarm wurde um 09:21 Uhr ausgelöst, sieben Minuten nachdem der erste Kundenbericht beim Stripe-Support eingegangen war. Die Asymmetrie — Stripe-Support-E-Mail um 09:14 Uhr, interner Alarm um 09:21 Uhr — ist real, aber akzeptabel. Das SLO ist auf die für den Benutzer sichtbare Oberfläche (API-Latenz) verankert, sodass die interne Wahrnehmung der Kundenwahrnehmung um einige Minuten hinterherhinkte, die Reaktion dadurch jedoch nicht blockierte.

Die PagerDuty-Rotation funktionierte reibungslos: Ich war der einzige diensthabende Engineer, der Pager erreichte mein Telefon und ich bestätigte innerhalb von zwei Minuten. Die Erkennungslücke ist gering genug, dass ich den Webhook für Stripe-Support-E-Mails noch nicht als First-Class-Alarm hinzugefügt habe — die Falsch-Positiv-Rate von Stripe-Support-E-Mails ist hoch genug, sodass das Hinzufügen in die Paging-Kette zur Alarm-Ermüdung führen würde. Der richtige nächste Schritt ist eine Slack-Channel-Benachrichtigung bei Stripe-Support-E-Mails, losgelöst von der Paging-Kette.

Wie das Billing-Modul von TanStack Ship dies verhindert

Wie das Billing-Modul von TanStack Ship dies verhindert

Das TanStack Ship Billing-Modul liefert den Queue Producer und den zusammenfassenden Workflow Writer als einzige verfügbare Konfiguration aus. Es gibt im Boilerplate keinen Code-Pfad, der einen synchronen Schreibvorgang nach usage_events oder credit_ledger aus einem Request-Handler heraus durchführt — der Schutz auf Schema-Ebene bricht den Build ab, wenn Sie es versuchen. Die Standardeinstellungen machen das Queue + Workflow-Pattern von "dem richtigen Weg" zu "dem einzigen Weg", was ich von Anfang an hätte ausliefern sollen.

Für die vollständige Serie von Produktionsvorfällen, die zu den Standardmodulen von TanStack Ship geführt haben, lesen Sie das Postmortem-Archiv. Wenn Sie das Billing-Modul mit einem Konkurrenzprodukt vergleichen möchten, zeigen Ihnen die Vergleiche vs-shipfast, vs-supastarter und vs-makerkit, wie sich die Standard-Billing-Optionen unter realen Produktions-Traffic-Bedingungen im Vergleich zu den Alternativen schlagen.


Sehen Sie, wie das Billing-Modul von TanStack Ship mit Write Contention umgeht: Der standardmäßige Schreibpfad des Boilerplates ist das Queue + Workflow-Pattern aus diesem Postmortem, eingebunden in ein einziges CLI-Scaffold. Die komplette TanStack Ship Preisgestaltung ist unter dem Link dokumentiert.