12k
All articles

Руководство для начинающих по Cloudflare Durable Objects

Cloudflare Durable Objects: маршрутизация, состояние одной инстанции, хранение SQLite и пример rate limiter на TypeScript для Workers.

OpenReplay Team
OpenReplay Team
Руководство для начинающих по Cloudflare Durable Objects

Durable Object — это единственный экземпляр JavaScript-класса, который Cloudflare запускает строго в одном месте в каждый момент времени; каждый запрос, адресованный этому экземпляру, маршрутизируется к нему, откуда бы в мире запрос ни пришёл, а сам экземпляр располагает собственным приватным хранилищем.

Реализуйте счётчик, блокировку или список «кто сейчас в этой комнате» на обычных Workers — и два запроса могут в итоге «разойтись во мнениях». Worker, обработавший первый запрос, и Worker, обработавший второй, могут оказаться разными изолятами в разных городах без общей памяти между ними.

В этом руководстве разбирается модель маршрутизации, благодаря которой Durable Objects работают, а затем приводится минимальный TypeScript-код, демонстрирующий её, — на едином сквозном примере ограничителя частоты запросов (rate limiter) для каждого API-ключа. Предполагается, что вы уже знакомы с bindings, wrangler и обработчиком fetch; если сначала нужна эта база, начните с руководства OpenReplay по Cloudflare Workers для начинающих.

Ключевые выводы

  • Durable Objects — это примитив вычислений со собственным приватным хранилищем, а не продукт для хранения данных, из которого вы читаете в Worker; хранилище доступно только из кода, выполняющегося внутри объекта.
  • Строка, передаваемая в env.BINDING.getByName(name), и есть идентичность объекта: любой запрос в сети Cloudflare, передающий ту же строку, попадает в тот же самый работающий экземпляр.
  • Каждый Durable Object владеет встроенной базой данных SQLite, работающей в том же потоке, что и его код, поэтому this.ctx.storage.sql.exec() синхронно возвращает курсор и не требует await.
  • Поля класса сохраняются между последовательными запросами, но отбрасываются, когда объект переходит в гибернацию примерно после 10 секунд неактивности; всё, что должно сохраниться, следует держать в ctx.storage.
  • Используйте Workers KV, когда одни и те же данные читаются из множества локаций и запись может не сразу стать видимой везде; используйте Durable Object, когда несколько клиентов должны согласованно видеть текущее значение в один и тот же момент.

Почему stateless Workers не справляются с координацией?

Worker ничего не сохраняет между запросами. Два вызова могут попасть на разные изоляты в разных местах, и ни один из них не увидит, что сделал другой, поэтому любая функциональность, требующая согласования значения между последовательными запросами, ломается. Собственные рекомендации Cloudflare по проектированию Durable Objects проводят ровно эту границу между stateless Workers и stateful-координацией.

Вот наивный rate limiter, который выглядит корректным, но таковым не является:

// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();

export default {
  async fetch(request): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";
    const count = (hits.get(key) ?? 0) + 1;
    hits.set(key, count);
    return new Response(count > 10 ? "slow down" : "ok", {
      status: count > 10 ? 429 : 200,
    });
  },
} satisfies ExportedHandler;

Объявленный на уровне модуля Map живёт в одном изоляте. Клиент, отправляющий 30 запросов, которые попадают на три изолята, получает три независимых счётчика, каждый из которых останавливается на 10, и лимит фактически никогда не применяется. Перенос счётчика во внешнюю базу данных решает проблему разделяемости, но привносит состояние гонки «чтение, затем запись» между параллельными запросами. Проблема не в том, где находятся данные; проблема в том, что ничто не гарантирует единственное место, где проверка и обновление происходят вместе.

Основная идея: один объект на имя, один и тот же экземпляр каждый раз

Durable Objects решают задачу координации, выделяя каждому имени ровно один работающий экземпляр и маршрутизируя к нему все запросы с этим именем. Страница концепций описывает три свойства, лежащих в основе этого: каждый объект отвечает на имя, уникальное в масштабах всего мира, его хранилище находится рядом с ним, а не «через сеть», и он выполняет одну задачу за раз — так же, как JavaScript во вкладке браузера.

Из этой модели следуют три свойства:

  1. Идентичность — это имя. Ваш Worker выбирает строку (API-ключ, ID комнаты, ID документа), и платформа отображает её на один экземпляр. Два Worker’а на разных континентах, передающие одну и ту же строку, общаются с одним и тем же объектом.
  2. Создание неявное. Никакого вызова создания нет. Справочник по API namespace объясняет, что сам по себе ID ничего не создаёт, и что объекты не конструируются, пока к ним фактически кто-нибудь не обратится. На практике конструктор выполняется при поступлении первого вызова метода на stub.
  3. Выполнение однопоточное. Синхронный код внутри метода не может быть прерван другим запросом. Другие запросы могут выполняться только пока ваш код ожидает ввода-вывода, не связанного с хранилищем, например fetch().

