Trwałość na krawędzi: Workers KV, R2 Object Storage i D1 SQLite
Praktyczne porównanie trzech warstw pamięci masowej dostępnych w Cloudflare Workers: KV dla globalnych danych klucz-wartość, R2 dla przechowywania obiektów bez opłat za ruch wychodzący i D1 dla relacyjnych zapytań SQL na krawędzi.
Problem trwałości na krawędzi
Izolatory V8 obsługujące pracowników Cloudflare są z założenia bezpaństwowcami: każdy jest izolowany można tworzyć i niszczyć w ciągu mikrosekund, a lokalny stan JavaScript nie utrzymuje się między żądaniami (lub utrzymuje się tylko sporadycznie ze względu na współbieżność tego samego izolowany). Aby zbudować prawdziwe aplikacje, potrzebujesz zewnętrznej trwałości.
Cloudflare oferuje trzy natywne produkty pamięci masowej, każdy zaprojektowany do konkretnego przypadku użycia konkretny. Wybór niewłaściwego może oznaczać wolne zapytania i wysokie koszty lub niespójności, które są trudne do usunięcia. W tym artykule szczegółowo porównano je z Prawdziwe przykłady kodu.
Czego się nauczysz
- Workers KV: ostatecznie spójna architektura, API, ograniczenia i idealne przypadki użycia
- Cloudflare R2: pamięć obiektów zgodna z S3 bez opłaty za wyjście, przesyłanie wieloczęściowe
- D1 SQLite: relacyjna baza danych na brzegu, zapytania SQL, migracje z Drizzle ORM
- Porównanie kosztów: KV vs R2 vs D1 przy różnych skalach użytkowania
- Wzorzec integracji: Jak połączyć KV + D1 w celu inteligentnego buforowania
- Konfigurowanie powiązań w wrangler.toml dla każdego typu pamięci
Workers KV: Globalna klucz-wartość i ostatecznie spójna
Workers KV to globalnie dystrybuowany magazyn danych typu klucz-wartość. Kiedy piszesz wartość jest replikowana we wszystkich punktach PoP Cloudflare w ciągu kilku sekund. Kiedy czytasz, pobierz wartość z najbliższego PoP: opóźnienie odczytu zwykle jest mniejsze niż 1 ms.
Podstawowy kompromis: KV jest ostatecznie spójne. Pismo zanim będzie widoczny na całym świecie, może minąć do 60 sekund. To sprawia, że to się udaje nieodpowiednie dla danych, które często się zmieniają i należy je natychmiast odczytać po napisaniu.
Konfiguracja KV w 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
Workers KV API w 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;
}
| Charakterystyczny | Szczegóły KV |
|---|---|
| Model spójności | Ostatecznie spójne (maks. propagacja 60 s) |
| Przeczytaj opóźnienie | < 1 ms (z lokalnego PoP po nagrzaniu) |
| Napisz opóźnienie | ~100 ms (potwierdzenie), asynchroniczna replikacja globalna |
| Maksymalny rozmiar wartości | 25MB na wartość |
| Maksymalny rozmiar klucza | 512 bajtów |
| Koszt czytania | 0,50 USD za milion (bezpłatnie: 10 mln/miesiąc) |
| Koszt pisania | 5 USD za milion (bezpłatnie: 1 mln/miesiąc) |
| Idealny dla | Konfiguracja, flagi funkcji, magazyn sesji, buforowanie API |
| Nie nadaje się do | Liczniki w czasie rzeczywistym, dane zmieniające się co sekundę |
Cloudflare R2: Przechowywanie obiektów zgodne z S3 bez opłat za wyjście
R2 to produkt, który narobił najwięcej hałasu w momencie premiery w 2022 r.: przechowywanie obiektów kompatybilny z API AWS S3, z zerowe koszty wyjścia. Na S3, Przesyłanie 1 TB danych do Internetu kosztuje ~90 dolarów. Na R2 jest to bezpłatne.
R2 idealnie nadaje się do: przesyłania plików użytkownika, zasobów statycznych, kopii zapasowych, archiwizowanych logów, dowolny obiekt binarny, który musi zostać pobrany przez przeglądarki lub inne procesy robocze.
Konfiguracja R2 w 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
API R2 w 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 z dostępem publicznym i wcześniej wyznaczonym adresem URL
// 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: relacyjna baza danych na krawędzi
D1 to najnowszy i najbardziej ambitny produkt: natywny SQLite na krawędzi. Kompletna relacyjna baza danych z JOINami, transakcjami, złożonymi zapytaniami, wszystkim dostępne dla Pracowników bez podłączenia zimnego startu, bez opłaty za wyjście, z automatyczną replikacją do wszystkich punktów PoP.
D1 używa wewnętrznie SQLite i replikuje go do regionalnych centrów danych Cloudflare. Zapisy trafiają do węzła podstawowego (możliwa spójność replik), a odczyty mogą służyć jako replika lokalna przy minimalnych opóźnieniach.
Konfiguracja D1 w 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
Migracje i schemat z 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
Interfejs API D1 w TypeScript: zapytania bezpośrednie
// 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 z Drizzle ORM: Typ Safety Complete
Drizzle ORM obsługuje natywnie D1 i oferuje doskonałe doświadczenie programistyczne z automatycznym wnioskowaniem typu ze schematu:
// 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();
}
Porównanie: KV vs R2 vs D1
| Kryterium | Pracownicy KV | R2 | SQLite D1 |
|---|---|---|---|
| Typ danych | Para klucz-wartość (ciąg/blob) | Obiekty binarne (pliki) | Relacyjne (tabele SQL) |
| Konsystencja | W końcu spójne | Ewentualne (silne z Robotnikami) | Silny (główny), ostateczny (replika) |
| Możliwości zapytań | Pobieraj/wstaw/usuń tylko za pomocą klucza | Pobieraj/wstaw/usuń tylko za pomocą klucza | Pełny SQL: JOIN, agregaty, indeksy |
| Maksymalny rozmiar | 25MB na wartość | 5 TB za sztukę | 10 GB (beta: 2 GB) |
| Koszt przechowywania | 0,50 USD/GB/miesiąc | 0,015 USD/GB/miesiąc | 0,75 USD/GB/miesiąc |
| Koszt operacji | Zapis 5 USD/M, odczyt 0,50 USD/M | Zapis 4,50 USD/M, odczyt 0,36 USD/M | Zapis wiersza 0,001 USD/M, odczyt wiersza 0,001 USD/M |
| Przeczytaj opóźnienie | < 1 ms (z pamięci podręcznej PoP) | ~10-50ms (z pamięci) | ~1-5ms (proste zapytania) |
| Idealne przypadki użycia | Sesje, konfiguracja, pamięć podręczna API | Przesyłaj pliki, zasoby, kopie zapasowe | Aplikacja CRUD, katalog, użytkownicy |
Wzorzec zaawansowany: KV jako warstwa pamięci podręcznej dla D1
Efektywny wzorzec łączy D1 (dane wiarygodne) z KV (szybka pamięć podręczna):
// 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);
}
Wnioski i dalsze kroki
Wybór warstwy przechowywania zależy od typu danych: KV dla par klucz-wartość z dużą szybkością odczytu i niską szybkością zapisu, R2 dla plików binarnych i aktywa, D1 dla danych strukturalnych ze złożonymi relacjami i zapytaniami. W wielu zastosowaniach real, użyjesz wszystkich trzech w kombinacji.
Następne artykuły z serii
- Artykuł 4: Obiekty trwałe — stan silnie spójny i WebSocket: gdy KV nie wystarczy i potrzebujesz spójności silna i rozproszona koordynacja.
- Artykuł 5: Workers AI — wnioskowanie z modeli LLM i wizji Prosto do krawędzi: jak uruchamiać modele AI u pracowników przy użyciu powiązania AI.
- Artykuł 10: Architektury Full-Stack na krawędzi — jeden przypadek Pełne badanie łączące pracowników + D1 + R2 + CI/CD z akcjami GitHub.







