Как известно, компания OpenAI, создавшая ChatGPT, находится на переднем крае развития ИИ и многим небезинтересно, какие технологии и решения работают "под капотом" этой популярной компании. Сотрудник технического отдела OpenAI, Бохан Чжан (Bohan Zhang) приоткрыл небольшую завесу о том, как компания работает с базами данных и с какими вызовами им приходиться сталкиваться и как их приходиться преодолевать. Это статья является пользовательским переводом оригинальной статьи Scaling PostgreSQL to power 800 million ChatGPT users, опубликованной на официальном сайте OpenAI, с некоторыми пояснениями и умозаключениями от переводчика, с учетом его знаний и опыта.

1. Вступление

В течение многих лет PostgreSQL был одной из наиболее важных систем для работы данными, поддерживающих в рабочем состоянии такие основные продукты компании, как ChatGPT и API OpenAI. По мере быстрого роста данных, требования к базам данных также возрастали в геометрической прогрессии. За последний год нагрузка на PostgreSQL выросла более чем в 10 раз и продолжает быстро расти.

Усилия команды по развитию инфраструктуры, для поддержания такого роста, позволили сделать новое открытие: оказалось что PostgreSQL можно масштабировать для надежной поддержки для гораздо больших рабочих нагрузок с высокой интенсивностью на чтение, чем многие ранее считали возможным. Система (изначально созданная командой ученых из Калифорнийского университета в Беркли) позволила поддерживать массовый глобальный трафик с помощью одного основного управляемого сервера PostgreSQL в облаке Azure с 50-ью репликами на чтение, распределенных по нескольким регионам по всему мир��. Это история о том, как мы масштабировали PostgreSQL в OpenAI для поддержки миллионов запросов в секунду для 800 миллионов пользователей, благодаря кропотливой оптимизации и тщательному проектированию. Мы также расскажем о ключевых выводах, которые обнаружили в процессе работы.

2. Недостатки первоначальной архитектуры

После запуска ChatGPT трафик стал расти экспоненциально. Для поддержания такого темпа была быстро проведена масштабная оптимизация, как на уровне приложений, так и на уровне PostgreSQL. Увеличили масштаб, за счет увеличения размера основного инстанса и добавили больше реплик для чтения. Это архитектурное решение долгое время хорошо нам служило. И благодаря постоянным улучшениям, оно по-прежнему обеспечивает обширные возможности для будущего роста.

Рис 1. Порочный круг под нагрузкой
Рис 1. Порочный круг под нагрузкой

Несмотря на то, что PostgreSQL хорошо масштабируется под наши нагрузки, с преобладанием операций на чтение, мы всё же сталкиваемся с проблемами, в периоды высокого трафика на операции записи. Это во многом связано с реализацией механизма многопользовательского управления параллельным доступом MVCC в PostgreSQL, который делает его не таким эффективным, для сценариев с интенсивными операциями на запись. Например, когда запрос обновляет кортеж (строку в таблице) или даже одно поле, вся строка копируется для создания своей новой версии. При высокой нагрузке на запись это приводит к значительному усилению записи (write amplification). Это также увеличивает и усиление чтения (read amplification), поскольку запросам приходится просматривать несколько версий кортежей (так называемые, мёртвые кортежи), чтобы найти последнюю актуальную запись.

Скрытый текст

Речь о технологии MVCC (англ. multiversion concurrency control), которая позволяет нескольким пользователям одновременно работать с данными в БД, без ожидания завершения "чужих" операций.

Write amplification (усиление записи) — это явление, когда одна логическая операция записи от приложения приводит к выполнению нескольких физических операций записи на уровне системы хранения данных. Проще говоря, система записывает больше данных, чем фактически запросило приложение.

В то время как Read amplification (усиление чтения) соответственно, это явление, когда для получения одной логической единицы данных системе приходится физически читать значительно больше данных, чем запрошено. То есть чтобы найти нужные данные, системе приходится "перечитать" много лишней информации.

И Write amplification и Read amplification это основа подхода MVCC и в какой-то степени архитектурный компромисс параллелизма, в результате которого каждое изменение данных не перезаписывает "старые" данные. Вместо этого создаётся новая версия кортежа (строки). А старая версия помечается как "невидимая". Поэтому система вынуждено фильтрует множество её старых копий, чтоб�� получить одну актуальную строку. На что и сетует автор.

В итоге MVCC создаёт дополнительные сложности, такие как раздувание таблиц и индексов, повышенные накладные расходы на обслуживание индексов и сложную настройку автовакуума (autovacuum). (Более подробную информацию по этим вопросам можно найти в статье, которую автор написал совместно с профессором Энди Павел из Университета Карнеги-Меллона: "Та часть PostgreSQL, которую мы ненавидим больше всего")

