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

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

Усилия по развитию производственной инфраструктуры для поддержания этого роста выявили новый инсайт: PostgreSQL можно масштабировать для надёжной поддержки значительно больших read-heavy нагрузок, чем ранее считали возможным. Система позволила обслуживать огромный глобальный трафик с помощью одного primary-инстанса Azure PostgreSQL Flexible Server и почти 50 реплик чтения, распределённых по нескольким регионам мира.

Это история о том, как масштабировали PostgreSQL в OpenAI для поддержки миллионов запросов в секунду для 800 миллионов пользователей через строгие оптимизации и солидную инженерию; также здесь будут ключевые уроки, решения и выводы.

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

Может показаться удивительным, что архитектура с одним primary способна удовлетворять требованиям масштаба OpenAI; однако сделать это на практике не просто. Мы видели несколько SEV (инцидентов высокой серьёзности), вызванных перегрузкой Postgres, и они часто следовали одному паттерну: upstream-проблема вызывала внезапный всплеск нагрузки на базу данных, такой как массовые промахи кэша из-за сбоя кэширующего слоя, всплеск дорогих многосторонних JOIN, насыщающих CPU, или шторм записей от запуска новой фичи. По мере роста использования ресурсов задержка запросов увеличивалась, и запросы начинали таймаутить. Ретраи затем ещё больше усиливали нагрузку, запуская порочный круг с потенциалом деградации всего ChatGPT и API-сервисов.
Хотя PostgreSQL хорошо масштабируется для наших read-heavy нагрузок, мы по-прежнему сталкиваемся с вызовами в периоды высокой нагрузки на записи. Это в значительной степени обусловлено реализацией multiversion concurrency control (MVCC) в PostgreSQL, которая делает её менее эффективной для write-heavy нагрузок. Например, когда запрос обновляет кортеж или даже одно поле, копируется вся строка для создания новой версии. При высокой нагрузке на записи это приводит к значительному усилению записи. Это также увеличивает усиление чтения, поскольку запросы должны сканировать несколько версий кортежей (мёртвых кортежей), чтобы получить последнюю. MVCC вводит дополнительные вызовы, такие как раздувание таблиц и индексов, повышенные накладные расходы на обслуживание индексов и сложную настройку autovacuum.
Масштабирование PostgreSQL до миллионов QPS
Чтобы смягчить эти ограничения и снизить давление на записи, мы мигрировали и продолжаем мигрировать шардируемые (то есть нагрузки, которые можно горизонтально разделить) write-heavy нагрузки в шардированные системы, такие как Azure Cosmos DB, оптимизируя логику приложения для минимизации ненужных записей. Мы также больше не разрешаем добавлять новые таблицы в текущий деплоймент PostgreSQL. Новые нагрузки по умолчанию идут в шардированные системы.

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

В следующих разделах мы углубимся в вызовы, с которыми столкнулись, и в обширные оптимизации, которые внедрили для их решения и предотвращения будущих сбоев, раздвигая границы PostgreSQL и масштабируя его до миллионов запросов в секунду (QPS).
Снижение нагрузки на primary
Вызов: с одним writer архитектура single-primary не масштабирует записи. Тяжёлые всплески записей могут быстро перегрузить primary и повлиять на сервисы вроде ChatGPT и нашего API.

Решение: мы минимизируем нагрузку на primary насколько возможно — как чтения, так и записи — чтобы обеспечить достаточный запас для обработки всплесков записей. Трафик чтения перенаправляется на реплики, где возможно. Однако некоторые запросы чтения должны оставаться на primary, поскольку они часть транзакций записи. Для них мы фокусируемся на эффективности и избегании медленных запросов. Для трафика записей мы мигрировали шардируемые write-heavy нагрузки в шардированные системы вроде Azure CosmosDB. Нагрузки, которые сложнее шардировать, но всё равно генерируют высокий объём записей, занимают больше времени на миграцию, и этот процесс продолжается. Мы также агрессивно оптимизировали приложения для снижения нагрузки записей; например, исправили баги приложений, вызывавшие redundant записи, и ввели lazy записи, где уместно, для сглаживания всплесков трафика. Кроме того, при backfill полей таблицы мы применяем строгие rate limit, чтобы предотвратить чрезмерное давление на записи.
Оптимизация запросов
Вызов: мы идентифицировали несколько дорогих запросов в PostgreSQL. В прошлом внезапные всплески объёма в этих запросах потребляли большое количество CPU, замедляя как ChatGPT, так и запросы API.

