Problema persistenței la margine

V8 izolează care putere Lucrătorii Cloudflare sunt apatrizi prin proiectare: fiecare izolat poate fi creat și distrus în microsecunde, iar starea JavaScript locală nu poate persistă între solicitări (sau persistă doar ocazional, din cauza concurenței pe aceeași izolat). Pentru a construi aplicații reale aveți nevoie de persistență externă.

Cloudflare oferă trei produse de stocare native, fiecare proiectat pentru un caz de utilizare specifice. Alegerea greșită poate însemna interogări lente, costuri mari sau inconsecvențe care sunt greu de depanat. Acest articol le compară în detaliu cu Exemple de cod reale.

Ce vei învăța

  • Workers KV: în cele din urmă arhitectură consistentă, API, limite și cazuri de utilizare ideale
  • Cloudflare R2: stocare de obiecte compatibile cu S3 fără taxă de ieșire, încărcare în mai multe părți
  • D1 SQLite: bază de date relațională la margine, interogări SQL, migrări cu Drizzle ORM
  • Comparație de costuri: KV vs R2 vs D1 la diferite scări de utilizare
  • Model de integrare: Cum să combinați KV + D1 pentru memorarea în cache inteligentă
  • Configurarea legăturilor în wrangler.toml pentru fiecare tip de stocare

Workers KV: Valoare-cheie globală și, în cele din urmă, consecventă

Workers KV este un depozit de date cheie-valoare distribuit la nivel global. Când scrii o valoare, este replicată în toate punctele de operare Cloudflare în câteva secunde. Când citești, obțineți valoare de la cel mai apropiat PoP: latența de citire este de obicei mai mică de 1 ms.

Schimbul fundamental: KV este în cele din urmă consistent. O scriere poate dura până la 60 de secunde pentru a fi vizibil la nivel global. Asta face nepotrivit pentru date care se modifică frecvent și care trebuie citite imediat după scris.

Configurație KV în wrangler.toml

# wrangler.toml

[[kv_namespaces]]
binding = "CACHE"          # Nome del binding nel codice TypeScript
id = "xxxxxxxxxxxxxxxxxx"  # ID del namespace in produzione
preview_id = "yyyyyyyyyy"  # ID del namespace per wrangler dev

[[kv_namespaces]]
binding = "SESSIONS"
id = "aaaaaaaaaaaaaaaaa"
preview_id = "bbbbbbbbbbbbb"

# Per creare i namespace:
# wrangler kv namespace create CACHE
# wrangler kv namespace create CACHE --preview

API-ul Workers KV în TypeScript

// src/services/cache.service.ts

export interface Env {
  CACHE: KVNamespace;
  SESSIONS: KVNamespace;
}

// -------- OPERAZIONI BASE --------

// WRITE: put con TTL opzionale (in secondi)
await env.CACHE.put('user:123', JSON.stringify({ name: 'Mario', role: 'admin' }), {
  expirationTtl: 3600,  // scade tra 1 ora
});

// WRITE senza TTL (valore permanente)
await env.CACHE.put('config:features', JSON.stringify({ darkMode: true }));

// WRITE con expiration assoluta (Unix timestamp)
await env.CACHE.put('promo:summer', 'active', {
  expiration: Math.floor(Date.now() / 1000) + 86400,  // scade tra 24h
});

// READ: get con tipo di decodifica
const raw = await env.CACHE.get('user:123');
// raw: string | null

const parsed = await env.CACHE.get<{ name: string; role: string }>('user:123', 'json');
// parsed: { name: string; role: string } | null

// READ con metadata
const { value, metadata } = await env.CACHE.getWithMetadata('user:123', 'json');

// DELETE
await env.CACHE.delete('user:123');

// -------- LISTING (con limitazioni) --------
// KV supporta listing, ma e lento e con limite di 1000 chiavi per chiamata
const listing = await env.CACHE.list({ prefix: 'user:', limit: 100 });
for (const key of listing.keys) {
  console.log(key.name, key.expiration, key.metadata);
}

// -------- PATTERN: cache-aside --------
async function getUserCached(userId: string, env: Env): Promise<User | null> {
  const cacheKey = `user:${userId}`;

  // 1. Controlla la cache KV
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) {
    return cached;  // Cache hit: risposta da KV < 1ms
  }

  // 2. Cache miss: fetch dall'origine
  const user = await fetchUserFromDatabase(userId);
  if (!user) return null;

  // 3. Popola la cache (eventually consistent, ok per profili utente)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: 300,  // 5 minuti
  });

  return user;
}
Caracteristică Detaliu KV
Model de consistență În cele din urmă consistent (propagare maximă de 60 de secunde)
Latența de citire < 1 ms (de la PoP local după cald)
Latența de scriere ~100 ms (confirmare), replicare globală asincronă
Dimensiunea maximă a valorii 25 MB per valoare
Dimensiunea maximă a cheii 512 octeți
Costul citirii 0,50 USD per milion (gratuit: 10 milioane/lună)
Costul scrisului 5 USD per milion (gratuit: 1 milion/lună)
Ideal pentru Configurare, semne de caracteristică, stocare sesiune, cache API
Nu este potrivit pentru Contoare în timp real, date care se schimbă în fiecare secundă

