Une bonne conception de système
(seangoedecke.com)- Une bonne conception de système est une architecture qui ne paraît pas complexe et qui fonctionne longtemps sans problème particulier
- La partie la plus difficile de la conception de système est la gestion de l’état (state), et il est important de réduire autant que possible le nombre de composants qui stockent de l’état
- La base de données est généralement l’endroit où l’état est conservé ; il faut donc privilégier une approche centrée sur la conception du schéma et l’indexation, ainsi que sur la suppression des goulots d’étranglement
- Des éléments comme le caching, le traitement d’événements et les tâches en arrière-plan doivent être introduits avec prudence pour les performances et la maintenabilité, et il vaut mieux éviter d’en abuser
- Plutôt qu’une conception complexe, l’essentiel pour construire un système durable et stable est d’utiliser correctement des composants et des méthodes simples, suffisamment éprouvés
Définition de la conception de système et approche globale
- Si la conception logicielle consiste à assembler du code, la conception de système consiste à combiner différents services
- Les principaux composants d’une conception de système sont les serveurs applicatifs, bases de données, caches, files, bus d’événements, proxys, etc.
- Une bonne conception suscite des réactions comme « il n’y a pas de problème particulier », « ça s’est terminé plus facilement que prévu » ou « il n’y a pas besoin de se préoccuper de cette partie »
- À l’inverse, une conception complexe et tape-à-l’œil peut masquer un problème fondamental ou révéler une surconception
- Plutôt que d’introduire d’emblée un système complexe, il est préférable de partir d’une structure simple, minimale mais fonctionnelle, puis de la faire évoluer progressivement
Distinction entre état (state) et sans état (stateless)
- En conception logicielle, la partie la plus délicate est précisément la gestion de l’état
- Un service qui ne stocke pas d’information et renvoie immédiatement un résultat (comme le rendu PDF de GitHub) est stateless
- En revanche, un service qui écrit dans une base de données gère de l’état
- Il est préférable de réduire au maximum le nombre de composants qui stockent l’état dans le système. Cela diminue la complexité du système et le risque de panne
- Une architecture où une seule partie gère l’état, tandis que les autres services se concentrent sur des rôles stateless comme l’appel d’API ou l’émission d’événements, est recommandée
Conception de la base de données et points de goulot d’étranglement
Conception du schéma et des index
- Pour stocker les données, il faut une conception de schéma lisible par des humains
- Un schéma trop flexible (par exemple, tout stocker dans une colonne JSON) peut peser sur le code applicatif et sur les performances
- Il faut définir des index adaptés en fonction des colonnes qui seront fréquemment interrogées. Indexer absolument tout crée au contraire un surcoût inutile
Méthodes de résolution des goulots d’étranglement
- L’accès à la base de données constitue souvent un goulot d’étranglement lourd
- Quand c’est possible, il est plus performant de traiter les données complexes dans la base elle-même, via des JOIN par exemple, plutôt que dans l’application
- Lorsqu’on utilise un ORM, il faut faire attention à ne pas déclencher des requêtes dans une boucle
- Selon les besoins, on peut aussi scinder les requêtes afin de mieux gérer la charge sur la base ou la complexité des requêtes
- Une stratégie efficace consiste à répartir les requêtes de lecture sur des read replicas afin de réduire la charge du nœud principal d’écriture
- Lorsqu’un grand volume de requêtes arrive, les transactions et les écritures peuvent facilement surcharger la base de données ; il faut donc envisager le query throttling
Séparer les tâches lentes des tâches rapides
- Les opérations avec lesquelles l’utilisateur interagit doivent répondre en quelques centaines de millisecondes
- Pour les tâches longues (par exemple une conversion PDF volumineuse), il est efficace de fournir immédiatement le strict minimum côté front, puis de déléguer le reste en arrière-plan
- Les tâches en arrière-plan fonctionnent généralement avec une file (par exemple Redis) et un job runner
- Pour les tâches planifiées très en avance, il est souvent plus pratique de les gérer dans une table dédiée en base de données plutôt que dans Redis, puis de les exécuter via un scheduler
Caching
- Le caching permet de réduire les coûts et d’améliorer les performances lorsqu’on répète des opérations identiques ou coûteuses
- En général, les ingénieurs juniors qui découvrent le cache veulent tout mettre en cache, tandis que les ingénieurs plus expérimentés sont plus prudents sur son adoption
- Le cache introduit un nouvel état ; il existe donc des risques de synchronisation, d’erreurs et de données périmées
- Il est préférable d’essayer d’abord des optimisations comme l’ajout d’index sur les requêtes, puis d’appliquer le caching si nécessaire
- Pour des caches volumineux, on peut aussi utiliser une approche consistant à stocker périodiquement les données dans un stockage de documents comme S3 ou Azure Blob Storage, plutôt que dans Redis/Memcached
Traitement des événements
- La plupart des entreprises disposent d’un event hub (par exemple Kafka), et divers services y distribuent le traitement de manière orientée événements
- Plutôt que de multiplier les événements, une simple conception d’API en requête-réponse est plus utile pour la journalisation et la résolution de problèmes
- Le traitement orienté événements est adapté lorsque l’émetteur n’a pas à se soucier du comportement du récepteur, ou dans des scénarios à fort volume et tolérants à la latence
Modes de transfert des données : push et pull
- Pour le transfert de données, il existe deux approches : Pull (requête puis réponse) et Push (transmission automatique lors d’un changement)
- L’approche Pull est simple, mais elle peut entraîner des requêtes répétées et une surcharge
- Avec l’approche Push, le serveur transmet immédiatement les changements au client ; elle est donc plus efficace et plus adaptée au maintien de données à jour
- Pour gérer un grand nombre de clients, il faut faire évoluer l’infrastructure selon l’approche choisie (file d’événements, plusieurs serveurs de cache, etc.)
Se concentrer sur les hot paths
- Les hot paths désignent les chemins les plus importants du système, là où circule le plus de données
- Les hot paths offrent peu d’alternatives et, si leur conception échoue, ils peuvent provoquer de graves problèmes pour l’ensemble du service ; une conception soignée est donc indispensable
- Plutôt que de disperser les ressources sur des fonctionnalités mineures aux nombreuses options, il est plus efficace de concentrer la conception et les tests sur les hot paths
Journalisation, métriques et traçage
- En cas d’incident, il faut consigner activement des logs détaillés sur les chemins anormaux (unhappy path) afin de faciliter le diagnostic
- Il est nécessaire de collecter des indicateurs d’observabilité de base comme les ressources système (CPU/mémoire), la taille des files, ou les temps de requête et de traitement
- Au lieu de ne regarder que la moyenne, il faut absolument observer aussi des indicateurs de distribution comme les latences p95 et p99. Une petite fraction des requêtes les plus lentes peut concerner les utilisateurs les plus importants
Kill switch, retries et reprise après incident
- L’usage stratégique d’un kill switch (blocage temporaire du système) et des retries est important
- Retenter sans discernement ne fait qu’alourdir les autres services ; il est plus efficace de contrôler les requêtes en amont avec un circuit breaker, par exemple
- L’introduction d’une Idempotency Key permet d’éviter les opérations en double lors du retraitement d’une même requête
- Dans certaines situations de panne, il faut choisir entre fail open et fail closed. Par exemple, pour le rate limiting, un fail open (autoriser) a moins d’impact sur l’utilisateur. En revanche, pour l’authentification, le fail closed est indispensable
Conclusion
- Certains sujets comme la séparation des services, les conteneurs, l’introduction de VM ou le tracing ont été laissés de côté, mais utiliser des composants bien éprouvés au bon endroit reste, sur le long terme, la manière la plus stable de construire un système
- Les conceptions techniquement « spéciales » sont en réalité très rares, et une conception simple au point d’en être ennuyeuse est au contraire celle qu’on rencontre le plus souvent en pratique
- Fondamentalement, une bonne conception de système consiste à combiner de manière sûre des méthodes suffisamment éprouvées, sans chercher à attirer l’attention
1 commentaires
Commentaires Hacker News
J’ai souvent l’impression d’être seul sur ce point. Quand les ingénieurs voient un système complexe, ils y trouvent plein d’éléments intéressants et se disent : « c’est là qu’il y a de la vraie conception de systèmes ! » Alors qu’en réalité, un système complexe est souvent le résultat d’une absence de bonne conception. Si vous cherchez un emploi, il faut toutefois complètement oublier ce fait pendant l’entretien. J’ai moi-même fait l’erreur d’exprimer honnêtement cette idée lors d’un entretien de system design. Dans un entretien hypothétique pour une app de startup, j’ai répondu des choses comme « à ce niveau de QPS, on peut ignorer le backpressure », « inutile d’utiliser une queue à la place d’un cron job, même s’il y a bien sûr des trade-offs », « SQL vs NoSQL ? Il faut prendre ce que l’équipe maîtrise le mieux ». Mais ce n’est pas le type de réponse que les recruteurs veulent entendre. Il faut remplir le tableau blanc et montrer une architecture si compliquée que Kubernetes gère Kubernetes pour envoyer les signaux qu’ils attendent
Je parle en ayant passé des centaines d’entretiens de system design et formé plusieurs personnes. Les réponses que tu as mentionnées envoient un signal faible (sauf celle sur les queues) ; ce que les recruteurs veulent vraiment savoir, c’est pourquoi tu prends ces décisions, quels facteurs tu as pris en compte, et entendre ton raisonnement. Si tu n’expliques pas ta réponse en détail, l’intervieweur peut facilement penser : « je n’obtiens pas beaucoup d’informations ». Le candidat doit donc transmettre activement les informations que l’intervieweur attend. Même un bon intervieweur notera quelque chose comme « explication raisonnable mais communication inefficace » s’il doit te tirer les réponses de force. Les compétences en communication sont aussi évaluées. Enfin, je ne suis pas d’accord avec la réponse SQL/NoSQL. L’expérience de l’équipe compte, mais les différences entre technologies sont nettes et les écarts de performance peuvent être importants selon le contexte. Cette réponse donne l’impression d’un manque d’expérience face à des situations variées
Comme on le dit souvent, « un entretien va dans les deux sens », et je trouve tes réponses tout à fait raisonnables. Si j’avais été l’intervieweur, je t’aurais plutôt mis une bonne note. À l’inverse, si une entreprise te rejette à cause de ce type de réponses, il est possible que ce soit surtout l’entreprise qui ne soit pas terrible. Cela dit, dans la réalité on doit souvent se caser rapidement, donc il faut aussi trouver un équilibre et adapter un peu ses réponses à ce que l’autre veut entendre
Ce conseil n’est pas bon. Une conception simple et élégante ne commence pas par ignorer les problèmes potentiels. Les questions de relance ne sont pas un moment pour réciter des trivia techniques, mais un signal pour discuter ensemble. Tes réponses ne montrent pas de la sagesse ; elles donnent plutôt une impression d’immaturité. Ce n’est pas la faute de l’intervieweur
Je rejoins le point du commentaire voisin sur le fait que « l’entretien est bilatéral », mais un bon intervieweur dira honnêtement : « cette réponse est bonne aussi, mais en ce moment j’évalue tes connaissances sur ce thème précis ». Si la personne continue à parler à côté du sujet, c’est plutôt un signal inquiétant
Je trouve que c’est un exemple parfait de l’existence du LinkedIn-driven development. Dans la réalité, lister une foule de technologies sur son CV paraît bien plus impressionnant que d’expliquer qu’on a très bien utilisé un seul Postgres et un monolithe modulaire
Je trouve que c’est un vraiment très bon article. Mais je voudrais aussi souligner les limites de ce genre de best practices. Par exemple, il y a ce conseil : « au lieu que cinq services différents écrivent dans la même table, faites en sorte que quatre passent par des appels API ou envoient des événements, et qu’un seul service écrive dans la table ». Dans la réalité, ce n’est pas aussi proprement découpé. Si les cinq accèdent à la base, on est déjà en train de construire un système distribué ; or la base fournit déjà nativement les permissions, les transactions et les requêtes personnalisées, ce qui peut éviter de concevoir une interface séparée. En revanche, si l’on crée une interface de plus haut niveau dans un seul service, il faut alors implémenter soi-même l’authentification, les transactions et la gestion des exceptions. On peut se demander si, en pratique, cela n’ajoute pas surtout des modes de panne et la taxe de gestion d’une architecture microservices plus complexe. D’un autre côté, le simple fait que plusieurs services accèdent à la même base peut être un code smell. Cette base est peut-être le vestige de plusieurs bases fusionnées, et ces services pourraient peut-être en réalité être ramenés à deux ou trois
À la question « qu’est-ce qu’on y gagne », une API s’adapte beaucoup mieux au changement qu’un schéma de base partagé. Après avoir travaillé sur plusieurs systèmes, je ne referais pas une architecture où plusieurs services partagent la même base. Cela pouvait peut-être convenir à une petite entreprise au début des années 2000, mais depuis je n’ai vu que des échecs avec ce modèle (sauf dans les cas où seuls les chemins de lecture/écriture sont séparés au sein d’un même service)
Je ne suis pas d’accord avec l’idée que la base de données puisse servir d’interface sans conception supplémentaire. Quand plusieurs clients utilisent la même base, leurs patterns d’accès diffèrent et les problèmes de migration deviennent plus lourds. Au final, il faut ajouter une vraie conception autour des vues, de la gestion des permissions, etc., et la charge de maintenance augmente. Dans une situation idéale, une API est bien plus propre. En réalité, la pression pour livrer vite pousse souvent à autoriser l’accès direct à la base comme raccourci, mais au fond c’est surtout parce que beaucoup de gens rechignent à refaire l’ensemble de la conception pour s’adapter à de nouvelles exigences ou à un nouveau design
Lorsqu’un changement devient nécessaire, le but est de minimiser le périmètre de coordination. Si l’on doit modifier la structure d’un datastore, il faut contrôler toutes les parties qui y accèdent ; plus il y a peu de points d’accès, plus le changement est facile. Par exemple, dans mon travail, quand on a scindé une base de données, plus de 40 équipes ont dû modifier leur code. Et ça, c’était pour une simple « demande de fonctionnalité ». Si le changement avait été motivé par un problème de « scalabilité », le produit lui-même aurait pu casser
Tu as qualifié de « code smell » le fait que plusieurs services se connectent à une même base, mais à l’inverse, si chaque service doit avoir sa propre base physique, la disponibilité peut passer de N à N puissance M et rendre le tout plus instable en pratique (si l’on raisonne au niveau des clusters de base de données)
Quand on interroge une base de données, le plus efficace est souvent de laisser la base faire le travail. Si l’on a besoin de données provenant de plusieurs tables, il vaut mieux utiliser des jointures plutôt que de faire plusieurs requêtes dans l’application puis de tout recomposer. Et je recommande vivement les vues, voire même les procédures stockées. Les vues constituent une couche d’abstraction des données, ce qui aide énormément à la conception, et du SQL bien écrit reste facile à comprendre et à maintenir
C’est justement pour cela que les ORMs causent tant de problèmes. Dans un environnement SSR, utiliser directement des vues SQL ou des requêtes personnalisées dans chaque vue MVC est la manière de rendre un grand service web à la fois efficace et élégante. Il faut confier le gros du travail au RDBMS, puis laisser le serveur web transmettre directement les résultats SQL aux tables. Des RDBMS hérités comme MSSQL ou Oracle contiennent énormément d’optimisations intégrées. À l’inverse, les ORMs imposent un modèle d’objet unique et manquent presque totalement de souplesse
Les procédures stockées ont l’air utiles, mais en pratique, les limites du langage (T-SQL, etc.) rendent difficile le fait de développer dans un langage moderne que tous les membres de l’équipe maîtrisent. Je maintiens une grosse base de code T-SQL, et les outils de versioning ou de diagnostic sont médiocres ; le code des nouveaux arrivants reste lisible, mais le T-SQL est un cauchemar
Je ne suis pas d’accord. Dans les architectures modernes axées sur la scalabilité, il vaut mieux faire les jointures dans le backend devant la base. Si on structure le système pour laisser à la base les simples recherches indexées et effectuer les jointures côté backend, la scalabilité de la base est meilleure et les performances aussi. Il est plus facile d’ajouter des instances serveur que de faire grossir la base. Si les jointures concernent un volume tellement massif qu’elles ne peuvent raisonnablement être faites qu’en base, alors c’est à ce moment-là qu’il faut changer l’architecture. Et si l’on peut pousser les jointures jusqu’au front, cela aide aussi le cache de résultats et devient avantageux
Vraiment ? Par exemple, avec 10 000 clients et 1 000 000 de commandes, si l’on joint puis transmet une table clients à 20 champs et une table commandes à 5 champs, on envoie 25 000 000 de champs. Si on récupère les deux séparément avec deux requêtes puis qu’on joint ensuite, on tombe à 5 000 000 de champs pour les commandes et 200 000 pour les clients. En bande passante et en performance, c’est bien meilleur
Cette règle est un bon point de départ, mais il faut bien savoir quand une exception est nécessaire. Une application dont je m’occupais avait une structure où les jointures faisaient exploser le nombre d’enregistrements de manière exponentielle. En séparant les requêtes, on a obtenu de bien meilleures performances, car le gain sur le traitement/filtrage des résultats dépassait largement le surcoût réseau. Plus tard, on est même passé à une structure où toutes les données étaient stockées en JSONB, et c’était encore mieux
Je trouve dommage que, dans une discussion sur la bonne system design, le domaine du problème ne soit pratiquement jamais évoqué. L’élément le plus central et le plus difficile en system design, c’est l’interface que le système fournit à ses utilisateurs. Au fond, un système logiciel est toujours une sorte de troc avec le problème : « je vous offre cette fonctionnalité, mais en échange il faut comprendre cette structure ou ce modèle ». Les erreurs de conception d’interface sont les plus coûteuses ; si l’on ne passe pas l’essentiel de son temps à parler des interfaces, on manque ce qui est vraiment important. Tout le reste du système peut ensuite être corrigé autant qu’on veut sans affecter les utilisateurs
Je me reconnais très concrètement dans cette idée que « une bonne conception ne se remarque pas, alors qu’une mauvaise conception paraît plus convaincante ». On dirait que l’évaluation des techniciens se fait selon le critère de la « complexité », ce qui encourage la surconception. Le principe KISS n’est pas assez reconnu depuis bien trop longtemps
Il m’arrive parfois de revenir sur des parties d’une base de code auxquelles je n’avais même pas particulièrement réfléchi sur le moment ; et au fond, c’est justement le signe qu’il y avait là une bonne conception
C’est malheureusement vrai. La plupart des gens sont plus attirés par les solutions complexes, et proposer une réponse simple peut donner l’impression d’être incompétent. Pourtant, dans la pratique, une structure simple et facile à gérer contribue bien davantage au succès global d’un projet. Bien sûr, certains problèmes sont inévitablement complexes, mais la plupart du temps on a juste affaire à une application web banale
Dans la conception de schéma, le plus important est la flexibilité. Une fois que les données s’accumulent, changer le schéma devient très difficile. Mais si l’on conçoit quelque chose de trop flexible (mettre toutes les données dans du JSON ou dans une structure EAV !), le code applicatif devient infiniment plus complexe et l’on ajoute toutes sortes de problèmes de performance bizarres. En général, je préfère donc les schémas lisibles par des humains, au point qu’en regardant la structure des tables on comprenne intuitivement à quoi elles servent. Quand je vois trop souvent de l’EAV ou des colonnes/tables JSON, j’ai vraiment envie d’arrêter le développement. L’EAV a certainement des cas d’usage valables, mais dans la plupart des cas cela ne crée que de la confusion sur le terrain. Les problèmes N+1, la génération dynamique de requêtes, le fait de stocker les données d’audit dans la même base pour qu’elles finissent absorbées par la logique métier, les environnements Oracle compliqués, ou encore une mauvaise séparation entre ce qui doit aller en base et ce qui doit rester dans l’application : chacun de ces facteurs détériore énormément la qualité de vie des développeurs
À ce sujet, le livre de Bill Karwin, “SQL Antipatterns”, présente très bien les risques et les limites du pattern EAV. Malgré cela, quand il est difficile de dessiner un schéma (par exemple avec une colonne JSONB dans Postgres), cela peut parfois servir de solution temporaire, mais cela ne peut pas devenir une règle de bonne pratique. Si la normalisation est possible, il vaut toujours mieux la choisir
À propos de « si l’on stocke les données d’audit dans la même base, elles finissent par devenir une partie de la logique métier, ce qui pose problème » : du coup, quelle est la solution « orthodoxe » ? Une base séparée ? Un stockage complètement indépendant ?
Concernant le conseil « évitez que cinq services écrivent dans la même table ; faites en sorte que quatre passent seulement par des appels API ou des événements, et qu’un seul écrive directement en base », l’idéal est surtout d’avoir une architecture où cinq services n’ont pas besoin d’écrire dans la même table dès le départ. Si c’est le cas, il y a peut-être en réalité un fort chevauchement de logique entre ces services. Il faut alors se demander s’ils doivent vraiment être cinq services distincts, et s’il n’est pas possible d’en fusionner certains. En pratique, on peut parfois résoudre le problème en attribuant des tables de données séparées ou en faisant du refactoring
La distinction stateful/stateless est essentielle pour répartir les responsabilités entre l’infrastructure et le développement. Quand on fait tourner des conteneurs de manière stateless, peu de choses peuvent vraiment mal tourner ; si ça échoue, il suffit de redéployer. Tant qu’on évite les erreurs en base assez graves pour corrompre les jeux de données, on peut généralement rétablir le service rapidement. Des personnes aux niveaux d’expérience, au temps disponible et au sérieux très variables peuvent gérer ça sans trop de problème. En revanche, dès qu’il y a de l’état, comme avec les bases de données ou le stockage de fichiers, c’est complètement différent. Une seule erreur peut mettre l’ensemble de l’activité en danger ; il faut donc du personnel dédié avec une solide expérience terrain. Une base peut fonctionner en apparence sans problème, mais sans sauvegardes c’est déjà un risque majeur. Et dans ce genre de domaine, ce n’est pas un problème qu’on résout en quelques minutes avec un déploiement
À propos du conseil « utiliser un timestamp au lieu d’un bool », je me demande si ce n’est pas une recommandation trop générale. Par exemple,
is_on→true,on_at→1023030, c’est clair ; maisis_a_bear→true,a_bear_at→12312231231, c’est complètement absurde. La plupart des ours ne « deviennent » pas ours à un moment donné… Cela ne marche que dans des cas précisJe pense qu’il vaut mieux utiliser un timestamp ou un integer à la place d’un bool dans presque tous les cas. En particulier, les champs à seulement deux états ont souvent tendance à évoluer vers une « classification de type ». Même s’il n’y a que des ours aujourd’hui, il vaut mieux anticiper une extension vers un enum ; et les champs d’état eux aussi finissent souvent par s’élargir au-delà du simple actif/inactif, vers arrêté, supprimé, en pause, etc. À force d’ajouter des booleans, cela complexifie au lieu de simplifier. Un integer est préférable
Si on prend l’énoncé au pied de la lettre, cela veut dire qu’utiliser un boolean dans une base est en soi une odeur de conception, et je suis assez d’accord. En revanche, ce genre d’approche (remplacer un bool par un timestamp) est souvent surtout une commodité dans les jointures, pas une « solution complète ». Si les changements en temps réel comptent, une table d’audit est probablement la bonne réponse dès le départ. Même chose pour le soft delete : cela me semble aussi une solution tiède. Si l’intention réelle est d’empêcher l’effacement, des sauvegardes et des restaurations offrent en fait une protection plus efficace
Le type boolean occupe moins d’espace, donc il est plus efficace pour certaines charges de travail, notamment sur de gros volumes analytiques. Et il existe bien des cas où stocker logiquement un booléen est la bonne solution. Par exemple, pour le résultat d’un processus (succès/échec), un boolean est tout à fait pratique
Je me demande s’il y a vraiment une raison de réserver ce traitement aux seuls booleans pour les représenter avec un timestamp. Avec
isDarkTheme,paginationItems, etc., on peut aussi vouloir connaître le moment du changement. On a presque l’impression d’un poor-man changelogDans ce cas, il vaut mieux utiliser une valeur enum comme
BearSi vous cherchez un livre permettant d’aborder la bonne conception de systèmes sous un angle plus abstrait, je recommande très fortement Systemantics de John Gall. Je pense que c’est une lecture indispensable pour un ingénieur