엣지 컴퓨팅의 상태 문제

이 시리즈의 이전 기사에서는 Cloudflare Workers가 어떻게 뛰어난지 보여주었습니다. 상태 비저장 워크로드의 경우: 각 요청은 공유 메모리 없이 별도로 처리됩니다. 호출 사이. 이 기능은 확장성의 장점입니다. 수평이지만 애플리케이션에 조정이 필요한 순간 장애물이 됩니다.

다음과 같은 일반적인 시나리오를 고려하십시오. 메시지를 전달해야 하는 채팅방 모든 참가자에게 주문 및 배송됩니다. 더 많은 정보를 제공하는 공동 문서 사용자는 동시에 편집합니다. 요청 수를 계산해야 하는 API의 속도 제한기 전 세계적으로 일관된 방식으로. 이 모든 경우에 노동자의 무국적 모델은 충분하지 않습니다. 하나가 필요합니다 매우 일관적이었습니다 및 용량 한 곳에서 경쟁 요청을 조정합니다.

이것이 바로 내가 가진 문제이다 내구성 있는 개체 그들은 해결합니다.

무엇을 배울 것인가

  • 지속형 개체란 무엇이며 Workers KV와 어떻게 다른가요?
  • 강력한 일관성 모델: 단일 작성자, 전역 조정
  • 지속성 개체를 사용하여 세션 관리로 WebSocket을 구현하는 방법
  • 트랜잭션 스토리지: 읽기, 쓰기 및 원자성 트랜잭션
  • 채팅방, 속도 제한 및 문서 협업 패턴
  • 경보: 지속성 개체 내에서 예약된 작업
  • 한도, 가격 및 DO, KV, D1 선택 시기

지속성 개체란 무엇입니까?

DO(내구성 개체)는 Cloudflare가 인스턴스화하는 JavaScript/TypeScript 클래스입니다. 특정 식별자에 대한 단일 지리적 위치에 있습니다. 워커즈 KV와 달리 (300개 이상의 PoP에서 일관성 가능) DO는 다음을 보장합니다.

  • 단일 작성자 일관성: 특정 ID에 대해 전 세계적으로 단 하나의 DO 인스턴스만 존재합니다.
  • 요청 직렬화: DO 호출은 동시에 실행되지 않고 순차적으로 실행됩니다.
  • 내구성 있는 스토리지: 각 인스턴스에 대한 개인 트랜잭션 키-값 저장소
  • WebSocket 최대 절전 모드: WebSocket 연결은 유휴 기간 동안 비용 없이 유지됩니다.

DO의 위치는 첫 번째 활성화 시 자동으로 결정됩니다. 고정된 상태로 유지됩니다. Cloudflare는 첫 번째 요청에 가장 가까운 데이터 센터를 선택합니다. 이후의 모든 요청은 전 세계 어디서나 해당 단일 요청으로 라우팅됩니다. Cloudflare 애니캐스트 라우팅을 통한 인스턴스.

원어 일관성 경쟁 읽기 대기 시간 사용 사례
노동자 KV 최종(분) 멀티라이터 ~1ms(캐시됨) 구성, 자산, 읽기가 많은 세션
내구성 있는 개체 강력함(선형화 가능) 단일 작가 ~50-150ms(원격 가장자리에서) 채팅, 속도 제한기, 게임 상태, CRDT
D1 SQLite 강함(기본) 다중 판독기, 단일 작성자 ~5-20ms(근처 PoP에서) 관계형 쿼리, 보고서, OLTP
R2 객체 스토리지 강함(객체별) 멀티 작성자(충돌 감지) ~50-200ms 파일, 이미지, 백업

지속성 개체의 구조

지속성 개체는 Cloudflare 런타임이 수행하는 특정 메서드가 포함된 클래스입니다. 인식합니다. 가장 중요한 것은 fetch(), 매번 호출됨 DO로 전달된 HTTP 요청. 기본 구조를 살펴보겠습니다.

// src/counter-do.ts
// Un Durable Object semplice: un contatore con persistenza