Поскольку каждый объект — это один поток на одной машине, пропускная способность масштабируется «в ширину», а не «вверх»: rate limiter должен быть одним объектом на API-ключ, а не единым глобальным объектом для всего трафика.

Как определить и зарегистрировать класс Durable Object?

Durable Object — это экспортируемый класс, наследующий DurableObject из cloudflare:workers, принимающий ctx и env в конструкторе и предоставляющий свои публичные методы Worker’ам через RPC. Руководство по началу работы фиксирует сигнатуру конструктора как (ctx: DurableObjectState, env: Env) с обязательным вызовом super(ctx, env).

import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.ctx.storage.sql.exec(
      "CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
    );
  }

  async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
    const bucket = Math.floor(Date.now() / windowMs);

    // No await between the read and the write: nothing else can run in between.
    const row = this.ctx.storage.sql
      .exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
      .toArray()[0];
    const count = row ? row.count + 1 : 1;

    this.ctx.storage.sql.exec(
      "INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
      bucket, count, count
    );

    return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
  }
}

SELECT и upsert выполняются без await между ними, поэтому ни один другой запрос для этого API-ключа не сможет «вклиниться». Именно этого и не могла обеспечить stateless-версия.

Регистрация класса требует двух записей в wrangler.jsonc. Запись exports помечает класс как durable-object с хранилищем sqlite и именно она создаёт namespace при первом деплое. Запись durable_objects.bindings даёт Worker’у доступ через env:

{
  "durable_objects": {
    "bindings": [
      { "name": "RATE_LIMITER", "class_name": "RateLimiter" }
    ]
  },
  "exports": {
    "RateLimiter": {
      "type": "durable-object",
      "storage": "sqlite"
    }
  }
}

В более старых примерах классы регистрируются через массив migrations с new_sqlite_classes. Эта форма всё ещё поддерживается для существующих Workers, но exports — актуальный способ, и эти два варианта не могут сосуществовать в одном файле конфигурации.

Как вызвать Durable Object из Worker?

Worker обращается к Durable Object, запрашивая у binding’а stub через getByName(name), а затем вызывая публичные методы класса на этом stub’е как обычные асинхронные функции. Stub — это лишь локальный дескриптор: вызовы на нём перенаправляются к тому единственному экземпляру, которому принадлежит имя.

export default {
  async fetch(request, env): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";

    // `key` is the object's identity. Same key, same instance, everywhere.
    const stub = env.RATE_LIMITER.getByName(key);
    const { allowed, remaining } = await stub.increment(10, 60_000);

    return new Response(allowed ? "ok" : "slow down", {
      status: allowed ? 200 : 429,
      headers: { "x-ratelimit-remaining": String(remaining) },
    });
  },
} satisfies ExportedHandler<Env>;

Строка, передаваемая в getByName(), — это ключ маршрутизации. getByName(name) — это сокращение для более старой двухшаговой схемы idFromName(name) с последующим get(id), которую вы всё ещё встретите во многих примерах; оба варианта адресуют один и тот же объект. Прямой вызов методов на stub’е как RPC требует compatibility date 2024-04-03 или новее, чему удовлетворяет любой новый шаблон.

Где Durable Object хранит своё состояние?

У каждого Durable Object есть два вида состояния: приватная встроенная база данных SQLite, сохраняющаяся между перезапусками, и обычные поля класса, живущие только пока объект находится в памяти. API хранилища SQLite работает в том же потоке, что и ваш код, поэтому exec() сразу возвращает SqlStorageCursor без await. Вычитывайте этот курсор до следующего await, используя .toArray(), .one() или цикл: курсор, оставленный открытым через await, может подхватить строки, записанные за это время, включая записи, которые впоследствии будут откачены.

Поле класса — это «быстрый путь». Добавление такого поля в rate limiter показывает разницу:

export class RateLimiter extends DurableObject<Env> {
  // In-memory: fast, private to this instance, gone after hibernation.
  private lastSeen = 0;

  async increment(limit: number, windowMs: number) {
    this.lastSeen = Date.now();
    // ... SQLite read and write as before (durable)
  }
}

lastSeen сохраняется между последовательными запросами, однако документация по жизненному циклу указывает, что после 10 секунд без входящих событий (и без ожидающих таймеров, WebSocket’ов на стандартном API или незавершённых fetch()) объект переходит в гибернацию, а его память отбрасывается. Деплои и обслуживание среды выполнения также могут перезапустить его в любой момент. Таблица hits переживает всё это. Каждый объект с SQLite-хранилищем может содержать до 10 ГБ на плане Workers Paid, согласно странице лимитов.

