Под капотом, вне горячего пути: как D1 масштабируется до миллионов баз данных
- Число баз D1 превысило 5,5 млн; за год ежедневно активных баз стало в 18 раз больше (по состоянию на 8 октября 2026 года).
- Для каждой базы создаётся отдельный SQLite Durable Object; Workers KV хранит метаданные маршрутизации, а Hyperdrive обеспечивает доступ CRUD-операций к Postgres.
- При создании по умолчанию базу размещают рядом с пользователем; архитектура рассчитана на миллионы баз и сотни тысяч активных одновременно.
Почему это важно: Это важно платформам с ИИ и несколькими арендаторами, которым нужно быстро, надёжно и с минимальными затратами создавать множество баз данных.
Традиционная архитектура баз данных предполагает наличие одного или двух крупных кластеров баз данных для каждого приложения, обычно дополненных решением для пулов соединений. Платформы для разработки с помощью ИИ и вайб-кодинга переворачивают эту модель с ног на голову и ведут к появлению множества небольших баз данных: по одной на пользователя, приложение, среду предварительного просмотра или арендатора.
Эта модель особенно хорошо подходит для D1. D1 — это управляемая бессерверная база данных Cloudflare на основе SQLite. Она позволяет создавать миллионы баз данных и платить только за то, чем вы пользуетесь.
Возникает вопрос: как масштабировать плоскость управления для миллионов баз данных, не замедляя каждый запрос из-за прохождения через централизованные узкие места?
Мы внесли множество архитектурных изменений, чтобы масштабировать D1 и поддерживать платформы с миллионами созданных баз данных, сотни тысяч из которых активны в любой момент времени.
В этой публикации мы подробно расскажем о том, как устроена плоскость управления D1. Многие из описанных архитектурных решений могут пригодиться всем, кто разрабатывает приложения на платформе для разработчиков Cloudflare. Кроме того, публикация позволяет заглянуть за кулисы и увидеть, как мы создаём собственные плоскости управления.
Рост в цифрах
Когда мы начали планировать эти изменения, использование D1 уже стабильно росло, но мы понимали, что нам потребуется поддерживать гораздо больший масштаб, когда крупные платформы начнут создавать приложения на D1.
Вот как развивалась ситуация.
Число баз данных D1 превысило 5,5 млн, а за последний год количество действующих баз данных, не помеченных как удалённые, выросло в 15 раз, а за последние 2 года — в 65 раз (по состоянию на 8 октября 2026 года).

Кроме того, за последний год количество ежедневно активных баз данных выросло в 18 раз (по состоянию на 8 октября 2026 года). База данных считается активной, если за этот день в ней был выполнен хотя бы один запрос.