Cloudflare R2: Stocare de obiecte compatibile cu S3 fără taxă de ieșire

R2 este produsul care a făcut cel mai mult zgomot la lansarea sa în 2022: stocarea obiectelor compatibil cu API-urile AWS S3, cu costuri de ieșire zero. Pe S3, Transferul a 1 TB de date pe internet costă ~ 90 USD. Pe R2, este gratuit.

R2 este ideal pentru: încărcarea fișierelor utilizatorului, activelor statice, backup-urilor, jurnalelor arhivate, orice obiect binar care trebuie preluat de browsere sau alți lucrători.

Configurația R2 în wrangler.toml

# wrangler.toml

[[r2_buckets]]
binding = "ASSETS"         # Nome del binding TypeScript
bucket_name = "my-assets"  # Nome del bucket in Cloudflare

# Per creare il bucket:
# wrangler r2 bucket create my-assets

R2 API în TypeScript

// src/services/storage.service.ts

export interface Env {
  ASSETS: R2Bucket;
}

// -------- UPLOAD --------

// Upload di testo semplice
await env.ASSETS.put('documents/readme.txt', 'Contenuto del file', {
  httpMetadata: { contentType: 'text/plain; charset=utf-8' },
  customMetadata: { uploadedBy: 'user-123', version: '1' },
});

// Upload di JSON
const data = { users: [{ id: 1, name: 'Mario' }] };
await env.ASSETS.put('data/users.json', JSON.stringify(data), {
  httpMetadata: { contentType: 'application/json' },
});

// Upload di un file binario da una Request (multipart form)
async function handleFileUpload(request: Request, env: Env): Promise<Response> {
  const formData = await request.formData();
  const file = formData.get('file') as File | null;

  if (!file) {
    return new Response('No file provided', { status: 400 });
  }

  // Genera un nome unico per evitare collisioni
  const key = `uploads/${Date.now()}-${file.name}`;

  await env.ASSETS.put(key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
      contentLength: file.size,
    },
    customMetadata: {
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  });

  return Response.json({ key, size: file.size, type: file.type });
}

// -------- DOWNLOAD --------

async function serveAsset(key: string, env: Env): Promise<Response> {
  const object = await env.ASSETS.get(key);

  if (!object) {
    return new Response('Not Found', { status: 404 });
  }

  // Leggi i metadata HTTP
  const headers = new Headers();
  object.writeHttpMetadata(headers);
  headers.set('etag', object.httpEtag);

  // Aggiungi headers di caching appropriati
  headers.set('Cache-Control', 'public, max-age=31536000, immutable');

  return new Response(object.body, { headers });
}

// -------- LISTING --------

async function listUploads(prefix: string, env: Env) {
  const listed = await env.ASSETS.list({
    prefix: `uploads/${prefix}`,
    limit: 100,
    // cursor: paginationCursor  // per paginare
  });

  return listed.objects.map(obj => ({
    key: obj.key,
    size: obj.size,
    uploaded: obj.uploaded.toISOString(),
    etag: obj.etag,
    customMetadata: obj.customMetadata,
  }));
}

// -------- DELETE --------

await env.ASSETS.delete('uploads/old-file.txt');

// Delete multiplo (fino a 1000 chiavi)
await env.ASSETS.delete(['file1.txt', 'file2.txt', 'file3.txt']);

// -------- HEAD: verifica esistenza senza download --------

const headResult = await env.ASSETS.head('uploads/documento.pdf');
if (headResult) {
  console.log('Size:', headResult.size);
  console.log('Uploaded:', headResult.uploaded);
}

R2 cu acces public și URL presemnat

// Pattern: servire file pubblicamente tramite Worker con access control

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // URL pattern: /files/{key}
    const key = url.pathname.replace('/files/', '');

    if (!key) {
      return new Response('Key required', { status: 400 });
    }

    // Implementa qui la tua logica di autenticazione/autorizzazione
    const authorized = await checkAccess(request, key, env);
    if (!authorized) {
      return new Response('Forbidden', { status: 403 });
    }

    // Gestisci il conditional request (ETag/If-None-Match)
    const etag = request.headers.get('If-None-Match');
    const object = await env.ASSETS.get(key, {
      onlyIf: etag ? { etagDoesNotMatch: etag } : undefined,
      range: request.headers.get('Range') ?? undefined,
    });

    if (!object) {
      // Potrebbe essere 404 o 304 Not Modified
      const head = await env.ASSETS.head(key);
      if (!head) return new Response('Not Found', { status: 404 });
      return new Response(null, { status: 304, headers: { etag: head.httpEtag } });
    }

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('etag', object.httpEtag);

    if (object.range) {
      headers.set('Content-Range', `bytes ${object.range.offset}-${
        (object.range.offset ?? 0) + (object.range.length ?? 0) - 1
      }/${object.size}`);
      return new Response(object.body, { status: 206, headers });
    }

    return new Response(object.body, { headers });
  },
};

