Масштабирование PostgreSQL для поддержки 800 миллионов пользователей ChatGPT

Автор: Бохан Чжан (Bohan Zhang), член технического персонала

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

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

Прорехи в нашей первоначальной архитектуре

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

Может показаться удивительным, что архитектура с единственным первичным узлом способна справляться с масштабами OpenAI; однако на практике добиться этого не так просто. Мы сталкивались с несколькими серьезными инцидентами (SEV), вызванными перегрузкой Postgres, и все они обычно развиваются по одному сценарию: какая-либо проблема в вышестоящих системах приводит к внезапному всплеску нагрузки на базу данных (например, к массовым промахам кэша из-за сбоя кэширующего слоя, всплеску ресурсоемких многотабличных соединений, насыщающих ЦП, или к шквалу операций записи после запуска новой функции). По мере роста использования ресурсов задержка запросов увеличивается, а запросы начинают завершаться по тайм-ауту. Повторные попытки запросов (ретры) еще сильнее усиливают нагрузку, запуска K порочный круг, способный деградировать работу всех сервисов ChatGPT и API.

Scaling load diagram

Хотя PostgreSQL отлично масштабируется для рабочих нагрузок с преобладанием чтения, мы по-прежнему сталкиваемся со сложностями в периоды интенсивного трафика записи. Во многом это обусловлено реализацией многоверсионного управления параллельным доступом (MVCC) в PostgreSQL, которая делает ее менее эффективной для нагрузок с большим количеством операций записи. Например, когда запрос обновляет кортеж или даже всего одно поле, вся строка копируется для создания новой версии. При высоких нагрузках на запись это приводит к значительному усилению записи (write amplification). Это также увеличивает усиление чтения, поскольку запросам приходится сканировать несколько версий кортежей (устаревшие кортежи), чтобы извлечь самую свежую. MVCC порождает и другие трудности, такие как раздувание таблиц и индексов, рост накладных расходов на обслуживание индексов и сложная настройка автоочистки (autovacuum). (Подробный разбор этих проблем можно найти в блоге, который я написал совместно с проф. Энди Павло (Andy Pavlo) из Университета Карнеги — Меллона под названием То, за что мы больше всего ненавидим PostgreSQL, на которое ссылаются на странице PostgreSQL в Википедии.)

Масштабирование PostgreSQL до миллионов запросов в секунду (QPS)

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

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

В следующих разделах мы подробно рассмотрим проблемы, с которыми мы столкнулись, и масштабные оптимизации, которые мы внедрили для их решения и предотвращения будущих сбоев, доведя PostgreSQL до ее пределов и масштабировав ее до миллионов запросов в секунду (QPS).

Снижение нагрузки на первичный узел

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

Решение: Мы максимально снижаем нагрузку на первичный узел — как по чтению, так и по записи — чтобы у него оставалось достаточно емкости для обработки всплесков записи. Трафик чтения по возможности переносится на реплики. Тем не менее, некоторые запросы чтения должны оставаться на первичном узле, поскольку они являются частью транзакций записи. Для них мы стараемся обеспечить максимальную эффективность и избегать медленных запросов. Что касается трафика записи, мы перенесли шардируемые рабочие нагрузки с интенсивной записью в распределенные системы вроде Azure Cosmos DB. Нагрузки, которые труднее поддаются шардированию, но при этом генерируют высокий объем записи, требуют больше времени на миграцию, и этот процесс все еще продолжается. Мы также агрессивно оптимизировали наши приложения для снижения нагрузки на запись: например, мы исправили баги в приложениях, приводившие к избыточным записям, и внедрили отложенную запись (lazy writes) там, где это уместно, для сглаживания пиков трафика. Кроме того, при заполнении полей в таблицах мы строго ограничиваем скорость (rate limiting), чтобы предотвратить чрезмерное давление на запись.

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

Проблема: Мы выявили в PostgreSQL несколько ресурсоемких запросов. В прошлом внезапные всплески объема таких запросов потребляли огромные объемы ЦП, замедляя обработку как запросов ChatGPT, так и API.

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

Минимизация единой точки отказа

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

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

Для предотвращения сбоев основного узла мы запускаем его в режиме высокой доступности (HA) с горячим резервом (hot standby) — непрерывно синхронизируемой репликой, которая всегда готова принять на себя обслуживание трафика. Если первичный узел выходит из строя или его необходимо отключить для обслуживания, мы можем быстро повысить роль резервной копии (промоутить standby), чтобы минимизировать время простоя. Команда Azure PostgreSQL проделала значительную работу, чтобы гарантировать безопасность и надежность таких переключений при очень высоких нагрузках. Для обработки сбоев реплик чтения мы развертываем несколько реплик в каждом регионе с достаточным запасом емкости, гарантируя, что выход из строя одной реплики не приведет к региональному сбою.

Изоляция рабочих нагрузок

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

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

Пулинг соединений

Проблема: Каждый экземпляр имеет лимит максимального количества подключений (5 000 в Azure PostgreSQL). Соединения легко могут исчерпаться или накопиться в неактивном состоянии. Ранее мы уже сталкивались с инцидентами, вызванными шквалом подключений, которые исчерпывали все доступные слоты.

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

