지속성 개체: 에지의 강력한 일관성 상태 및 WebSocket
지속성 개체는 Cloudflare 생태계에서 가장 강력한 기본 개체입니다. 강력하게 일관된 상태 허용, 세션 관리 기능이 있는 WebSocket 및 중앙 집중식 서버 없이 분산 조정이 모두 글로벌 에지에서 이루어집니다.
엣지 컴퓨팅의 상태 문제
이 시리즈의 이전 기사에서는 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.







