Persistență la margine: Workers KV, R2 Object Storage și D1 SQLite
Comparație practică între cele trei straturi de stocare disponibile în Cloudflare Workers: KV pentru date globale cheie-valoare, R2 pentru stocarea obiectelor fără taxe de ieșire și D1 pentru interogări SQL relaționale la margine.
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.