export class CounterDO implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
    // state.storage e il key-value store privato di questa istanza
    // Persiste attraverso i riavvii del DO
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    switch (url.pathname) {
      case '/increment': {
        // Legge il valore corrente (undefined se non esiste)
        const current = (await this.state.storage.get<number>('count')) ?? 0;
        const next = current + 1;
        // Scrittura atomica: garantita durabile prima del return
        await this.state.storage.put('count', next);
        return Response.json({ count: next });
      }

      case '/decrement': {
        const current = (await this.state.storage.get<number>('count')) ?? 0;
        const next = Math.max(0, current - 1);
        await this.state.storage.put('count', next);
        return Response.json({ count: next });
      }

      case '/value': {
        const count = (await this.state.storage.get<number>('count')) ?? 0;
        return Response.json({ count });
      }

      case '/reset': {
        await this.state.storage.put('count', 0);
        return Response.json({ count: 0, reset: true });
      }

      default:
        return new Response('Not Found', { status: 404 });
    }
  }
}

interface Env {
  COUNTER: DurableObjectNamespace;
}

작업자 진입점에서 DO를 사용하려면 네임스페이스 바인딩을 통해 인스턴스화해야 합니다. ID:

// src/worker.ts - entry point del Worker
export { CounterDO } from './counter-do';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Estrae l'ID dalla query string: /counter?id=room-1
    const counterId = url.searchParams.get('id') ?? 'global';

    // idFromName() crea un ID deterministico da una stringa
    // Lo stesso nome produce sempre lo stesso ID (e la stessa istanza)
    const id = env.COUNTER.idFromName(counterId);

    // Ottiene lo stub per comunicare con l'istanza
    const stub = env.COUNTER.get(id);

    // Invia la richiesta al Durable Object
    // La richiesta viene instradata al datacenter corretto automaticamente
    return stub.fetch(request);
  },
};

interface Env {
  COUNTER: DurableObjectNamespace;
}

구성 wrangler.toml 바인딩을 선언해야 합니다.

# wrangler.toml
name = "counter-worker"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "CounterDO"

[[migrations]]
tag = "v1"
new_classes = ["CounterDO"]

지속형 개체가 포함된 WebSocket: 채팅방

지속성 개체의 가장 강력한 사용 사례는 세션 관리입니다. 상태 저장 WebSocket을 공유합니다. 각 채팅방은 DO의 별도 인스턴스입니다. 활성 연결 목록과 메시지 기록을 유지합니다.

Cloudflare는 다음을 지원합니다. WebSocket 최대 절전 모드 API: DO가 온다 처리할 메시지가 없을 때 "최대 절전 모드"로 전환되어 비용이 대폭 절감됩니다. (열려 있는 연결에 대해서는 비용을 지불하지 않고 처리 시간에 대해서만 비용을 지불합니다.)

// src/chat-room-do.ts
// Durable Object per una stanza di chat con WebSocket Hibernation

export class ChatRoomDO implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/ws') {
      // Verifica che sia una richiesta di upgrade WebSocket
      const upgradeHeader = request.headers.get('Upgrade');
      if (!upgradeHeader || upgradeHeader !== 'websocket') {
        return new Response('Expected WebSocket upgrade', { status: 426 });
      }

      // Crea la coppia WebSocket server/client
      const { 0: clientWs, 1: serverWs } = new WebSocketPair();

      // Accetta la connessione tramite la Hibernation API
      // Il DO sara ibernato tra i messaggi (no costo di CPU idle)
      this.state.acceptWebSocket(serverWs);

      // Opzionale: associa metadata alla connessione
      // Utile per identificare l'utente nei messaggi successivi
      const userId = url.searchParams.get('userId') ?? `anon-${Date.now()}`;
      serverWs.serializeAttachment({ userId });

      // Invia la storia recente al nuovo utente
      const history = (await this.state.storage.get<Message[]>('history')) ?? [];
      if (history.length > 0) {
        serverWs.send(JSON.stringify({ type: 'history', messages: history }));
      }

      return new Response(null, {
        status: 101,
        webSocket: clientWs,
      });
    }

    if (url.pathname === '/messages' && request.method === 'GET') {
      const history = (await this.state.storage.get<Message[]>('history')) ?? [];
      return Response.json({ messages: history });
    }

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

  // Chiamato dalla Hibernation API quando arriva un messaggio WebSocket
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    const { userId } = ws.deserializeAttachment() as { userId: string };

    let parsed: ClientMessage;
    try {
      parsed = JSON.parse(message as string);
    } catch {
      ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
      return;
    }

    if (parsed.type === 'chat') {
      const msg: Message = {
        id: crypto.randomUUID(),
        userId,
        text: parsed.text,
        timestamp: Date.now(),
      };

      // Salva nella history (mantieni solo gli ultimi 100 messaggi)
      const history = (await this.state.storage.get<Message[]>('history')) ?? [];
      const newHistory = [...history, msg].slice(-100);
      await this.state.storage.put('history', newHistory);

      // Broadcast a tutte le connessioni WebSocket attive nel DO
      const allWebSockets = this.state.getWebSockets();
      const payload = JSON.stringify({ type: 'message', message: msg });
      for (const socket of allWebSockets) {
        try {
          socket.send(payload);
        } catch {
          // Connessione chiusa, ignorala
        }
      }
    }
  }

  // Chiamato quando una connessione WebSocket viene chiusa
  async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
    const { userId } = ws.deserializeAttachment() as { userId: string };
    ws.close(code, reason);

    // Notifica gli altri utenti dell'uscita
    const notification = JSON.stringify({
      type: 'user_left',
      userId,
      timestamp: Date.now(),
    });
    for (const socket of this.state.getWebSockets()) {
      try {
        socket.send(notification);
      } catch { /* ignore */ }
    }
  }

  // Chiamato in caso di errore sulla connessione WebSocket
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    console.error('WebSocket error:', error);
    ws.close(1011, 'Internal error');
  }
}

