← Blog
·12 blog.minutes

Les fondamentaux de la conception systeme que tout developpeur devrait connaitre

Equilibrage de charge, mise en cache, partitionnement, CDN, files d'attente de messages, theoreme CAP, limite de taux - demystifies avec des exemples concrets et des configurations que vous pouvez reellement utiliser.

Tout developpeur backend finit par se heurter a un mur. L'application fonctionne parfaitement sur votre portable. Elle fonctionne parfaitement en staging avec trois utilisateurs simultanes. Puis vous deployez en production, mille personnes arrivent d'un coup, et la base de donnees s'effondre. L'API commence a retourner des erreurs 5xx, le frontend se bloque, et quelque part dans Slack quelqu'un poste la capture d'ecran qui fait vibrer votre telephone a 2 heures du matin.

La conception systeme est ce qui se trouve de l'autre cote de ce mur. C'est l'ensemble des modeles, compromis et decisions d'infrastructure qui separent une application jouet d'un systeme de production capable de gerer du trafic reel sans s'effondrer. La bonne nouvelle est que vous n'avez pas besoin d'etre un ingenieur infrastructure senior pour comprendre les fondamentaux. Vous devez connaitre environ huit concepts, comment ils interagissent, et quand utiliser chacun.

Cet article couvre les modeles de conception systeme les plus importants pour les developpeurs backend en activite. Chaque section explique le concept, montre un exemple pratique et decrit les compromis que vous devez evaluer avant de l'adopter. Ce ne sont pas des concepts abstraits de manuel - ce sont des outils auxquels vous ferez appel chaque fois que vous construirez quelque chose qui doit passer a l'echelle.

Equilibrage de charge - garder chaque serveur occupe, mais pas trop

L'equilibrage de charge est le modele de conception systeme le plus simple et le plus impactant que vous puissiez implementer. L'idee est simple : au lieu d'envoyer tout le trafic vers un seul serveur, vous distribuez les requetes entrantes sur un pool de serveurs. Cela vous donne deux choses a la fois : une disponibilite plus elevee (si un serveur meurt, les autres continuent a servir) et un debit plus eleve (plusieurs serveurs partagent le travail).

Les algorithmes d'equilibrage de charge les plus courants sont le round-robin, le moins de connexions et le hachage IP. Le round-robin parcourt la liste des serveurs dans l'ordre - simple, previsible, mais ignorant de l'occupation reelle de chaque serveur. Le moins de connexions envoie chaque requete au serveur ayant le moins de connexions actives, ce qui gere mieux les charges de requetes inegales. Le hachage IP utilise l'adresse IP du client pour choisir un serveur de maniere deterministe, ce qui importe quand vous avez besoin de sessions persistantes - garantissant que le meme client atteint toujours le meme serveur.

En pratique, la plupart des configurations de production utilisent une combinaison. Un equilibreur de charge de couche 4 (au niveau TCP) distribue les connexions brutes sur un pool de proxies inverses ou de passerelles API, qui effectuent ensuite un equilibrage de charge de couche 7 (au niveau HTTP) pour acheminer les requetes vers des instances d'application specifiques. Cette approche en couches maintient le plan de donnees rapide et la logique de routage flexible.

# nginx.conf — equilibrage de charge round-robin simple sur trois serveurs d'application
upstream app_cluster {
    round-robin;
    server app1.internal:3000 max_fails=3 fail_timeout=30s;
    server app2.internal:3000 max_fails=3 fail_timeout=30s;
    server app3.internal:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Le detail critique que la plupart des developpeurs neglect est la verification de l'etat de sante. Un equilibreur de charge n'est utile que s'il sait quels serveurs sont en bonne sante. Si app2 plante mais que l'equilibreur continue a lui envoyer du trafic, votre taux d'erreur grimpe d'environ un tiers. Configurez toujours des verifications de sante actives - l'equilibreur de charge ping chaque serveur periodiquement et retire automatiquement ceux qui ne repondent pas.

Mise en cache - de la vitesse, mais a quel prix ?

La mise en cache est l'optimisation de performance la plus efficace a la disposition de tout developpeur. Un seul hit de cache peut transformer une requete base de donnees de 200 millisecondes en une recherche en memoire de 2 millisecondes. Deux ordres de grandeur. Applique a des millions de requetes, cette difference est la difference entre une facture mensuelle de base de donnees de 10 000 $ et une de 500 $.

La pile de cache canonique a trois couches, chacune avec des caracteristiques differentes. La mise en cache au niveau application (caches en memoire comme Redis ou Memcached) stocke les resultats de calculs couteux ou de requetes base de donnees. La mise en cache CDN stocke les actifs statiques et semi-statiques sur des sites périphériques proches des utilisateurs. La mise en cache HTTP utilise les en-tetes Cache-Control et ETag pour permettre aux navigateurs et proxies de mettre en cache les reponses sans impliquer vos serveurs du tout.

La partie la plus difficile de la mise en cache n'est pas sa configuration - c'est invalider le cache quand les donnees sous-jacentes changent. L'industrie s'est arretee sur quelques modeles fiables. Cache-aside (aussi appele chargement paresseux) signifie que l'application verifie d'abord le cache, se rabat sur la base de donnees en cas d'echec, et peuple le cache avec le resultat. Write-through signifie que chaque ecriture va a la fois au cache et a la base de donnees simultanement. Write-behind signifie que les ecritures vont d'abord au cache et sont videes de maniere asynchrone dans la base de donnees.

Il n'y a que deux choses difficiles en informatique : l'invalidation de cache et le nommage. L'invalidation de cache est plus difficile car vous ne pouvez pas simplement la renommer et esperer qu'elle disparaisse.

Pour la plupart des applications, le cache-aside avec une courte duree de vie est le bon defaut. Fixez une TTL qui correspond a votre tolerance aux donnees obsoletes - 60 secondes pour les profils utilisateur, 5 minutes pour les listes de produits, 24 heures pour les donnees de reference. Quand vous avez besoin d'une coherence plus forte, utilisez un cache write-through mais acceptez la latence d'ecriture plus elevee. Quand vous avez besoin d'un debit de lecture maximal et pouvez tolerer une coherence eventual, utilisez cache-aside avec une TTL genereuse.

import redis.asyncio as aioredis
import json

cache = aioredis.Redis.from_url("redis://cache:6379")

CACHE_TTL = 300  # 5 minutes

async def get_user_profile(user_id: str) -> dict:
    key = f"profile:{user_id}"
    cached = await cache.get(key)
    if cached:
        return json.loads(cached)
    profile = await db.fetch_user(user_id)
    if profile:
        await cache.setex(key, CACHE_TTL, json.dumps(profile))
    return profile

Un mot d'avertissement : la mise en cache peut masquer des problemes plutot que les resoudre. Si vos requetes base de donnees sont lentes parce qu'il manque un index, l'ajout d'un cache cache le symptome mais la requete sous-jacente est toujours lente. Le cache est evince, la requete lente s'execute, l'utilisateur attend. Profilez et optimisez toujours d'abord vos chemins lents, puis ajoutez la mise en cache par-dessus la version optimisee.

Partitionnement de base de donnees - diviser le travail pour qu'aucune base de donnees ne se noie

Le partitionnement (sharding) est ce que vous utilisez quand une seule instance de base de donnees ne peut pas gerer votre debit d'ecriture ou la taille de votre jeu de donnees. L'idee est de diviser vos donnees sur plusieurs instances de base de donnees, chaque instance (shard) contenant un sous-ensemble des donnees. L'application determine quel shard interroger en fonction d'une cle de shard - generalement un hachage de l'ID utilisateur, une region geographique ou une plage temporelle.

La cle de shard est la decision la plus importante dans tout systeme partitionne. Une bonne cle de shard distribue les donnees uniformement entre les shards et s'aligne sur vos modeles de requetes. Une mauvaise cle de shard cree des shards chauds - quelques shards qui gerent la plupart du trafic pendant que les autres restent inactifs. Par exemple, partitionner par horodatage de creation semble raisonnable jusqu'a ce que vous realisiez que le shard d'aujourd'hui gere toutes les ecritures tandis que les shards de l'annee derniere n'en gerent aucune.

Le hachage coherent resout le probleme de reequilibrage qui afflige le partitionnement naif. Dans un schema de partitionnement simple base sur modulo (shard = hash(cle) % N), l'ajout d'un nouveau shard necessite de remanager presque toutes les donnees. Le hachage coherent map a la fois les cles et les shards sur un anneau de hachage ; quand vous ajoutez un shard, seules les cles dans le voisinage immediat du nouveau shard doivent etre deplacees. Cela rend la montee et la descente en charge beaucoup moins douloureuses.

  • Partitionnement par hachage — distribue en hachant la cle de shard ; simple et uniforme mais le re-partitionnement est couteux
  • Partitionnement par plage — divise par plages de valeurs (ex : utilisateurs avec IDs 1–10000 sur le shard A, 10001–20000 sur le shard B) ; efficace pour les requetes de plage mais sujet aux points chauds
  • Partitionnement par repertoire — maintient une table de correspondance mappant les cles aux shards ; flexible mais ajoute un saut de recherche et un point de defaillance unique si le repertoire tombe
  • Partitionnement geographique — divise par region utilisateur ; excellent pour la latence mais problematique si les utilisateurs voyagent ou si les donnees doivent etre globales

Le compromis que vous acceptez avec le partitionnement est que les requetes inter-shards deviennent couteuses ou impossibles. Si vous partitionnez votre table users par user_id et votre table orders par user_id, une requete pour toutes les commandes des 30 derniers jours doit frapper chaque shard. Les applications qui necessitent une analyse globale ou des jointures inter-shards utilisent souvent une replica de lecture secondaire (ou une base de donnees analytique dediee) qui agrège les donnees de tous les shards de maniere asynchrone.

Le theoreme CAP — vous ne pouvez en choisir que deux

Le theoreme CAP stipule qu'un magasin de donnees distribue ne peut pas fournir simultanement plus de deux des trois garanties : Coherence (chaque lecture recoit la derniere ecriture), Disponibilite (chaque requete recoit une reponse, meme si ce ne sont pas les donnees les plus recentes), et Tolerance au partitionnement (le systeme continue de fonctionner malgre les pannes reseau entre les noeuds).

En pratique, la tolerance au partitionnement n'est pas optionnelle. Les reseaux tombent en panne. Les paquets sont perdus, les connexions expirent, les centres de donnees perdent l'alimentation. Le vrai choix est donc entre CP (coherence + tolerance au partitionnement) et AP (disponibilite + tolerance au partitionnement). Un systeme CP comme etcd ou Zookeeper refusera de servir des lectures s'il ne peut pas garantir la coherence entre les noeuds. Un systeme AP comme Cassandra ou DynamoDB servira les lectures depuis n'importe quel noeud joignable, meme si ce noeud a des donnees obsoletes.

Ce n'est pas une distinction academique. Quand vous concevez un systeme qui s'etend sur plusieurs centres de donnees, vous devez decider ce qui se passe quand le lien reseau entre eux est coupe. Continuez-vous a servir les requetes avec des donnees potentiellement obsoletes (AP) ? Ou arretes-vous de servir jusqu'a ce que le reseau retablisse (CP) ? La reponse depend de votre application. Un reseau de diffusion de contenu devrait etre AP - un contenu obselete vaut mieux que pas de contenu. Un systeme de traitement des paiements devrait etre CP - vous ne voulez jamais facturer deux fois un client parce que deux noeuds ont accepte le meme paiement pendant une partition.

Files d'attente de messages — transformer la douleur synchrone en grace asynchrone

Les files d'attente de messages sont l'epine dorsale du traitement asynchrone dans les systemes distribues. Elles permettent a un service (le producteur) d'envoyer un message dans une file d'attente sans attendre que le consommateur le traite. Le consommateur recupere le message quand il est pret, le traite et accuse reception. Cela decouple le producteur du consommateur a la fois dans le temps et dans l'espace - ils n'ont pas besoin de fonctionner a la meme vitesse, ni meme en meme temps.

Tout systeme backend non trivial devrait utiliser une file d'attente de messages quelque part. L'exemple canonique est l'envoi d'emails. Quand un utilisateur s'inscrit sur votre plateforme, vous ne voulez pas que la reponse HTTP attende que le service de livraison d'emails rende un template, se connecte a SendGrid et delivre le message. Au lieu de cela, votre API pousse un evenement send_email dans une file d'attente et retourne immediatement une reponse 201 Created. Un worker separe recupere l'evenement, envoie l'email et marque la tache comme terminee.

Les deux modeles dominants de files d'attente de messages sont le publish-subscribe (pub/sub) et les files de travail. En pub/sub, chaque message est diffuse a tous les abonnes. Ceci est utile pour les architectures pilotées par les evenements ou plusieurs services doivent reagir au meme evenement - une nouvelle inscription d'utilisateur peut declencher un email de bienvenue, une mise a jour CRM et un evenement analytique simultanement. Dans une file de travail, chaque message est livre a exactement un consommateur. Ceci est utile pour distribuer le travail sur un pool de workers - chaque telechargement d'image va a exactement un generateur de miniatures.

# docker-compose.yml — configuration minimal RabbitMQ pour le developpement local
version: "3.8"
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"   # Port AMQP pour producteurs/consommateurs
      - "15672:15672" # Interface de gestion
    environment:
      RABBITMQ_DEFAULT_USER: app
      RABBITMQ_DEFAULT_PASS: dev-only-password
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  rabbitmq_data:

La partie delicate des files d'attente de messages est la gestion gracieuse des pannes. Que se passe-t-il si le consommateur plante au milieu du traitement d'un message ? RabbitMQ et Amazon SQS gerent cela avec des accusés de reception - le consommateur doit explicitement accuser reception d'un message traité avec succes. Si le consommateur se deconnecte sans accuser reception, le message est remis dans la file et livre a un autre consommateur. Cette garantie de livraison au-moins-une-fois signifie que vos consommateurs doivent être idempotents : traiter le meme message deux fois doit produire le meme resultat que le traiter une fois.

Les files d'attente de lettres mortes sont un autre modele essentiel. Quand un message ne peut pas etre traite apres plusieurs tentatives (le service aval est indisponible, les donnees sont malformees, la regle metier a change), le message est deplace vers une file de lettres mortes au lieu d'etre retente indefiniment. Un operateur surveille la file de lettres mortes, examine la cause racine et soit corrige le message et le remet dans la file, soit le jette apres avoir confirme qu'il peut etre ignore sans danger.

CDN et limite de taux — les defenses de première ligne

Les reseaux de diffusion de contenu (CDN) et les limiteurs de taux servent des objectifs differents mais partagent une caracteristique commune : ils sont la premiere ligne de defense entre vos utilisateurs et vos serveurs. Un CDN maintient les actifs statiques et les reponses en cache pres de l'utilisateur, reduisant la latence et dechargeant le trafic de vos serveurs d'origine. Un limiteur de taux empeche tout utilisateur ou client unique de submerger votre systeme de requetes.

Les CDN fonctionnent en distribuant votre contenu sur un reseau mondial de serveurs peripheriques. Quand un utilisateur a Tokyo demande un actif, le CDN le sert depuis l'emplacement peripherique le plus proche plutot que d'acheminer la requete jusqu'a votre serveur d'origine en Virginie. Cela reduit la latence de 200 millisecondes a 10 millisecondes pour les actifs statiques. Les CDN modernes vont plus loin - ils peuvent mettre en cache les reponses API, terminer les connexions TLS et meme executer des fonctions serverless en peripherie.

La limitation de taux protege votre systeme a plusieurs niveaux. La limitation de taux globale plafonne le nombre total de requetes que votre systeme entier peut gerer par seconde, protegeant contre les pics de trafic et les attaques DDoS. La limitation de taux par utilisateur garantit qu'un locataire abusif ne peut pas priver les autres utilisateurs de ressources. La limitation de taux au niveau des endpoints applique des limites differentes a differentes routes - un endpoint de connexion peut autoriser 5 requetes par minute tandis qu'un endpoint de recherche en lecture seule autorise 100 requetes par minute.

L'algorithme de fenetre glissante est la norme industrielle pour la limitation de taux car il est a la fois precis et efficace. Au lieu de reinitialiser les compteurs a intervalles fixes (ce qui permet des rafales a la limite), une fenetre glissante considere les requetes sur une fenetre temporelle glissante. Redis est le choix naturel pour implementer cela - utilisez un ensemble trie avec des horodatages comme scores, supprimez les entrees en dehors de la fenetre et comptez les entrees restantes. Le cout memoire est minimal (quelques octets par requete) et la complexite temporelle est logarithmique.

// Limiteur de taux a fenetre glissante base sur Redis (TypeScript)
import { createClient } from "redis";

const redis = createClient({ url: "redis://ratelimit:6379" });

async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  const multi = redis.multi();
  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));

  const [, , count] = await multi.exec() as [any, any, number];
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