Решение: несколько дорогих запросов, такие как JOIN многих таблиц, могут значительно деградировать или даже вывести из строя весь сервис. Мы должны continuously оптимизировать запросы PostgreSQL, чтобы они были эффективными и избегали общих антипаттернов OLTP. Например, мы однажды идентифицировали крайне дорогой запрос, присоединяющий 12 таблиц, где всплески в этом запросе были причиной прошлых SEV высокой серьёзности. Мы должны избегать сложных многосторонних JOIN, когда возможно. Если JOIN необходимы, мы научились разбивать запрос и перемещать сложную логику JOIN в слой приложения. Многие из этих проблемных запросов генерируются ORM-фреймворками, поэтому важно тщательно проверять производимый ими SQL и убедиться, что он ведёт себя как ожидается. Также часто встречаются long-running idle-запросы в PostgreSQL. Настройка таймаутов вроде idle_in_transaction_session_timeout необходима, чтобы предотвратить их блокировку autovacuum.
Смягчение single point of failure
Вызов: если реплика чтения падает, трафик можно перенаправить на другие реплики. Однако зависимость от одного writer означает single point of failure — если он падает, весь сервис затрагивается.

Решение: большинство критичных запросов включают только чтение. Чтобы смягчить single point of failure в primary, мы сделали offload эти чтения с writer на реплики, обеспечивая, что такие запросы продолжают обслуживаться, даже если primary down. Хотя операции записи всё равно потерпят неудачу, влияние снижается; это больше не SEV0, поскольку чтения остаются доступными.
Чтобы смягчить отказы primary, мы запускаем primary в режиме High-Availability (HA) с hot standby — continuously синхронизированной репликой, всегда готовой взять на себя трафик. Если primary down или нужно отключить для maintenance, мы можем быстро промоутить standby, минимизируя downtime. Команда Azure PostgreSQL провела значительную работу, чтобы обеспечить, что эти failover остаются безопасными и надёжными даже при очень высокой нагрузке. Чтобы обработать отказы реплик чтения, мы деплоим несколько реплик в каждом регионе с достаточным запасом ёмкости, обеспечивая, что отказ одной реплики не приводит к региональному outage.
Изоляция нагрузок
Вызов: мы часто сталкиваемся с ситуациями, когда определённые запросы потребляют непропорционально много ресурсов на инстансах PostgreSQL. Это может привести к деградации производительности для других нагрузок на тех же инстансах. Например, запуск новой фичи может ввести неэффективные запросы, которые сильно потребляют CPU PostgreSQL, замедляя запросы для других критичных фич.

Решение: чтобы смягчить проблему "noisy neighbor", мы изолируем нагрузки на dedicated инстансах, обеспечивая, что внезапные всплески ресурсоёмких запросов не влияют на другой трафик. Конкретно, мы разделяем запросы на low-priority и high-priority уровни и маршрутизируем их на отдельные инстансы. Таким образом, даже если low-priority нагрузка становится ресурсоёмкой, она не деградирует high-priority запросы. Мы применяем ту же стратегию для разных продуктов и сервисов, чтобы активность одного продукта не влияла на производительность или надёжность другого.
Connection pooling
Вызов: каждый инстанс имеет максимальный лимит соединений (5000 в Azure PostgreSQL). Легко исчерпать соединения или накопить слишком много idle. Мы ранее имели инциденты, вызванные connection storm, которые исчерпывали все доступные соединения.

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

Каждая реплика чтения имеет свой деплоймент в Kubernetes с несколькими подами PgBouncer. Мы запускаем несколько деплойментов Kubernetes за одним Kubernetes Service, который балансирует трафик по подам.
Кэширование
Вызов: внезапный всплеск cache miss может вызвать surge чтений на базу данных PostgreSQL, насыщая CPU и замедляя запросы пользователей.