interface Message {
  id: string;
  userId: string;
  text: string;
  timestamp: number;
}

interface ClientMessage {
  type: 'chat' | 'ping';
  text?: string;
}

interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

작업자 진입점은 경로에 따라 요청을 올바른 공간으로 라우팅합니다.

// src/worker.ts
export { ChatRoomDO } from './chat-room-do';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // /room/:roomId/ws -> WebSocket per la stanza
    // /room/:roomId/messages -> GET cronologia
    const match = url.pathname.match(/^\/room\/([^/]+)(\/.*)?$/);
    if (!match) {
      return new Response('Not Found', { status: 404 });
    }

    const roomId = match[1];
    const subpath = match[2] ?? '/ws';

    // Ogni stanza e una istanza distinta del DO
    const id = env.CHAT_ROOM.idFromName(roomId);
    const stub = env.CHAT_ROOM.get(id);

    // Rewrite del path per il DO
    const doUrl = new URL(request.url);
    doUrl.pathname = subpath;

    return stub.fetch(new Request(doUrl.toString(), request));
  },
};

interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

트랜잭션 스토리지: 원자적 작업

내구성 있는 개체 스토리지는 다음을 통해 원자성 트랜잭션을 지원합니다. state.storage.transaction(). 이는 다음과 같은 경우에 매우 중요합니다. 작업은 여러 키를 일관되게 읽고 써야 합니다.

// Esempio: trasferimento di crediti tra utenti (atomico)
export class AccountDO implements DurableObject {
  state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method Not Allowed', { status: 405 });
    }

    const { from, to, amount } = await request.json<TransferRequest>();

    try {
      // La transazione e atomica: o tutto va a buon fine, o niente
      await this.state.storage.transaction(async (txn) => {
        const fromBalance = (await txn.get<number>(`balance:${from}`)) ?? 0;
        const toBalance = (await txn.get<number>(`balance:${to}`)) ?? 0;

        if (fromBalance < amount) {
          // Il throw annulla la transazione
          throw new Error(`Insufficient balance: ${fromBalance} < ${amount}`);
        }

        await txn.put(`balance:${from}`, fromBalance - amount);
        await txn.put(`balance:${to}`, toBalance + amount);

        // Log dell'operazione
        const txLog = (await txn.get<TxRecord[]>('tx_log')) ?? [];
        txLog.push({ from, to, amount, timestamp: Date.now() });
        await txn.put('tx_log', txLog.slice(-1000));
      });

      return Response.json({ success: true, from, to, amount });
    } catch (err) {
      return Response.json(
        { success: false, error: (err as Error).message },
        { status: 400 }
      );
    }
  }
}

interface TransferRequest {
  from: string;
  to: string;
  amount: number;
}

interface TxRecord {
  from: string;
  to: string;
  amount: number;
  timestamp: number;
}

interface Env {
  ACCOUNT: DurableObjectNamespace;
}

경보: 지속성 개체의 예약된 작업

지속성 개체 지원 알람: 콜백 alarm() 내가 거기 없더라도 예정된 시간에 호출되는 DO에 대한 활성 요청. 이는 TTL, 마감일 및 정기 작업에 유용합니다. DO 상태와 연결됨:

// src/session-do.ts
// DO con alarm per scadenza automatica della sessione

