Volharding aan de rand: Workers KV, R2 Object Storage en D1 SQLite
Praktische vergelijking tussen de drie opslaglagen die beschikbaar zijn in Cloudflare Workers: KV voor globale sleutelwaardegegevens, R2 voor objectopslag zonder uitgaande kosten, en D1 voor relationele SQL-query's aan de rand.
Het probleem van volharding aan de rand
De V8 isoleert die kracht. Cloudflare Workers zijn van nature staatloos: elk geïsoleerd kan in microseconden worden gemaakt en vernietigd, en de lokale JavaScript-status niet blijft bestaan tussen verzoeken (of blijft slechts af en toe bestaan, vanwege gelijktijdigheid op hetzelfde geïsoleerd). Om echte applicaties te bouwen heb je externe persistentie nodig.
Cloudflare biedt drie native opslagproducten, elk ontworpen voor een gebruiksscenario specifiek. Als u de verkeerde kiest, kan dit langzame zoekopdrachten en hoge kosten met zich meebrengen of inconsistenties die moeilijk te debuggen zijn. Dit artikel vergelijkt ze in detail met Echte codevoorbeelden.
Wat je gaat leren
- Workers KV: uiteindelijk consistente architectuur, API, limieten en ideale use cases
- Cloudflare R2: S3-compatibele objectopslag zonder uitgaande kosten, upload in meerdere delen
- D1 SQLite: relationele database aan de rand, SQL-query's, migraties met Drizzle ORM
- Kostenvergelijking: KV versus R2 versus D1 bij verschillende gebruiksschalen
- Integratiepatroon: Hoe KV + D1 te combineren voor intelligente caching
- Bindingen configureren in wrangler.toml voor elk opslagtype
Werknemers KV: mondiale sleutelwaarde en uiteindelijk consistent
Workers KV is een wereldwijd gedistribueerde sleutel/waarde-datastore. Wanneer je schrijft een waarde wordt binnen enkele seconden gerepliceerd naar alle Cloudflare PoP's. Wanneer je leest, haal waarde uit de dichtstbijzijnde PoP: leeslatentie doorgaans minder dan 1 ms.
De fundamentele afweging: KV is uiteindelijk consistent. Een schrijven het kan tot 60 seconden duren voordat het wereldwijd zichtbaar is. Dit maakt het niet geschikt voor gegevens die vaak veranderen en onmiddellijk moeten worden gelezen na het schrijven.
KV-configuratie in 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 in 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;
}
| Kenmerkend | KV-detail |
|---|---|
| Consistentiemodel | Uiteindelijk consistent (maximale voortplanting in de jaren 60) |
| Lees latentie | < 1 ms (van lokale PoP na warm) |
| Schrijf latentie | ~100 ms (bevestiging), asynchrone globale replicatie |
| Maximale waardegrootte | 25 MB per waarde |
| Maximale sleutelgrootte | 512 bytes |
| Kosten lezen | $ 0,50 per miljoen (gratis: 10 miljoen/maand) |
| Kosten schrijven | $ 5 per miljoen (gratis: 1 miljoen/maand) |
| Ideaal voor | Configuratie, functievlaggen, sessieopslag, API-caching |
| Niet geschikt voor | Realtime tellers, gegevens die elke seconde veranderen |
Cloudflare R2: S3-compatibele objectopslag zonder uitgaande kosten
R2 is het product dat bij de lancering in 2022 het meeste lawaai maakte: objectopslag compatibel met AWS S3 API's, met nul uitgaande kosten. Op S3, Het overbrengen van 1TB aan gegevens naar internet kost ~$90. Op R2 is het gratis.
R2 is ideaal voor: het uploaden van gebruikersbestanden, statische assets, back-ups, gearchiveerde logs, elk binair object dat moet worden opgehaald door browsers of andere werknemers.
R2-configuratie in 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 in 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 met openbare toegang en vooraf ondertekende 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: relationele database aan de rand
D1 is het nieuwste en meest ambitieuze product: native SQLite at the edge. Een complete relationele database, met JOIN's, transacties, complexe queries, alles toegankelijk voor werknemers zonder koudestartverbinding, zonder uitstapkosten, met automatische replicatie naar alle PoP's.
D1 gebruikt SQLite intern en repliceert het naar regionale datacenters van Cloudflare. De schrijfbewerkingen gaan naar het primaire knooppunt (mogelijke consistentie voor de replica's), de leesbewerkingen ze kunnen dienen als lokale replica voor minimale latentie.
D1-configuratie in 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
Migraties en schema met 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
D1 API in TypeScript: directe zoekopdrachten
// 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 met motregen ORM: Type Safety Complete
Drizzle ORM ondersteunt D1 native en biedt een uitstekende ontwikkelaarservaring met automatische type-afleiding uit het 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();
}
Vergelijking: KV versus R2 versus D1
| Criterium | Arbeiders KV | R2 | D1 SQLite |
|---|---|---|---|
| Gegevenstype | Sleutelwaarde (tekenreeks/blob) | Binaire objecten (bestanden) | Relationeel (SQL-tabellen) |
| Samenhang | Uiteindelijk consistent | Eventueel (sterk met werknemers) | Sterk (primair), uiteindelijk (replica) |
| Querymogelijkheden | Alleen ophalen/plaatsen/verwijderen met de sleutel | Alleen ophalen/plaatsen/verwijderen met de sleutel | Volledige SQL: JOIN's, aggregaten, indexen |
| Maximale grootte | 25 MB per waarde | 5TB per stuk | 10 GB (bèta: 2 GB) |
| Opslagkosten | $ 0,50/GB/maand | $ 0,015/GB/maand | $ 0,75/GB/maand |
| Kosten van operaties | $5/M schrijven, $0,50/M lezen | $4,50/M schrijven, $0,36/M lezen | $0,001/M rij schrijven, $0,001/M rij lezen |
| Lees latentie | < 1 ms (uit PoP-cache) | ~10-50 ms (vanaf opslag) | ~1-5 ms (eenvoudige zoekopdrachten) |
| Ideale gebruiksscenario's | Sessies, configuratie, API-cache | Upload bestanden, middelen, back-ups | CRUD-app, catalogus, gebruikers |
Geavanceerd patroon: KV als cachelaag voor D1
Een effectief patroon combineert D1 (gezaghebbende gegevens) met KV (snelle cache):
// 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);
}
Conclusies en volgende stappen
De keuze van de opslaglaag is afhankelijk van het gegevenstype: KV voor sleutelwaarden met hoge leessnelheid en lage schrijfsnelheid, R2 voor binaire bestanden en assets, D1 voor gestructureerde gegevens met complexe relaties en query's. In veel toepassingen In werkelijkheid gebruik je ze alle drie in combinatie.
Volgende artikelen in de serie
- Artikel 4: Duurzame objecten – Sterk consistente staat en WebSocket: wanneer KV niet genoeg is en u consistentie nodig heeft sterke en gedistribueerde coördinatie.
- Artikel 5: Workers AI - Inferentie van LLM- en visiemodellen Rechtstreeks naar de rand: AI-modellen uitvoeren in Workers met behulp van AI-binding.
- Artikel 10: Full-stack architecturen aan de edge: één voorbeeld Volledige studie waarbij Workers + D1 + R2 + CI/CD wordt gecombineerd met GitHub Actions.







