Les patterns de scalabilite backend qui tiennent vraiment sous la charge

Les patterns de scalabilite backend qui tiennent vraiment sous la charge

La plupart des backends ne s effondrent pas à cause d un seul gros problème. Ils s effondrent à cause d une douzaine de petites hypothèses qui tenaient très bien à mille requêtes par jour et qui cessent discrètement de tenir à un million. J ai passé une bonne partie de ma carrière à traquer ces hypothèses, souvent à trois heures du matin, et les patterns ci dessous sont ceux vers lesquels je reviens toujours. Aucun n est exotique. La compétence, c est de savoir lequel appliquer et quand s arrêter.

Scaler verticalement avant de scaler horizontalement

La première question que je pose quand un service souffre, c est de savoir si je peux simplement lui donner une machine plus grosse. La scalabilité verticale a mauvaise réputation parce qu elle a un plafond, mais ce plafond est bien plus haut qu on ne le croit. Une instance moderne avec 64 cœurs et 256 gigaoctets de mémoire encaisse une quantité énorme de trafic, et vous l obtenez sans toucher à votre code, à votre déploiement ni à votre modèle mental du système.

La scalabilité horizontale, c est quand vous ajoutez des machines et répartissez le travail entre elles. Elle va plus loin, mais elle vous impose des décisions. L état doit vivre quelque part de partagé. Les requêtes doivent être routées. Les pannes se multiplient parce que vous avez désormais dix choses qui peuvent casser au lieu d une. Ma règle est simple. Je scale verticalement jusqu à ce que ça devienne cher ou que j atteigne le plafond de l instance, et seulement alors je scale horizontalement. Déployer une flotte de minuscules nœuds dès le premier jour, c est la meilleure façon de déboguer des problèmes de systèmes distribués que vous n aviez pas encore besoin d avoir.

La statelessness rend tout le reste possible

Vous ne pouvez pas répartir le trafic sur plusieurs serveurs si l un d eux est secrètement spécial. Dès qu une requête ne fonctionne que parce qu elle est tombée sur la même machine que la requête précédente, la scalabilité horizontale est morte. C est pour ça que je traite la statelessness comme une fondation plutôt qu une optimisation.

En pratique, cela veut dire pas de données de session en mémoire locale, pas de fichiers uploadés posés sur le disque local, pas de compteurs en mémoire qui comptent. Poussez l état de session dans Redis ou dans un token signé. Poussez les fichiers dans du stockage objet. Poussez tout ce qui est durable dans une base de données. Quand chaque nœud applicatif est interchangeable, un répartiteur de charge peut envoyer une requête à n importe lequel, vous pouvez ajouter et retirer des nœuds librement, et un nœud planté ne vous coûte que les requêtes en cours qu il portait.

Si vous voulez le contexte plus large de la place de tout ça dans un système, j en ai parlé dans l architecture fullstack moderne, où la statelessness réapparaît comme une condition pour des déploiements propres.

La répartition de charge et comment une requête trouve un foyer

Une fois que vous avez plusieurs nœuds interchangeables, quelque chose doit décider où va chaque requête. Un répartiteur de charge se place devant et distribue le trafic. Le round robin est le point de départ évident et il convient pour des charges uniformes, mais il devient vite bête quand le coût des requêtes varie. Least connections est généralement un meilleur choix par défaut parce qu il envoie le nouveau travail au nœud le moins occupé à l instant présent.

Ce que les gens oublient, ce sont les health checks. Un répartiteur ne vaut que par sa capacité à repérer un nœud malade et à cesser de lui envoyer du trafic. Je configure toujours des health checks actifs avec un vrai endpoint qui touche les dépendances critiques, pas une route qui renvoie 200 quoi qu il arrive.

upstream api_backend {
    least_conn;
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://api_backend;
        proxy_next_upstream error timeout http_502 http_503;
    }
    location /healthz {
        access_log off;
        proxy_pass http://api_backend;
    }
}

Le cache, le levier au plus fort rendement

Rien ne vous offre de la marge plus vite que de ne pas faire le travail deux fois. Le cache est l outil au plus fort rendement de toute cette liste, et il opère à plusieurs niveaux. Il y a le CDN en bordure pour les ressources statiques et les réponses cacheables. Il y a un cache applicatif comme Redis ou Memcached pour les résultats calculés et les lignes chaudes. Il y a le cache de requêtes de la base et le cache de pages du système d exploitation en dessous. Chaque niveau depuis lequel vous pouvez servir est un niveau de travail que les niveaux inférieurs ne voient jamais.