3. Как масштабировать PostgreSQL до миллионов QPS

Для того чтобы смягчить эти ограничения и снизить нагрузку на запись, мы перенесли и продолжаем переносить шардируемые задачи (то есть нагрузки которые можно разделить горизонтально) с высокой интенсивностью на запись в шардированные системы, такие как Azure Cosmos DB, оптимизируя при этом логику в приложениях, для минимизации ненужных операций на запись.

Скрытый текст

Azure Cosmos DB – это полностью управляемая NoSQL база данных от компании Microsoft. Создана специально для облачных и глобально распределённых приложений.

В отличии от PostgreSQL, которая работает по принципам MVCC, Cosmos DB использует так называем подход "обновления на месте". То есть при изменении данных система физически перезаписывает только изменённые поля в существующей записи, а не создаёт её полную копию. Что естественно резко снижает нагрузку и увеличивает скорость. Платой же за использование этого механизма является специфическая, отличная от классического реляционного подхода, работа с данными. Деление всех данных на логические части (партиции) по ключу для применения операций автоматического горизонтального шардирования.

Мы также больше не разрешаем добавлять новые таблицы в текущее развертывание PostgreSQL. Поэтому новые рабочие нагрузки, по умолчанию, направляются в шардированные системы.

В тоже время, даже по мере развития инфраструктуры, PostgreSQL остаётся не шардированным, с одним основным мастер-инстансом, обслуживающим все операции на запись. Основная причина этого решения в том, что шардирование существующей операционной нагрузки от наших приложений было бы чрезвычайно сложным и трудоемким процессом, потребовавшим бы многих изменений и потенциально занявшим месяцы или даже годы.

Поскольку наша нагрузка в основном связана с чтением, и мы провели масштабную оптимизацию, текущая архитектура по-прежнему обеспечивает достаточный запас прочности для поддержания устойчивого роста трафика. Хотя мы и не исключаем возможность шардирования PostgreSQL в будущем, в краткосрочной перспективе это не является приоритетом, ввиду имеющихся резервов для масштабирования под растущие требования.

4. Снижение нагрузки

Проблема: Поскольку все операции записи проходят через один главный узел (primary), такая архитектура не масштабируется горизонтально. В результате любые всплески нагрузки на запись создают риск его перегрузки и ухудшения работы ключевых продуктов, ChatGPT и нашего API.

Решение: Мы максимально снижаем нагрузку на primary-сервер, чтобы у него всегда был резерв для работы с операциями на записи. Чтение хоть и уходит на реплики, но за исключением запросов внутри транзакций связанных с записью. Таким запросам мы уделяем особое внимание, делая их эффективнее и не допуская медленных операций.

Основную нагрузку на запись выносим в шардированные системы вроде Cosmos DB. Нагрузки, которые сложнее шардировать, но при этом создают значительный объём, требуют больше времени на миграцию, поэтому этот процесс продолжается.

Естественно мы также провели агрессивную оптимизацию логики приложений, чтобы снизить нагрузку на запись. Например, мы исправили ошибки, вызывавшие избыточные операции обновления, и там, где это уместно, внедрили механизм "ленивой" записи (lazy writes) для сглаживания пиковой нагрузки. А для массового обновления данных установили жёсткие лимиты.

Скрытый текст

Lazy writes (ленивая/отложенная запись) — это архитектурный паттерн, при котором немедленная запись данных на диск заменяется на временное сохранение в кэше с последующей асинхронной записью в постоянное хранилище. В данном контексте скорее всего речь идёт или о перекладывании этой операции на уровень приложения или об использовании так называемого буферного кеша PostgreSQL. Рекомендуемое значение для shared_buffers, как правило это где-то 25% от общей оперативной памяти на сервере. Важно понимать, что память под буфер выделяется при запуске сервера, и её объём при работе динамически не изменяется.

5. Оптимизация запросов

Проблема: Мы выявили несколько ресурсоёмких запросов в PostgreSQL. Ранее внезапные всплески нагрузки этих запросов потребляли большое количество процессорного времени (CPU), замедляя работу как ChatGPT, так и запросов к нашему API.

Решение: Несколько ресурсоёмких запросов, таких как объединения (join) множества таблиц, могут значительно ухудшить производительность или даже привести к полному отказу всего сервиса. Поэтому нам необходимо постоянно оптимизировать такие запросы, чтобы обеспечить их эффективность и избегать типичных антипаттернов систем оперативной обработки транзакций (OLTP). Например, однажды мы выявили крайне ресурсоёмкий запрос, выполнявший объединение 12 таблиц. Всплески его выполнения становились причиной прошлых инцидентов высокой степени серьёзности (SEVs). По возможности следует избегать сложных объединений множества таблиц. Если же соединения необходимы, мы усвоили, что стоит рассмотреть возможность декомпозиции такого запроса, с вынесением сложной логики объединений на уровень приложения. Многие из таких проблемных запросов генерируются ORM-фреймворками, поэтому важно тщательно проверять производимый ими SQL-код и подтверждать его ожидаемое поведение. Также в PostgreSQL часто встречаются длительные “простаивающие” запросы. Настройка таймаутов, таких как idle_in_transaction_session_timeout, крайне важна для предотвращения их блокировки процесса автоочистки (autovacuum).