Les CDN et les limiteurs de taux partagent un principe operationnel important : fail open ou fail closed ? Si le noeud CDN ne peut pas atteindre l'origine, doit-il servir une reponse en cache obsolete (fail open) ou retourner une erreur (fail closed) ? Si le cluster Redis du limiteur de taux tombe, toutes les requetes doivent-elles passer (fail open - risque de surcharge) ou toutes les requetes doivent-elles etre rejetees (fail closed - indisponibilite garantie) ?

Il n'y a pas de reponse universelle, mais un bon defaut pour la plupart des systemes est : fail open pour les lectures, fail closed pour les ecritures. Une page de liste de produits obsoletes est acceptable. Un bon de commande perdu ne l'est pas. Documentez cette decision explicitement dans votre runbook pour que l'ingenieur d'astreinte sache a quel comportement s'attendre quand l'infrastructure flechit.

Tout assembler — penser en termes de compromis

La conception systeme ne consiste pas a memoriser des modeles. Il s'agit de comprendre les compromis et de reconnaitre quel modele correspond a vos contraintes. Chaque decision implique un compromis entre coherence et disponibilite, entre debit de lecture et latence d'ecriture, entre complexite operationnelle et performance brute. Les meilleurs ingenieurs ne sont pas ceux qui connaissent le plus de modeles - ce sont ceux qui peuvent regarder un probleme et identifier quelles contraintes sont fixes et lesquelles sont negociables.