Уже сейчас известные и успешные платформы создают приложения на D1, используя те же инструменты, которые доступны всем пользователям Cloudflare.
«ChatGPT Sites позволяет любому превращать идеи в проекты, которыми можно поделиться. D1 даёт нашим пользователям простой и масштабируемый способ добавлять такие функции, как пользовательские метрики, таблицы лидеров и комментарии, на панели управления, в игры и инструменты».
— Джон Абрамс, ChatGPT Sites @ OpenAI
В процессе описанных в этой публикации изменений мы периодически проводили нагрузочное тестирование, задавая целевые показатели, значительно превышающие ожидаемые темпы роста. Это помогло определить, на каких оптимизациях следует сосредоточиться.
Основополагающие инженерные принципы
Приложения, созданные на платформе для разработчиков Cloudflare, бывают самыми разными: от полнофункциональных приложений до API-сервисов, от внутренних порталов для одного пользователя до масштабных платформ вайб-кодинга с несколькими арендаторами — и всё, что находится между ними.
Создавая плоскость управления D1 для поддержки миллионов баз данных, мы руководствовались следующими установками и принципами при выборе архитектурных решений. Они могут пригодиться всем, кто разрабатывает на нашей платформе собственные плоскости управления и данных.
- Проектируйте все процессы с учётом возможных сбоев и идемпотентности. Сбои неизбежны, поэтому система должна по возможности самостоятельно исправлять ошибки и продолжать работу.
- Избегайте централизованных компонентов в одном регионе на критически важных путях операций плоскости данных (в нашем случае — для всех запросов).
- Используйте Workers KV — глобально распределённое KV-хранилище для критически важных метаданных маршрутизации, чтобы при промахах кеша обращаться к центральным базам данных приходилось как можно реже.
- Используйте Hyperdrive для доступа к центральным базам данных Postgres: это обеспечивает эффективное управление пулами соединений, оптимизированную маршрутизацию и кеширование, а также использование реплик для чтения, где это возможно.
- Периодически предварительно вычисляйте агрегированные значения в базе данных, чтобы избежать сканирующих запросов при обработке пользовательских запросов.
- Используйте согласованность с задержкой и очереди между компонентами, которым не требуется строгая согласованность, чтобы повысить надёжность и доступность.
- Проверяйте приложение нагрузочными тестами с реалистичными сценариями, чтобы выявить узкие места и неэффективные решения, и в первую очередь оптимизируйте их.
Внутри компании мы следуем и другим инженерным принципам при создании наших систем, но перечисленные выше достаточно универсальны и подойдут всем, кто разрабатывает на платформе Cloudflare.
В следующих разделах мы подробно рассмотрим основные компоненты и процессы системы, связанные с жизненным циклом базы данных D1, и покажем на примере реальной системы, как применять эти принципы на практике.
Общая архитектура
Для начала познакомимся с основными компонентами D1.

На схеме не показаны некоторые стрелки и блоки, связанные со второстепенными процессами поддержки D1. Здесь представлены компоненты, обеспечивающие основные общедоступные функции D1. Остальные мы рассмотрим в отдельных разделах этой публикации.
Прежде всего, за каждой базой данных D1 стоит один SQLite Durable Object, благодаря чему можно легко создавать миллионы таких баз данных.
Блок «HTTP API» обозначает все наши интерфейсы API, включая панель управления Cloudflare, Wrangler и интерфейс командной строки Cloudflare. Все запросы API проходят через пограничный шлюз Cloudflare и обрабатываются D1 Edge API Worker.
На схеме HTTP-конечные точки, позволяющие пользователям выполнять SQL-запросы к своим базам данных, обозначены как «конечные точки запросов». Остальные конечные точки отвечают за стандартные операции с базами данных, такие как создание, удаление и просмотр списка баз данных, а также за импорт и экспорт данных и функцию перемещения во времени. Они обозначены как «конечные точки CRUD».
Конечные точки CRUD используют Hyperdrive для запросов к кластеру Postgres в наших центральных центрах обработки данных (далее — «Core»). Есть две конфигурации. В одной включено кеширование, и она обращается к репликам Postgres для чтения. В другой кеширование отключено, и запросы направляются к основному серверу Postgres.
В зависимости от типа запроса Edge API Worker может вызвать D1 Eyeball Worker. Eyeball Worker — это наш прокси для всех запросов, которые должны обрабатываться базовыми D1 Durable Objects (DO). Благодаря этому вся логика обработки запросов находится в Eyeball Worker, включая наблюдаемость, автоматические повторные попытки, обработку ошибок и другие функции, связанные с запросами.
Workers KV служит пограничным уровнем кеширования метаданных, к которым часто обращаются при чтении, и доступен всем D1 Workers.
Блок «Worker Binding» обозначает Workers всех пользователей, у которых есть привязка D1 Worker. Запросы от D1 Binding напрямую поступают в D1 Eyeball Worker, который затем передаёт их Durable Object, обслуживающему базу данных, и возвращает ответ User Worker.
Пользователи могут выполнять запросы к своим базам данных двумя способами. Первый — через конечные точки запросов HTTP API, второй — через Worker, который они разворачивают в своих аккаунтах и связывают с D1 Binding.
С точки зрения операций мы распределяем их по следующим категориям:
- Плоскость данных: любые запросы, связанные с базой данных; они должны выполняться максимально быстро и всегда на периферии сети.
- Плоскость управления: любые операции HTTP API или внутренние процессы, запускаемые в ответ на пользовательский запрос, не связанный с запросами к базе данных; они могут зависеть от централизованных компонентов в одном регионе.
- Фоновая асинхронная плоскость: любые аналитические процессы и процессы, связанные с выставлением счетов; они могут допускать согласованность с задержкой и выполняться где угодно.
За кулисами происходит ещё много всего, поэтому перейдём к основным процессам.
Быстрое создание баз данных по запросу рядом с пользователем
Платформам с несколькими арендаторами и средам разработки под управлением ИИ-агентов необходимо быстро, надёжно и с минимальными затратами создавать множество экземпляров баз данных.
Вот пример команды Wrangler для создания базы данных D1:
% time wrangler d1 create "demo-01"
✅ Successfully created DB 'demo-01' in region WEUR
...
0.56s user 0.13s system 42% cpu 1.614 totalПри создании пользователем базы данных D1 необходимо выполнить несколько действий, прежде чем база будет готова принимать запросы.
- Проверить, может ли аккаунт создавать дополнительные базы данных с учётом ограничений бесплатного тарифа или лимитов аккаунта.
- Сгенерировать уникальный UUID базы данных D1 и уникальный идентификатор Durable Object (
newUniqueId()), а также инициализировать базовый Durable Object (DO), передав ему сведения об аккаунте и базе данных. - Сохранить информацию о новой базе данных в Postgres (одна строка на базу данных, включая метаданные и привязку к идентификатору инициализированного Durable Object).
- Записать в Workers KV соответствие между идентификатором базы данных и идентификатором Durable Object (в дальнейшем оно используется плоскостью данных и конечными точками запросов).
Рассмотрим реальный процесс и способы восстановления после сбоев на каждом шаге.

