12k
All articles

Ein Einsteigerleitfaden zu Cloudflare Durable Objects

Cloudflare Durable Objects erklärt: Routing, Single-Instance-State, SQLite-Speicher und ein TypeScript-Beispiel für einen Rate Limiter mit Workers.

OpenReplay Team
OpenReplay Team
Ein Einsteigerleitfaden zu Cloudflare Durable Objects

Ein Durable Object ist eine einzelne Instanz einer JavaScript-Klasse, die Cloudflare zu jedem Zeitpunkt an genau einem Ort ausführt; jede Anfrage, die diese Instanz benennt, wird an sie weitergeleitet – unabhängig davon, wo auf der Welt die Anfrage ihren Ursprung hat –, und die Instanz bringt ihren eigenen privaten Speicher mit.

Wenn Sie einen Zähler, ein Lock oder eine „Wer ist in diesem Raum?”-Liste auf herkömmlichen Workers aufbauen, können zwei Anfragen zu widersprüchlichen Ergebnissen führen. Der Worker, der die erste Anfrage verarbeitet hat, und der Worker, der die zweite verarbeitet hat, können unterschiedliche Isolates in unterschiedlichen Städten sein, ohne gemeinsamen Speicher.

Dieser Leitfaden erklärt das Routing-Modell, das Durable Objects funktionsfähig macht, und zeigt anschließend das minimale TypeScript, das dies demonstriert – anhand eines Rate Limiters pro API-Key als durchgängigem Beispiel. Vorausgesetzt werden Kenntnisse zu Bindings, wrangler und dem fetch-Handler; falls Sie diese Grundlagen zuerst benötigen, beginnen Sie mit dem OpenReplay-Einsteigerleitfaden zu Cloudflare Workers.

Die wichtigsten Erkenntnisse

  • Durable Objects sind ein Compute-Primitiv, das seinen eigenen privaten Speicher mitbringt – kein Storage-Produkt, das Sie aus einem Worker heraus lesen; der Speicher ist ausschließlich von Code aus erreichbar, der innerhalb des Objekts läuft.
  • Der String, der an env.BINDING.getByName(name) übergeben wird, ist die Identität des Objekts: Jede Anfrage im gesamten Cloudflare-Netzwerk, die denselben String übergibt, erreicht dieselbe laufende Instanz.
  • Jedes Durable Object besitzt eine eingebettete SQLite-Datenbank im selben Thread wie sein Code, daher liefert this.ctx.storage.sql.exec() einen Cursor synchron zurück und benötigt kein await.
  • Klassenfelder überdauern aufeinanderfolgende Anfragen, werden aber verworfen, wenn das Objekt nach etwa 10 Sekunden Inaktivität in den Hibernation-Modus wechselt; alles, was überdauern muss, gehört in ctx.storage.
  • Nutzen Sie Workers KV, wenn viele Standorte dieselben Daten lesen und eine Schreiboperation Zeit benötigen darf, bis sie überall sichtbar wird; nutzen Sie ein Durable Object, wenn sich mehrere Clients zum selben Zeitpunkt auf den aktuellen Wert einigen müssen.

Warum scheitern zustandslose Workers bei der Koordination?

Ein Worker behält zwischen Anfragen nichts bei. Zwei Aufrufe können auf unterschiedlichen Isolates an unterschiedlichen Orten landen, und keiner kann sehen, was der andere getan hat. Damit bricht jede Funktion zusammen, bei der sich aufeinanderfolgende Anfragen auf einen Wert einigen müssen. Cloudflares eigene Design-Richtlinien für Durable Objects ziehen genau diese Trennlinie zwischen zustandslosen Workers und zustandsbehafteter Koordination.

Hier ist der naive Rate Limiter, der korrekt aussieht, es aber nicht ist:

// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();

export default {
  async fetch(request): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";
    const count = (hits.get(key) ?? 0) + 1;
    hits.set(key, count);
    return new Response(count > 10 ? "slow down" : "ok", {
      status: count > 10 ? 429 : 200,
    });
  },
} satisfies ExportedHandler;

Die Map auf Modulebene existiert in genau einem Isolate. Ein Client, der 30 Anfragen sendet, die auf drei Isolates landen, erhält drei unabhängige Zähler, die jeweils bei 10 stoppen – das Limit wird nie durchgesetzt. Den Zähler in eine externe Datenbank zu verlagern, löst zwar das Problem der gemeinsamen Nutzung, führt aber eine Read-then-Write-Race-Condition zwischen gleichzeitigen Anfragen ein. Das Problem ist nicht, wo die Daten liegen; das Problem ist, dass nichts einen einzigen Ort garantiert, an dem Prüfung und Aktualisierung gemeinsam stattfinden.