Скрытый текст

Под “простаивающими” запросами имеются ввиду запросы, которые начали выполнение, но зависли по разным причинам. Или создают блокировки, но ничего не делают. Или просто напросто не завершаются часами, днями, а иногда и неделями из-за своей ресурсоёмкости. Параметр idle_in_transaction_session_timeout о котором идёт речь, устанавливает допустимое время жизни таких запросов и автоматически прерывает их после достижения допустимого порога. Как правило этот параметр узнаётся эмпирически.

6. Устранение единой точки отказа

Проблема: Если реплика выйдет из строя, трафик будет перенаправлен на другие реплики. Наличие же всего одного узла для записи создаёт критическую и единую точку отказа. Его выход из строя приведёт к полному нарушению работы сервиса.

Решение: Большинство наших критически важных запросов связаны исключительно с операциями на чтение. Чтобы снизить риски единой точки отказа на мастере, мы перенесли эти операции чтения на реплики. Это гарантирует, что такие запросы могут продолжать обслуживаться даже в случае падения основного узла. Хотя операции записи при этом всё равно будут недоступны, общее воздействие сбоя снижается. Инцидент больше не классифицируется как инцидент наивысшей критичности (SEV0), поскольку чтение данных остаётся доступным.

Чтобы снизить влияние сбоев, мы запускаем его в режиме высокой доступности (High-Availability), то есть с горячим ��езервным узлом (hot standby). Резервный узел – это постоянно синхронизируемая реплика, всегда готовая принять на себя основной трафик. Если primary-сервер выходит из строя или нам требуется его отключение для обслуживания, мы можем быстро повысить роль резервного узла, чтобы минимизировать время простоя. Команда Azure PostgreSQL проделала большую работу, чтобы обеспечить безопасность и надёжность таких переключений даже при очень высокой нагрузке. Для обработки же сбоев "реплик с чтением" мы также разворачиваем несколько реплик в каждом регионе с достаточным запасом мощности. Это гарантирует нам, что отказ одной реплики не приведёт к региональному простою.

7. Изоляция рабочей нагрузки

Проблема: Мы часто сталкиваемся с ситуациями, когда определённые запросы потребляют непропорционально большое количество ресурсов на инстансах PostgreSQL. Это может приводить к снижению производительности других рабочих нагрузок, выполняющихся на тех же инстансах. Например, запуск новой функциональности может сопровождаться неэффективными запросами, которые активно потребляют процессорное время (CPU) PostgreSQL, замедляя обработку запросов для других критически важных функций.

Решение: Чтобы устранить проблему “шумного соседа”, мы распределяем рабочие нагрузки по выделенным инстансам, чтобы гарантировать, что внезапные всплески ресурсоемких запросов не повлияют на остальной трафик. В частности, мы разделяем запросы на уровни с низким и высоким приоритетом и направляем их в отдельные инстансы. Таким образом, даже если рабочая нагрузка с низким приоритетом становится ресурсоемкой, это не снижает производительность высокоприоритетных запросов. Мы применяем подобную стратегию для различных продуктов и сервисов, чтобы действия, выполняемые одним продуктом, не влияли на производительность или надежность другого.

8. Пул подключений

Проблема: Каждый инстанс имеет максимальный лимит подключений (в Azure PostgreSQL это 5000). Легко исчерпать все доступные соединения или накопить слишком много “простаивающих” соеденений. Ранее у нас уже были инциденты, вызванные такими "штормами" подключений, которые полностью расходовали все доступные соединения.

Решение: Мы развернули PgBouncer в качестве прокси-сервера для объединения подключений к базе данных. Его работа в режиме объединения на уровне операторов (statement pooling) или транзакций (transaction pooling) позволяет эффективно переиспользовать соединения, значительно сокращая количество активных клиентских подключений. Это также сократило задержку при установке соединения. В наших тестах среднее время подключения сократилось и варьируется с 5 мс до 50 мс.

Межрегиональные соединения и запросы могут быть ресурсоёмкими, поэтому мы размещаем прокси, клиентские приложения и реплики в одном регионе, чтобы минимизировать сетевые накладные расходы и время использования соединений.

Кроме того, PgBouncer требует тщательной настройки. Параметры, такие как таймауты простоя (idle timeouts), критически важны для предотвращения исчерпания лимита подключений.