После поступления запроса на создание базы данных обработчик Edge API Worker запускается в ближайшей к пользователю точке присутствия Cloudflare. Edge API обращается через Hyperdrive к репликам Postgres для чтения с включённым кешированием (срок жизни кеша составляет несколько секунд), чтобы узнать число неудалённых баз данных в указанном аккаунте.
Для конечных точек, выполняющих несколько обращений к Postgres (шаг 2), Edge API Worker перенаправляет запросы плоскости управления в центры обработки данных рядом с кластером Postgres. К таким конечным точкам относится и создание базы данных. Это связано с тем, что удалённый запрос туда и обратно может занимать сотни миллисекунд, тогда как при работе рядом с Postgres он занимает несколько миллисекунд. Для конечных точек, выполняющих не более одного запроса к Postgres, такая оптимизация не нужна: Hyperdrive и так эффективно маршрутизирует запросы по сети Cloudflare.
Если при создании базы данных D1 не заданы параметры, по умолчанию она размещается рядом с местом, откуда поступил запрос. На расположение базы данных можно повлиять, указав подсказку о конкретном местоположении или юрисдикцию. Подсказки о местоположении и настройки юрисдикции совпадают с теми, которые поддерживают Durable Objects, поэтому при их указании значения напрямую передаются базовому Durable Object при инициализации.
При создании по умолчанию мы хотим размещать базы данных D1 рядом с исходным местоположением запроса, то есть рядом с пользователем. Для этого на шаге 4 схемы мы внутри системы перенаправляем выполнение запроса в центр обработки данных рядом с исходным местоположением запроса и уже оттуда вызываем базовый Durable Object. Это гарантирует, что Durable Object будет размещён рядом с пользователем.
Похожие методы маршрутизации можно реализовать с помощью явных подсказок о размещении Worker и вызова нужного Worker во время выполнения через Service Bindings.
С точки зрения размещения конечная точка создания — особый случай: хотя мы обращаемся к Postgres как минимум дважды, мы также выполняем удалённый вызов базовых Durable Objects, которые различаются для каждого запроса. Поэтому выполнение запроса в любой точке мира может быть неэффективным. Именно поэтому мы решили направлять исходный запрос в сторону Postgres.
На следующем шаге с помощью API newUniqueId() генерируется UUID базы данных и новый уникальный идентификатор Durable Object, а затем на новом заглушечном объекте Durable Object вызывается RPC-функция init(options). Durable Object сохраняет сведения в постоянном хранилище, чтобы с этого момента каждый входящий запрос содержал все метаданные связанной базы данных.
Получив ответ об инициализации, Edge API Worker сохраняет метаданные базы данных в основной центральной базе данных Postgres через Hyperdrive — по одной строке метаданных на каждую базу.
Описанные выше шаги заключены в цикл повторных попыток (шаги 5–7 на схеме). Если инициализация DO или запись в Postgres завершается ошибкой, мы повторяем попытку с другими идентификаторами (это важно, как мы увидим в следующем разделе).
Запись в Postgres — это «контрольная точка создания»: с этого момента база данных считается созданной, даже если итоговый ответ не дошёл до пользователя. С этого момента база участвует во всех операциях плоскости управления, и её можно привязать к Worker для обработки запросов.
На этом этапе база данных D1 готова принимать запросы.
Итоговый RPC-вызов, подтверждающий создание, выполняется на Durable Object с помощью waitUntil(), и его сбой допустим. Этот вызов сообщает DO, что база данных активна, после чего DO обновляет метаданные базы в Workers KV. Благодаря этому последующие запросы могут сразу получать нужные сведения из кеша Workers KV, не обращаясь к Postgres.
Часто спрашивают, почему нельзя выполнить весь процесс создания в одной транзакции Postgres, чтобы его упростить. Ответ прост: это не масштабируется, поскольку соединения оставались бы занятыми на время инициализации Durable Object, который может находиться на другом конце света.
Сигналы активности для самокорректирующихся баз данных