D1 SQLite: Baza de date relațională la margine

D1 este cel mai recent și cel mai ambițios produs: SQLite nativ la margine. O bază de date relațională completă, cu JOIN-uri, tranzacții, interogări complexe, totul accesibil de către lucrători fără conexiune la pornire la rece, fără taxă de ieșire, cu replicare automată la toate PoP-urile.

D1 folosește SQLite intern și îl replică în centrele de date regionale Cloudflare. Scrierile merg la nodul primar (consistență posibilă pentru replici), citirile pot servi ca o replică locală pentru o latență minimă.

Configurația D1 în wrangler.toml

# wrangler.toml

[[d1_databases]]
binding = "DB"
database_name = "mio-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# Per creare il database:
# wrangler d1 create mio-database

Migrații și Schema cu D1

# Crea il file di migration
wrangler d1 migrations create mio-database "create users table"
# Crea: migrations/0001_create_users_table.sql

# migrations/0001_create_users_table.sql
-- migrations/0001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  email     TEXT UNIQUE NOT NULL,
  name      TEXT NOT NULL,
  role      TEXT NOT NULL DEFAULT 'user',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at DATETIME NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
# Applica le migration in locale (wrangler dev)
wrangler d1 migrations apply mio-database --local

# Applica le migration in produzione
wrangler d1 migrations apply mio-database

API D1 în TypeScript: Interogări directe

// src/services/user.service.ts

export interface Env {
  DB: D1Database;
}

interface User {
  id: number;
  email: string;
  name: string;
  role: string;
  created_at: string;
}

// -------- SELECT --------

// Query prepared statement (SEMPRE usare prepared statements: previene SQL injection)
async function getUserById(id: number, env: Env): Promise<User | null> {
  const result = await env.DB
    .prepare('SELECT * FROM users WHERE id = ?')
    .bind(id)
    .first<User>();
  return result;
}

// SELECT multipli
async function getUsersByRole(role: string, env: Env): Promise<User[]> {
  const { results } = await env.DB
    .prepare('SELECT id, email, name, role, created_at FROM users WHERE role = ? ORDER BY created_at DESC')
    .bind(role)
    .all<User>();
  return results;
}

// SELECT con pagination
async function getUsers(page: number, pageSize: number, env: Env) {
  const offset = (page - 1) * pageSize;

  const [{ results }, { total }] = await Promise.all([
    env.DB
      .prepare('SELECT * FROM users LIMIT ? OFFSET ?')
      .bind(pageSize, offset)
      .all<User>(),
    env.DB
      .prepare('SELECT COUNT(*) as total FROM users')
      .first<{ total: number }>()
      .then(r => r ?? { total: 0 }),
  ]);

  return {
    users: results,
    pagination: { page, pageSize, total: total.total, totalPages: Math.ceil(total.total / pageSize) },
  };
}

// -------- INSERT --------

async function createUser(
  email: string,
  name: string,
  env: Env,
): Promise<User> {
  const result = await env.DB
    .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(email, name)
    .first<User>();

  if (!result) {
    throw new Error('Failed to create user');
  }

  return result;
}

// -------- UPDATE --------