postgreSQL proxy diagram

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

Кэширование

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

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

Масштабирование реплик чтения

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

Решение: Для минимизации задержек мы эксплуатируем около 50 реплик чтения в различных географических регионах. Однако при текущей архитектуре мастер должен передавать WAL каждой реплике. Хотя на данный момент решение хорошо масштабируется благодаря экземплярам очень больших типов и высокой пропускной способности сети, мы не можем бесконечно добавлять реплики без риска в конечном итоге перегрузить мастер. Для решения этой проблемы мы сотрудничаем с командой Azure PostgreSQL по внедрению каскадной репликации, при которой промежуточные реплики передают WAL дочерним репликам. Такой подход позволяет нам масштабировать систему потенциально до более чем ста реплик без перегрузки мастера. Тем не менее, это также создает дополнительную операционную сложность, особенно в части управления переключением при сбое (failover). Эта функция все еще находится на стадии тестирования; перед внедрением в производство мы убедимся в ее надежности и возможности безопасного аварийного переключения.

postgreSQL cascading replication diagram

Ограничение частоты запросов (Rate Limiting)

Проблема: Внезапный всплеск трафика на определенных эндпоинтах, лавинообразный рост тяжелых запросов или шторм повторных попыток (retry storm) могут быстро исчерпать критически важные ресурсы, такие как ЦП, дисковый ввод-вывод и соединения, что приводит к масштабной деградации сервиса.

Решение: Мы внедрили ограничение частоты запросов на нескольких уровнях — приложения, пула соединений, прокси и запросов — чтобы предотвратить перегрузку экземпляров баз данных внезапными пиками трафика и каскадными сбоями. Также критически важно избегать слишком коротких интервалов между повторными попытками, которые могут спровоцировать штормы повторов. Мы также усовершенствовали уровень ORM для поддержки ограничения частоты и при необходимости полной блокировки определенных дайджестов запросов. Такая целенаправленная форма сброса нагрузки (load shedding) обеспечивает быстрое восстановление после внезапных всплесков тяжелых запросов.

Управление схемой данных

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

Решение: Разрешены только легковесные изменения схемы, такие как добавление или удаление определенных столбцов, которые не вызывают полную перезапись таблицы. Мы строго соблюдаем 5-секундный лимит времени ожидания для изменений схемы. Разрешено создание и удаление индексов в параллельном режиме (конкурентно). Изменения схемы ограничены существующими таблицами. Если для новой функции требуются дополнительные таблицы, они должны создаваться в альтернативных шардированных системах, таких как Azure CosmosDB, а не в PostgreSQL. При заполнении полей таблицы данными мы применяем строгие ограничения частоты, чтобы предотвратить пики записи. Хотя этот процесс иногда может занимать более недели, он гарантирует стабильность и исключает любое влияние на производственную среду.

Результаты и планы на будущее

Эта работа демонстрирует, что при правильном проектировании и оптимизации Azure PostgreSQL можно масштабировать для обработки крупнейших производственных нагрузок. PostgreSQL обрабатывает миллионы QPS для рабочих нагрузок с интенсивным чтением, обеспечивая работу важнейших продуктов OpenAI, таких как ChatGPT и платформа API. Мы добавили почти 50 реплик для чтения, сохранив задержку репликации близкой к нулю, поддержали чтение с низким уровнем задержки в геораспределенных регионах и создали достаточный запас емкости для поддержки будущего роста.

Это масштабирование работает при одновременной минимизации задержек и повышении надежности. Мы стабильно обеспечиваем задержку на стороне клиента p99 в пределах двузначных миллисекунд и уровень доступности в пять девяток в продакшене. За последние 12 месяцев у нас был лишь один инцидент уровня SEV-0 с PostgreSQL (он произошел во время вирусного запуска генератора изображений ChatGPT ImageGen, когда трафик записи внезапно вырос более чем в 10 раз из-за регистрации более 100 миллионов новых пользователей за неделю).

Хотя мы довольны тем, каких результатов помогла добиться PostgreSQL, мы продолжаем расширять ее возможности, чтобы обеспечить достаточный запас прочности для будущего роста. Мы уже перенесли шардируемые рабочие нагрузки с интенсивной записью в наши шардированные системы, такие как CosmosDB. Оставшиеся рабочие нагрузки с интенсивной записью сложнее поддаются шардированию — мы также активно переносим их, чтобы дополнительно разгрузить операции записи с первичного узла PostgreSQL. Мы также работаем с Azure над внедрением каскадной репликации, чтобы безопасно масштабировать систему до значительно большего числа реплик чтения.

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

Автор

Бохан Чжан (Bohan Zhang)

Благодарности

Особая благодарность Джону Ли (Jon Lee), Сичену Лю (Sicheng Liu), Чаомину Ю (Chaomin Yu) и Ченглону Хао (Chenglong Hao), внесшим свой вклад в эту публикацию, а также всей команде, которая помогла масштабировать PostgreSQL. Мы также выражаем признательность команде Azure PostgreSQL за надежное партнерство.

Полный текст статьи читайте на OpenAI