Каждый D1 Durable Object периодически отправляет плоскости управления сигнал активности, чтобы получить актуальное состояние базы данных (активна она или удалена) и лимиты аккаунта (подробнее об этом — в разделах о контроле лимитов ниже). Интервал составляет 30 секунд, к нему добавляется случайное смещение для каждой базы данных. Мы также можем менять интервал для конкретной базы данных или аккаунта либо полностью отключать сигналы активности.
Для таких сигналов допустимы немного устаревшие данные, поэтому их можно обслуживать через разные уровни кеширования Hyperdrive или реплику Postgres для чтения. Благодаря этому процессу Durable Object самостоятельно корректирует своё состояние в соответствии с первоисточником данных (Postgres) при создании и удалении баз данных.
Прежде чем двигаться дальше, поясним один момент. В процессе создания из предыдущего раздела (шаг 5) при каждой повторной попытке мы используем разные UUID базы данных и идентификаторы Durable Object. Если бы мы повторно использовали UUID базы данных с новыми идентификаторами Durable Object, несколько Durable Objects считали бы себя владельцами одной и той же базы данных, что привело бы к трудноуловимым ошибкам. Периодические сигналы активности также позволяют удалять осиротевшие объекты.
Кроме того, важно сначала инициализировать Durable Object и лишь затем сохранять привязку базы данных в Postgres. В противном случае запросы могли бы начать поступать в Durable Object до его инициализации и отклоняться.
Рассмотрим пример сбоя и разберёмся, почему система самостоятельно восстанавливается:
- Мы выполнили все действия из предыдущего раздела, инициализировали Durable Object A, но запись в Postgres завершилась ошибкой.
- Worker повторяет цикл, инициализирует новый DO с другим идентификатором B и на этот раз успешно сохраняет запись в Postgres.
- Теперь в рамках этого запроса инициализированы и работают два Durable Objects, но только один из них действителен.
- Пользователь может отправлять запросы только в Durable Object B, поскольку только его идентификатор есть в Postgres — нашем первоисточнике данных.
- В течение 30 секунд DO A отправляет следующий сигнал активности в плоскость управления и получает ответ, что его база данных не существует. В результате DO A помечает себя как удалённый.
При удалении базы данных происходит похожий процесс.
Сигналы активности также позволяют системе самостоятельно корректировать данные Workers KV, чтобы они в итоге отражали реальное состояние базы данных — активна она или удалена.
На последнем шаге процесса создания обновление Workers KV выполняется «по возможности» и может завершиться ошибкой без последствий. Данные согласуются с задержкой: при следующем сигнале активности Durable Object проверит, что в Workers KV записано правильное значение. Поскольку в Workers KV используется согласованность с задержкой и нет возможности выполнить согласованное чтение, Durable Object служит точкой сериализации для записей в Workers KV.
Удаление работает так же, но в обратном порядке: Durable Object отвечает за запись своего состояния «удалено» в Workers KV — также по возможности во время самой операции удаления. Если запись не удастся, это в итоге исправит сигнал активности. Durable Object также хранит состояние «активно» или «удалено» в собственном хранилище, поэтому может сразу принимать или отклонять запросы, даже если данные Workers KV устарели и пропускают запросы.
В целом сигналы активности баз данных служат резервным механизмом, позволяющим добиться согласованного состояния, если в ходе операции нужно записать данные во внешнее хранилище и одновременно обновить сам Durable Object.
Разгрузка критически важного пути: запросы без обращения к центральной базе данных
Главный принцип работы конечных точек запросов — не обращаться к Postgres или другим централизованным компонентам в одном регионе. Каждый HTTP-запрос обрабатывается нашим Edge API Worker рядом с исходным местоположением пользователя, после чего напрямую направляется в D1 Durable Object через Eyeball Worker.
Перед выполнением основной бизнес-логики Edge API Worker отвечает за стандартную аутентификацию HTTP API, авторизацию на основе ресурсов, ограничение частоты запросов на уровне аккаунта и базы данных, проверку запросов и прав аккаунта, а также другие функции, связанные с API.
Eyeball Worker, напротив, пропускает эти дополнительные проверки (за исключением собственного специального ограничения частоты запросов), поскольку D1 Binding в основе своей представляет собой Service Binding к D1 Eyeball Worker. D1 Binding каждого пользовательского Worker содержит закодированные сведения, которые разрешают доступ к конкретной базе данных D1. D1 Eyeball Worker проверяет эти сведения и пропускает запрос.
На следующей схеме показано выполнение запроса через HTTP API.