Voici un cadre de decision rapide pour quand vous faites face a un nouveau probleme de conception systeme. Commencez par lister vos exigences non fonctionnelles : volume de trafic attendu, objectifs de latence, exigences de coherence, budget pour les couts d'infrastructure et familiarite de l'equipe avec la technologie. Ensuite, parcourez les modeles de cet article et demandez-vous si chacun vous rapproche ou vous eloigne de vos exigences.

Si la latence est votre preoccupation principale, commencez par la mise en cache et un CDN - ils donnent la plus grande amelioration pour le moins de complexite. Si la disponibilite est critique, utilisez l'equilibrage de charge avec des verifications de sante, concevez vos services pour qu'ils soient sans etat et choisissez AP plutot que CP la ou le metier le permet. Si vous traitez des charges de travail intensives en ecriture, evaluez le partitionnement et les files d'attente de messages tot - ils sont beaucoup plus faciles a introduire avant d'avoir des millions de lignes de donnees. Si vous traitez avec une API publique que des developpeurs tiers consommeront, implementez la limitation de taux des le premier jour. L'ajouter plus tard signifie versionner votre API ou casser les clients existants.

La competence la plus importante en conception systeme est de savoir ce dont vous n'avez pas besoin. La plupart des applications n'ont pas besoin de partitionnement. La plupart des applications n'ont pas besoin de file d'attente de messages. La distribution prematuree est la racine de tous les maux en conception systeme - chaque systeme distribue introduit des modes de defaillance qu'un systeme mono-serveur n'a tout simplement pas. N'ajoutez de la complexite que quand les metriques vous le disent, pas parce que le modele a l'air impressionnant dans un entretien d'embauche.

Commencez simplement. Mesurez tout. Ajoutez un modele a la fois. Verifiez l'amelioration avant de continuer. Les systemes qui survivent ne sont pas ceux avec l'architecture la plus sophistiquee - ce sont ceux qui sont faciles a comprendre, faciles a exploiter et faciles a modifier quand le prochain goulot d'etranglement apparaît.