export class SessionDO implements DurableObject {
  state: DurableObjectState;
  static SESSION_TTL_MS = 30 * 60 * 1000; // 30 minuti

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/create' && request.method === 'POST') {
      const data = await request.json<SessionData>();

      // Salva i dati della sessione
      await this.state.storage.put('session', {
        ...data,
        createdAt: Date.now(),
      });

      // Schedula l'alarm per la scadenza della sessione
      // Se l'alarm e gia schedulato, viene sostituito
      await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);

      return Response.json({ ok: true, expiresIn: SessionDO.SESSION_TTL_MS });
    }

    if (url.pathname === '/get') {
      const session = await this.state.storage.get<SessionData>('session');
      if (!session) {
        return Response.json({ error: 'Session not found' }, { status: 404 });
      }

      // Refresh del TTL ad ogni accesso (sliding expiry)
      await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);

      return Response.json({ session });
    }

    if (url.pathname === '/invalidate' && request.method === 'DELETE') {
      await this.state.storage.deleteAll();
      await this.state.storage.deleteAlarm();
      return Response.json({ ok: true });
    }

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

  // Chiamato automaticamente quando scatta l'alarm
  async alarm(): Promise<void> {
    // Pulisce i dati della sessione scaduta
    const session = await this.state.storage.get<SessionData>('session');
    if (session) {
      console.log(`Session expired for user: ${session.userId}`);
      await this.state.storage.deleteAll();
    }
  }
}

interface SessionData {
  userId: string;
  role: string;
  metadata?: Record<string, unknown>;
}

interface Env {
  SESSION: DurableObjectNamespace;
}

지속형 객체를 포함한 전역 속도 제한기

분산 비율 제한기는 공개 API에 대해 가장 많이 요청되는 패턴 중 하나입니다. Workers KV를 사용하면 구현에 경쟁 조건이 적용됩니다. C를 사용하면 매 속도 제한 "버킷"은 강력한 일관성을 갖춘 인스턴스입니다.

// src/rate-limiter-do.ts
// Token bucket rate limiter con Durable Objects

export class RateLimiterDO implements DurableObject {
  state: DurableObjectState;

  // Configurazione: 100 req/minuto per IP
  static MAX_TOKENS = 100;
  static REFILL_RATE_MS = 60_000; // 1 minuto per refill completo

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const now = Date.now();

    // Legge lo stato attuale del bucket
    const bucket = (await this.state.storage.get<TokenBucket>('bucket')) ?? {
      tokens: RateLimiterDO.MAX_TOKENS,
      lastRefill: now,
    };

    // Calcola quanti token sono stati aggiunti dall'ultimo accesso
    const elapsed = now - bucket.lastRefill;
    const tokensToAdd = (elapsed / RateLimiterDO.REFILL_RATE_MS) * RateLimiterDO.MAX_TOKENS;
    const currentTokens = Math.min(
      RateLimiterDO.MAX_TOKENS,
      bucket.tokens + tokensToAdd
    );

    if (currentTokens < 1) {
      // Rate limit superato
      const retryAfterMs = Math.ceil(
        (1 - currentTokens) / (RateLimiterDO.MAX_TOKENS / RateLimiterDO.REFILL_RATE_MS)
      );

      await this.state.storage.put('bucket', {
        tokens: currentTokens,
        lastRefill: now,
      });

      return Response.json(
        {
          allowed: false,
          retryAfter: Math.ceil(retryAfterMs / 1000),
          remaining: 0,
        },
        {
          status: 429,
          headers: {
            'Retry-After': String(Math.ceil(retryAfterMs / 1000)),
            'X-RateLimit-Limit': String(RateLimiterDO.MAX_TOKENS),
            'X-RateLimit-Remaining': '0',
          },
        }
      );
    }

    // Consuma un token e aggiorna il bucket
    await this.state.storage.put('bucket', {
      tokens: currentTokens - 1,
      lastRefill: now,
    });

    return Response.json({
      allowed: true,
      remaining: Math.floor(currentTokens - 1),
    });
  }
}

interface TokenBucket {
  tokens: number;
  lastRefill: number;
}

interface Env {
  RATE_LIMITER: DurableObjectNamespace;
}

작업자는 속도 제한기를 각 요청의 흐름에 통합합니다.