- Пользователь отправляет запрос через HTTP API.
- Сначала Edge API Worker пытается получить из Workers KV соответствие между UUID D1 и метаданными базы данных.
- В редких случаях запись не удаётся найти в Workers KV — например, если запись завершилась ошибкой, как описано выше, или из-за проблем с доступностью. Тогда мы обращаемся к Hyperdrive, настроенному на реплики Postgres, чтобы получить соответствие. Прежде чем запрашивать непосредственно реплики, Hyperdrive проверяет свой кеш в местоположении вызывающего компонента, затем — кеш рядом с кластером Postgres. И только если в обоих случаях происходит промах кеша, Hyperdrive обращается к репликам Postgres за нужным соответствием.
- Получив соответствие, Edge API Worker вызывает Eyeball Worker через Worker Service Bindings и передаёт ему нужные идентификаторы.
- Eyeball Worker проверяет данные и пересылает запрос в D1 Durable Object.
- D1 Durable Object выполняет SQL-запрос и возвращает результат через Eyeball Worker в Edge API Worker, который отправляет его пользователю.
// Edge API Worker
const parsed = parseQueries(request);
const cachedDB = await withRetries(getCachedDatabase(parsed.dbUuid));
const response = await env.EYEBALL_WORKER.query({
db: cachedDB,
queries: parsed.queries,
});
return response;Если вы задаётесь вопросом, почему при создании базы данных мы используем newUniqueId(), а не обращаемся к Durable Objects по «имени», чтобы исключить шаги 2 и 3, ответ таков: это позволяет избежать дополнительного обращения к центральному хранилищу соответствий для преобразования «имени» в «идентификатор Durable Object» при обработке запросов. Одного идентификатора Durable Object недостаточно: Eyeball Worker нужны и другие метаданные, чтобы выбрать подходящее пространство имён Durable Object. Таким образом, вместо одного обращения нам потребовалось бы два.
В большинстве случаев — даже во многих наших собственных приложениях — отлично работает обращение к Durable Objects по имени. Это избавляет от необходимости преобразовывать собственные идентификаторы в идентификаторы Durable Object, поэтому начните с этого способа и переходите к уникальным идентификаторам DO, только если это действительно необходимо.
Возможно, вы также задаётесь вопросом, зачем нам Workers KV, если у нас уже есть кеширование Hyperdrive.
Workers KV — это постоянное хранилище данных, а не просто кеш: если запись исчезнет из пограничного кеша KV, данные будут прочитаны из собственного хранилища KV, а не из Postgres. Так Postgres остаётся вне плоскости данных. При катастрофической очистке кеша попытки миллиона баз данных получить сведения о маршрутизации привели бы к метастабильным сбоям.
Напомним: запросам от пользовательского Worker с D1 Binding не нужно получать метаданные соответствия UUID D1, поскольку эта информация уже содержится в самой привязке. Поэтому запросы направляются напрямую в Eyeball Worker и Durable Object.
Подробнее о выполнении запросов и внутреннем устройстве Durable Objects читайте в нашей публикации о репликации чтения в D1 — там разбираются все технические детали.
Теперь рассмотрим асинхронные процессы, обеспечивающие работу D1.
Контроль лимитов использования хранилища
Платформы, достигшие определённого масштаба, рано или поздно должны вводить лимиты на использование ресурсов и выставление счетов. На момент написания этой публикации для D1 действует лимит хранилища 5 ГБ на аккаунт в бесплатном тарифе и 1 ТБ в Workers Paid (по запросу лимит можно увеличить).
В целом системы контроля лимитов использования и выставления счетов хорошо подходят для обработки с задержкой и асинхронных процессов. В следующих разделах мы рассмотрим, как в D1 устроен контроль этих лимитов.