Die Kernidee: Ein Objekt pro Name, jedes Mal dieselbe Instanz

Durable Objects lösen die Koordination, indem sie jedem Namen genau eine laufende Instanz zuordnen und jede Anfrage für diesen Namen dorthin routen. Die Konzeptseite benennt drei Eigenschaften, die dahinterstehen: Jedes Objekt hört auf einen weltweit eindeutigen Namen, sein Speicher liegt bei ihm statt am anderen Ende eines Netzwerks, und es führt immer nur eine Sache zur Zeit aus – so wie JavaScript in einem Browser-Tab.

Aus diesem Modell folgen drei Eigenschaften:

  1. Die Identität ist der Name. Ihr Worker wählt einen String (einen API-Key, eine Room-ID, eine Dokument-ID), und die Plattform bildet ihn auf genau eine Instanz ab. Zwei Workers auf unterschiedlichen Kontinenten, die denselben String übergeben, sprechen mit demselben Objekt.
  2. Die Erzeugung ist implizit. Es gibt keinen Create-Aufruf. Die Namespace-API-Referenz erläutert, dass eine ID für sich genommen nichts erzeugt und dass Objekte erst entstehen, wenn tatsächlich etwas sie erreicht. In der Praxis läuft der Konstruktor, wenn der erste Methodenaufruf auf dem Stub eintrifft.
  3. Die Ausführung ist Single-Threaded. Synchroner Code innerhalb einer Methode kann nicht von einer anderen Anfrage unterbrochen werden. Andere Anfragen können nur laufen, während Ihr Code auf Nicht-Storage-I/O wie fetch() wartet.

Da jedes Objekt ein Thread auf einer Maschine ist, skaliert der Durchsatz horizontal statt vertikal: Ein Rate Limiter sollte ein Objekt pro API-Key verwenden, niemals ein globales Objekt für den gesamten Traffic.

Wie definiert und registriert man eine Durable-Object-Klasse?

Ein Durable Object ist eine exportierte Klasse, die DurableObject aus cloudflare:workers erweitert, ctx und env in ihrem Konstruktor entgegennimmt und ihre öffentlichen Methoden Workers per RPC zur Verfügung stellt. Der Get-Started-Leitfaden legt die Konstruktorsignatur als (ctx: DurableObjectState, env: Env) mit einem verpflichtenden super(ctx, env)-Aufruf fest.

import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.ctx.storage.sql.exec(
      "CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
    );
  }

  async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
    const bucket = Math.floor(Date.now() / windowMs);

    // No await between the read and the write: nothing else can run in between.
    const row = this.ctx.storage.sql
      .exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
      .toArray()[0];
    const count = row ? row.count + 1 : 1;

    this.ctx.storage.sql.exec(
      "INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
      bucket, count, count
    );

    return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
  }
}

Zwischen dem SELECT und dem Upsert steht kein await, sodass keine andere Anfrage für diesen API-Key dazwischenrutschen kann. Genau das konnte die zustandslose Variante nicht bieten.

Die Registrierung der Klasse erfordert zwei Einträge in wrangler.jsonc. Der exports-Eintrag kennzeichnet die Klasse als durable-object mit sqlite-Storage und sorgt beim ersten Deploy für die Bereitstellung des Namespace. Der durable_objects.bindings-Eintrag gibt dem Worker einen Handle über env:

{
  "durable_objects": {
    "bindings": [
      { "name": "RATE_LIMITER", "class_name": "RateLimiter" }
    ]
  },
  "exports": {
    "RateLimiter": {
      "type": "durable-object",
      "storage": "sqlite"
    }
  }
}

Ältere Beispiele registrieren Klassen über ein migrations-Array mit new_sqlite_classes. Diese Form wird für bestehende Workers weiterhin unterstützt, aber exports ist die aktuelle Methode, und beide können nicht gemeinsam in einer Konfigurationsdatei existieren.

Wie ruft man ein Durable Object aus einem Worker auf?