Что выбрать: Workers KV или Durable Objects?

Используйте Workers KV, когда одни и те же данные нужно читать из множества локаций и допустимо, что запись станет видимой везде не сразу; используйте Durable Object, когда несколько клиентов должны согласованно видеть текущее значение в один и тот же момент. Документация о согласованности KV прямо говорит о компромиссе: KV жертвует согласованностью в пользу скорости, изменение может проявиться в других локациях спустя минуту или дольше, и всё, что требует атомарного чтения и записи вместе, должно использовать Durable Objects.

Workers KVDurable Objects
СогласованностьИтоговая (eventual); кэшированные копии истекают по TTLСтрогая; данными владеет один экземпляр
Где происходят чтенияВ любой локации, из кэшаВнутри единственного владеющего экземпляра
Безопасность «чтение, затем запись»Отсутствует между запросамиГарантирована при отсутствии промежуточного await вне хранилища
Шаблон записиРедкие записи на ключЗаписи по объекту, сериализуемые средой выполнения
Типичное применениеКонфигурация, feature flags, allow-list’ыСчётчики, блокировки, комнаты, состояние по сущности

Сравнение вариантов хранения разделяет их так же: KV подходит для конфигурации и подобных значений, которые читаются гораздо чаще, чем изменяются, тогда как Durable Objects покрывают координацию между клиентами и хранилище, остающееся согласованным в рамках объекта. Rate limiter — это счётчик по схеме «чтение, затем запись», поэтому ему место в Durable Object. Feature flag для каждого тенанта, читаемый при каждом запросе, — место в KV.

Заключение

Durable Objects решают задачу координации, снимая вопрос о том, где живёт состояние: имя, которое вы передаёте в getByName(), выбирает ровно один работающий экземпляр, его база данных SQLite находится в том же потоке, что и его код, а чтение, за которым следует запись без await вне хранилища между ними, не может быть «переплетено» с другим выполнением. Следующий шаг — сгенерировать шаблон Worker + Durable Objects с помощью npm create cloudflare@latest, заменить сгенерированный класс приведённым выше rate limiter’ом и запустить npx wrangler dev, чтобы увидеть, как счётчик растёт по запросам, которые иначе никогда бы не согласовались.

Часто задаваемые вопросы

В чём разница между Durable Objects и D1?

D1 — это управляемая база данных SQLite, к которой ваш Worker обращается по сети, со встроенными HTTP API и миграциями схемы. База данных SQLite у Durable Object работает на той же машине, что и код объекта, и доступна Worker'ам только через этот объект. Оба варианта ограничены 10 ГБ на базу данных на плане Workers Paid, и у обоих ниже лимиты на бесплатном плане. Используйте D1 для одной общей реляционной базы данных; используйте Durable Objects для состояния по пользователю или по сущности, которому нужна координация.

Сколько запросов в секунду может обработать один Durable Object?

У одного Durable Object мягкий лимит около 1 000 запросов в секунду, поскольку каждый объект работает в одном потоке на одной машине. Сверх этого среда выполнения ставит в очередь то, что может, а затем отклоняет лишние вызовы с ошибкой перегрузки. Каждый вызов по умолчанию получает 30 секунд CPU-времени, настраиваемых до 5 минут через limits.cpu_ms в конфигурации Wrangler. Масштабируйтесь «в ширину», используя по одному объекту на имя, например по одному объекту на API-ключ.

Работают ли Durable Objects на бесплатном плане Workers?

Да. Durable Objects с бэкендом хранения SQLite доступны на бесплатном плане Workers с ограничениями: 1 ГБ на объект, 5 ГБ суммарного хранилища Durable Objects на аккаунт и 100 классов Durable Object. Workers Paid поднимает эти значения до 10 ГБ на объект, неограниченного хранилища на аккаунт и 500 классов. Когда объект заполнен, записи завершаются ошибкой SQLITE_FULL, однако вы по-прежнему можете читать строки и удалять их, чтобы освободить место.

Нужны ли явные транзакции при использовании sql.exec() в Durable Object?

Обычно нет. Каждый вызов sql.exec() уже выполняется внутри собственной транзакции, а чтения и записи, идущие одно за другим без await между ними, фиксируются как один атомарный пакет, поэтому схема «чтение, затем запись» в rate limiter безопасна в том виде, в котором написана. sql.exec() не может выполнять инструкции BEGIN TRANSACTION или SAVEPOINT. Чтобы сгруппировать несколько инструкций так, чтобы все они откатились, если одна выбросит исключение, используйте ctx.storage.transactionSync(callback). Коллбэк должен быть полностью синхронным: не объявлен как async и не возвращать Promise.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.