System Design
Concevoir un URL shortener comme projet d'apprentissage
Un URL shortener en .NET 9, trois copies derrière nginx, adossé à Cassandra et mis en cache avec Redis, construit pour vraiment comprendre une vidéo de system design au lieu de juste la regarder.
J'ai regardé une vidéo de system design sur la conception d'un URL shortener et, au lieu de me contenter de la regarder, j'ai décidé de le construire pour de vrai : un vrai URL shortener en .NET 9, adossé à Cassandra, mis en cache avec Redis, derrière un load balancer nginx, avec une partie qui tourne dans Azure.
Ce qu'il fait concrètement
Deux endpoints. POST /shorten prend une URL longue et renvoie un short code, quelque chose comme jxs3y2C. GET /{code} redirige vers l'URL d'origine. Voilà toute la surface. Tout ce qui est intéressant se trouve dans ce qui le rend rapide et capable d'encaisser du trafic réel.
Trois copies, aucune mémoire partagée
L'app tourne en trois copies identiques derrière nginx, qui répartit le trafic entre elles. Les trois parlent au même Cassandra database et au même Redis cache, et aucune ne garde quoi que ce soit dans sa propre mémoire. Quelle que soit la copie qui répond à un request, elle voit les mêmes données que les deux autres, et c'est ce qui me permet d'ajouter des copies quand le trafic grimpe au lieu de tout reconcevoir.
Un tout petit endpoint /whoami prouve que la rotation a bien lieu :
for i in {1..9}; do curl -s http://localhost:8080/whoami; echo; done
{"server":"a0988b503bdf"} # copie 1
{"server":"da9800fda566"} # copie 2
{"server":"e6299466c10c"} # copie 3
{"server":"a0988b503bdf"} # copie 1 à nouveau...
Des short codes en Base62
Le code doit être court et unique, il est donc généré à partir d'un alphabet de 62 caractères, chiffres plus minuscules plus majuscules :
private const string Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public const int CodeLength = 7;
public string Generate()
{
var buffer = new char[CodeLength];
for (int i = 0; i < CodeLength; i++)
{
buffer[i] = Alphabet[Random.Shared.Next(Alphabet.Length)];
}
return new string(buffer);
}
Sept caractères tirés de cet alphabet couvrent environ 3 500 milliards de codes possibles.
Pourquoi Cassandra
Un URL shortener ne fait jamais qu'un seul type de lookup : donne-moi l'URL de ce short code. Cassandra est fait exactement pour ce genre de lookup, et il peut répartir les données sur de nombreuses machines à mesure qu'elles grossissent. C'est franchement disproportionné pour un projet aussi petit, un seul SQL database ferait très bien l'affaire, mais l'intérêt de le construire était justement de travailler avec le database qui correspond à la version réelle du problème.
Ajouter un cache sans toucher au reste
Sinon, chaque redirect irait taper le database, alors qu'un lien populaire renvoie exactement la même réponse à chaque fois. Redis garde les liens chauds en mémoire, si bien que les clics répétés sont servis instantanément et que le database est laissé tranquille.
Ce que je préfère dans le résultat : le cache est un decorator autour de la couche database, pas une réécriture de celle-ci. CachedUrlStore implémente la même interface IUrlStore que le Cassandra store simple, regarde d'abord dans Redis, et ne retombe sur le database qu'en cas de miss :
public async Task<ShortenedUrl?> GetByCodeAsync(string code)
{
var key = $"url:{code}";
var cached = await _cache.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<ShortenedUrl>(cached!);
var entity = await _inner.GetByCodeAsync(code);
if (entity is null)
return null;
await _cache.StringSetAsync(key, JsonSerializer.Serialize(entity), Ttl);
return entity;
}
Passer de « database seul » à « database plus cache » est un changement d'une ligne dans l'enregistrement des services. Rien de ce qui appelle IUrlStore n'a besoin de savoir lequel des deux se trouve réellement en face.
301 vs 302, et pourquoi ça compte ici
Un redirect 301 est mis en cache par le navigateur : c'est rapide, mais ça veut dire que je ne pourrai plus compter les clics après le premier. Un 302 repasse toujours par mon serveur, un peu plus lentement, mais il me permet de suivre les clics et de changer la destination plus tard si besoin. J'ai choisi le 302 pour garder les analytics possibles.
Déplacer le cache dans Azure
La Redis cache tourne maintenant sur Azure Cache for Redis plutôt que dans un container local. Azure s'occupe de l'entretien, et en échange j'ai moins de contrôle et un peu de coût. La connexion se fait uniquement en TLS, le mot de passe vit dans un secret store local plutôt que dans le dépôt, et l'accès public doit être activé délibérément, ce qui est le bon default.
Ce que j'en retire
Le construire au lieu de seulement regarder la vidéo, c'est ce qui a fait tenir les pièces : comment fonctionnent les short codes en Base62, comment Cassandra garde des lookups rapides à grande échelle, pourquoi un cache se place devant un database et non à sa place, et pourquoi garder l'app sans mémoire propre est ce qui rend le load balancing possible. Docker Compose démarre les six morceaux, trois copies de l'app, Cassandra, Redis et nginx, en une seule commande, ce qui à lui seul m'a appris plus sur la façon dont ces pièces s'emboîtent que n'importe quel schéma.
L'étape suivante, c'est de déplacer le database lui-même dans le cloud. Azure Cosmos DB parle le protocole de Cassandra, donc ça devrait signifier le même code pointé vers une autre adresse, puis héberger l'app elle-même dans Azure Container Apps avec un véritable autoscaling au lieu de trois copies figées sur mon portable.