Conception de systèmes backend selon les goulots d’étranglement : du raccourcisseur de URL au e-commerce
Une approche axée sur les exigences pour concevoir des backends Node.js : quand ajouter des balanceurs de charge, Redis, des réplicas, des files d’attente et des limites de vitesse, ainsi que le coût de chacun d’eux.
Sources: System Design for Backend Engineers: A Practical Guide With Real Examples
De nombreux ingénieurs abordent la conception de systèmes en assemblant un diagramme : un équilibreur de charge, Redis, Kafka, une passerelle API, une douzaine de microservices. Le résultat semble impressionnant mais ne dit rien. Que ce soit lors d’un entretien ou pour un produit réel, la conception consiste à choisir les composants appropriés pour résoudre un problème spécifique et à pouvoir justifier chacun d’eux. Ce guide présente ce raisonnement à travers deux systèmes concrets, un raccourcisseur d’URL et un petit backend de e-commerce, afin que vous puissiez développer une architecture étape par étape, en justifiant chaque choix, et identifier les compromis introduits à chaque étape.
Partez des exigences, pas de la technologie
La première erreur courante est de commencer par les outils. Quelqu’un décrit un problème et les premières pensées sont : « Redis ou pas ? Kafka ? Microservices ? » Cette séquence est inversée. Tant que l’on ne sait pas ce que le système doit faire et à quelle échelle, on n’a aucune base pour choisir quoi que ce soit.
Prenons un raccourcisseur d’URL. Avant même de dessiner le moindre élément, il faut des réponses à des questions comme celles-ci :
- Combien d’utilisateurs y aura-t-il, et combien d’URL créeront-ils ?
- Quel taux de requêtes doit-il gérer en période de pointe ?
- Pendant combien de temps les liens restent-ils valides, et peuvent-ils expirer ?
- Les utilisateurs ont-ils besoin d’analyses de clics ?
- Quel temps d’arrêt est acceptable ?
- À quelle vitesse un redirigeant doit-il fonctionner ?
Chaque réponse oriente le design. Un service qui reçoit autant de trafic :
1,000 requests/day
n’a presque rien en commun avec un service qui doit gérer autant de trafic :
10 million requests/secon
Le premier s’adapte facilement à un petit serveur unique. Le second oblige dès le départ à prendre des décisions concernant le cache, la réplication et la géographie.
Commencez avec l’architecture la plus simple qui fonctionne
Pour un raccourcisseur d’URL de petite taille, la première version peut être presque extrêmement simple :
Client
↓
Node.js API
↓
PostgreSQL
L’API génère un code court et renvoie un lien du type :
https://example.com/abc123
Elle stocke ensuite une seule correspondance dans PostgreSQL :
short_code → original_url
Lorsqu’un navigateur demande un chemin comme celui ci-dessous, l’API cherche l’URL originale et répond par une redirection :
/abc123
Pour une charge de travail modérée, il s’agit d’un système complet et correct. Rien dans ce système ne nécessite les solutions habituelles :
Kafka
Redis
Kubernetes
Microservices
Les ajouter simplement parce qu’ils sont disponibles ne fait qu’augmenter les coûts opérationnels, les modes de défaillance et la charge d’apprentissage pour chaque nouveau membre de l’équipe. Développez quelque chose qui fonctionne, mesurez-le, puis élargissez progressivement les parties qui se révèlent être des goulets d’étranglement.
Échellez horizontalement lorsque un seul serveur ne suffit pas
Supposons que le service d’abréviation devienne populaire et que son unique processus API soit submergé :
1 API server
On pourrait continuer à acheter des machines plus puissantes, mais l’échelle verticale a ses limites et une seule machine représente un point unique de défaillance. La solution la plus courante consiste à exécuter plusieurs instances identiques derrière un équilibreur de charge :
Load Balancer
↓
┌───────────┼───────────┐
↓ ↓ ↓
Node API Node API Node API
↓ ↓ ↓
└───────────┼───────────┘
↓
PostgreSQL
Les requêtes sont désormais réparties sur plusieurs instances d’application. C’est ce qu’on appelle l’échelle horizontale : au lieu de compter sur
1 huge server
on compte sur
multiple smaller servers
La mise à l’échelle horizontale ne fonctionne que si les instances sont interchangeables, ce qui soulève des questions qui n’existaient pas auparavant :
- Où se trouve l’état de la session, si la prochaine requête d’un utilisateur peut cibler une instance différente ?
- Toute instance est-elle vraiment sans état ?
- Où sont stockés les fichiers téléchargés, puisque le disque local n’est plus partagé ?
- Comment chaque instance accède-t-elle à des infrastructures partagées telles que la base de données ?
Mettre à l’échelle un composant a tendance à créer des exigences ailleurs ; ici, l’état doit être transféré dans des stores partagés.
Ajoutez un cache uniquement lorsque vous avez réellement un problème de mise en cache
Les liens courts populaires sont demandés encore et encore :
/abc123
/abc123
/abc123
/abc123
/abc123
Il n’y a aucune raison pour que chacune de ces requêtes interroge PostgreSQL. Un cache placé en amont de la base de données fournit les données fréquemment demandées depuis la mémoire, recourant à la base de données en cas d’échec et stockant le résultat pour une utilisation ultérieure :
Client
↓
Load Balancer
↓
Node.js API
↓
Redis
│
├── Cache Hit → Return URL
│
└── Cache Miss
↓
PostgreSQL
↓
Redis
Cette approche basée sur un cache peut réduire considérablement la charge de lecture sur la base de données dans le cas d’un travail chargé comme les redirections de liens. Cependant, elle soulève une nouvelle question : si la destination d’un lien court est modifiée ou supprimée, comment la copie en cache le sait-elle ? Il devient alors nécessaire d’adopter une stratégie d’invalidation, que ce soit en supprimant la clé à chaque écriture, en définissant une durée de vie ou les deux. Pour en savoir plus sur ces options, consultez notre guide sur les modèles et pièges liés au cache Redis.
Chaque amélioration dans ce guide s’accompagne d’un coût comme celui-ci. La capacité à identifier ce coût est ce qui distingue un design d’un diagramme.
Réparez la base de données avant d’en ajouter d’autres
À un certain moment, PostgreSQL peut sembler être le goulot d’étranglement. Avant d’ajouter de nouvelles infrastructures, découvrez pourquoi elle est lente. Les causes fréquentes incluent :
- Manque d’index sur les colonnes utilisées pour les recherches
- Requêtes ou jointures inefficaces
- Modèles de requêtes N+1 dans l’application
- Retour de bien plus de lignes ou de colonnes que nécessaire
- Pagination insuffisante, comme des offsets trop élevés
- Pool de connexions mal configuré ou épuisé
Très souvent, la solution se résume à une seule instruction :
CREATE INDEX ...
C’est moins coûteux et plus sûr qu’une nouvelle composante. Règle à retenir : réparez d’abord le goulot d’étranglement que vous avez identifié avant d’y ajouter une architecture autour.
Répliques de lecture pour les charges de travail axées sur la lecture
De nombreuses applications lisent bien plus qu’elles n’écrivent, par exemple :
90% reads
10% writes
Dans ce cas, la base de données principale peut être submergée par le trafic de lecture. Une solution consiste à ajouter des répliques de lecture : la base principale gère toutes les écritures, tandis que les répliques prennent en charge une partie des lectures.
Node.js API
↓
┌────────┴────────┐
↓ ↓
Primary DB Read Replica
↓ ↑
Writes Reads
Le coût du retard de réplication
Dans la plupart des configurations, les répliques sont mises à jour de manière asynchrone, ce qui fait que l’écriture atteint d’abord la base principale avant de parvenir à une réplique :
Write → Primary
↓
Replication
↓
Read Replica
Si une requête écrit quelque chose puis le lit immédiatement à partir d’une réplique, elle peut voir des données obsolètes pendant une courte période. L’application doit donc savoir quelles lectures nécessitent l’état le plus récent. Une approche courante consiste à envoyer des requêtes de type « lire ses propres écritures », comme charger un profil juste après l’avoir enregistré, vers la base principale, et à diriger tout le reste vers les répliques.
Déplacer les tâches lentes en jobs en arrière-plan
De nombreuses opérations n’ont pas besoin d’être terminées avant que l’utilisateur ne reçoive une réponse :
- Envoi d’e-mails
- Génération de factures
- Envoi de notifications
- Création de rapports
- Appel à des services externes lents
Au lieu de faire attendre l’utilisateur, l’API enregistre ce qui doit être fait, insère la tâche dans une file d’attente et répond immédiatement :
Client
↓
Node.js API
↓
Create Record
↓
Queue Job
↓
Response
Un processus travailleur distinct prend en charge la tâche et l’exécute :
Queue
↓
Worker
↓
Process Job
En pratique, une seule file d’attente peut alimenter des travailleurs qui gèrent plusieurs types de tâches :
API
↓
Redis / Queue
↓
Worker
├── Email
├── Invoice
└── Notification
Cela maintient une faible latence des requêtes et offre un endroit contrôlé pour les tentatives de réexécution ainsi que les limites de débit. Cela soulève également son propre ensemble de problèmes à prendre en compte :
- Une tâche peut être exécutée plus d’une fois, donc les traitements doivent supporter les doublons.
La scalabilité n’est jamais gratuite. Si vous développez cela en Node.js, notre guide sur les systèmes de tâches en arrière-plan avec BullMQ et Redis aborde ces problèmes à travers du code.
Concevoir pour l’échec avant qu’il ne se produise
Un travail de conception intéressant commence lorsque l’on cesse de se demander uniquement ce qui se passe quand tout fonctionne et que l’on commence à se demander ce qui se passe lorsque quelque chose tombe en panne.
Fonctionner de manière dégradée lorsque une dépendance tombe en panne
Supposons que Redis devienne indisponible. L’API doit-elle s’arrêter complètement ? Si Redis est utilisé uniquement comme cache, il ne devrait probablement pas l’être. L’API peut ignorer le cache et lire directement dans PostgreSQL, ce qui est plus lent mais toujours correct :
Request
↓
Redis
↓
FAIL
↓
PostgreSQL
↓
Response
Si Redis gère également votre file d’attente de tâches ou votre limite de vitesse, une panne devient bien plus grave, car ces fonctions n’ont pas de solution de secours simple. La conception doit préciser le rôle de chaque composant, car c’est ce rôle qui détermine le comportement du système en cas de panne de ce composant.
Imposer un délai d’attente à chaque appel externe
Imaginez que l’API appelle un service de paiement externe sans délai d’attente configuré :
API
↓
Payment Service
↓
... waiting ...
↓
... waiting ...
↓
... waiting ...
Pendant qu’elle attend, le serveur maintient des sockets, de la mémoire et des slots de demande occupés pour une dépendance qui ne répondra peut-être jamais. Sous charge, ces demandes bloquées peuvent épuiser tout le processus. Un système bien conçu fixe une date limite à chaque appel :
API
↓
Payment Service
↓
5 second timeout
↓
Failure
Lorsque la date limite est dépassée, l’application doit décider de la marche à suivre, et le choix approprié dépend de l’opération :
- Réessayer l’appel
- Retourner une erreur au client
- Mettre l’opération en file d’attente pour plus tard
- Utiliser une solution de secours
- Marquer l’opération comme en attente et la régler ultérieurement
Les tentatives de réessai ne sont sûres que lorsque les opérations sont idempotentes
Lorsqu’un appel de paiement expire, il semble évident de le réessayer. Cependant, une expiration ne permet pas de savoir si le paiement a échoué. Le chargement a peut-être réussi et seule la réponse a été perdue. Un réessai aveugle pourrait facturer le client deux fois.
La solution réside dans l’idempotence : associer un identifiant stable à l’opération afin que la partie destinataire puisse reconnaître une répétition.
payment_id = order_123
Le fournisseur de paiement utilise cette clé pour détecter que la demande a déjà été traitée et renvoie le résultat initial au lieu de procéder à un nouveau prélèvement :
Request
↓
Idempotency Key
↓
Payment Provider
↓
Already processed?
├── Yes → Return previous result
└── No → Process payment
Ajouter des tentatives de réessai ne nécessite qu’une ligne de code. En faire des opérations sûres exige que l’ensemble du système coopère. Notre article sur les clés d’idempotence dans les endpoints POST de Node.js montre comment mettre en œuvre cela soi-même du côté récepteur.
Protéger le système avec une limitation de débit
Supposons maintenant qu’un seul client commence à générer autant de trafic :
100,000 requests/minute
Accepter tout ce trafic permettrait à un utilisateur unique de détériorer les services pour tous les autres. Un limiteur de débit est placé en amont de l’API et rejette les demandes excessives avec un code HTTP 429 :
Client
↓
Rate Limiter
↓
Allowed?
├── Yes → API
└── No → 429
Avec plusieurs instances d’API, les compteurs doivent être partagés, c’est pourquoi Redis est un stockage de référence populaire pour la limitation des fréquences distribuée : chaque instance incrémente les mêmes clés.
Les limites doivent refléter ce que fait chaque point de terminaison. Un point de terminaison de connexion est une cible vulnérable aux attaques par force brute, il est donc raisonnable d’imposer une limite stricte comme celle-ci :
Login:
10 requests/minute
En revanche, une liste de produits publique peut supporter bien plus de trafic :
GET /products:
1000 requests/minute
Fixez les limites en fonction du coût de l’opération et de son modèle de menace, plutôt que d’appliquer un chiffre global.
La conception de système repose principalement sur des compromis
Rarement existe-t-il une architecture parfaite unique. La plupart des décisions consistent à choisir entre des options qui sont chacune adaptées à des choses différentes.
SQL contre NoSQL
Caché versus base de données
Monolithe versus microservices
Préférer un monolithe modulaire aux microservices initiaux
Ce choix mérite sa propre section, car c’est là que l’over-engineering est le plus coûteux. Un nouveau produit n’a pas besoin de commencer en tant que ensemble de services tels que ceux-ci :
Auth Service
User Service
Product Service
Order Service
Payment Service
Notification Service
Une meilleure option par défaut est une application unique déployable :
Node.js Application
organisée en interne en modules aux frontières claires :
├── Auth
├── Users
├── Products
├── Orders
├── Payments
└── Notifications
On obtient une seule base de code, un seul déploiement et une séparation explicite entre les domaines. Si un module doit ultérieurement être échelonné ou déployé de manière indépendante, les frontières claires facilitent son extraction.
Les microservices résolvent bien des problèmes réels liés à l’organisation et à l’échelle. Cependant, ils entraînent également des pannes réseau entre les services, un suivi distribué, une découverte de services, des pipelines de déploiement plus complexes, des défis de cohérence des données au-delà des frontières des services, ainsi qu’un besoin accru en surveillance et en infrastructure. Ne prenez pas en charge ces coûts que lorsque vous avez besoin de leurs avantages.
Résolvez chaque goulot d’étranglement au niveau le plus approprié
Considérez un système à trois niveaux simple avec une base de données lente :
Client
↓
API
↓
Database
Il existe une série de solutions possibles, allant de la moins coûteuse à celle la plus disruptive. Si une requête spécifique est lente, optimisez-la :
Slow query
↓
Optimize query
Si les recherches parcourent toute la table, ajoutez un index :
Missing index
↓
Add index
Si les mêmes données sont récupérées à plusieurs reprises, ajoutez un cache :
Repeated reads
↓
Add cache
Si les lectures dominent, ajoutez des réplicas :
Read-heavy workload
↓
Read replicas
Seuls à une échelle véritablement importante recourt-on à des changements structurels :
Massive scale
↓
Partitioning / sharding / architectural changes
L’ordre est essentiel. Chaque étape n’est entreprise que lorsque la précédente ne suffit plus, et chacune ajoute plus de complexité que la précédente.
Un processus reproductible pour les entretiens de conception
Le même raisonnement s’applique aux entretiens de conception de systèmes. Une séquence solide est la suivante :
- Clarifier les exigences fonctionnelles et non fonctionnelles.
- Estimer le trafic et l’espace de stockage.
- Proposer l’architecture la plus simple qui les satisfasse.
- Identifier où elle échouera en premier.
- Élargir spécifiquement ces goulots d’étranglement.
- Examiner les scénarios de défaillance.
- Discuter des compromis que l’on fait.
- Justifier chaque composant ajouté.
Cette approche est bien plus convaincante que de dessiner immédiatement vingt cases. Un diagramme soigné sans raisonnement derrière ne constitue pas un bon design.
Assemblage : un petit backend e-commerce
En appliquant ces idées à une boutique en ligne, on obtient une architecture de départ raisonnable comme celle-ci :
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
Ce qui la rend défendable, c’est que chaque composant répond à une question :
- Redis gère le cache, les données à durée de vie courte, les compteurs de limitation de vitesse et l’infrastructure de file d’attente.
- PostgreSQL stocke les données métier durables et fournit des transactions relationnelles pour les commandes et les paiements.
- La file d’attente prend en charge les tâches lentes et asynchrones.
- Plusieurs instances API permettent une mise à l’échelle horizontale et de la redondance.
- Le balanceur de charge distribue le trafic entre ces instances.
Remarquez que Redis joue plusieurs rôles ici. Cela est efficace, mais cela signifie également qu’une panne de Redis affecte en même temps le cache, la limitation des débits et le traitement des tâches, ce qui correspond précisément au type de dépendance que l’analyse des pannes mentionnée ci-dessus devrait mettre en évidence.
Six questions à se poser avant de finaliser une conception
- Quel problème cela résout-il ? Si vous ne pouvez pas le formuler clairement, l’architecture n’est pas prête.
- Quelle échelle est requise ? Nombre d’utilisateurs, fréquence des requêtes, volume de données, objectifs en matière de stockage et de latence.
Une complexité adaptée vaut mieux qu’une complexité impressionnante
Le plus grand piège dans l’apprentissage de la conception de systèmes est la croyance selon laquelle une architecture plus complexe doit être meilleure. Ce n’est pas le cas ; elle est simplement plus compliquée. Une bonne conception vise une complexité adaptée.
Si une application Node.js et une base de données PostgreSQL répondent aux exigences, c’est déjà une architecture tout à fait satisfaisante. Lorsque le trafic augmente, ajoutez des instances. Lorsque les lectures deviennent un goulot d’étranglement, optimisez-les. Lorsque le cache est utile, ajoutez Redis. Lorsque certaines tâches peuvent être exécutées plus tard, ajoutez une file d’attente. Lorsqu’un module a réellement besoin d’une scalabilité ou d’un déploiement indépendants, envisagez de l’extraire. L’architecture évolue avec le produit.
Points clés
- Les exigences et les estimations approximatives de capacité viennent en premier ; les choix technologiques en découlent.
- Commencez par le système fonctionnel le plus simple et n’augmentez la capacité que là où les mesures indiquent un goulot d’étranglement.
- Toute ajoutation a un coût : état partagé pour la scalabilité horizontale, invalidation pour les caches, retard pour les réplicas, doublons pour les files d’attente.
- Planifiez explicitement les pannes en prévoyant des solutions de secours, des délais d’expiration, des tentatives idempotentes et des limites de vitesse.