Scalare PostgreSQL per supportare 800 milioni di utenti ChatGPT
Di Bohan Zhang, membro del personale tecnico
Da anni, PostgreSQL è uno dei sistemi di dati più critici, dietro le quinte, che alimentano prodotti fondamentali come ChatGPT e l'API di OpenAI. Con la rapida crescita della nostra base di utenti, anche le richieste sui nostri database sono aumentate in modo esponenziale. Nell'ultimo anno, il nostro carico di PostgreSQL è aumentato di oltre 10 volte e continua a crescere rapidamente.
I nostri sforzi per migliorare l'infrastruttura di produzione al fine di sostenere questa crescita hanno rivelato una nuova consapevolezza: PostgreSQL può essere scalato in modo affidabile per supportare carichi di lavoro molto più grandi, prevalentemente in lettura, di quanto molti pensassero possibile. Il sistema (inizialmente creato da un team di scienziati dell'Università della California, Berkeley) ci ha permesso di gestire un traffico globale massiccio con un'unica istanza primaria di server flessibile Azure PostgreSQL(si apre in una nuova finestra) e quasi 50 repliche di lettura distribuite su più regioni a livello globale. Questa è la storia di come abbiamo scalato PostgreSQL presso OpenAI per supportare milioni di query al secondo per 800 milioni di utenti attraverso ottimizzazioni rigorose e un'ingegneria solida; tratteremo anche i principali insegnamenti che abbiamo appreso lungo il percorso.
Dopo il lancio di ChatGPT, il traffico è cresciuto a un ritmo senza precedenti. Per supportarlo, abbiamo implementato rapidamente ampie ottimizzazioni sia a livello di applicazione che di database PostgreSQL, aumentato la dimensione delle istanze (scale-up) e aggiunto più repliche di lettura (scale-out). Questa architettura ci ha servito bene per molto tempo. Con i miglioramenti continui, continua a offrire ampio margine per la crescita futura.
Può sorprendere che un'architettura con un solo nodo primario sia in grado di sostenere la scala di OpenAI; tuttavia, realizzare questo obiettivo nella pratica è complesso. Abbiamo osservato diversi SEV causati dal sovraccarico di Postgres, che spesso si manifestano seguendo uno schema simile: un problema a monte provoca un improvviso aumento del carico sul database, come ad esempio cache miss diffusi a causa di un guasto nel livello di caching, un picco di join multi-way intensivi che saturano la CPU, o un'ondata di scritture generata dal lancio di una nuova funzionalità. Man mano che l'utilizzo delle risorse aumenta, la latenza delle query cresce e le richieste iniziano a scadere. I tentativi di ripetizione amplificano ulteriormente il carico, innescando un circolo vizioso con il potenziale di degradare l'intero servizio ChatGPT e i servizi API.
Sebbene PostgreSQL scaldi bene per i nostri carichi di lavoro prevalentemente in lettura, incontriamo ancora difficoltà durante i periodi di traffico intenso in scrittura. Questo è dovuto principalmente all'implementazione del controllo della concorrenza multiversione (MVCC) di PostgreSQL, che lo rende meno efficiente per carichi di lavoro intensivi in scrittura. Ad esempio, quando una query aggiorna una tupla o anche un singolo campo, l'intera riga viene copiata per creare una nuova versione. Sotto carichi di scrittura pesanti, ciò comporta una significativa amplificazione delle scritture. Aumenta anche l'amplificazione della lettura, poiché le query devono scansionare più versioni di tuple (tuple obsolete) per recuperare l'ultima. L'MVCC introduce ulteriori sfide, come il rigonfiamento di tabelle e indici, un maggiore sovraccarico di manutenzione degli indici e una complessa ottimizzazione dell'autovacuum. (Per un approfondimento su questi temi, si può leggere il blog scritto con il Prof. Andy Pavlo della Carnegie Mellon University intitolato The Part of PostgreSQL We Hate the Most(si apre in una nuova finestra), citato(si apre in una nuova finestra) anche nella pagina Wikipedia di PostgreSQL.)
Per mitigare queste limitazioni e ridurre la pressione sulle scritture, abbiamo migrato e continuiamo a migrare i carichi di lavoro shardabili (cioè quelli che possono essere partizionati orizzontalmente) e ad alta intensità di scrittura verso sistemi sharded come Azure Cosmos DB, ottimizzando la logica dell'applicazione per minimizzare le scritture superflue. Inoltre, non permettiamo più l'aggiunta di nuove tabelle all'attuale deployment di PostgreSQL. I nuovi carichi di lavoro vengono automaticamente indirizzati ai sistemi sharded.
Anche se la nostra infrastruttura è evoluta, PostgreSQL è rimasto non sharded, con un'unica istanza primaria che gestisce tutte le scritture. La motivazione principale è che lo sharding dei carichi di lavoro delle applicazioni esistenti risulterebbe molto complesso e richiederebbe molto tempo, comportando modifiche a centinaia di endpoint e potenzialmente mesi o anni di lavoro. Poiché i nostri carichi di lavoro sono principalmente orientati alla lettura e abbiamo implementato ottimizzazioni estese, l'architettura attuale offre ancora un ampio margine per supportare la crescita continua del traffico. Anche se non escludiamo di implementare lo sharding di PostgreSQL in futuro, non è una priorità immediata, dato il margine sufficiente che abbiamo per la crescita attuale e futura.
Nelle sezioni seguenti, esploreremo le sfide affrontate e le ottimizzazioni estese che abbiamo implementato per risolverle e prevenire future interruzioni, spingendo PostgreSQL al limite e scalando fino a milioni di query al secondo (QPS).
Sfida: con un solo writer, una configurazione con un solo nodo primario non può scalare le operazioni di scrittura. Picchi di scrittura intensi possono rapidamente sovraccaricare il nodo primario e influire su servizi come ChatGPT e la nostra API.
Soluzione: riduciamo al minimo il carico sul nodo primario il più possibile, sia in lettura che in scrittura, per garantire che abbia capacità sufficiente per gestire i picchi di scrittura. Il traffico di lettura viene delegato alle repliche ove possibile. Tuttavia, alcune query di lettura devono rimanere sul nodo primario perché fanno parte di transazioni di scrittura. Per queste, ci concentriamo sul garantire che siano efficienti ed evitino query lente. Per il traffico di scrittura, abbiamo migrato carichi di lavoro shardabili e ad alta intensità di scrittura verso sistemi sharded come Azure CosmosDB. I carichi di lavoro che sono più difficili da suddividere in shard, ma che generano comunque un elevato volume di scritture, richiedono più tempo per la migrazione, e quel processo è ancora in corso. Abbiamo anche ottimizzato in modo aggressivo le nostre applicazioni per ridurre il carico di scrittura; ad esempio, abbiamo corretto bug dell'applicazione che causavano scritture ridondanti e, dove appropriato, introdotto scritture differite per attenuare i picchi di traffico. Inoltre, durante il backfilling dei campi della tabella, imponiamo limiti di utilizzo rigorosi per evitare un'eccessiva pressione di scrittura.
Sfida: abbiamo identificato diverse query costose in PostgreSQL. In passato, improvvisi picchi di volume in queste query consumavano grandi quantità di CPU, rallentando sia ChatGPT che le richieste API.
Soluzione: alcune query costose, come quelle che uniscono molte tabelle, possono degradare significativamente o persino interrompere l'intero servizio. Dobbiamo ottimizzare continuamente le query di PostgreSQL per assicurarci che siano efficienti ed evitare i comuni anti-pattern dell'Online Transaction Processing (OLTP). Ad esempio, in passato abbiamo individuato una query estremamente costosa che effettuava join su 12 tabelle: i picchi di utilizzo di questa query sono stati responsabili di SEV ad alta gravità. È quindi opportuno evitare, ove possibile, join complessi su più tabelle. Quando i join sono inevitabili, abbiamo imparato a valutare la scomposizione della query e a spostare la logica di join più complessa a livello applicativo. Molte di queste query problematiche sono generate dai framework di Object-Relational Mapping (ORM), quindi è importante esaminare attentamente l'SQL che producono e assicurarsi che si comporti come previsto. È inoltre frequente riscontrare query inattive di lunga durata in PostgreSQL. Configurare timeout come idle_in_transaction_session_timeout è essenziale per evitare che blocchino l'autovacuum.
Sfida: se una replica di lettura si arresta, il traffico può comunque essere instradato verso altre repliche. Tuttavia, affidarsi a un singolo writer introduce un single point of failure: se il nodo primario non è disponibile, l'intero servizio ne risente.
Soluzione: le richieste più critiche riguardano solo le query di lettura. Per mitigare il single point of failure del nodo primario, abbiamo spostato tali letture dal writer alle repliche, garantendo la continuità del servizio anche in caso di indisponibilità del nodo primario. Sebbene le operazioni di scrittura continuino a fallire, l'impatto è ridotto: non si tratta più di un SEV0, poiché le letture rimangono disponibili.
Per mitigare i guasti del nodo primario, eseguiamo il nodo primario in modalità High-Availability (HA) con una replica hot standby, una replica continuamente sincronizzata sempre pronta a subentrare nel traffico. Se il nodo primario si interrompe o deve essere messo offline per manutenzione, possiamo rapidamente promuovere la replica standby per ridurre al minimo il tempo di inattività. Il team di Azure PostgreSQL ha svolto un lavoro significativo per garantire che questi failover restino sicuri e affidabili anche sotto carichi molto elevati. Per gestire i guasti delle repliche di lettura, distribuiamo più repliche in ogni regione con un margine di capacità sufficiente, assicurandoci che il guasto di una singola replica non comporti un'interruzione regionale.
Sfida: spesso ci troviamo in situazioni in cui alcune richieste consumano una quantità sproporzionata di risorse sulle istanze di PostgreSQL. Questo può portare a prestazioni degradate per altri carichi di lavoro che girano sulle stesse istanze. Ad esempio, il lancio di una nuova funzionalità può introdurre query inefficienti che consumano notevolmente la CPU di PostgreSQL, rallentando le richieste per altre funzionalità critiche.
Solution: To mitigate the “noisy neighbor” problem, we isolate workloads onto dedicated instances to ensure that sudden spikes in resource-intensive requests don’t impact other traffic. Specifically, we split requests into low-priority and high-priority tiers and route them to separate instances. This way, even if a low-priority workload becomes resource-intensive, it won’t degrade the performance of high-priority requests. We apply the same strategy across different products and services as well, so that activity from one product does not affect the performance or reliability of another.
Challenge: Each instance has a maximum connection limit (5,000 in Azure PostgreSQL). It’s easy to run out of connections or accumulate too many idle ones. We’ve previously had incidents caused by connection storms that exhausted all available connections.
Solution: We deployed PgBouncer as a proxy layer to pool database connections. Running it in statement or transaction pooling mode allows us to efficiently reuse connections, greatly reducing the number of active client connections. This also cuts connection setup latency: in our benchmarks, the average connection time dropped from 50 milliseconds (ms) to 5 ms. Inter-region connections and requests can be expensive, so we co-locate the proxy, clients, and replicas in the same region to minimize network overhead and connection use time. Moreover, PgBouncer must be configured carefully. Settings like idle timeouts are critical to prevent connection exhaustion.
Ogni replica di lettura ha il proprio deployment Kubernetes che esegue più pod di PgBouncer. Eseguiamo più deployment di Kubernetes dietro lo stesso servizio Kubernetes, che bilancia il traffico tra i pod.
Challenge: A sudden spike in cache misses can trigger a surge of reads on the PostgreSQL database, saturating CPU and slowing user requests.
Solution: To reduce read pressure on PostgreSQL, we use a caching layer to serve most of the read traffic. However, when cache hit rates drop unexpectedly, the burst of cache misses can push a large volume of requests directly to PostgreSQL. This sudden increase in database reads consumes significant resources, slowing down the service. To prevent overload during cache-miss storms, we implement a cache locking (and leasing) mechanism so that only a single reader that misses on a particular key fetches the data from PostgreSQL. When multiple requests miss on the same cache key, only one request acquires the lock and proceeds to retrieve the data and repopulate the cache. All other requests wait for the cache to be updated rather than all hitting PostgreSQL at once. This significantly reduces redundant database reads and protects the system from cascading load spikes.
Challenge: The primary streams Write Ahead Log (WAL) data to every read replica. As the number of replicas increases, the primary must ship WAL to more instances, increasing pressure on both network bandwidth and CPU. This causes higher and more unstable replica lag, which makes the system harder to scale reliably.
Solution: We operate nearly 50 read replicas across multiple geographic regions to minimize latency. However, with the current architecture, the primary must stream WAL to every replica. Although it currently scales well with very large instance types and high-network bandwidth, we can’t keep adding replicas indefinitely without eventually overloading the primary. To address this, we’re collaborating with the Azure PostgreSQL team on cascading replication(si apre in una nuova finestra), where intermediate replicas relay WAL to downstream replicas. This approach allows us to scale to potentially over a hundred replicas without overwhelming the primary. However, it also introduces additional operational complexity, particularly around failover management. The feature is still in testing; we’ll ensure it’s robust and can fail over safely before rolling it out to production.
Challenge: A sudden traffic spike on specific endpoints, a surge of expensive queries, or a retry storm can quickly exhaust critical resources such as CPU, I/O, and connections, which causes widespread service degradation.
Solution: We implemented rate-limiting across multiple layers—application, connection pooler, proxy, and query—to prevent sudden traffic spikes from overwhelming database instances and triggering cascading failures. It’s also crucial to avoid overly short retry intervals, which can trigger retry storms. We also enhanced the ORM layer to support rate limiting and when necessary, fully block specific query digests. This targeted form of load shedding enables rapid recovery from sudden surges of expensive queries.
Challenge: Even a small schema change, such as altering a column type, can trigger a full table rewrite(si apre in una nuova finestra). We therefore apply schema changes cautiously—limiting them to lightweight operations and avoiding any that rewrite entire tables.
Solution: Only lightweight schema changes are permitted, such as adding or removing certain columns that do not trigger a full table rewrite. We enforce a strict 5-second timeout on schema changes. Creating and dropping indexes concurrently is allowed. Schema changes are restricted to existing tables. If a new feature requires additional tables, they must be in alternative sharded systems such as Azure CosmosDB rather than PostgreSQL. When backfilling a table field, we apply strict rate limits to prevent write spikes. Although this process can sometimes take over a week, it ensures stability and avoids any production impact.
This effort demonstrates that with the right design and optimizations, Azure PostgreSQL can be scaled to handle the largest production workloads. PostgreSQL handles millions of QPS for read-heavy workloads, powering OpenAI’s most critical products like ChatGPT and the API platform. We added nearly 50 read replicas, while keeping replication lag near zero, maintained low-latency reads across geo-distributed regions, and built sufficient capacity headroom to support future growth.
This scaling works while still minimizing latency and improving reliability. We consistently deliver low double-digit millisecond p99 client-side latency and five-nines availability in production. And over the past 12 months, we’ve had only one SEV-0 PostgreSQL incident (it occurred during the viral launch(si apre in una nuova finestra) of ChatGPT ImageGen, when write traffic suddenly surged by more than 10x as over 100 million new users signed up within a week.)
While we’re happy with how far PostgreSQL has taken us, we continue to push its limits to ensure we have sufficient runway for future growth. We’ve already migrated the shardable write-heavy workloads to our sharded systems like CosmosDB. The remaining write-heavy workloads are more challenging to shard—we’re actively migrating those as well to further offload writes from the PostgreSQL primary. We’re also working with Azure to enable cascading replication so we can safely scale to significantly more read replicas.
Looking ahead, we’ll continue to explore additional approaches to further scale, including sharded PostgreSQL or alternative distributed systems, as our infrastructure demands continue to grow.
Autore
Ringraziamenti
Un ringraziamento speciale a Jon Lee, Sicheng Liu, Chaomin Yu e Chenglong Hao, che hanno contribuito a questo post, e all'intero team che ha aiutato a scalare PostgreSQL. Vorremmo anche ringraziare il team di Azure PostgreSQL per la loro forte collaborazione.