Асинхронная обработка обновлений с помощью очередей
Сначала каждый Durable Object публикует обновления об использовании хранилища в очередь, аналогичную Kafka, где они объединяются в пакеты и обрабатываются асинхронно. Плоскости управления не нужно обрабатывать каждое обновление синхронно: допустимо, если всплеск нагрузки отразится не сразу, а сведения об использовании окажутся устаревшими. Главное — не допустить перегрузки Postgres и ухудшения работы D1 для пользователей. Для этого мы ограничиваем количество потребителей очереди и, соответственно, нагрузку на запись в Postgres.
Система обработки очередей работает в центральных центрах обработки данных Cloudflare («Core»), тогда как сами Durable Objects распределены по всему миру на периферии сети. Данные об использовании агрегируются локально на каждом пограничном сервере, а затем отправляются во внутреннюю систему очередей.
Основной принцип здесь — использовать очередь, чтобы сглаживать и группировать операции записи в центральное региональное хранилище данных Postgres.
Группировка изменений использования и предварительная агрегация

Потребитель очереди записывает в таблицу Postgres последние данные о размере каждой базы данных. Однако для принятия решений о применении ограничений нужны другие данные: суммарное использование хранилища всеми базами данных аккаунта. При проверке использования в момент применения ограничений мы не можем позволить себе выполнять SELECT sum(storage) FROM database_stats WHERE account_id = ?. Стоимость такого запроса линейно растёт с количеством баз данных в каждом аккаунте. Это не подходит аккаунтам с миллионами баз данных D1, например некоторым платформам программирования с ИИ-агентами, созданным на D1.
В идеале нам нужна таблица поиска, которая позволяет сразу получить общий объём хранилища баз данных по идентификатору аккаунта. Решение — использовать инкрементальное обслуживание представлений для предварительного вычисления общей суммы и обновлять её при каждом изменении объёма данных в базах.
Назовём эту таблицу account_database_stats.
Теоретически потребитель очереди мог бы при каждом поступающем обновлении самостоятельно обновлять database_stats и account_database_stats. На практике это привело бы к серьёзной конкуренции за блокировки записи одной строки для крупных аккаунтов. Вместо этого мы добавляем третью таблицу под названием account_database_stats_changes, которая служит очередью изменений. В ней регистрируются все изменения, и для одного аккаунта может быть несколько записей. Затем мы периодически агрегируем обновления из этой таблицы в account_database_stats. Процесс выглядит так:
- Потребитель очереди записывает сведения об использовании в таблицу
database_statsдля конкретной базы данных. - Триггер вычисляет изменение объёма данных в этой базе и записывает разницу в таблицу
account_database_stats_changes(одна строка на каждое изменение). - Каждую секунду запланированное задание обрабатывает все записи в
account_database_stats_changesи обновляетaccount_database_stats, добавляя сумму этих изменений.
На практике запланированное задание выполняет следующий запрос:
WITH updates AS MATERIALIZED (
DELETE FROM account_database_stats_changes
RETURNING *
),
changes AS MATERIALIZED (
SELECT
account_id,
SUM(storage_change) AS storage
FROM updates
GROUP BY account_id
)
INSERT INTO account_database_stats (account_id, storage)
SELECT
account_id,
storage
FROM changes
ON CONFLICT (account_id) DO UPDATE
SET storage = account_database_stats.storage + EXCLUDED.storage;Запрос получает список обновлений из account_database_stats_changes, агрегирует их по аккаунтам, а затем записывает изменения в таблицу для каждого аккаунта. Это позволяет избежать конкуренции за запись в отдельные строки: перед записью мы сначала агрегируем изменения.
Общее табличное выражение (CTE) DELETE ... RETURNING атомарно обрабатывает ожидающие изменения и передаёт удалённые строки следующему CTE. Postgres выполняет CTE, изменяющее данные, ровно один раз и до завершения. Затем запрос группирует эти изменения по аккаунтам и применяет одно агрегированное обновление для каждого аккаунта, снижая конкуренцию за запись.
Эту схему используют многие другие команды Cloudflare. Она позволяет масштабироваться до миллионов ресурсов на аккаунт, не делая стандартные операции дороже с каждым новым ресурсом.
Стоит отметить, что итоговое значение согласуется не сразу. Это не похоже на встроенное инкрементальное обновление представлений, доступное в некоторых других базах данных. Мы уже используем очередь для передачи обновлений из Durable Objects в Postgres, поэтому нас устраивает eventual consistency.
Чтение из кэша через Hyperdrive
Теперь у нас есть таблица для поиска общего объёма хранилища каждого аккаунта. Что дальше?
Каждый D1 Durable Object периодически получает данные об использовании ресурсов аккаунта через упомянутый ранее периодический сигнал активности. Мы отправляем запрос в Postgres с помощью SELECT storage FROM account_database_stats WHERE account_id = ? и сравниваем результат с доступными аккаунту возможностями и лимитами его тарифного плана. Если общий объём превышает лимит, мы запрещаем все операции записи, которые увеличили бы размер базы данных.
Запрос направляется к репликам Postgres через Hyperdrive и использует встроенный кэш Hyperdrive. Ключ кэша Hyperdrive формируется из запроса и всех значений параметров, а срок его действия составляет несколько секунд. Если единственным параметром сделать идентификатор аккаунта, все базы данных этого аккаунта будут использовать одно и то же кэшированное значение — именно этого мы и добиваемся.
А теперь небольшой трюк с кэшем. По умолчанию срок действия данных, полученных через кэш Hyperdrive, составляет 60 секунд, а настройки кэша Hyperdrive применяются ко всем запросам. Для отдельных запросов нам нужны кэшированные значения с меньшим сроком действия. Если добавить в запрос параметр dynamic_ttl, каждые X секунд будет формироваться новый ключ кэша, благодаря чему свежие значения будут поступать быстрее.
-- $2 = Math.floor(Date.now() / (X * 1000))
SELECT storage, $2 as dynamic_ttl
FROM account_database_stats WHERE account_id = $1Может возникнуть соблазн создать запрос с идентификаторами аккаунта и базы данных в качестве параметров. Тогда можно было бы получить общий объём аккаунта и вычесть из него объём текущей базы данных. Это дало бы точный ответ на вопрос: «Сколько места может занимать эта база данных до начала применения ограничений?» — и позволило бы вводить ограничения раньше.
Но тогда идентификатор базы данных попал бы в ключ кэша Hyperdrive, из-за чего доля попаданий в кэш резко снизилась бы. Для аккаунтов с миллионами активных баз данных ключ на основе идентификатора аккаунта позволяет большинству сигналов активности использовать одну и ту же кэшированную информацию из Hyperdrive и не обращаться к Postgres.
Реакция на изменения в использовании ресурсов при таком подходе немного запаздывает, но это приемлемо.
Помимо описанного процесса, мы периодически переносим снимки данных об использовании хранилища из Postgres в кластер ClickHouse, чтобы отслеживать, как меняется объём хранилища со временем. Эти события в ClickHouse также служат источником достоверных данных для расчёта платы за использование хранилища.
Применение ограничений на число строк на периферии сети
D1 также ограничивает дневной объём выполнения запросов для пользователей бесплатного тарифа. Здесь мы используем другой подход, о котором тоже стоит рассказать.