async function updateUser(id: number, updates: Partial<Pick<User, 'name' | 'role'>>, env: Env) {
  const setClauses: string[] = [];
  const values: (string | number)[] = [];

  if (updates.name !== undefined) {
    setClauses.push('name = ?');
    values.push(updates.name);
  }
  if (updates.role !== undefined) {
    setClauses.push('role = ?');
    values.push(updates.role);
  }

  if (setClauses.length === 0) return;

  setClauses.push('updated_at = CURRENT_TIMESTAMP');
  values.push(id);

  await env.DB
    .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`)
    .bind(...values)
    .run();
}

// -------- TRANSAZIONI (batch) --------

async function createUserWithSession(
  email: string,
  name: string,
  sessionId: string,
  expiresAt: Date,
  env: Env,
): Promise<User> {
  // D1 supporta batch per eseguire piu statement in una singola round-trip
  const [userResult] = await env.DB.batch([
    env.DB
      .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
      .bind(email, name),
    env.DB
      .prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, last_insert_rowid(), ?)')
      .bind(sessionId, expiresAt.toISOString()),
  ]);

  const user = userResult.results[0] as User;
  return user;
}

D1 cu Burniță ORM: Tip Safety Complete

Drizzle ORM acceptă D1 nativ și oferă o experiență excelentă pentru dezvoltatori cu inferență automată de tip din schema:

// npm install drizzle-orm
// npm install -D drizzle-kit @types/better-sqlite3

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').unique().notNull(),
  name: text('name').notNull(),
  role: text('role', { enum: ['user', 'admin', 'moderator'] }).default('user').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expiresAt: text('expires_at').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

// Tipi TypeScript inferiti dallo schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';

export function createDb(d1: D1Database) {
  return drizzle(d1, { schema });
}

// src/services/user.drizzle.service.ts
import { eq, desc, count } from 'drizzle-orm';
import { createDb } from '../db';
import { users, type User, type NewUser } from '../db/schema';

export async function getUserById(id: number, env: { DB: D1Database }): Promise<User | undefined> {
  const db = createDb(env.DB);
  return db.select().from(users).where(eq(users.id, id)).get();
}

export async function createUser(data: NewUser, env: { DB: D1Database }): Promise<User> {
  const db = createDb(env.DB);
  const [user] = await db.insert(users).values(data).returning();
  return user;
}

export async function getAdminUsers(env: { DB: D1Database }): Promise<User[]> {
  const db = createDb(env.DB);
  return db.select()
    .from(users)
    .where(eq(users.role, 'admin'))
    .orderBy(desc(users.createdAt))
    .all();
}

Comparație: KV vs R2 vs D1

Criteriu Muncitori KV R2 D1 SQLite
Tip de date Cheie-valoare (șir/blob) Obiecte binare (fișiere) Relațional (tabele SQL)
Consecvență În cele din urmă consistent Eventual (puternic cu muncitorii) Puternic (primar), eventual (replica)
Capabilitati de interogare Obțineți/puneți/ștergeți numai cu cheie Obțineți/puneți/ștergeți numai cu cheie SQL complet: JOIN-uri, agregate, indexuri
Dimensiunea maxima 25 MB per valoare 5TB per articol 10 GB (beta: 2 GB)
Costul depozitării 0,50 USD/GB/lună 0,015 USD/GB/lună 0,75 USD/GB/lună
Costul operațiunilor 5 USD/M scriere, 0,50 USD/M citit 4,50 USD/M în scriere, 0,36 USD/M în citire 0,001 USD/M rând scriere, 0,001 USD/M rând citit
Latența de citire < 1 ms (din memoria cache PoP) ~10-50 ms (din stocare) ~1-5ms (interogări simple)
Cazuri de utilizare ideale Sesiuni, configurare, cache API Încărcați fișiere, active, copii de rezervă Aplicație CRUD, catalog, utilizatori

Model avansat: KV ca strat cache pentru D1

Un model eficient combină D1 (date autorizate) cu KV (cache rapidă):

// src/services/cached-user.service.ts

export interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
}

const USER_CACHE_TTL = 300; // 5 minuti

export async function getUserCached(userId: number, env: Env) {
  const cacheKey = `user:v2:${userId}`;

  // 1. Controlla KV cache prima (sub-ms)
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) return cached;

  // 2. Miss: leggi da D1 (query SQL)
  const user = await getUserById(userId, env);
  if (!user) return null;

  // 3. Popola la cache KV in background (non blocca la risposta)
  // (usa ctx.waitUntil() nel fetch handler per non bloccare)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: USER_CACHE_TTL,
  });

  return user;
}

export async function invalidateUserCache(userId: number, env: Env): Promise<void> {
  await env.CACHE.delete(`user:v2:${userId}`);
}

// Quando aggiorni un utente, invalida la cache
export async function updateUserAndInvalidate(
  userId: number,
  updates: Partial<User>,
  env: Env,
): Promise<void> {
  await updateUser(userId, updates, env);
  await invalidateUserCache(userId, env);
}

Concluzii și pașii următori

Alegerea stratului de stocare depinde de tipul de date: KV pentru cheie-valoare cu rată mare de citire și rată scăzută de scriere, R2 pentru fișiere binare și active, D1 pentru date structurate cu relații și interogări complexe. În multe aplicații real le vei folosi pe toate trei în combinație.

Următoarele articole din serie

  • Articolul 4: Obiecte durabile — Stare puternic consistentă și WebSocket: când KV nu este suficient și aveți nevoie de consistență coordonare puternică și distribuită.
  • Articolul 5: Workers AI — Inferența LLM și modele de viziune Direct la margine: Cum să rulați modele AI în Workers folosind legarea AI.
  • Articolul 10: Arhitecturi Full-Stack la margine — un singur caz Studiu complet care combină Workers + D1 + R2 + CI/CD cu GitHub Actions.