Cloudflare
Durable Objects
Programação
Estado
Concorrência

Das Programmiermodell von Durable Objects: Was unterscheidet es von allem, was Sie jemals verwendet haben?

Wie sich das Programmiermodell von Durable Objects von allen herkömmlichen serverlosen Modellen unterscheidet, mit den Speicher-, Transaktions- und Alarm-APIs, die die Möglichkeiten verändern.

Das Programmiermodell von Durable Objects: Was unterscheidet es von allem, was Sie jemals verwendet haben?

Die meisten Ingenieure, die zum ersten Mal mit langlebigen Objekten in Berührung kommen, lesen sie als „Arbeiter mit [eingebetteter Datenbank]“ und beginnen, Code in diese Richtung zu schreiben. Das Ergebnis sind Anwendungen, die beim Testen funktionieren, in der Produktion jedoch auf subtile Weise scheitern. Das mentale Modell ist falsch, denn der Unterschied zwischen einem DO und einem Arbeiter mit Zugang zu einer Bank liegt nicht in der Bequemlichkeit, sondern darin, wo der Staat lebt und wer den Zugang dazu kontrolliert.

Die Struktur einer Durable Object-Klasse

Ein DO ist eine JavaScript-Klasse mit drei Kernelementen: dem Konstruktor, der fetch()-Methode und this.ctx.storage-Zugriff. Der Konstruktor wird einmal ausgeführt, wenn das DO erstellt wird oder aus dem Ruhezustand aufwacht. fetch() verarbeitet jede eingegangene Anfrage. Und this.ctx.storage ist die dauerhafte Speicher-API, die den Zustand zwischen Anforderungen und Ruhezuständen beibehält.

export class Counter implements DurableObject { private count: number = 0; constructor(private ctx: DurableObjectState, private env: Env) { this.ctx.blockConcurrencyWhile(async () => { this.count = (await this.ctx.storage.get<number>('count')) ?? 0; }); } async fetch(request: Request): Promise<Response> { this.count++; await this.ctx.storage.put('count', this.count); return new Response(String(this.count)); } }

Das blockConcurrencyWhile() im Konstruktor ist erforderlich, wenn Sie den Speicherstatus vor der Verarbeitung einer Anfrage hydratisieren müssen. Ohne sie würde eine Anfrage, die vor der Auflösung des Speichers await eintraf, einen unvollständigen Status sehen. blockConcurrencyWhile stellt Anfragen in die Warteschlange, bis der Rückruf aufgelöst wird – auf diese Weise kann der DO sicher initialisiert werden.

Was ändert sich, wenn der Staat in der Erinnerung lebt?

Der Vorgang „Aus Bank lesen, berechnen, zurückschreiben“ ist das häufigste Muster in serverseitigen Anwendungen und auch die Quelle der häufigsten Race Conditions. Zwischen dem Lesen und Schreiben hat möglicherweise ein anderer Prozess denselben Datensatz geändert. Um dieses Problem bei einer herkömmlichen Bank zu lösen, verwenden Sie Transaktionen mit Sperren, SELECT FOR UPDATE oder eine Form der optimistischen Versionierung.

Bei einem DO besteht dieses Problem nicht in gleicher Weise. Der Zustand bleibt im Speicher – this.count hat bereits den aktuellen Wert. Es gibt keinen Hin- und Rückweg zum Lesen. Das Inkrementieren und die Persistenz erfolgen innerhalb derselben Ausführung, ohne dass eine andere Anforderung verschachtelt werden kann. Die Garantie stammt nicht von der Bank, sondern von der seriellen Ausführung des DO selbst.

Dies hat Kosten, die explizit angegeben werden müssen: Die Konsistenz hängt davon ab, dass Sie den von Ihnen geänderten Zustand im Speicher beibehalten. Wenn der DO in den Ruhezustand wechselt, bevor ein put() aufgerufen wird, geht die Speicheränderung verloren. Jede wichtige Änderung muss im Speicher gespeichert werden, bevor die Anforderung endet. Das richtige Muster besteht darin, denselben Vorgang zu ändern und beizubehalten, ohne sich auf eine spätere Löschung zu verlassen.

Die Speicher-API und wann Transaktion() verwendet werden sollte

Die Speicher-API verfügt über direkte Operationen: get(), put(), delete(), list(). Alle sind einzeln atomar und dauerhaft – was geschrieben wurde, ist garantiert auf der Festplatte, selbst wenn das DO unmittelbar danach in den Ruhezustand wechselt. Die Linearisierbarkeit gilt für den gesamten Speicher einer Instanz: Bei jedem Lesevorgang nach put() wird der Wert ausnahmslos geschrieben.

Wenn eine logische Operation mehrere Schlüssel ändert und diese alle miteinander konsistent sein müssen, ist transaction() das richtige Werkzeug:

await this.ctx.storage.transaction(async (txn) => { const balance = await txn.get<number>('balance') ?? 0; await txn.put('balance', balance - amount); await txn.put('last_debit', Date.now()); await txn.put('debit_count', (await txn.get<number>('debit_count') ?? 0) + 1); });

