System Design
Progettare un URL shortener come progetto di studio
Un URL shortener in .NET 9, tre copie dietro nginx, con Cassandra alla base e cache su Redis, costruito per capire davvero un video di system design invece di guardarlo e basta.
Ho guardato un video di system design su come progettare un URL shortener e, invece di limitarmi a guardarlo, ho deciso di costruirlo per davvero: un vero URL shortener in .NET 9, con Cassandra alla base, cache su Redis, dietro un load balancer nginx, con un pezzo che gira su Azure.
Cosa fa concretamente
Due endpoint. POST /shorten prende un URL lungo e restituisce uno short code, qualcosa come jxs3y2C. GET /{code} reindirizza all'URL originale. È tutta la superficie. Tutto ciò che è interessante sta in quello che lo rende veloce e capace di gestire traffico reale.
Tre copie, nessuna memoria condivisa
L'app gira come tre copie identiche dietro nginx, che divide il traffico tra loro. Tutte e tre parlano con lo stesso Cassandra database e con la stessa Redis cache, e nessuna tiene niente nella propria memoria. Qualunque copia risponda a un request, vede gli stessi dati delle altre due, ed è questo che mi permette di aggiungere copie quando il traffico cresce invece di riprogettare qualcosa.
Un minuscolo endpoint /whoami dimostra che la rotazione avviene davvero:
for i in {1..9}; do curl -s http://localhost:8080/whoami; echo; done
{"server":"a0988b503bdf"} # copia 1
{"server":"da9800fda566"} # copia 2
{"server":"e6299466c10c"} # copia 3
{"server":"a0988b503bdf"} # di nuovo la copia 1...
Short code con Base62
Il codice deve essere breve e univoco, quindi viene generato da un alfabeto di 62 caratteri, cifre più minuscole più maiuscole:
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);
}
Sette caratteri da quell'alfabeto coprono circa 3.500 miliardi di codici possibili.
Perché Cassandra
Un URL shortener fa sempre e solo un tipo di lookup: dammi l'URL per questo short code. Cassandra è costruito esattamente per quel tipo di lookup e può distribuire i dati su molte macchine man mano che cresce. È onestamente sovradimensionato per un progetto così piccolo, un singolo SQL database andrebbe benissimo, ma il senso di costruirlo era lavorare davvero con il database che si adatta alla versione reale del problema.
Aggiungere una cache senza toccare nient'altro
Altrimenti ogni redirect andrebbe al database, anche se un link popolare restituisce sempre esattamente la stessa risposta. Redis tiene i link caldi in memoria, così i clic ripetuti vengono serviti all'istante e il database viene lasciato in pace.
La parte che mi piace di più di come è venuta: la cache è un decorator attorno allo strato del database, non una sua riscrittura. CachedUrlStore implementa la stessa interfaccia IUrlStore del Cassandra store semplice, controlla prima Redis e ricade sul database solo in caso di 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;
}
Passare da "solo database" a "database più cache" è una modifica di una riga in come vengono registrati i servizi. Niente di ciò che usa IUrlStore deve sapere o preoccuparsi di quale dei due ha davvero davanti.
301 vs 302, e perché qui conta
Un redirect 301 viene messo in cache dal browser: è veloce, ma significa che dopo il primo clic non potrò più contarli. Un 302 passa sempre dal mio server, un po' più lento, ma mi permette di tracciare i clic e di cambiare la destinazione più avanti, se serve. Sono andato di 302 per tenere aperta la possibilità delle analytics.
Spostare la cache su Azure
La Redis cache ora gira su Azure Cache for Redis invece che in un container locale. Azure si occupa della manutenzione, e in cambio ho meno controllo e un po' di costo. La connessione è solo via TLS, la password vive in un secret store locale invece che nel repository, e l'accesso pubblico va abilitato deliberatamente, che è il default giusto.
Cosa mi porto via
Costruirlo invece di guardare solo il video è ciò che ha fatto attaccare davvero i pezzi: come funzionano gli short code in Base62, come Cassandra tiene veloci i lookup su larga scala, perché una cache si mette davanti a un database e non al suo posto, e perché tenere l'app priva di memoria propria è ciò che rende possibile il load balancing. Docker Compose avvia tutti e sei i pezzi, tre copie dell'app, Cassandra, Redis e nginx, con un solo comando, e questo da solo mi ha insegnato più su come questi pezzi si incastrano di qualsiasi diagramma.
Il passo successivo è spostare nel cloud il database stesso. Azure Cosmos DB parla il protocollo di Cassandra, quindi dovrebbe voler dire lo stesso codice puntato a un indirizzo diverso, e poi ospitare l'app stessa su Azure Container Apps con autoscaling reale invece di tre copie fisse sul mio portatile.