Ein Worker erreicht ein Durable Object, indem er beim Binding mit getByName(name) einen Stub anfordert und dann die öffentlichen Methoden der Klasse auf diesem Stub wie gewöhnliche async-Funktionen aufruft. Ein Stub ist lediglich ein lokaler Handle: Aufrufe darauf werden an die eine Instanz weitergeleitet, der der Name gehört.

export default {
  async fetch(request, env): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";

    // `key` is the object's identity. Same key, same instance, everywhere.
    const stub = env.RATE_LIMITER.getByName(key);
    const { allowed, remaining } = await stub.increment(10, 60_000);

    return new Response(allowed ? "ok" : "slow down", {
      status: allowed ? 200 : 429,
      headers: { "x-ratelimit-remaining": String(remaining) },
    });
  },
} satisfies ExportedHandler<Env>;

Der an getByName() übergebene String ist der Routing-Key. getByName(name) ist eine Kurzform für das ältere zweistufige idFromName(name) gefolgt von get(id), das Ihnen in vielen Beispielen weiterhin begegnen wird; beide adressieren dasselbe Objekt. Methoden direkt auf dem Stub per RPC aufzurufen, erfordert ein Compatibility Date von 2024-04-03 oder später, was jedes neue Template erfüllt.

Wo speichert ein Durable Object seinen Zustand?

Jedes Durable Object hat zwei Arten von Zustand: eine private eingebettete SQLite-Datenbank, die Neustarts überdauert, und gewöhnliche Klassenfelder, die nur existieren, solange sich das Objekt im Speicher befindet. Die SQLite Storage API läuft im selben Thread wie Ihr Code, daher liefert exec() unmittelbar einen SqlStorageCursor zurück – ohne await. Leeren Sie diesen Cursor vor dem nächsten await, etwa mit .toArray(), .one() oder einer Schleife: Ein Cursor, der über ein await hinweg offen bleibt, kann zwischenzeitlich geschriebene Zeilen aufnehmen – einschließlich Schreibvorgängen, die später zurückgerollt werden.

Ein Klassenfeld ist der schnelle Weg. Wenn man dem Rate Limiter eines hinzufügt, wird der Unterschied deutlich:

export class RateLimiter extends DurableObject<Env> {
  // In-memory: fast, private to this instance, gone after hibernation.
  private lastSeen = 0;

  async increment(limit: number, windowMs: number) {
    this.lastSeen = Date.now();
    // ... SQLite read and write as before (durable)
  }
}

lastSeen überdauert aufeinanderfolgende Anfragen, aber die Lifecycle-Dokumentation hält fest, dass das Objekt nach 10 Sekunden ohne eingehende Events (und ohne ausstehende Timer, Standard-API-WebSockets oder laufende fetch()-Aufrufe) in den Hibernation-Modus wechselt und sein Speicher verworfen wird. Auch Deployments und Runtime-Wartung können es jederzeit neu starten. Die hits-Tabelle überdauert all das. Jedes SQLite-gestützte Objekt kann laut der Limits-Seite im Workers-Paid-Plan bis zu 10 GB fassen.

Workers KV oder Durable Objects – was sollten Sie verwenden?

Nutzen Sie Workers KV, wenn viele Standorte dieselben Daten lesen müssen und es akzeptabel ist, dass ein Schreibvorgang Zeit braucht, bis er überall sichtbar wird; nutzen Sie ein Durable Object, wenn sich mehrere Clients zum selben Zeitpunkt auf den aktuellen Wert einigen müssen. Die KV-Konsistenzdokumentation benennt den Kompromiss unmissverständlich: KV gibt Konsistenz zugunsten von Geschwindigkeit auf, eine Änderung kann eine Minute oder länger brauchen, bis sie an anderen Standorten sichtbar wird, und alles, was ein atomares Lesen und Schreiben in einem Schritt benötigt, sollte stattdessen Durable Objects verwenden.

Workers KVDurable Objects
KonsistenzEventual; zwischengespeicherte Kopien verfallen über eine TTLStark; eine Instanz besitzt die Daten
Wo Lesevorgänge stattfindenAn jedem Standort, aus dem CacheInnerhalb der einen besitzenden Instanz
Read-then-Write-SicherheitKeine über Anfragen hinwegGarantiert, sofern kein Nicht-Storage-await dazwischenliegt
SchreibmusterSeltene Schreibvorgänge pro KeySchreibvorgänge pro Objekt, von der Runtime serialisiert
Typischer EinsatzKonfiguration, Feature Flags, Allow-ListsZähler, Locks, Räume, Zustand pro Entität