Скрытый текст

И statement pooling и transaction pooling – два достаточно агрессивных режима работы PgBouncer. Для ясности, самый простой это session pooling, 1 клиент на 1 соединение на всё время его работы. Далее идет transaction pooling, в этом случае одно физическое соединение с PostgreSQL используется разными клиентами для выполнения отдельных транзакций. В режиме же statement pooling соединение возвращается в пул после каждого отдельного SQL-запроса, а не после всей транзакции.

Как правило transaction pooling золотая середина при выборе режимов работы, так как statement pooling не умеет в транзакции, не имеет временных таблиц, не имеет настроек сессий и как правило ломает работу со многими ORM. Однако, если вам нужен чистый read-only трафик с работой через запросы типа SELECT, то он вполне может вам подойти. Собственно, то о чём и пишет автор, во многом касается именно работы с репликами, а потому такой режим вполне приемлем.

Судя по картинке ниже, различные инсталяции PgBouncer работают в этих двух различных режимах. И на уровне приложений автоматически определяется к какому из них необходимо обращаться, в зависимости от текущих потребностей.

   Рис 2. PgBouncer как PostgreSQL прокси
Рис 2. PgBouncer как PostgreSQL прокси

Пояснение: Каждая реплика имеет свое собственное развертывание в Kubernetes, в котором запущено несколько модулей PgBouncer. Мы запускаем несколько развертываний Kubernetes в рамках одной и той же службы Kubernetes, что позволяет распределять нагрузку на трафик между модулями.

9. Кэширование

Проблема: Внезапный всплеск пропусков в кэше, то есть когда ключ в кэше не найден, может спровоцировать всплеск операций на чтение в базе данных PostgreSQL, что приведет к перегрузке и замедлению запросов пользователей.

Решение: Чтобы снизить нагрузку на чтение в PostgreSQL, мы используем слой кэширования для обслуживания большей части трафика. Однако при неожиданном падении процента попаданий в кэш, всплеск "промахов" может направить огромный объём запросов напрямую в PostgreSQL. Этот резкий рост операций чтения из базы потребляет значительные ресурсы, замедляя работу сервиса.

Для предотвращения перегрузки базы данных во время подобных “промахов” кэша мы реализовали механизм блокировки кэша (а также его “аренды”). Принцип в том, что если только один клиент, который не получил свои данные по определённому ключу кэша, получает их из PostgreSQL в обычном режиме. Когда несколько запросов одновременно по одному и тому же ключу не могут получить данные, только один из них "захватывает" блокировку и выполняет получение данных с последующим обновлением кэша. Все остальные запросы ожидают, пока кэш не будет обновлён, вместо того чтобы одновременно запрашивать данные из PostgreSQL.

Это значительно сокращает избыточные чтения из базы данных и защищает систему от каскадных всплесков нагрузки.

10. Масштабирование реплик

Проблема: Primary-сервер регулярно передаёт данные WAL-логов всех изменений на каждую из реплик. По мере увеличения количества реплик информация должна отправляться на всё большее число инстансов, что повышает нагрузку как на пропускную способность сети, так и на CPU.

Решение: Для минимизации задержек мы работаем почти с 50 репликами для чтения, распределёнными по нескольким географическим регионам. Однако при текущей ситуации, основной сервер должен передавать WAL-логи в потоковом режиме на каждую реплику напрямую. И хотя в настоящее время наше решение хорошо масштабируется, благодаря использованию очень крупных типов инстансов и высокой пропускной способности сети, мы не можем бесконечно добавлять реплики, не боясь перегрузить primary-сервер.

Для решения этой проблемы мы сотрудничаем с командой Azure PostgreSQL над внедрением каскадной репликации⁠, при которой промежуточные реплики передают WAL-логи дальше, на нижестоящие реплики. Этот подход позволит нам масштабироваться до, возможно, более сотни реплик, не боясь перегрузить основной сервер. Однако это также добавляет дополнительные издержки в части управления отказоустойчивостью, особенно в управлении переключением во время возможных сбоев. Эта функциональность пока находится на стадии тестирования, так как мы хотим убедиться, что она надёжна и обеспечивает безопасное переключение, прежде чем внедрять её в промышленную эксплуатацию.

11. Послесловие

В качестве послесловия хочу отметить что опыт OpenAI наглядно демонстрирует, что даже в эпоху специализированных NoSQL решений, традиционные реляционные СУБД, такие как PostgreSQL могут ещё дать фору новомодным решениям и обеспечить приемлимый уровень доступности и что не всегда нужно спешить, и заниматься построением "космолетов". Также я во многом считаю, что успех этого рабочего решения во многом обеспечило именно использование облачного менеджет сервиса со стороны Azure, который по видимому забрал на себя огромный пласт инфраструктурных вопросов.