// src/worker.ts con rate limiting
export { RateLimiterDO } from './rate-limiter-do';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Identifica il client (IP o API key)
    const clientIp = request.headers.get('CF-Connecting-IP') ?? 'unknown';
    const apiKey = request.headers.get('X-API-Key');
    const bucketId = apiKey ?? `ip:${clientIp}`;

    // Controlla il rate limit per questo client
    const rateLimiterId = env.RATE_LIMITER.idFromName(bucketId);
    const rateLimiter = env.RATE_LIMITER.get(rateLimiterId);
    const limitCheck = await rateLimiter.fetch(new Request('https://dummy/check'));

    if (limitCheck.status === 429) {
      return limitCheck; // Propaga la risposta 429 con gli headers
    }

    // Prosegue con la logica dell'API
    return handleApiRequest(request, env);
  },
};

async function handleApiRequest(request: Request, env: Env): Promise<Response> {
  return Response.json({ data: 'your api response here' });
}

interface Env {
  RATE_LIMITER: DurableObjectNamespace;
}

성능 및 비용 고려 사항

내구성 개체는 비용 및 성능 프로필이 매우 다릅니다. 단순한 노동자에게. 명심해야 할 몇 가지 핵심 사항은 다음과 같습니다.

지연 시간: 항상 로컬은 아님

각 DO 인스턴스는 단일 데이터 센터에 상주합니다. 유럽의 사용자인 경우 북미에서 인스턴스화된 DO에 액세스하며 각 작업의 대기 시간 대서양 횡단 왕복(~100-150ms)이 포함됩니다. DO ID 디자인 지리적 공유를 제한하려면 가능하면 지역 기반 ID를 사용하세요. (idFromName(`${region}:${resourceId}`)) 또는 대기 시간을 수락합니다. 강력한 지역 간 일관성이 필요한 작업의 경우에만 높습니다.

목소리 무료 등급 유급(유급근로자)
DO에 대한 요청 100만/월 포함 100만 달러당 0.15달러 이상
DO 수명(CPU) 400,000GB-초/월 백만 GB-초당 $12.50
저장 1GB 포함 이후 월 $0.20/GB
WebSocket 최대 절전 모드 사용 가능 가능(유휴 비용 없음)
알람 사용 가능 사용 가능

모범 사례

  • ID 세분성: 특정 ID를 사용하세요(예: chat:room-42) 개별 인스턴스의 핫스팟을 피하기 위해 전역 ID가 아닌
  • WebSocket 최대 절전 모드는 항상 다음과 같습니다. 미국 state.acceptWebSocket() 수동 이벤트 리스너 대신 유휴 연결 비용을 줄입니다.
  • 일괄 저장: 미국 storage.put(map) 쓰다 여러 키 대신 한 번의 작업으로 여러 키 put() 싱글.
  • 저장소 크기 제한: 각 인스턴스의 제한은 128MB입니다. 저장의. 대용량 데이터의 경우 R2를 사용하고 DO에만 참조를 저장하세요.
  • 직렬화 예산: 요청이 대기열에 추가되어 처리됩니다. 순차적으로; 느린 작업은 후속 작업을 차단합니다. 핸들러 유지 빠릅니다(1초 미만이 이상적).

결론 및 다음 단계

내구성 있는 객체는 작업자의 상태 비저장 모델과 조정 및 공유 상태가 필요한 최신 애플리케이션. 그들의 것 WebSocket Hibernation API 및 경보와 결합하여 기본 요소로 만듭니다. 채팅, 게임, 문서 협업 및 글로벌 조정 시스템에 적합합니다.

단점은 대기 시간입니다. DO는 물리적으로 단 하나의 데이터 센터에만 있습니다. 따라서 지역 간 쓰기 작업으로 인해 대기 시간이 추가됩니다. 독서용 확장 가능 DO(쓰기용)와 KV(캐시된 읽기용) 결합을 고려하세요.

시리즈의 다음 기사

  • 제5조: Workers AI — LLM 및 비전 모델 추론 on the Edge: Workers에서 Llama, Whisper 및 비전 모델을 직접 실행하는 방법 전용 GPU 없이 Workers AI가 전년 대비 4000% 성장했습니다.
  • 제6조: Vercel Edge 런타임 — 고급 미들웨어, 지리적 위치 및 A/B 테스트: Next.js를 사용하여 엣지에 대한 Vercel의 접근 방식.
  • 제7조: 엣지에서의 지리적 라우팅 — 개인화 GDPR 콘텐츠 및 규정 준수: 지오펜싱, 현지화된 가격 책정 및 GDPR.