Tirez le maximum du système que vous avez
(blog.danslimmon.com)- Le goulot d’étranglement Postgres d’une application SaaS monolithique s’est aggravé : l’utilisation CPU restait entre 60 et 80 %, et a même atteint 100 % une fois, provoquant une courte interruption
- Le scaling vertical consistant à passer à une instance de base de données plus grande avait déjà atteint ses limites, ce qui rendait difficile d’absorber davantage de charge par la même approche
- Le sharding des écritures et les microservices peuvent accroître la capacité et la résilience opérationnelle, mais ils laissent en permanence un coût de complexité sur les sauvegardes, le monitoring, les migrations, l’ORM et jusqu’à la topologie réseau
- En pratique, pendant trois mois, l’équipe a supprimé des requêtes lourdes, optimisé le code Rails, ajusté Postgres et déplacé certaines requêtes coûteuses en lecture seule vers une base de données répliquée
- Résultat : le pic hebdomadaire d’utilisation CPU de la base de données est passé de 90 % à 30 %, laissant davantage de marge pour exploiter plus longtemps le système actuel avant de basculer vers une architecture de nouvelle génération
Le goulot d’étranglement Postgres et la fin du scaling vertical
- À mesure que la charge d’une application SaaS monolithique augmentait, les performances de Postgres sont devenues le principal goulot d’étranglement
- L’utilisation CPU restait dans la plage 60–80 %
- Au moins une fois, elle est montée jusqu’à 100 %, provoquant une courte interruption
- Auparavant, chaque fois que la base de données devenait trop sollicitée, l’équipe gagnait du temps en la remplaçant par une instance plus grande
- Cette approche permettait de se concentrer sur d’autres tâches, comme le développement de fonctionnalités
- À ce moment-là, l’instance la plus grande était déjà utilisée, rendant tout scaling vertical supplémentaire impossible
Une architecture de nouvelle génération séduisante, mais coûteuse
- Les principales options discutées étaient le sharding des écritures et les microservices
- Le sharding des écritures consiste à disposer de clusters de bases de données indépendants et à écrire les données dans une base donnée selon une stratégie de partitionnement
- Il peut potentiellement augmenter la capacité d’un facteur à deux ou trois chiffres
- Les microservices consistent à diviser le monolithe en plusieurs services, chacun disposant de son propre stockage de données
- Cela permet de choisir un stockage de données adapté à la charge de travail de chaque service
- Les deux approches élargissent les options en matière de tolérance aux pannes et de résilience opérationnelle, mais l’objectif immédiat était de ramener les performances de la base de données dans un état maîtrisable
Une fois introduite, la complexité continue de coûter
- L’augmentation de la complexité dépasse le coût de mise en œuvre d’une nouvelle structure : elle se transforme ensuite en coût d’attention
- Si l’on choisit le sharding de base de données, chaque décision technique future devra composer avec cette nouvelle complexité
- Sauvegardes
- Monitoring
- Migrations
- ORM
- Topologie réseau
- Les microservices créent une charge similaire, et le maintien de cette architecture supplémentaire peut retarder, voire faire abandonner, la livraison de fonctionnalités
Chercher d’abord de la marge dans le système actuel
- Même lorsqu’une grande transition d’architecture semble nécessaire, il reste souvent de la marge supplémentaire dans le système existant
- Ajuster les workloads, optimiser les performances et ajouter des systèmes auxiliaires peut faire gagner plusieurs mois, voire plusieurs années
- Si ces options sont praticables, elles valent la peine d’être tentées avant de construire un nouveau système de nouvelle génération
Les optimisations réellement effectuées
- Lors du premier chantier, deux ingénieurs se sont principalement consacrés aux problèmes de performance de la base de données pendant environ 3 mois
- Il n’y avait pas de solution miracle unique
- Ils ont utilisé la télémétrie pour repérer les requêtes lourdes
- Ils ont identifié dans la codebase Rails les endroits où ces requêtes étaient déclenchées
- Ils ont optimisé ou supprimé les requêtes
- Ils ont ajusté plusieurs paramètres Postgres
- Lors du second chantier, deux autres ingénieurs ont modifié la codebase pour exécuter certaines requêtes coûteuses en lecture seule sur une base de données répliquée
- Ils ont séparé de la base principale les requêtes
SELECTles plus fréquentes, provoquées par le polling du client web
- Ils ont séparé de la base principale les requêtes
Résultats et principes d’exploitation
- Ensemble, ces deux chantiers ont fait passer le pic hebdomadaire d’utilisation CPU de la base de données de 90 % à 30 %
- Pic hebdomadaire d’utilisation CPU : {l:90,30}
- La marge CPU a fortement augmenté, tout comme la capacité à délester la base de données principale
- En touchant plusieurs parties de la codebase et grâce à la collaboration de plusieurs développeurs, une connaissance distribuée du système existant s’est également constituée
- La complexité n’est pas toujours mauvaise, et il faudra un jour passer à une structure plus complexe avant d’atteindre les limites fondamentales de l’architecture de base de données
- D’ici là, tirer d’abord le maximum du système actuel permet de travailler plus longtemps avec des systèmes ennuyeux et simples, ce qui est aussi avantageux en termes de coûts et de pragmatisme
1 commentaires
Avis sur Hacker News
En voyant les problèmes de performance des bases de données, mon idée la plus tranchée pour les nouveaux projets est de concevoir les chemins chauds de l’application de façon à n’utiliser aucun join
Le stockage ne coûte pas cher, donc on peut tout dénormaliser et tout mettre à jour dans une transaction. C’est vraiment surprenant de voir à quel point supprimer les joins accélère les choses. Pour les requêtes analytiques ponctuelles, il suffit de répliquer vers une autre base de données dédiée à l’analyse
J’ai des sentiments mitigés sur DynamoDB d’Amazon, mais l’idée qu’il faut d’abord planifier les patterns d’usage, puis définir le schéma ensuite, mérite d’être reprise aussi dans les bases de données relationnelles. Ces temps-ci, j’en viens même à penser que les joins sont inutiles hors usages analytiques. Les principales bases de données ont des propriétés ACID et le stockage est ridiculement bon marché, donc autant dénormaliser
Pour éviter les partitions chaudes, mieux vaut utiliser quelque chose de proche d’un UUID plutôt que des entiers. Ce n’est pas une solution miracle et il y a des inconvénients, mais on peut s’habituer à des performances « toujours correctes » et scalables horizontalement plutôt qu’aux excellentes performances d’entiers qui finiront un jour par s’effondrer
Idée encore plus brûlante : indexer toutes les colonnes, mais ce sera un sujet pour un autre jour
Du point de vue de la dette technique, essayer de tout rendre rapide dès le départ aurait coûté bien plus cher. La vitesse de développement aurait fortement ralenti, au point que nous aurions probablement échoué lors de plusieurs crises
À la place, nous avons payé quelques milliers de dollars par mois de coûts machine en plus de ce qui était réellement nécessaire, et nous avons économisé plusieurs mois de travail à un moment où nous ne pouvions pas recruter assez d’ingénieurs et où le coût d’opportunité sur le développement de fonctionnalités était élevé. Comme il était impossible de savoir à l’avance où serait le goulot d’étranglement, rendre tout rapide dès le départ aurait demandé 10 à 20 fois plus de travail. Certains goulots étaient inattendus
Les joins peuvent être néfastes à grande échelle, mais la plupart des startups n’ont pas de problème d’échelle, au moins au début. La dénormalisation peut être une bonne optimisation, mais on paie un coût de vitesse à chaque changement, pour synchroniser toutes les copies. Quelqu’un finira par introduire un bug qui ne met pas à jour un champ dénormalisé non canonique, et les utilisateurs verront des données périmées. En général, utiliser des joins puis optimiser plus tard avec un cache read-aside, etc., revient moins cher au total que de tordre le schéma
À l’inverse, écrire une même donnée à 20 endroits au lieu d’un seul est bien plus lent en termes de performance, et les requêtes deviennent extrêmement complexes et sujettes aux bugs. On met à jour 18 endroits et on en oublie 2
On cite le stockage bon marché comme avantage de la dénormalisation, mais ici le stockage est le plus petit des problèmes. La surface de bugs beaucoup plus grande et les mauvaises performances en écriture sont les vrais problèmes, et ces performances en écriture peuvent facilement grignoter aussi les performances en lecture
Une application haute fait essentiellement une seule chose, et le reste sert à l’épauler. La plupart des Big Tech qui viennent à l’esprit entrent dans cette catégorie. Dans leur modèle de données, il n’y a que quelques concepts moteurs vraiment importants
Facebook, en pratique, ce sont des personnes, des publications et des publicités. Netflix, ce sont surtout des comptes et des séries/films. Pour les produits Amazon, les éléments clés sont les vendeurs, les acheteurs et les produits, avec éventuellement quelques concepts logistiques derrière
S’il y a beaucoup d’applications hautes, c’est parce que c’est plus facile. C’est beaucoup plus facile que les applications larges qu’on appelle souvent « enterprise ». Si les logiciels enterprise sont mauvais, c’est parce qu’ils sont difficiles, et c’est là que se trouvent les domaines les moins explorés et d’énormes opportunités. Les acteurs installés comme Oracle y échouent lamentablement, et si vous y entrez avec un mode de pensée d’application haute, vous échouerez aussi
Des conseils comme « n’utilisez jamais de joins » ou « concevez autour d’une seule table » ont du sens pour les applications hautes, mais sont de très mauvais conseils pour les applications larges. On voit souvent des entreprises d’applications très hautes échouer lamentablement quand elles tentent quelque chose hors de leur cœur de compétence, parce qu’elles sont remplies de gens qui sacralisent ce type de conseils
Ces conseils sont faits pour des entreprises déjà prospères, qui font des choses faciles et ont déjà cueilli tous les fruits à portée de main. Même les applications hautes qui ne sont pas encore devenues victimes de leur succès n’ont pas besoin de massacrer leur modèle de données pour les performances. Seules les entreprises déjà gigantesquement prospères, qui cherchent à extraire la dernière goutte de performance, se posent ce genre de questions — et ce sont précisément celles qui ont le moins besoin de conseils. Les conseils centrés sur les applications hautes, du type « si les FAANG le font, tu dois le faire aussi » ou « et si tu as un milliard d’utilisateurs ? », contaminent l’esprit de ceux qui veulent faire quelque chose de plus intéressant que montrer des publicités à des milliards de personnes
Les gros joins peuvent être corrigés plus tard en les poussant dans des vues matérialisées, ou en faisant de l’ETL vers un stockage en colonnes. Mais si quelqu’un copie une colonne
subtotal_centsdans les modèles Order, Invoice, Payment, NotificationEmail et UserProfileRecentOrders, puis la fait référencer ou mettre à jour à 296 endroits, le chemin du retour vers un état sain devient longEn général, les problèmes apparaissent dans les tables de type historique. Les données nécessaires aux opérations quotidiennes ne représentent qu’une infime partie de la table réelle, mais quelle que soit la présence d’index, travailler sur une énorme table finit forcément par ralentir. Le simple fait d’ajouter davantage d’index devient lui-même un problème
Au moins dans les bases de données relationnelles traditionnelles, indexer toutes les colonnes ne suffit pas : il faut définir les bons index sur combinaisons de colonnes qui pourront être utilisés. DynamoDB peut être différent
Tirez le maximum de ce que vous avez ; après l’avoir fait pendant un moment, changez votre façon d’aborder le problème et, en grattant ici, puis là, puis encore là, vous vous rendrez soudain compte qu’il reste énormément à gagner
Nous avons optimisé un énorme monolithe pendant environ deux mois : depuis un état à moins de 2 000 RPS, où le PM et l’équipe pensaient qu’il n’y avait plus rien à extraire, un changement de matériel nous a fait monter à moins de 3 200 RPS ; quelques jours de retouches nous ont amenés à 4 000 RPS, un peu plus d’efforts à 10 000 RPS, puis environ une semaine plus tard à 40 000 RPS
On a entendu : « c’est suffisant, pas besoin d’aller plus loin », mais après avoir changé pas mal de choses, on est passé à plus de 2 millions de RPS sur une seule machine ; un mois plus tard, le système traitait de façon stable plus de 40 millions de RPS avec une faible latence. Il reste encore un peu de marge pour pousser davantage
Aujourd’hui, nous n’utilisons même pas 5 % de la capacité que l’on pourrait en tirer. Rien que changer notre manière de penser le problème a produit ce genre d’évolution. Le simple passage d’un vieux serveur à un nouveau n’avait fait passer le système que de 1 800 RPS à un peu plus de 3 000 RPS. Ajouter du matériel ne corrigeait pas le problème de fond, et ajouter de la complexité ne faisait que repousser le problème. En changeant notre façon de réfléchir au problème, le problème lui-même et la réponse ont changé
Pour compléter l’idée de « découper le monolithe en plusieurs services interconnectés, chacun avec son propre stockage de données pouvant évoluer selon ses contraintes », il n’est pas nécessaire, à ce stade, d’extraire tous les microservices possibles. Il suffit de demander : « quelle séparation aurait le plus grand impact ? »
Dans mon cas, j’ai extrait de Mongo certaines données de séries temporelles vers Cassandra. La structure de tables de Cassandra convenait beaucoup mieux. Ce jeu de données avait un schéma bien défini, et Cassandra pouvait compacter les données de manière bien plus efficace. Pour cette partie, nous n’avions pas besoin de la flexibilité des documents JSON
Comme ces données représentaient la majeure partie de l’ensemble, Mongo est ensuite devenu tout à fait satisfaisant. Une seule séparation a été nécessaire. Techniquement, c’était un monolithe avant comme après ; c’est simplement le même service qui s’est mis à écrire dans deux bases de données
Ironiquement, plus tard, un architecte du genre très théorique a voulu regrouper toutes les données dans un stockage de documents JSON, et nous avons eu plusieurs discussions du type : « nous avons déjà emprunté cette voie et nous savons où elle mène »
Le passage à l’échelle vertical devrait être la première réponse évidente. Beaucoup de gens passent à côté de ce point, et l’article l’aborde en partie, mais le scaling vertical ressemble beaucoup à du scaling horizontal qui ne casse pas la cohérence de la base de données
Il y a beaucoup de marge à extraire, et il est rare qu’un problème de performance justifie de sortir facilement des anti-patterns comme ignorer les jointures ou la validation des données
Si les développeurs savent lire les résultats de EXPLAIN/ANALYZE et faire correctement l’indexation et l’optimisation des requêtes, on peut éviter quantité de décisions de suringénierie
Il suffit de journaliser les requêtes, de filtrer celles qui s’exécutent très souvent ou prennent beaucoup de temps, de mettre en cache celles qui sont fréquentes et d’optimiser celles qui sont lourdes. En le faisant systématiquement, le système devient plus sain
D’après mon expérience, ce qui aide beaucoup, ce sont l’APM, les logs de requêtes lentes, les réplicas de lecture/écriture de base de données, le partitionnement et le sharding
Des outils comme https://explainmysql.com montrent plus clairement ce qu’il faut réellement optimiser, ce qui donne un cadre plus accessible aux développeurs qui en savent assez pour configurer une base de données, mais pas assez pour comprendre son fonctionnement interne
J’imagine que quelqu’un est déjà en train de créer un système d’IA dans lequel on met le schéma et les logs, et qui lance magiquement le SQL à améliorer. Je ne sais pas si je lui ferais confiance, mais beaucoup d’entreprises préféreraient utiliser ce genre de chose plutôt que recruter un DBA dédié
Je ne sais pas s’il faut toujours préciser un indice d’index pour chaque requête. Il arrive qu’une requête semble ne pas utiliser un index pourtant existant. Utiliser les plans d’exécution SQL m’aiderait sans doute à mieux comprendre ce problème
On ne corrige pas du mauvais code avec une nouvelle architecture. On ne fait que repousser le problème à plus tard
Cela me rappelle l’une de mes citations préférées : « On part à la guerre avec l’armée qu’on a, pas avec celle qu’on voudrait ou qu’on aimerait avoir plus tard. »
On peut avoir envie d’ignorer le fait que cette phrase vienne de Donald Rumsfeld. Il a quand même eu quelques excellentes formules, comme « unknown unknowns ».
J’y pense souvent quand je travaille en équipe. Tout le monde n’est pas parfaitement d’accord, ni ne partage la même compréhension ou les mêmes objectifs communs. Certains peuvent travailler de façon inefficace, ou d’une manière que je ne privilégierais pas. Mais comme il vaut mieux avoir une équipe que pas d’équipe du tout, il faut trouver la meilleure façon d’atteindre l’objectif avec l’équipe qu’on a.
Cela s’applique aussi très bien aux systèmes.
https://www.youtube.com/watch?v=XH_34tqxAjA
https://www.google.com/search?q=no+battle+plan+survives
Cela s’applique davantage à nous-mêmes qu’aux systèmes.
D’après mon expérience, dans les applications web construites au-dessus d’un ORM, il existe énormément de fruits faciles à cueillir côté optimisation des requêtes quand la charge de la base de données devient un problème.
Au-delà des bases du type « y a-t-il un problème N+1 ? », les ORM ne produisent pas toujours les requêtes optimales. Je ne voudrais pas construire une application web de production complexe sans ORM, mais il faut parfois pouvoir s’en échapper.
Il faut profiler les requêtes réellement exécutées en production et qui consomment le plus de ressources. Les examiner, puis comprendre la forme des tables concernées. L’ORM utilise parfois des jointures alors qu’il faudrait en réalité une sous-requête, et l’inverse arrive aussi. Il faut parfois pré-agréger une partie des résultats ou ajuster les conditions WHERE dans des jointures complexes.
J’ai déjà vu une requête générée par un ORM, exécutée assez régulièrement, mettre la base de données à genoux : elle prenait plus de 20 secondes et est passée sous la seconde avec seulement quelques petites modifications.
expire_on_commit=False.Même cette option ne garantit pas l’utilisation de RETURNING : il est utilisé si le driver de base de données et la base le prennent en charge, et si l’ORM le prend en charge pour cette combinaison driver/base. Le SQL généré apparaît dans les logs, mais il n’existe pas d’API pour l’inspecter directement ; il n’y a donc aucun moyen d’imposer l’usage de RETURNING dans la suite de tests sans capturer soi-même les logs et les parser. Heureusement, dans le framework Pytest, c’est très simple.
J’aime les ORM, mais ce genre de chose est frustrant de complexité à plusieurs niveaux. Je comprends aussi que SQLAlchemy soit une énorme bibliothèque et que tout ne puisse pas être simple. Mais cet exemple illustre bien les compromis liés à l’usage d’un ORM.
Je sais qu’en utilisant directement
insert()dans Core, on obtient le comportement voulu. Ici, je parle du cas où l’on fait.add()d’un objet ORM dans uneAsyncSession.La plupart de la logique métier s’exprime mieux avec le langage de l’algèbre relationnelle et quelques extensions qu’avec l’OOP.
« Le vrai coût de l’augmentation de la complexité, et souvent le coût de loin le plus important, c’est l’attention » revient aussi à parler de charge cognitive.
J’en ai assez de travailler sur des systèmes de microservices où il y a toujours du downtime et où personne ne sait comment l’ensemble fonctionne. La plupart sont en réalité des monolithes distribués : les changements traversent plusieurs services et doivent être déployés dans un ordre précis. Les données doivent être répliquées, les tâches synchronisées, et l’état partagé.
https://www.youtube.com/watch?v=y8OnoxKotPQ
Mais si quelqu’un doit connaître l’ensemble pour corriger ou modifier le système, c’est un signal fort que des règles comme la responsabilité unique ou la bonne abstraction via des API ont été violées. Dans mon expérience, c’est pourtant très fréquent. Pour déboguer un pipeline composé de N microservices, il faut souvent exécuter et builder localement les N services.
À strictement parler, c’est un monolithe auquel on a ajouté des partitions réseau et une variabilité infinie de build/déploiement. Au final, j’y vois un environnement de travail extrêmement difficile, qui dépasse les capacités de n’importe quel programmeur humain.
J’aime bien cet article. Ces derniers mois, j’ai essayé de faire passer le même message à mon manager, sans grand succès.
Notre cluster Redshift surchargé s’est effondré plusieurs fois, et comme nous l’avons déjà poussé au maximum avec des nœuds RA3, nous sommes sur le point de lancer une grosse migration vers une « infrastructure de nouvelle génération ». Par infrastructure de nouvelle génération, il faut comprendre trois clusters Redshift gérés avec CDK.
La nouvelle infrastructure sera bien plus complexe que la configuration actuelle, et je ne suis pas convaincu qu’elle sera la solution miracle que tout le monde espère.
C’est la solution ennuyeuse. Sauf si l’on construit un système ultra-critique dont dépendent des vies ou l’intégrité physique de personnes, cela devrait être la réponse par défaut. Le coût total de possession sera clairement beaucoup plus bas.
Si l’on n’a pas les ressources pour exploiter un gros système redondant, j’ai trop souvent vu la complexité ajoutée par cette redondance devenir elle-même le problème. Mieux vaut se concentrer sur la simplicité.
Si la complexité nécessite d’ajouter beaucoup de personnes pour la prendre en charge, mais que le budget et l’évaluation des risques ne le justifient pas, l’option la plus simple est de loin préférable. Je ne dis pas que je n’ai jamais vu de cas où avancer finissait par nécessiter un énorme projet, mais parfois, même celui-ci me semble inférieur au cumul de la complexité supportée jusque-là. Cela dépend énormément de ce que l’on est en train de construire.
Les solutions qui consistent à chercher des optimisations de performance dans un système pour en extraire les performances restantes sont vraiment réjouissantes
Ça me rappelle le livre de Richard L. Sites, Understanding_Software_Dynamics. Ce livre explique comment mesurer et corriger les problèmes de latence, et à quel point la réduction de la latence à grande échelle peut générer des économies importantes
Mesurer ce genre de problème et raisonner dessus est difficile, mais les solutions sont souvent simples. Par exemple, à la page 9, il dit : « [a] simple change paid for 10 years of my salary. »
J’aimerais un jour réaliser une optimisation ayant un tel impact
https://research.google/pubs/pub36575/
Mais il y a aussi beaucoup de gens brillants autour de soi, donc si l’on trouve une grosse opportunité, il y a généralement une raison pour laquelle personne d’autre ne s’y est attaqué. Elle peut être technique ou organisationnelle
Pour prendre un exemple de ce dernier cas, Google ne récompense généralement pas beaucoup ce type de travail, sauf lorsqu’il y a une pression sur les ressources. Il se peut que j’aie reçu environ 100 dollars de bonus entre collègues pour une optimisation, mais certainement pas une commission de 10 %, une promotion, ni le droit de ne pas venir travailler pendant 10 ans tout en étant payé. En général, l’entreprise préfère que les ingénieurs travaillent à la croissance du chiffre d’affaires plutôt qu’à la réduction des coûts. Savoir si cette politique est la bonne dépasse mon niveau de rémunération
À ce stade, il n’était plus possible d’augmenter la configuration en quelques clics dans la console d’administration, et c’est sans doute pour cela qu’il a fallu vraiment se creuser la tête pour résoudre le problème de capacité. Si cette partie précise du code avait été optimisée plus tôt, une configuration d’instance aussi grosse n’aurait peut-être jamais été nécessaire