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.