Adapter PostgreSQL pour 800 millions d’utilisateurs de ChatGPT
Par Bohan Zhang, membre de l’équipe technique
Depuis des années, PostgreSQL s’est imposé comme l’une des bases de données les plus essentielles de l’infrastructure qui soutient des produits clés tels que ChatGPT et l’API d’OpenAI. Face à la croissance rapide de notre base d’utilisateurs, les sollicitations de nos bases de données augmentent, elles aussi, de manière exponentielle. Au cours de l’année écoulée, notre charge PostgreSQL a été multipliée par plus de dix, et elle continue d’augmenter rapidement.
L’évolution de notre infrastructure de production face à cette croissance a permis de tirer un nouvel enseignement : PostgreSQL peut être mis à l’échelle de manière à prendre en charge, en toute fiabilité, des charges de travail axées sur la lecture bien plus importantes que ce que l’on pensait possible auparavant. Le système (initialement créé par une équipe de scientifiques de l’Université de Californie à Berkeley) nous a permis de gérer un trafic mondial massif avec une seule instance principale de serveur flexible Azure PostgreSQL(ouverture dans une nouvelle fenêtre) et avec près de 50 réplicas de lecture répartis dans plusieurs régions à travers le monde. Voici l’histoire de la façon dont nous avons mis PostgreSQL à l’échelle chez OpenAI pour prendre en charge des millions de QPS pour 800 millions d’utilisateurs grâce à des optimisations rigoureuses et à une ingénierie solide. Nous aborderons également les principaux enseignements que nous avons tirés en chemin.
Après le lancement de ChatGPT, le trafic a augmenté à un rythme sans précédent. Pour y faire face, nous avons rapidement mis en œuvre d’importantes optimisations à la fois au niveau de l’application et de la couche de base de données PostgreSQL, procédé à une mise à l’échelle verticale en augmentant la taille des instances et à une mise à l’échelle horizontale en ajoutant des réplicas de lecture. Cette architecture a parfaitement répondu à nos besoins pendant longtemps. Grâce à des améliorations constantes, elle offre encore une large marge de croissance pour l’avenir.
Il peut paraître surprenant qu’une architecture mono-primaire puisse répondre à des exigences aussi fortes que celles d’OpenAI. Pour autant, sa mise en œuvre concrète est loin d’être simple. Nous avons constaté plusieurs incidents de sévérité (SEV) causés par une surcharge de Postgres, et ils suivent souvent le même schéma : un problème en amont provoque un pic soudain de charge sur la base de données (défauts de cache généralisés dus à une défaillance de la couche de mise en cache, augmentation de jointures multiples coûteuses saturant le CPU, ou afflux massif d’écritures lors du lancement d’une nouvelle fonctionnalité). À mesure que la consommation des ressources augmente, la latence des requêtes s’accroît et celles-ci finissent par expirer avant d’avoir pu être traitées. Les tentatives de réessai amplifient ensuite la charge, ce qui déclenche un cercle vicieux susceptible de dégrader l’ensemble des services de ChatGPT et de l’API.
Bien que PostgreSQL accepte sans difficulté la montée en charge pour nos charges de travail à forte intensité de lecture, nous rencontrons encore des défis lors des périodes de trafic d’écriture intense. Cela est en grande partie dû à l’implémentation du contrôle de concurrence multiversion (MVCC) de PostgreSQL, qui le rend moins performant pour les charges de travail à forte intensité d’écriture. À titre d’exemple, lorsqu’une requête met à jour un tuple ou même un seul champ, la ligne entière est copiée pour créer une nouvelle version. Sous de lourdes charges d’écriture, cela entraîne une amplification d’écriture significative. Cela augmente également l’amplification de lecture, car les requêtes doivent parcourir plusieurs versions de tuples (tuples morts) pour récupérer la dernière. Le MVCC introduit des défis supplémentaires tels que le bloat des tables et des index, une surcharge accrue de maintenance des index et un réglage complexe de l’autovacuum. (Vous pouvez trouver une analyse approfondie de ces problèmes dans un article de blog que j’ai coécrit avec le professeur Andy Pavlo à l’université Carnegie Mellon, intitulé The Part of PostgreSQL We Hate the Most(ouverture dans une nouvelle fenêtre), cité(ouverture dans une nouvelle fenêtre) sur la page Wikipédia de PostgreSQL.)
Pour atténuer ces contraintes et pour réduire la charge d’écriture, nous avons migré, et continuons de le faire, des données partitionnables (c’est-à-dire des charges de travail qui peuvent être partitionnées horizontalement), en déplaçant les flux à forte intensité d’écriture vers des systèmes partitionnés tels qu’Azure Cosmos DB tout en optimisant la logique de l’application afin de minimiser les écritures inutiles. Nous n’autorisons plus l’ajout de nouvelles tables au déploiement PostgreSQL actuel. Les nouvelles charges de travail sont, par défaut, attribuées aux systèmes partitionnés.
Même si notre infrastructure a évolué, PostgreSQL est resté non partitionné, avec une instance mono-primaire unique qui traite l’intégralité des écritures. La principale raison est que le partitionnement des charges de travail des applications existantes serait extrêmement complexe et chronophage ; cela nécessiterait des modifications de centaines d’endpoints applicatifs et pourrait prendre des mois, voire des années. Étant donné que nos charges de travail sont principalement axées sur la lecture et que nous avons mis en œuvre de nombreuses optimisations, l’architecture actuelle offre toujours une marge de croissance suffisante pour absorber et soutenir l’augmentation continue du trafic. Bien que nous n’excluions pas le partitionnement de PostgreSQL à l’avenir, ce n’est pas une priorité à court terme étant donné la marge de manœuvre suffisante dont nous disposons pour la croissance actuelle et future.
Dans les sections suivantes, nous allons examiner les défis auxquels nous avons été confrontés ainsi que les optimisations poussées que nous avons mises en œuvre pour y répondre et pour éviter de futures pannes, en poussant PostgreSQL dans ses retranchements et en le faisant monter en charge jusqu’à atteindre des millions de requêtes par seconde (QPS).
Défi : avec un seul nœud d’écriture, une architecture mono-primaire ne peut pas monter en charge pour traiter un volume d’écritures plus important. Des pics d’écriture importants peuvent rapidement surcharger l’instance principale et affecter des services tels que ChatGPT et notre API.
Solution : nous minimisons autant que possible la charge sur l’instance principale – en lecture comme en écriture – afin de garantir qu’elle dispose d’une réserve de capacité suffisante pour gérer les pics d’écriture. Le trafic de lecture est déchargé vers des réplicas chaque fois que possible. Cependant, certaines requêtes de lecture doivent rester sur l’instance principale, car elles font partie de transactions d’écriture. Pour ces dernières, nous nous concentrons sur l’optimisation de leur efficacité et sur l’élimination des requêtes lentes. En ce qui concerne le trafic d’écriture, nous avons migré les charges de travail partitionnables et fortement axées sur l’écriture vers des systèmes partitionnés tels qu’Azure CosmosDB. Les charges de travail plus difficiles à partitionner, mais qui génèrent néanmoins un volume d’écriture élevé, prennent plus de temps à être migrées ; ce processus est toujours en cours. Nous avons également optimisé nos applications en profondeur pour réduire la charge d’écriture ; par exemple, nous avons corrigé des bogues d’application qui entraînaient des écritures redondantes et nous avons introduit des écritures différées, lorsque cela était approprié, pour lisser les pics de trafic. De plus, lors du remplissage a posteriori des colonnes de table, nous appliquons une limitation de débit stricte pour éviter une pression d’écriture excessive.
Défi : nous avons identifié plusieurs requêtes coûteuses au sein de PostgreSQL. Par le passé, des pics soudains de volume au sein de ces requêtes consommaient d’importantes ressources CPU, ce qui ralentissait à la fois ChatGPT et les requêtes de l’API.
Solution : quelques requêtes coûteuses, telles que celles qui joignent de nombreuses tables, peuvent considérablement dégrader l’intégralité de la plateforme, voire la mettre hors service. Nous devons optimiser en continu les requêtes PostgreSQL pour garantir leur efficacité et pour éviter les anti-modèles OLTP (« Online Transaction Processing ») courants. Par exemple, nous avons identifié une requête extrêmement coûteuse qui joignait 12 tables ; les pics de trafic sur cette dernière étaient responsables de SEV majeurs par le passé. Nous devrions éviter, dans la mesure du possible, de joindre plusieurs tables au sein d’une même requête. S’il est nécessaire de joindre des tables, nous avons appris à envisager de décomposer la requête et de déplacer la logique d’association complexe vers la couche applicative. Bon nombre de ces requêtes problématiques sont générées par des frameworks de type ORM (« Object-Relational Mapping ») ; il est donc important d’examiner attentivement le SQL généré et de s’assurer qu’il s’exécute comme prévu. Il est également courant de trouver des transactions inactives de longue durée au sein de PostgreSQL. Configurer des délais d’attente tels que idle_in_transaction_session_timeout est essentiel pour éviter que ces transactions ne bloquent l’autovacuum.
Défi : si un réplica de lecture subit une défaillance, le trafic doit pouvoir être redirigé vers d’autres réplicas. Cependant, s’appuyer sur une seule instance primaire revient à créer un point de défaillance unique ; en cas de défaillance de celle-ci, l’intégralité du service est impactée.
Solution : la plupart des requêtes critiques ne concernent que des opérations de lecture. Pour atténuer le point de défaillance unique de l’instance primaire, nous avons transféré ces flux de lecture du nœud d’écriture vers des réplicas, ce qui garantit que ces requêtes puissent continuer à être traitées même en cas de panne du serveur principal. Bien que les opérations d’écriture continuent d’échouer, l’impact est réduit ; la situation ne constitue plus un SEV0 car les flux de lecture restent disponibles.
Pour atténuer les défaillances de l’instance primaire, nous exécutons celle-ci en mode haute disponibilité (HA) avec un « hot standby » ; il s’agit d’un réplica synchronisé en continu, toujours prêt à prendre le relais pour gérer le trafic. Si l’instance primaire subit une défaillance ou doit être mise hors ligne pour maintenance, nous pouvons rapidement promouvoir le mode haute disponibilité afin de minimiser les temps d’arrêt. L’équipe Azure PostgreSQL a accompli un travail substantiel pour s’assurer que ces basculements demeurent sûrs et de surcroît fiables, même sous une charge très élevée. Pour gérer les défaillances des réplicas de lecture, nous déployons plusieurs réplicas dans chaque région avec une marge de capacité suffisante ; nous garantissons ainsi que la défaillance d’un seul réplica n’entraîne pas de panne régionale.
Défi : nous rencontrons souvent des situations au sein desquelles certaines requêtes consomment une quantité disproportionnée de ressources sur les instances PostgreSQL. Cela peut entraîner une dégradation des performances pour d’autres charges de travail qui s’exécutent sur les mêmes instances. Par exemple, le lancement d’une nouvelle fonctionnalité peut introduire des requêtes inefficaces qui consomment une part importante des ressources CPU de PostgreSQL, ce qui ralentit les requêtes liées à d’autres fonctionnalités essentielles.
Solution : nous exploitons près de 50 réplicas de lecture dans plusieurs régions géographiques pour minimiser la latence. Cependant, avec l’architecture actuelle, le serveur principal doit diffuser le WAL vers chaque réplica. Bien qu’elle accepte actuellement sans difficulté la montée en charge sur des types d’instances très volumineux et sur une bande passante réseau élevée, nous ne pouvons pas continuer à ajouter des réplicas indéfiniment sans finir par surcharger le serveur principal. Pour remédier à ce problème, nous collaborons avec l’équipe Azure PostgreSQL sur la réplication en cascade(ouverture dans une nouvelle fenêtre), où des réplicas intermédiaires relaient le WAL vers des réplicas en aval. Cette approche nous permet de passer à l’échelle jusqu’à potentiellement plus d’une centaine de réplicas sans surcharger le serveur principal. Cependant, cela introduit également une complexité opérationnelle supplémentaire, notamment en ce qui concerne la gestion du basculement. La fonctionnalité est encore en phase de test ; nous veillerons à ce qu’elle soit robuste et à ce que son mécanisme de basculement soit parfaitement sécurisé avant de la déployer en production.
Défi : un pic soudain de trafic sur des endpoints spécifiques, une augmentation des requêtes coûteuses ou une tempête de réessais peut rapidement épuiser des ressources critiques telles que le processeur, les entrées/sorties et les connexions, ce qui entraîne une dégradation généralisée du service.
Solution : nous avons mis en place une limitation du débit à plusieurs niveaux (application, pool de connexions, proxy et requête) afin d’empêcher que des pics de trafic soudains ne submergent les instances de base de données et ne déclenchent des défaillances en cascade. Il est également crucial d’éviter des intervalles de réessai trop courts, qui peuvent déclencher des tempêtes de réessais. Nous avons également amélioré la couche ORM pour prendre en charge la limitation de débit et, si nécessaire, pour bloquer complètement certains condensés de requêtes. Cette forme ciblée de délestage de charge permet une reprise rapide après des pics soudains de requêtes coûteuses.
Défi : même une petite modification de schéma, comme le changement du type d’une colonne, peut déclencher une réécriture complète de la table(ouverture dans une nouvelle fenêtre). Nous appliquons donc les modifications de schéma avec prudence, en les limitant à des opérations légères et en évitant celles qui réécrivent des tables entières.
Solution : seules les modifications légères du schéma sont autorisées, comme l’ajout ou la suppression de certaines colonnes qui ne déclenchent pas une réécriture complète de la table. Nous imposons un délai d’attente strict de 5 secondes pour les modifications de schéma. La création et la suppression d’index en mode simultané sont autorisées. Les modifications de schéma sont restreintes aux tables existantes. Si une nouvelle fonctionnalité nécessite des tables supplémentaires, elles doivent être intégrées à des systèmes partitionnés alternatifs, tels qu’Azure CosmosDB, plutôt que dans PostgreSQL. Lors du remplissage rétroactif d’un champ de table, nous appliquons des limites de débit strictes pour éviter les pics d’écriture. Bien que ce processus puisse parfois prendre plus d’une semaine, il garantit la stabilité et évite tout impact sur la production.
Cet effort démontre qu’avec une conception et des optimisations appropriées, Azure PostgreSQL peut être mis à l’échelle pour gérer les plus grandes charges de travail en production. PostgreSQL gère des millions de QPS pour des charges de travail principalement en lecture et alimente les produits les plus critiques d’OpenAI, comme ChatGPT et la plateforme API. Nous avons ajouté près de 50 réplicas de lecture tout en maintenant une latence de réplication proche de zéro, nous avons conservé des lectures à faible latence dans des régions géographiquement distribuées, et nous avons constitué une marge de capacité suffisante pour soutenir la croissance future.
Cette mise à l’échelle fonctionne tout en réduisant la latence et en améliorant la fiabilité. Nous garantissons systématiquement une latence côté client p99 de quelques dizaines de millisecondes et une disponibilité de 99,999 % en production. Et au cours des 12 derniers mois, nous n’avons eu qu’un seul incident PostgreSQL SEV-0 (il s’est produit lors du lancement viral(ouverture dans une nouvelle fenêtre) de ChatGPT ImageGen, lorsque le trafic d’écriture a soudainement augmenté suivant un facteur 10, alors que plus de 100 millions de nouveaux utilisateurs se sont inscrits en une semaine).
Bien que nous soyons satisfaits de ce que PostgreSQL nous a permis d’accomplir jusqu’à présent, nous continuons d’en repousser les limites afin de garantir une marge de capacité suffisante pour notre croissance future. Nous avons déjà migré les charges de travail à forte intensité d’écriture et les charges partitionnables vers nos systèmes partitionnés, comme CosmosDB. Les charges de travail restantes à forte intensité d’écriture sont plus difficiles à partitionner – nous poursuivons d’ailleurs activement leur migration afin de décharger davantage le serveur principal PostgreSQL de ses écritures. Nous collaborons également avec Azure pour activer la réplication en cascade, ce qui nous permettra d’augmenter en toute sécurité le nombre de réplicas de lecture.
À l’avenir, nous continuerons à explorer d’autres approches pour accroître notre capacité, notamment le PostgreSQL partitionné ou d’autres systèmes distribués, à mesure que les besoins de notre infrastructure continuent de croître.
Auteur
Remerciements
Un grand merci à Jon Lee, à Sicheng Liu, à Chaomin Yu et à Chenglong Hao pour leur contribution à cet article, ainsi qu’à toute l’équipe qui a aidé à mettre PostgreSQL à l’échelle. Nous souhaitons également remercier l’équipe Azure PostgreSQL pour son partenariat solide.


