← tous les articles

System Design

Pourquoi le scaling compte même avant que votre application devienne grosse

Le scaling ne concerne pas un trafic que vous n'avez pas encore. Quelques habitudes .NET, garder l'app stateless, async jusqu'au bout, et ne pas appeler le database dans une boucle, ne coûtent rien maintenant et sont douloureuses à ajouter plus tard.

Pourquoi le scaling compte même avant que votre application devienne grosse

« Vous n'avez pas besoin de vous soucier du scaling pour l'instant » est un conseil que j'ai beaucoup entendu, et il est globalement juste. Concevoir pour des millions d'utilisateurs quand vous n'en avez aucun, c'est la façon dont les projets ne sortent jamais.

Mais il existe une catégorie de décisions qui ne coûte rien quand on la prend tôt et qui est vraiment douloureuse à ajouter après coup, et en tant que développeur junior, ce sont celles qui valent la peine d'être connues. Pas parce que mes projets ont du trafic, mais parce que certaines d'entre elles font la différence entre ajouter un second server qui est un changement de config ou une réécriture.

Gardez l'état hors de l'application

C'est la grosse. Si mon API stocke quoi que ce soit d'important dans sa propre mémoire, alors un request doit revenir à la même instance pour fonctionner correctement, ce qui veut dire que je ne peux jamais faire tourner deux copies.

Des données de session en mémoire, une liste mise en cache dans un champ static, un fichier téléversé écrit sur le disque local. Chacune de ces choses lie silencieusement un utilisateur à une instance précise. Sortez-les de là, vers un database, un cache distribué, du blob storage, et soudain peu importe quelle instance répond à un request.

Je suis tombé concrètement là-dessus avec le URL shortener que j'ai construit. Trois copies de l'app derrière un load balancer ne fonctionnent que parce qu'aucune d'elles ne garde rien en local. Chaque copie voit les mêmes données parce que la donnée vit dans Cassandra et Redis, pas dans l'app. Ce n'était pas une optimisation de scaling, c'était juste comme l'app devait être écrite pour que le load balancing soit possible du tout.

Async jusqu'au bout

Dans ASP.NET Core, un thread bloqué en attente d'un appel au database est un thread qui ne peut pas servir un autre request. Avec un utilisateur, c'est invisible. Avec cent requests concurrents, c'est la différence entre les traiter et les mettre en file d'attente.

// bloque un thread pendant l'attente
var product = _context.Products.Find(id);

// libère le thread vers le pool
var product = await _context.Products.FindAsync(id);

La version async n'est pas plus rapide pour un seul request. Ça veut juste dire que le thread retourne dans le pool au lieu de rester là à ne rien faire, ce qui est ce qui permet à un petit server de traiter bien plus de travail concurrent qu'il n'a de threads.

Écrire await dès le départ ne coûte rien. Convertir un codebase synchrone en async plus tard veut dire toucher à chaque méthode de la chaîne d'appels, parce que async doit aller jusqu'au bout.

N'appelez pas le database dans une boucle

L'erreur classique, et une que j'ai faite :

var orders = await _context.Orders.ToListAsync();

foreach (var order in orders)
{
    // une query en plus par commande
    order.Customer = await _context.Customers.FindAsync(order.CustomerId);
}

Avec dix commandes, ça fait onze queries et personne ne le remarque. Avec mille commandes, ça en fait mille une, et l'endpoint fait un timeout. La solution, c'est de demander ce dont vous avez besoin à l'avance :

var orders = await _context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

Ce qui rend ça digne d'être repéré tôt, c'est que ça fonctionne très bien en développement. Mon database de test a cinq lignes dedans. Le problème n'apparaît qu'avec de vraies données, ce qui est le pire moment pour le découvrir.

Ne récupérez que ce dont vous avez besoin

Habitude liée : AsNoTracking() sur les queries en lecture seule, et sélectionner les colonnes précises plutôt que des entities entières quand je n'ai besoin que de quelques champs.

var names = await _context.Products
    .AsNoTracking()
    .Select(p => new { p.Id, p.Name })
    .ToListAsync();

Le change tracker d'EF Core existe pour pouvoir détecter ce que vous avez modifié et générer des updates. Sur une query où rien n'est modifié, c'est de la comptabilité sans raison.

Mettez les index tôt

Si je sais qu'une colonne va être interrogée, l'index a sa place dans la migration qui crée la table. Ajouter un index à une table vide est instantané. En ajouter un à une grosse table en production est une opération autour de laquelle il faut planifier.

Ce que j'ignorerais encore

Les couches de cache, les message queues, les read replicas, le sharding, tout ça. Ces choses résolvent des problèmes que je peux mesurer, et je ne peux pas encore les mesurer. Ajouter un cache à un projet sans trafic veut juste dire plus de code et une nouvelle classe de bug où le cache et le database sont en désaccord.

La vraie ligne que je trace

La question que je pose n'est pas « est-ce que ça va scale ». C'est « si ça doit changer plus tard, est-ce un changement de config ou une réécriture ? »

Stateless, async, pas de queries dans des boucles, des index sensés — tout ça, c'est « écrivez-le comme ça dès le départ et c'est gratuit ». Caching, queues, et replicas, c'est tout « ajoutez-le quand vous avez une mesure qui dit que vous en avez besoin ». Bien faire la première liste tôt, c'est ce qui fait de la seconde liste un choix plutôt qu'une urgence.