Wenn der Rückruf transaction() eine Ausnahme auslöst, wird keiner der Schreibvorgänge beibehalten. Der Speicherzustand wird auf den Zustand vor Beginn der Transaktion zurückgesetzt. Es gibt kein teilweises Commit. Dies ersetzt die Notwendigkeit kompensierender Transaktionen oder Rollback-Logik in der Anwendung für Vorgänge, die mehrere Schlüssel betreffen.

Ein Betriebsdetail von list(): Standardmäßig werden maximal 128 Einträge zurückgegeben. Verwenden Sie für größere Mengen den Cursor, der in der Antwort auf Seite zurückgegeben wird. Dies zu ignorieren bedeutet, die Grenzen der Produktion mit realen Daten zu entdecken.

Die Alarm-API: setTimeout, die den Ruhezustand überlebt

Arbeiter haben setTimeout(), aber der Timer überlebt den isolierten Winterschlaf nicht. Wenn der Worker die Bearbeitung der Anfrage abgeschlossen hat, verschwinden alle ausstehenden Timer. Für einen DO, der zu einem späteren Zeitpunkt etwas tun muss – abgelaufene Sitzungen löschen, eine fehlgeschlagene Nachricht erneut senden, eine Sperre nach einer Zeitüberschreitung ungültig machen – ist die Alarm-API der richtige Mechanismus:

async fetch(request: Request): Promise<Response> { const body = await request.json() as { lockKey: string; ttlMs: number }; await this.ctx.storage.put(`lock:${body.lockKey}`, true); await this.ctx.storage.setAlarm(Date.now() + body.ttlMs); return new Response('lock acquired'); } async alarm(): Promise<void> { const keys = await this.ctx.storage.list({ prefix: 'lock:' }); for (const key of keys.keys()) { await this.ctx.storage.delete(key); } }

setAlarm() erhält einen absoluten Zeitstempel in Millisekunden. Wenn DO schläft, behält Cloudflare den geplanten Alarm bei. Zum richtigen Zeitpunkt wacht der DO auf und die Methode alarm() wird aufgerufen. Die Kosten betragen 0,15 US-Dollar pro Million Aufrufe – die gleiche Preisliste wie bei normalen Anfragen.

Es ist jeweils nur ein aktiver Alarm pro DO möglich. setAlarm() ersetzt den bisherigen. Wenn Sie mehrere Timer benötigen, besteht die Strategie darin, die Liste der bevorstehenden Ereignisse im Speicher zu speichern, einen Alarm für das nächste Ereignis zu planen und, wenn alarm() läuft, die abgelaufenen Ereignisse zu verarbeiten und für das nächste neu zu planen.

So funktioniert das Routing zur richtigen Instanz

Langlebige Objekte verfügen über drei ID-Generierungsstrategien, und die Auswahl bestimmt das Routing-Verhalten.

idFromName("room-123") ist deterministisch: Der gleiche Name erzeugt global immer die gleiche ID. Dies ist die Strategie, wenn Sie möchten, dass alle Kunden, die „Zimmer 123“ anfordern, am selben Ort ankommen. Die ID wird durch Hashing des Namens abgeleitet, und zwei DOs mit demselben Namen im selben Namespace sind dasselbe Objekt.

newUniqueId() generiert eine zufällige ID – immer ein neues DO. Wird zum Erstellen einer Entität verwendet und speichert die ID zur späteren Referenz, ohne dass sie von einem Schlüssel abgeleitet werden muss.

idFromString(hexStr) rekonstruiert eine ID aus einer hexadezimalen Zeichenfolge, die Sie zuvor gespeichert haben. Nützlich, wenn die ID mit newUniqueId() generiert wurde, in einer externen Datenbank oder im KV gespeichert ist und Sie später auf denselben DO verweisen müssen.

Mit Standorthinweisen können Sie eine geografische Gerichtsbarkeit (EU, USA) vorschlagen, garantieren jedoch keinen bestimmten PoP. Das DO wird in dem PoP erstellt, der der ersten Anfrage, die es instanziiert hat, am nächsten liegt. Bei Anwendungen mit EU-Datenresidenzanforderungen leitet locationHint: 'eu' die Erstellung an einen europäischen PoP weiter, aber Cloudflare wählt welchen aus.

Wie stellt dies eine Verbindung zum Worker her, der weiterleitet?

Der Worker, der die Anfrage des Clients empfängt, muss den DO-Stub erhalten und die Anfrage weiterleiten:

export default { async fetch(request: Request, env: Env): Promise<Response> { const roomId = new URL(request.url).searchParams.get('room') ?? 'default'; const id = env.ROOMS.idFromName(roomId); const stub = env.ROOMS.get(id); return stub.fetch(request); } };

Der Stub hat den gleichen fetch()-Vertrag wie ein normaler Arbeiter. Der Worker, der weiterleitet, hat keinen Zugriff auf den DO-Speicher, kann seinen internen Status nicht lesen und kann keine anderen Methoden als fetch() und RPC aufrufen (bei Konfiguration mit WorkerEntrypoint). Die Kommunikation erfolgt über normale HTTP-Anfragen.

Langlebige Objekte sind keine Datenbankabstraktion. Sie sind ein Stateful-Computing-Modell. Sie wie eine Datenbank zu behandeln, die auch Logik verarbeitet, und nicht wie ein Objekt mit Identität und Status, führt zu Verwirrung – und zu Code, der in der Produktion zufällig und nicht absichtlich funktioniert.

Lesen Sie auch