Duurzame objecten: sterk consistente status en websockets aan de rand
Duurzame objecten zijn de krachtigste primitief in het Cloudflare-ecosysteem: zorgen voor een sterk consistente status, WebSocket met sessiebeheer en gedistribueerde coördinatie zonder gecentraliseerde servers, allemaal aan de mondiale rand.
Het staatsprobleem bij edge computing
Eerdere artikelen in deze serie hebben laten zien hoe Cloudflare Workers uitblinkt voor staatloze workloads: elk verzoek wordt afzonderlijk afgehandeld, zonder gedeeld geheugen tussen aanroepingen. Deze functie is een sterk punt voor schaalbaarheid horizontaal, maar wordt een obstakel zodra de toepassing coördinatie vereist.
Overweeg deze veelvoorkomende scenario's: Een chatroom waar berichten moeten worden geplaatst besteld en afgeleverd bij alle deelnemers; een samenwerkingsdocument waar meer gebruikers bewerken gelijktijdig; een snelheidsbegrenzer voor API's die verzoeken moeten tellen op een mondiaal coherente manier. In al deze gevallen het staatloze model van de arbeiders het is niet genoeg: je hebt er een nodig zeer consistent geweest en capaciteit om concurrerende verzoeken op één plek te coördineren.
Dit is precies het probleem dat ik Duurzame voorwerpen zij lossen op.
Wat je gaat leren
- Wat is een duurzaam object en hoe verschilt het van Workers KV
- Het sterke consistentiemodel: één schrijver, mondiale coördinatie
- Hoe u WebSocket implementeert met sessiebeheer met behulp van duurzame objecten
- Transactionele opslag: lees-, schrijf- en atomaire transacties
- Patronen voor chatrooms, snelheidsbeperking en samenwerking aan documenten
- Alarmen: geplande handelingen binnen het Duurzame Object
- Limieten, prijzen en wanneer u DO versus KV versus D1 moet kiezen
Wat is een duurzaam object
Een Duurzaam Object (DO) is een JavaScript/TypeScript-klasse die Cloudflare instantieert op één geografische locatie voor een bepaalde identificatie. In tegenstelling tot Arbeiders KV (mogelijke consistentie op meer dan 300 PoP's), een DO heeft deze garanties:
- Consistentie bij één schrijver: Er bestaat wereldwijd slechts één exemplaar van de DO voor een bepaalde ID
- Serialisatie van verzoeken: DO-aanroepen worden opeenvolgend uitgevoerd, nooit gelijktijdig
- Duurzame opslag: een privé transactionele sleutelwaardeopslag voor elke instantie
- WebSocket-slaapstand: WebSocket-verbindingen overleven inactieve perioden zonder kosten
De locatie van een DO wordt automatisch bepaald bij de eerste activering en blijft vast. Cloudflare kiest het datacenter dat zich het dichtst bij het eerste verzoek bevindt. Alle daaropvolgende verzoeken, waar ook ter wereld, worden naar die ene gerouteerd bijvoorbeeld via Cloudflare anycast-routering.
| Primitief | Samenhang | Concurrentie | Lees latentie | Gebruiksgeval |
|---|---|---|---|---|
| Arbeiders KV | Eventueel (minuten) | Multi-schrijver | ~1ms (gecacht) | Configuratie, assets, leesintensieve sessies |
| Duurzame voorwerpen | Sterk (linearieerbaar) | Enkele schrijver | ~50-150 ms (vanaf externe rand) | Chat, snelheidsbegrenzer, spelstatus, CRDT |
| D1 SQLite | Sterk (primair) | Multi-lezer, enkele schrijver | ~5-20 ms (van PoP in de buurt) | Relationele vragen, rapporten, OLTP |
| R2 Objectopslag | Sterk (per object) | Multi-writer (conflictdetectie) | ~50-200 ms | Bestanden, afbeeldingen, back-ups |
Structuur van een duurzaam object
Een duurzaam object is een klasse met specifieke methoden die de Cloudflare-runtime uitvoert
herkent. Het belangrijkste is fetch(), belde voor elke
HTTP-verzoek doorgestuurd naar de DO. Laten we de basisstructuur bekijken:
// src/counter-do.ts
// Un Durable Object semplice: un contatore con persistenza
export class CounterDO implements DurableObject {
private state: DurableObjectState;
private env: Env;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
// state.storage e il key-value store privato di questa istanza
// Persiste attraverso i riavvii del DO
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/increment': {
// Legge il valore corrente (undefined se non esiste)
const current = (await this.state.storage.get<number>('count')) ?? 0;
const next = current + 1;
// Scrittura atomica: garantita durabile prima del return
await this.state.storage.put('count', next);
return Response.json({ count: next });
}
case '/decrement': {
const current = (await this.state.storage.get<number>('count')) ?? 0;
const next = Math.max(0, current - 1);
await this.state.storage.put('count', next);
return Response.json({ count: next });
}
case '/value': {
const count = (await this.state.storage.get<number>('count')) ?? 0;
return Response.json({ count });
}
case '/reset': {
await this.state.storage.put('count', 0);
return Response.json({ count: 0, reset: true });
}
default:
return new Response('Not Found', { status: 404 });
}
}
}
interface Env {
COUNTER: DurableObjectNamespace;
}
Als u de DO vanaf het Worker-ingangspunt wilt gebruiken, moet u deze instantiëren via naamruimtebinding en een identiteitsbewijs:
// src/worker.ts - entry point del Worker
export { CounterDO } from './counter-do';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Estrae l'ID dalla query string: /counter?id=room-1
const counterId = url.searchParams.get('id') ?? 'global';
// idFromName() crea un ID deterministico da una stringa
// Lo stesso nome produce sempre lo stesso ID (e la stessa istanza)
const id = env.COUNTER.idFromName(counterId);
// Ottiene lo stub per comunicare con l'istanza
const stub = env.COUNTER.get(id);
// Invia la richiesta al Durable Object
// La richiesta viene instradata al datacenter corretto automaticamente
return stub.fetch(request);
},
};
interface Env {
COUNTER: DurableObjectNamespace;
}
De configuratie wrangler.toml moet zich verbindend verklaren:
# wrangler.toml
name = "counter-worker"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "CounterDO"
[[migrations]]
tag = "v1"
new_classes = ["CounterDO"]
WebSocket met duurzame objecten: chatroom
Het krachtigste gebruiksscenario van duurzame objecten is sessiebeheer Gedeelde stateful WebSocket. Elke chatroom is een afzonderlijk exemplaar van de DO, die de lijst met actieve verbindingen en berichtgeschiedenis bijhoudt.
Cloudflare ondersteunt de WebSocket-slaapstand-API: de DO komt "in slaapstand" wanneer er geen berichten zijn om te verwerken, waardoor de kosten dramatisch worden verlaagd (u betaalt alleen voor de verwerkingstijd, niet voor open verbindingen).
// src/chat-room-do.ts
// Durable Object per una stanza di chat con WebSocket Hibernation
export class ChatRoomDO implements DurableObject {
private state: DurableObjectState;
private env: Env;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/ws') {
// Verifica che sia una richiesta di upgrade WebSocket
const upgradeHeader = request.headers.get('Upgrade');
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected WebSocket upgrade', { status: 426 });
}
// Crea la coppia WebSocket server/client
const { 0: clientWs, 1: serverWs } = new WebSocketPair();
// Accetta la connessione tramite la Hibernation API
// Il DO sara ibernato tra i messaggi (no costo di CPU idle)
this.state.acceptWebSocket(serverWs);
// Opzionale: associa metadata alla connessione
// Utile per identificare l'utente nei messaggi successivi
const userId = url.searchParams.get('userId') ?? `anon-${Date.now()}`;
serverWs.serializeAttachment({ userId });
// Invia la storia recente al nuovo utente
const history = (await this.state.storage.get<Message[]>('history')) ?? [];
if (history.length > 0) {
serverWs.send(JSON.stringify({ type: 'history', messages: history }));
}
return new Response(null, {
status: 101,
webSocket: clientWs,
});
}
if (url.pathname === '/messages' && request.method === 'GET') {
const history = (await this.state.storage.get<Message[]>('history')) ?? [];
return Response.json({ messages: history });
}
return new Response('Not Found', { status: 404 });
}
// Chiamato dalla Hibernation API quando arriva un messaggio WebSocket
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const { userId } = ws.deserializeAttachment() as { userId: string };
let parsed: ClientMessage;
try {
parsed = JSON.parse(message as string);
} catch {
ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
return;
}
if (parsed.type === 'chat') {
const msg: Message = {
id: crypto.randomUUID(),
userId,
text: parsed.text,
timestamp: Date.now(),
};
// Salva nella history (mantieni solo gli ultimi 100 messaggi)
const history = (await this.state.storage.get<Message[]>('history')) ?? [];
const newHistory = [...history, msg].slice(-100);
await this.state.storage.put('history', newHistory);
// Broadcast a tutte le connessioni WebSocket attive nel DO
const allWebSockets = this.state.getWebSockets();
const payload = JSON.stringify({ type: 'message', message: msg });
for (const socket of allWebSockets) {
try {
socket.send(payload);
} catch {
// Connessione chiusa, ignorala
}
}
}
}
// Chiamato quando una connessione WebSocket viene chiusa
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
const { userId } = ws.deserializeAttachment() as { userId: string };
ws.close(code, reason);
// Notifica gli altri utenti dell'uscita
const notification = JSON.stringify({
type: 'user_left',
userId,
timestamp: Date.now(),
});
for (const socket of this.state.getWebSockets()) {
try {
socket.send(notification);
} catch { /* ignore */ }
}
}
// Chiamato in caso di errore sulla connessione WebSocket
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error('WebSocket error:', error);
ws.close(1011, 'Internal error');
}
}
interface Message {
id: string;
userId: string;
text: string;
timestamp: number;
}
interface ClientMessage {
type: 'chat' | 'ping';
text?: string;
}
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
Het Worker-ingangspunt stuurt verzoeken naar de juiste kamer op basis van het pad:
// src/worker.ts
export { ChatRoomDO } from './chat-room-do';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// /room/:roomId/ws -> WebSocket per la stanza
// /room/:roomId/messages -> GET cronologia
const match = url.pathname.match(/^\/room\/([^/]+)(\/.*)?$/);
if (!match) {
return new Response('Not Found', { status: 404 });
}
const roomId = match[1];
const subpath = match[2] ?? '/ws';
// Ogni stanza e una istanza distinta del DO
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
// Rewrite del path per il DO
const doUrl = new URL(request.url);
doUrl.pathname = subpath;
return stub.fetch(new Request(doUrl.toString(), request));
},
};
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
Transactionele opslag: atomaire operaties
De opslag van duurzame objecten ondersteunt atomaire transacties via
state.storage.transaction(). Dit is van cruciaal belang wanneer
een bewerking moet meerdere sleutels consistent lezen en schrijven:
// Esempio: trasferimento di crediti tra utenti (atomico)
export class AccountDO implements DurableObject {
state: DurableObjectState;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const { from, to, amount } = await request.json<TransferRequest>();
try {
// La transazione e atomica: o tutto va a buon fine, o niente
await this.state.storage.transaction(async (txn) => {
const fromBalance = (await txn.get<number>(`balance:${from}`)) ?? 0;
const toBalance = (await txn.get<number>(`balance:${to}`)) ?? 0;
if (fromBalance < amount) {
// Il throw annulla la transazione
throw new Error(`Insufficient balance: ${fromBalance} < ${amount}`);
}
await txn.put(`balance:${from}`, fromBalance - amount);
await txn.put(`balance:${to}`, toBalance + amount);
// Log dell'operazione
const txLog = (await txn.get<TxRecord[]>('tx_log')) ?? [];
txLog.push({ from, to, amount, timestamp: Date.now() });
await txn.put('tx_log', txLog.slice(-1000));
});
return Response.json({ success: true, from, to, amount });
} catch (err) {
return Response.json(
{ success: false, error: (err as Error).message },
{ status: 400 }
);
}
}
}
interface TransferRequest {
from: string;
to: string;
amount: number;
}
interface TxRecord {
from: string;
to: string;
amount: number;
timestamp: number;
}
interface Env {
ACCOUNT: DurableObjectNamespace;
}
Alarmen: geplande bewerkingen in het duurzame object
Ondersteuning voor duurzame objecten Alarmen: een terugbelverzoek
alarm() die op een gepland tijdstip wordt aangeroepen, zelfs als ik er niet ben
actieve verzoeken aan de DO. Dit is handig voor TTL, deadlines en periodieke klussen
gekoppeld aan de status van de DO:
// src/session-do.ts
// DO con alarm per scadenza automatica della sessione
export class SessionDO implements DurableObject {
state: DurableObjectState;
static SESSION_TTL_MS = 30 * 60 * 1000; // 30 minuti
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/create' && request.method === 'POST') {
const data = await request.json<SessionData>();
// Salva i dati della sessione
await this.state.storage.put('session', {
...data,
createdAt: Date.now(),
});
// Schedula l'alarm per la scadenza della sessione
// Se l'alarm e gia schedulato, viene sostituito
await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);
return Response.json({ ok: true, expiresIn: SessionDO.SESSION_TTL_MS });
}
if (url.pathname === '/get') {
const session = await this.state.storage.get<SessionData>('session');
if (!session) {
return Response.json({ error: 'Session not found' }, { status: 404 });
}
// Refresh del TTL ad ogni accesso (sliding expiry)
await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);
return Response.json({ session });
}
if (url.pathname === '/invalidate' && request.method === 'DELETE') {
await this.state.storage.deleteAll();
await this.state.storage.deleteAlarm();
return Response.json({ ok: true });
}
return new Response('Not Found', { status: 404 });
}
// Chiamato automaticamente quando scatta l'alarm
async alarm(): Promise<void> {
// Pulisce i dati della sessione scaduta
const session = await this.state.storage.get<SessionData>('session');
if (session) {
console.log(`Session expired for user: ${session.userId}`);
await this.state.storage.deleteAll();
}
}
}
interface SessionData {
userId: string;
role: string;
metadata?: Record<string, unknown>;
}
interface Env {
SESSION: DurableObjectNamespace;
}
Global Rate Limiter met duurzame objecten
Een gedistribueerde snelheidsbegrenzer is een van de meest gevraagde patronen voor openbare API's. Bij Workers KV zou de implementatie onderhevig zijn aan racevoorwaarden. Met een C, elke snelheidsbeperkende "bucket" is een exemplaar met sterke consistentie:
// src/rate-limiter-do.ts
// Token bucket rate limiter con Durable Objects
export class RateLimiterDO implements DurableObject {
state: DurableObjectState;
// Configurazione: 100 req/minuto per IP
static MAX_TOKENS = 100;
static REFILL_RATE_MS = 60_000; // 1 minuto per refill completo
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
// Legge lo stato attuale del bucket
const bucket = (await this.state.storage.get<TokenBucket>('bucket')) ?? {
tokens: RateLimiterDO.MAX_TOKENS,
lastRefill: now,
};
// Calcola quanti token sono stati aggiunti dall'ultimo accesso
const elapsed = now - bucket.lastRefill;
const tokensToAdd = (elapsed / RateLimiterDO.REFILL_RATE_MS) * RateLimiterDO.MAX_TOKENS;
const currentTokens = Math.min(
RateLimiterDO.MAX_TOKENS,
bucket.tokens + tokensToAdd
);
if (currentTokens < 1) {
// Rate limit superato
const retryAfterMs = Math.ceil(
(1 - currentTokens) / (RateLimiterDO.MAX_TOKENS / RateLimiterDO.REFILL_RATE_MS)
);
await this.state.storage.put('bucket', {
tokens: currentTokens,
lastRefill: now,
});
return Response.json(
{
allowed: false,
retryAfter: Math.ceil(retryAfterMs / 1000),
remaining: 0,
},
{
status: 429,
headers: {
'Retry-After': String(Math.ceil(retryAfterMs / 1000)),
'X-RateLimit-Limit': String(RateLimiterDO.MAX_TOKENS),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Consuma un token e aggiorna il bucket
await this.state.storage.put('bucket', {
tokens: currentTokens - 1,
lastRefill: now,
});
return Response.json({
allowed: true,
remaining: Math.floor(currentTokens - 1),
});
}
}
interface TokenBucket {
tokens: number;
lastRefill: number;
}
interface Env {
RATE_LIMITER: DurableObjectNamespace;
}
De Worker integreert de snelheidsbegrenzer in de stroom van elk verzoek:
// src/worker.ts con rate limiting
export { RateLimiterDO } from './rate-limiter-do';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Identifica il client (IP o API key)
const clientIp = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const apiKey = request.headers.get('X-API-Key');
const bucketId = apiKey ?? `ip:${clientIp}`;
// Controlla il rate limit per questo client
const rateLimiterId = env.RATE_LIMITER.idFromName(bucketId);
const rateLimiter = env.RATE_LIMITER.get(rateLimiterId);
const limitCheck = await rateLimiter.fetch(new Request('https://dummy/check'));
if (limitCheck.status === 429) {
return limitCheck; // Propaga la risposta 429 con gli headers
}
// Prosegue con la logica dell'API
return handleApiRequest(request, env);
},
};
async function handleApiRequest(request: Request, env: Env): Promise<Response> {
return Response.json({ data: 'your api response here' });
}
interface Env {
RATE_LIMITER: DurableObjectNamespace;
}
Prestatie- en kostenoverwegingen
Duurzame objecten hebben een heel ander kosten- en prestatieprofiel tot eenvoudige arbeiders. Enkele belangrijke punten om in gedachten te houden:
Latentie: niet altijd lokaal
Elke DO-instantie bevindt zich in één datacenter. Als een gebruiker in Europa
toegang krijgt tot een DO die in Noord-Amerika is geïnstantieerd, de latentie van elke bewerking
inclusief transatlantische retourvlucht (~100-150 ms). Ontwerp DO-ID's
Geografisch delen beperken: Gebruik waar mogelijk regiogebaseerde ID's
(idFromName(`${region}:${resourceId}`)), of accepteer de latentie
alleen hoog voor activiteiten die een sterke interregionale consistentie vereisen.
| Stem | Gratis niveaus | Betaald (betaalde werknemers) |
|---|---|---|
| Verzoeken aan de DO | 1M/maand inbegrepen | $ 0,15 per miljoen hierboven |
| DOEN Levensduur (CPU) | 400.000 GB/maand | $ 12,50 per miljoen GB |
| Opslag | 1 GB inbegrepen | $ 0,20/GB per maand daarna |
| WebSocket-slaapstand | Beschikbaar | Beschikbaar (geen inactieve kosten) |
| Alarmen | Beschikbaar | Beschikbaar |
Beste praktijken
-
ID-granulariteit: gebruik specifieke ID's (bijv.
chat:room-42) in plaats van globale ID's om hotspots op individuele instanties te vermijden. -
WebSocket-slaapstand altijd: VS
state.acceptWebSocket()in plaats van handmatige gebeurtenislisteners om de kosten voor inactieve verbindingen te verlagen. -
Batchopslag: VS
storage.put(map)schrijven meerdere toetsen in één handeling in plaats van meerdereput()alleenstaanden. - Beperk de opslaggrootte: elke instantie heeft een limiet van 128 MB van opslag. Gebruik voor grote data R2 en sla alleen referenties op in de DO.
- Serialisatiebudget: verzoeken worden in de wachtrij geplaatst en verwerkt opeenvolgend; langzame bewerkingen blokkeren daaropvolgende handelingen. Houd begeleiders snel (<1s ideaal).
Conclusies en volgende stappen
Duurzame Objecten overbruggen de kloof tussen het staatloze model van de Arbeiders en de moderne toepassingen die coördinatie en gedeelde staat vereisen. Van hen gecombineerd met de WebSocket Hibernation API en Alarms zijn ze primitief compleet voor chatten, gamen, samenwerken aan documenten en wereldwijde coördinatiesystemen.
De afweging is latentie: een DO bevindt zich fysiek in slechts één datacenter, dus schrijfbewerkingen tussen regio's voegen latentie toe. Voor lezingen schaalbaar, overweeg om DO (voor schrijfbewerkingen) te combineren met KV (voor in de cache opgeslagen leesbewerkingen).
Volgende artikelen in de serie
- Artikel 5: Workers AI - Inferentie van LLM- en visiemodellen on the Edge: Lama-, Whisper- en vision-modellen rechtstreeks in Workers uitvoeren zonder speciale GPU, waarbij Workers AI met 4000% op jaarbasis groeit.
- Artikel 6: Vercel Edge Runtime — Geavanceerde middleware, Geolocatie en A/B-testen: Vercel's benadering van de edge met Next.js.
- Artikel 7: Geografische routing aan de rand: personalisatie AVG-inhoud en naleving: geofencing, gelokaliseerde prijzen en AVG.