Решение: чтобы снизить давление чтения на PostgreSQL, мы используем кэширующий слой для обслуживания большинства трафика чтения. Однако когда cache hit rates внезапно падают, burst cache miss может направить большой объём запросов прямо на PostgreSQL. Это внезапное увеличение чтений из базы данных потребляет значительные ресурсы, замедляя сервис. Чтобы предотвратить перегрузку во время cache-miss storm, мы внедрили механизм cache locking (and leasing), так что только один читатель, который miss на конкретном ключе, извлекает данные из PostgreSQL. Когда несколько запросов miss на одном cache-ключе, только один запрос захватывает lock и продолжает извлекать данные и repopulate кэш. Все другие запросы ждут обновления кэша, вместо того чтобы все разом бить в PostgreSQL. Это значительно снижает redundant чтения из базы данных и защищает систему от каскадных всплесков нагрузки.
Масштабирование реплик чтения
Вызов: primary транслирует Write Ahead Log (WAL) данные каждой реплике чтения. По мере роста числа реплик primary должен доставлять WAL большему числу инстансов, увеличивая давление на сетевую пропускную способность и CPU. Это вызывает более высокую и нестабильную задержку репликации, делая систему сложнее масштабировать надёжно.

Решение: мы эксплуатируем почти 50 реплик чтения по нескольким географическим регионам, чтобы минимизировать задержку. Однако в текущей архитектуре primary должен транслировать WAL каждой реплике. Хотя это сейчас масштабируется хорошо с очень крупными типами инстансов и высокой сетевой пропускной способностью, мы не можем бесконечно добавлять реплики без перегрузки primary в итоге. Чтобы решить это, мы сотрудничаем с командой Azure PostgreSQL над cascading replication, где промежуточные реплики ретранслируют WAL downstream-репликам. Этот подход позволяет масштабироваться потенциально на более чем 100 реплик без перегрузки primary. Однако он также вводит дополнительную операционную сложность, особенно вокруг управления failover. Фича всё ещё в тестировании; мы обеспечим, что она надёжная и может безопасно failover перед роллаутом в продакшен.
Rate limiting
Вызов: внезапный всплеск трафика на конкретных конечных точках, surge дорогих запросов или retry storm может быстро деградировать сервис.

Решение: мы внедрили rate limiting на нескольких слоях — приложение, connection pooler, прокси и запросы — чтобы предотвратить внезапные всплески трафика от перегрузки инстансов базы данных и запуска каскадных отказов. Также критична избегание слишком коротких интервалов retry, которые могут запустить retry storm. Мы также улучшили слой ORM для поддержки rate limiting и, при необходимости, полного блокирования конкретных query digest. Этот targeted load shedding позволяет быстро восстанавливаться от внезапных surge дорогих запросов.
Управление схемой
Вызов: даже небольшое изменение схемы, такое как ALTER COLUMN type, может вызвать полный rewrite таблицы. Мы поэтому применяем изменения схемы осторожно — ограничиваясь лёгкими операциями и избегая любых, которые перезаписывают всю таблицу.

Решение: разрешены только лёгкие изменения схемы, такие как добавление или удаление определённых колонок, которые не вызывают полный rewrite таблицы. Мы применяем строгий 5-секундный таймаут на изменения схемы. Создание и удаление индексов concurrently разрешено. Изменения схемы ограничены существующими таблицами. Если новая фича требует дополнительных таблиц, они должны быть в альтернативных шардированных системах, таких как Azure CosmosDB, а не в PostgreSQL. При backfill поля таблицы мы применяем строгие rate limit, чтобы предотвратить всплески записей. Хотя этот процесс иногда занимает более недели, он обеспечивает стабильность и избегает любого влияния на продакшен.
Результаты и планы
Это усилие демонстрирует, что с правильным дизайном и оптимизациями Azure PostgreSQL можно масштабировать для самых крупных производственных нагрузок. PostgreSQL обрабатывает миллионы QPS для read-heavy нагрузок, питая самые критичные продукты OpenAI, такие как ChatGPT и платформа API. Мы добавили почти 50 реплик чтения, сохраняя задержку репликации близкой к нулю, поддерживая низколатентное чтение по геораспределённым регионам и создав достаточный запас ёмкости для поддержки будущего роста.

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

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

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

Закажите аудит инфраструктуры и CI/CD

Даже если у вас нет четкой задачи, мы все обсудим и подскажем.

Узнать больше