При выполнении SQL-запроса в Durable Object мы учитываем, сколько строк он считывает и записывает (включая обновления индексов, если они требуются). После завершения запроса мы публикуем событие об использовании ресурсов, которое попадает в другой кластер ClickHouse. Эти события используются для расчёта платы за использование на основе числа строк.
Центральный компонент, назовём его «Limiter», работает в наших основных центрах обработки данных и периодически запрашивает в ClickHouse события об использовании ресурсов с начала дня:
SELECT account_id, sum(rows_read) as reads, sum(rows_written) as writes
FROM requests
WHERE timestamp > toStartOfDay(now())
GROUP BY account_id HAVING reads > ? or writes > ?ClickHouse лучше справляется с такими запросами, чем Postgres. Кроме того, благодаря этому мы не перегружаем кластер Postgres аналитическими запросами.
Для каждого аккаунта, превысившего лимит, мы проверяем доступные ему возможности и тариф (это ограничение действует только для пользователей бесплатного тарифа). Если лимит превышен, мы отправляем пользователю электронное письмо (не чаще одного раза в день) и устанавливаем ключ в Quicksilver — нашем внутреннем глобально распределённом хранилище «ключ — значение» с быстрой репликацией менее чем за секунду. Срок действия ключа истекает в конце дня. Если аккаунт переходит на платный тариф Workers, мы получаем сообщение в очереди и удаляем ключ Quicksilver.
Каждый D1 Durable Object периодически обращается к Quicksilver на периферии сети, чтобы узнать, установлено ли ограничение для его аккаунта. Если да, он отклоняет все последующие запросы до снятия ограничения.
Разработчики, работающие на Developer Platform, могут использовать Workers KV Instant, созданный на основе Quicksilver.
Этот подход позволяет избежать нагрузки на региональный Postgres на всех этапах и использовать подходящий инструмент для каждой задачи. Аналитические запросы эффективнее выполнять в ClickHouse, а не в Postgres.
Этот подход широко распространён в компании, но отличается от того, который мы применяем для ограничения использования хранилища. В будущем мы можем перевести на него и другие механизмы контроля лимитов.
Заключение
Архитектура D1 построена на разделении задач плоскости управления и выполнения запросов по критически важному пути.
Каждое решение соответствует нашим основным принципам: устранять центральные региональные узкие места, по возможности использовать кэширование и реплики для чтения, а также eventual consistency для увеличения пропускной способности чтения; выбирать подходящее хранилище данных для каждой задачи: Postgres — для транзакционных метаданных, ClickHouse — для аналитики, а Workers KV — для глобально реплицируемых данных маршрутизации.
Если вы создаёте платформу для вайб-кодинга и хотите предоставлять пользователям управляемые базы данных SQLite быстро, экономично и надёжно, рассмотрите D1.
Если вы разрабатываете приложения на Workers и Developer Platform, описанные здесь подходы и компромиссы отражают реальные решения, принятые при создании D1. Используйте их как основу для собственных приложений.
Загляните в документацию D1 и расскажите нам, что вы создаёте, в канале D1 в Discord!
Благодарности
Огромное спасибо внутренним командам Cloudflare, занимающимся базами данных и Hyperdrive: они помогли нам выявить проблемные сценарии использования и предложили улучшения.
