System Design
Projetando um URL shortener como projeto de estudo
Um URL shortener em .NET 9, três cópias atrás do nginx, com Cassandra por trás e cache no Redis, construído para realmente entender um vídeo de system design em vez de só assistir.
Assisti a um vídeo de system design sobre arquitetar um URL shortener e, em vez de só assistir, decidi construir de verdade: um URL shortener real em .NET 9, com Cassandra por trás, cache no Redis, atrás de um load balancer nginx, com uma parte rodando no Azure.
O que ele faz na prática
Dois endpoints. POST /shorten recebe uma URL longa e devolve um short code, algo como jxs3y2C. GET /{code} redireciona para a URL original. É toda a superfície. Tudo o que é interessante está no que o deixa rápido e capaz de aguentar tráfego real.
Três cópias, nenhuma memória compartilhada
A aplicação roda como três cópias idênticas atrás do nginx, que divide o tráfego entre elas. As três conversam com o mesmo Cassandra database e com o mesmo Redis cache, e nenhuma guarda nada na própria memória. Qualquer que seja a cópia que responda a um request, ela vê os mesmos dados que as outras duas, e é isso que me deixa adicionar mais cópias quando o tráfego crescer, em vez de redesenhar qualquer coisa.
Um endpoint minúsculo /whoami prova que a rotação está mesmo acontecendo:
for i in {1..9}; do curl -s http://localhost:8080/whoami; echo; done
{"server":"a0988b503bdf"} # cópia 1
{"server":"da9800fda566"} # cópia 2
{"server":"e6299466c10c"} # cópia 3
{"server":"a0988b503bdf"} # cópia 1 de novo...
Short codes com Base62
O código precisa ser curto e único, então é gerado a partir de um alfabeto de 62 caracteres, dígitos mais minúsculas mais maiúsculas:
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);
}
Sete caracteres desse alfabeto cobrem cerca de 3,5 trilhões de códigos possíveis.
Por que Cassandra
Um URL shortener só faz um tipo de lookup: me dê a URL desse short code. O Cassandra foi feito exatamente para esse tipo de lookup e consegue espalhar os dados por muitas máquinas conforme cresce. É sinceramente exagero para um projeto tão pequeno, um único SQL database daria conta tranquilamente, mas o objetivo de construir isso era trabalhar de fato com o database que combina com a versão real do problema.
Adicionando um cache sem mexer em nada mais
Todo redirect iria ao database, mesmo que um link popular devolva exatamente a mesma resposta sempre. O Redis mantém os links quentes em memória, então cliques repetidos são respondidos na hora e o database fica em paz.
A parte que mais gosto em como isso ficou: o cache é um decorator em volta da camada de database, não uma reescrita dela. CachedUrlStore implementa a mesma interface IUrlStore que o Cassandra store puro, consulta o Redis primeiro e só cai no database quando dá 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;
}
Alternar entre "só database" e "database mais cache" é uma mudança de uma linha em como os serviços são registrados. Nada que chama IUrlStore precisa saber ou se importar com qual dos dois está do outro lado.
301 vs 302, e por que isso importa aqui
Um redirect 301 é cacheado pelo navegador, o que é rápido, mas significa que nunca mais consigo contar cliques depois do primeiro. Um 302 sempre volta pelo meu servidor, um pouco mais lento, mas me deixa acompanhar cliques e mudar o destino depois, se precisar. Fiquei com o 302 para manter a possibilidade de analytics.
Levando o cache para o Azure
O Redis cache agora roda no Azure Cache for Redis em vez de um container local. O Azure cuida da manutenção, e eu ganho menos controle e um pouco de custo em troca. A conexão é só por TLS, a senha vive num secret store local em vez do repositório, e o acesso público tem que ser ligado de propósito, o que é o default certo.
O que estou tirando disso
Construir em vez de só assistir ao vídeo foi o que fez as peças realmente fixarem: como funcionam os short codes em Base62, como o Cassandra mantém lookups rápidos em escala, por que um cache vai na frente de um database e não no lugar dele, e por que manter a aplicação sem memória própria é o que torna o load balancing possível. O Docker Compose sobe as seis peças, três cópias da aplicação, Cassandra, Redis e nginx, com um comando só, o que por si já me ensinou mais sobre como essas peças se encaixam do que qualquer diagrama.
O próximo passo é levar o database em si para o cloud. O Azure Cosmos DB fala o protocolo do Cassandra, então isso deveria significar o mesmo código apontado para outro endereço, e eventualmente hospedar a própria aplicação no Azure Container Apps com autoscaling real em vez de três cópias fixas no meu laptop.