Der Vergleich der Storage-Optionen ordnet beide auf dieselbe Weise ein: KV deckt Konfiguration und ähnliche Werte ab, die weit häufiger gelesen als geändert werden, während Durable Objects die Koordination zwischen Clients sowie Speicher abdecken, der pro Objekt konsistent bleibt. Ein Rate Limiter ist ein Read-then-Write-Zähler und gehört daher in ein Durable Object. Ein Feature Flag pro Mandant, das bei jeder Anfrage gelesen wird, gehört in KV.

Fazit

Durable Objects lösen das Koordinationsproblem, indem sie die Frage, wo der Zustand liegt, schlicht beseitigen: Der Name, den Sie an getByName() übergeben, wählt genau eine laufende Instanz aus, ihre SQLite-Datenbank liegt im selben Thread wie ihr Code, und ein Lesevorgang gefolgt von einem Schreibvorgang ohne Nicht-Storage-await dazwischen kann nicht verschränkt werden. Der nächste Schritt: Gerüsten Sie mit npm create cloudflare@latest das Template Worker + Durable Objects, ersetzen Sie die generierte Klasse durch den obigen Rate Limiter und führen Sie npx wrangler dev aus, um zu beobachten, wie der Zähler über Anfragen hinweg steigt, die sich andernfalls nie einig würden.

FAQs

Was ist der Unterschied zwischen Durable Objects und D1?

D1 ist eine verwaltete SQLite-Datenbank, die Ihr Worker über das Netzwerk abfragt – mit integrierter HTTP-API und Schema-Migrationen. Die SQLite-Datenbank eines Durable Object läuft auf derselben Maschine wie der Code des Objekts und ist für Workers ausschließlich über dieses Objekt erreichbar. Beide sind im Workers-Paid-Plan auf 10 GB pro Datenbank begrenzt, und beide haben im kostenlosen Plan niedrigere Obergrenzen. Verwenden Sie D1 für eine gemeinsam genutzte relationale Datenbank; verwenden Sie Durable Objects für Zustand pro Benutzer oder pro Entität, der Koordination erfordert.

Wie viele Anfragen pro Sekunde kann ein einzelnes Durable Object verarbeiten?

Ein einzelnes Durable Object hat ein weiches Limit von etwa 1.000 Anfragen pro Sekunde, da jedes Objekt auf einem Thread auf einer Maschine läuft. Darüber hinaus stellt die Runtime in die Warteschlange, was sie kann, und lässt die überzähligen Aufrufe anschließend mit einem Overloaded-Fehler fehlschlagen. Jeder Aufruf erhält standardmäßig 30 Sekunden CPU-Zeit, konfigurierbar bis auf 5 Minuten über limits.cpu_ms in der Wrangler-Konfiguration. Skalieren Sie horizontal mit einem Objekt pro Name, etwa einem Objekt pro API-Key.

Funktionieren Durable Objects im kostenlosen Workers-Plan?

Ja. Durable Objects mit dem SQLite-Storage-Backend sind im kostenlosen Workers-Plan verfügbar, begrenzt auf 1 GB pro Objekt, 5 GB Durable-Objects-Speicher insgesamt pro Account und 100 Durable-Object-Klassen. Workers Paid erhöht dies auf 10 GB pro Objekt, unbegrenzten Account-Speicher und 500 Klassen. Sobald ein Objekt voll ist, schlagen Schreibvorgänge mit einem SQLITE_FULL-Fehler fehl; Sie können jedoch weiterhin Zeilen lesen und löschen, um Platz freizugeben.

Benötige ich explizite Transaktionen mit sql.exec() in einem Durable Object?

In der Regel nicht. Jeder Aufruf von sql.exec() läuft bereits in seiner eigenen Transaktion, und Lese- und Schreibvorgänge, die ohne dazwischenliegendes await aufeinanderfolgen, werden als ein einziger atomarer Batch committet – der Read-then-Write in einem Rate Limiter ist also so, wie er geschrieben ist, sicher. sql.exec() kann keine BEGIN TRANSACTION- oder SAVEPOINT-Anweisungen ausführen. Um mehrere Anweisungen so zu gruppieren, dass alle zurückgerollt werden, wenn eine davon eine Exception wirft, verwenden Sie ctx.storage.transactionSync(callback). Der Callback muss vollständig synchron sein: nicht als async deklariert und ohne Rückgabe eines Promise.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.