Le difficile n est jamais de lire depuis un cache. C est l invalidation. Un cache périmé est pire que pas de cache parce qu il ment avec aplomb. Je m appuie beaucoup sur l expiration par durée parce qu elle est prévisible, et je ne passe à l invalidation par événement que quand la péremption fait vraiment mal. Le cache-aside est mon pattern par défaut : on vérifie le cache, en cas de miss on va à la source, puis on réécrit le résultat avec un TTL.

def get_user(user_id):
    key = "user:" + str(user_id)
    cached = redis.get(key)
    if cached is not None:
        return deserialize(cached)
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(key, 300, serialize(user))
    return user

Deux modes de défaillance méritent d être nommés. Une ruée sur le cache survient quand une clé populaire expire et que mille requêtes ratent en même temps et frappent la base ensemble. On la combat avec des TTL jitterés ou un court verrou pour qu une seule requête reconstruise la valeur. Le second, c est la croissance non bornée, que l on gère avec une politique d éviction comme LRU et un plafond mémoire.

Les files de messages, ou comment arrêter le travail lent dans le chemin de la requête

Une grande partie de ce qui rend les requêtes lentes n a pas besoin de se produire pendant que l utilisateur attend. Envoyer un email, redimensionner une image, générer un rapport, synchroniser avec un tiers. Si l appelant n a pas besoin du résultat tout de suite, sortez le du chemin de la requête et mettez le dans une file. Le tier web accepte le job, le confie à un broker comme RabbitMQ ou SQS, répond immédiatement, et un pool de workers avale le backlog à son propre rythme.

Cela fait deux choses. Ça rend votre latence prévisible parce que la requête n attend plus le travail lent en aval. Et ça absorbe les pics, parce qu une file est un tampon. Quand le trafic triple pendant une heure, la file grossit et les workers rattrapent ensuite au lieu que tout le système fonde. Ce qu il faut absolument réussir, c est l idempotence. Les files redélivrent. Un message finira par être traité plus d une fois, donc chaque worker doit pouvoir tourner deux fois sur la même entrée sans danger.

  • Rendez les handlers idempotents avec une clé de déduplication ou un upsert pour qu une redélivrance soit sans effet.
  • Configurez une dead letter queue pour que les messages empoisonnés cessent de bloquer la file et atterrissent quelque part où vous pouvez les inspecter.
  • Surveillez la profondeur de la file comme une métrique de premier plan. Une file qui grossit est le tout premier signe que les workers ne suivent plus.

Backpressure et dégradation gracieuse

Scaler, ce n est pas seulement encaisser plus. C est échouer correctement quand on ne peut pas. Un système en vraie surcharge devrait délester volontairement plutôt que s effondrer. Je place des limites de débit aux bords, des timeouts sur chaque appel sortant, et des circuit breakers autour des dépendances instables pour qu un service aval lent n empile pas les threads et n emporte pas tout le processus avec lui.

La dégradation gracieuse, c est décider à l avance de ce qu on lâche en premier. Si le service de recommandation est en panne, montrez une liste générique au lieu d une page d erreur. Si le cache est froid, servez des données un peu périmées plutôt que rien. Les utilisateurs qui ont une expérience dégradée sont bien plus contents que ceux qui reçoivent une 500.

Où se place la base de données

Tout ce qui précède achète du temps, mais finit par buter sur la base de données, qui est presque toujours le vrai goulot. Le cache réduit les lectures, les files lissent les écritures, mais passé un certain point la couche de données elle même doit scaler. C est un sujet assez vaste pour que je lui aie consacré un article dédié sur scaler les bases de données avec la réplication et le sharding, et si votre schéma vous résiste avant même d en arriver là, corrigez ça d abord avec une conception de schéma solide.

L ordre dans lequel je les applique vraiment

Si je devais compresser tout ça en une séquence, ce serait celle ci. Rendre le service stateless pour qu il puisse scaler tout court. Scaler verticalement jusqu à ce que ce ne soit plus rentable. Mettre un répartiteur de charge et plus de nœuds devant. Ajouter du cache aux niveaux qui font le plus mal. Déplacer le travail lent vers des files. Ajouter du backpressure pour que la surcharge dégrade au lieu de planter. Et ensuite, en général seulement, faire le travail plus dur de scaler la base de données. Mesurez à chaque étape, parce que le goulot est rarement là où votre intuition le situe, et ajouter de la capacité à la mauvaise couche ne fait que déplacer la file.

Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *