MÓDULO 14 · CONCEITO 03 DE 12

URL Shortener — o "hello world" do system design

Design de um URL shortener (bit.ly, tinyurl) com 100M redirects/dia. O problema central não é o redirect — é a geração de IDs únicos em ambiente distribuído. Base62 vs hash vs KGS. 301 vs 302 e o impacto em analytics. Cache de redirect com hot key problem. Pipeline assíncrono de analytics sem impactar latência. Expiração e custom aliases.

Tempo de leitura ~28 min Pré-requisito 02 · Capacity Estimation Próximo 04 · Twitter Timeline →

O URL shortener é frequentemente chamado de "hello world" do system design não porque seja simples — é porque expõe uma densidade surpreendente de problemas interessantes num sistema aparentemente trivial. A superfície é pequena: receba uma URL longa, devolva uma URL curta, redirecione quando a URL curta for acessada. Mas abaixo dessa superfície estão problemas que aparecem em praticamente todo sistema distribuído: geração de IDs únicos sem coordenação central, caching de dados que têm ciclo de vida, pipelines assíncronos que não podem impactar o caminho crítico de latência, e o trade-off entre consistência e throughput em analytics.

O bit.ly em 2022 processava mais de 10 bilhões de clicks por mês — cerca de 4.000 redirects por segundo no pico. Cada redirect precisa ser resolvido em dezenas de milissegundos. Cada click precisa ser contabilizado para analytics sem adicionar latência perceptível ao redirect. E o sistema precisa estar disponível 24/7 porque cada URL que você encurtou agora depende da existência contínua do serviço.

Requisitos e estimation

Requisitos funcionais do sistema:

Requisitos não-funcionais assumidos para este design:

# Capacity estimation
# 100M redirects/dia ÷ 86.400s = 1.157 reads/s (média)
# Pico (3× média): ~3.500 reads/s

# Writes: 100M ÷ 100 (razão) = 1M criações/dia
# 1M ÷ 86.400 ≈ 12 writes/s | pico: ~35 writes/s

# Storage por URL: short_code(7B) + original_url(~200B) + metadados(~100B) ≈ 310 bytes
# Crescimento: 1M criações/dia × 310B = 310 MB/dia
# Em 5 anos: 310MB × 365 × 5 ≈ 566 GB (raw) | com replicação 3×: ~1,7 TB

# Memória de cache (hot set):
# Top 20% das URLs = ~200k URLs ativas
# 200k × 310B ≈ 62 MB → cabe em qualquer instância Redis

Os números revelam um sistema de escala moderada: 3.500 redirects/s é dentro da capacidade de um PostgreSQL com cache Redis na frente. Sharding não é necessário neste volume. O problema técnico mais difícil não é escala — é a geração correta de short codes únicos.

O problema central: geração de short codes únicos

Um short code de 7 caracteres em base62 (a-z, A-Z, 0-9) oferece 62⁷ = 3,5 trilhões de combinações. Com 1M de criações por dia, levaria ~9.600 anos para esgotar o espaço — o espaço não é o problema. O problema é gerar esses códigos de forma eficiente, sem colisões, e sem um ponto central de coordenação que se torna gargalo.

Opção A: Hash da URL original (MD5/SHA truncado)

# MD5("https://exemplo.com/artigo-longo") → "5d41402abc4b2a76b9719d911017c592"
# Pegar os primeiros 7 chars → "5d41402"

# Problemas:
# 1. Colisões: duas URLs diferentes podem gerar o mesmo prefixo de 7 chars
#    (birthday problem: com 3,5T de slots, colisão esperada após ~2.6M inserções)
# 2. Mesma URL sempre gera o mesmo código → não permite criar duas URLs curtas
#    diferentes para a mesma URL longa (analytics separado por campanha)
# 3. Se há colisão: re-hash com salt → código muda → URL curta muda → inconsistência

Hash funciona para sistemas pequenos mas tem problemas fundamentais em escala.

Opção B: Base62 de auto-increment ID

# ID sequencial do banco: 1, 2, 3, 4...
# Converter para base62:
# 1 → "000001" | 100 → "000001c" | 1.000.000 → "4c92"

def to_base62(num):
    chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    result = []
    while num:
        result.append(chars[num % 62])
        num //= 62
    return ''.join(reversed(result)).zfill(7)

# Vantagens: sem colisões, único garantido pelo banco, simples
# Problemas:
# 1. Enumerável: shr.tt/0000001, shr.tt/0000002... → scraping trivial de todas as URLs
# 2. Previsível: adversário pode adivinhar URLs criadas próximas no tempo
# 3. Single point of failure: o gerador de ID no banco é gargalo e SPOF

Opção C: KGS — Key Generation Service

A solução mais robusta para sistemas que precisam de IDs únicos, imprevisíveis e gerados sem coordenação em tempo real é o Key Generation Service: um serviço dedicado que pré-gera short codes aleatórios e os armazena em duas tabelas — codes_available e codes_used.

# KGS pré-gera short codes aleatórios e os armazena:
# Tabela codes_available: (code VARCHAR(7), PRIMARY KEY)
# Tabela codes_used:      (code VARCHAR(7), url_id BIGINT, PRIMARY KEY)

# Quando uma URL é criada:
# BEGIN TRANSACTION
#   SELECT code FROM codes_available LIMIT 1 FOR UPDATE
#   DELETE FROM codes_available WHERE code = :code
#   INSERT INTO codes_used (code, url_id) VALUES (:code, :url_id)
# COMMIT

# O KGS pode manter um buffer em memória de códigos já retirados da tabela
# mas ainda não atribuídos — elimina uma trip ao banco no caminho crítico.

# Vantagens:
# - Imprevisível (gerado aleatoriamente)
# - Sem colisão garantida por transação atômica
# - KGS pode ter réplicas para HA
# - Buffer em memória reduz latência de geração

# Desvantagens:
# - Serviço adicional para operar
# - Em crash do KGS sem persistência do buffer: códigos do buffer são perdidos
#   (aceitável — apenas desperdiça alguns códigos, não gera inconsistência)

Opção D: Snowflake ID + base62

# Snowflake (Twitter, 2010): ID de 64 bits composto de:
# [timestamp 41 bits][datacenter 5 bits][worker 5 bits][sequence 12 bits]
# Garante unicidade sem coordenação central para até 4096 IDs/ms por worker

# Conversão para base62 → 7-8 chars imprevisíveis (por causa do timestamp)
# Mas: timestamp no ID torna o código previsível no tempo
# "se eu sei que a URL foi criada agora, o código começa com X bits específicos"
# Mitigação: shuffle dos bits antes da conversão — complica sem resolver completamente

# Conclusão prática:
# Para URL shortener com imprevisibilidade como requisito: KGS é a melhor opção
# Para sistemas internos onde imprevisibilidade não importa: Snowflake é mais simples

Schema e modelo de dados

-- Tabela principal
CREATE TABLE urls (
    id          BIGSERIAL PRIMARY KEY,
    short_code  CHAR(7)      NOT NULL UNIQUE,
    original_url TEXT        NOT NULL,
    user_id     BIGINT       REFERENCES users(id),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMPTZ,           -- NULL = nunca expira
    custom_alias BOOLEAN     NOT NULL DEFAULT FALSE,

    -- Índice para o redirect path (hot path)
    -- Hash index para lookup exato por short_code (não precisa de range scan)
    INDEX idx_short_code USING HASH (short_code),

    -- Índice para listagem de URLs de um usuário
    INDEX idx_user_created (user_id, created_at DESC)
);

-- Tabela de analytics (separada do redirect path)
-- Particionada por mês para queries de período e archive eficiente
CREATE TABLE url_clicks (
    click_id    BIGSERIAL,
    short_code  CHAR(7)     NOT NULL,
    clicked_at  TIMESTAMPTZ NOT NULL,
    country     CHAR(2),
    device_type VARCHAR(20),   -- mobile | desktop | bot
    referrer    TEXT,
    PRIMARY KEY (click_id, clicked_at)
) PARTITION BY RANGE (clicked_at);

-- Analytics agregados (pré-computados para dashboard)
CREATE TABLE url_stats_daily (
    short_code  CHAR(7)     NOT NULL,
    stat_date   DATE        NOT NULL,
    click_count BIGINT      NOT NULL DEFAULT 0,
    PRIMARY KEY (short_code, stat_date)
);

A separação em tabelas distintas é deliberada: o redirect path só precisa da tabela urls (leitura por short_code). Analytics nunca aparecem no caminho crítico de latência — são escritos assincronamente e lidos em queries separadas.

O redirect: 301 vs 302

Esta é uma das decisões mais importantes do URL shortener e a que mais candidatos erram — porque a resposta correta é contra-intuitiva:

# 301 Moved Permanently
# → o browser cacheia o redirect indefinidamente
# → na segunda visita, o browser vai direto para a URL original sem passar pelo shortener
# → vantagem: menos carga no servidor | DESVANTAGEM: analytics incompleto
#   (o browser não visita mais o shortener → click não é contabilizado)

# 302 Found (Temporary Redirect)
# → o browser NÃO cacheia
# → toda visita passa pelo shortener
# → vantagem: todo click é contabilizado | desvantagem: mais carga no servidor

# Para um URL shortener com analytics: 302 é a única opção correta.
# O "custo" de mais carga é mitigado pelo cache Redis — o servidor de aplicação
# não vai ao banco em 95%+ dos redirects.

# Exceção: se o usuário explicitamente quer "sem tracking"
# → retornar 301 com Cache-Control: max-age=86400 (TTL de 1 dia)
# → balance entre performance e privacidade

Cache de redirects: o hot path

O redirect é o hot path do sistema — executado 3.500 vezes por segundo no pico. Cada milissegundo conta. A arquitetura do cache precisa ser pensada em camadas:

# Camada 1: Cache local em memória no servidor de aplicação
# Caffeine (Java) / ristretto (Go) / lru-cache (Python)
# Tamanho: top-1000 URLs mais acessadas nos últimos 60s
# TTL: 60 segundos (aceita dados levemente stale)
# Benefício: zero latência de rede; elimina ~80% das idas ao Redis para hot URLs

# Camada 2: Redis (cache centralizado)
# Estrutura: string simples   short_code → original_url
# TTL: alinhado com expires_at da URL (ou 24h para URLs sem expiração)
# Benefício: ~0.5ms vs ~5ms do banco | hit rate esperado: 95%+

# Camada 3: PostgreSQL (source of truth)
# Acessado apenas em cache miss (~5% dos redirects)
# Com hash index em short_code: ~1-3ms por query

# Fluxo completo do redirect:
# 1. Check local cache → HIT: redirect imediato (0ms overhead)
#                     → MISS: continuar
# 2. Check Redis      → HIT: popular local cache + redirect (~0.5ms)
#                     → MISS: continuar
# 3. Query PostgreSQL → popular Redis + local cache + redirect (~3-5ms)
#                     → NOT FOUND: 404
#                     → EXPIRED: 410 Gone

Hot key problem: uma URL viral pode receber 100k requests/segundo — muito acima da capacidade de uma única shard Redis de processar sem saturar. A solução é o local cache no servidor de aplicação absorver o pico: com 10 servidores de aplicação, cada um cacheando a URL viral localmente, o Redis recebe apenas os cache misses iniciais (um por servidor), não os 100k/s.

Pipeline de analytics assíncrono

O problema fundamental de analytics num URL shortener: você precisa registrar cada click, mas o registro não pode adicionar latência ao redirect. A solução é desacoplar completamente o registro de analytics do caminho de redirect:

# No handler de redirect (síncrono, caminho crítico):
func handleRedirect(shortCode string) http.Response {
    url := getFromCache(shortCode)   // Redis/local cache
    if url == nil {
        url = db.Get(shortCode)      // fallback ao banco
    }
    if url == nil { return 404 }
    if url.IsExpired() { return 410 }

    // Publicar evento de click de forma async (fire-and-forget)
    // A goroutine/thread publica para Kafka sem bloquear o response
    go publishClickEvent(ClickEvent{
        ShortCode: shortCode,
        Timestamp: time.Now(),
        IP:        req.RemoteAddr,
        UserAgent: req.Header.Get("User-Agent"),
        Referrer:  req.Header.Get("Referer"),
    })

    return http.Redirect(url.Original, 302)
    // Total: ~2-5ms no steady state com cache quente
}

# Consumer de Kafka (processo separado, não afeta latência do redirect):
# - Lê eventos de click em batches de 1000 a cada segundo
# - Resolve geolocalização do IP (MaxMind GeoIP2, em memória)
# - Detecta bots (User-Agent parsing + rate limiting heurístico)
# - Escreve em url_clicks (raw) e agrega em url_stats_daily
# - Se o consumer atrasar: Kafka mantém o backlog; analytics ficam atrasados
#   mas nenhum click é perdido (garantia de entrega at-least-once)

# Trade-off central:
# Analytics são eventually consistent (atraso de segundos a minutos)
# Redirect é always consistent e sempre disponível
# Esse trade-off é explícito e correto para o domínio

Expiração de URLs

Há três abordagens para tratar URLs expiradas, com trade-offs distintos:

# Abordagem 1: Verificação no momento do redirect
# → verificar expires_at em toda leitura do banco/cache
# → vantagem: simples, sem background jobs
# → desvantagem: URLs expiradas ficam no banco indefinidamente, desperdiçando storage
#   (mas ao longo de 5 anos: 566 GB raw — gerenciável sem limpeza ativa)

# Abordagem 2: TTL no Redis + soft delete com background job
# → Redis TTL = tempo até expiração → Redis remove automaticamente
# → background job nocturno: DELETE FROM urls WHERE expires_at < NOW()
#   e DELETE FROM url_clicks WHERE short_code IN (expired codes)
# → vantagem: storage se mantém limpo
# → desvantagem: complexidade de um job adicional; race conditions se o job
#   rodar enquanto um redirect está acontecendo (tratar com soft delete + grace period)

# Abordagem 3: Particionamento por data com archive
# → URLs expiradas são movidas para tabela de archive (cold storage)
# → redirect path nunca lê de archive (404 imediato para URLs arquivadas)
# → vantagem: performance do banco principal não degrada com o tempo
# → desvantagem: complexidade de archive pipeline
# → faz sentido apenas para volume muito alto (> 100M URLs criadas/dia)

Custom aliases e colisões

# Criação com alias customizado:
POST /urls
Body: { original_url: "https://...", custom_alias: "black-friday-2024" }

# Validação:
# 1. Alias deve ter 3-50 chars, apenas [a-zA-Z0-9-_]
# 2. Verificar se alias já está em uso:
#    SELECT 1 FROM urls WHERE short_code = :alias LIMIT 1
#    → se existe: 409 Conflict com mensagem clara
#    → se não existe: INSERT com o alias como short_code

# Race condition: dois usuários tentam criar o mesmo alias simultaneamente
# → a UNIQUE constraint em short_code garante que apenas um vai suceder
# → o segundo recebe um erro de constraint violation → retornar 409

# Namespace reservado:
# Certos aliases são reservados para uso interno: "api", "admin", "help"
# → tabela de aliases bloqueados OU prefixo reservado (e.g., __ para internos)
# → verificar no momento da criação antes de tentar inserir

Considerações de segurança

Um URL shortener cria anonimidade para qualquer URL — o que pode ser abusado para distribuir malware, phishing, e spam. Ignorar esse vetor de abuso resulta em bloqueio do domínio por provedores de email e browsers.

# 1. Scanning de URLs na criação
# → integrar com Google Safe Browsing API ou VirusTotal
# → URLs identificadas como maliciosas: rejeitar na criação (422 com razão)
# → URLs já criadas que são depois identificadas: soft-block + notificação

# 2. Rate limiting por IP e por usuário autenticado
# → IP não autenticado: 10 criações/hora (previne abuso anônimo)
# → usuário autenticado: 1000 criações/hora (uso legítimo de API)
# → implementar com Redis + sliding window counter
# → 429 Too Many Requests com Retry-After header

# 3. Detecção de bot no redirect
# → User-Agent + comportamento (muitos clicks em ms)
# → bots não devem ser contabilizados em analytics
# → identificar com heurísticas simples + listas de User-Agents conhecidos de bots

# 4. URL original: sanitização e validação
# → rejeitar javascript:, data:, e outros schemas não-HTTP(S)
# → verificar que o domínio resolve (DNS lookup opcional)
# → limitar tamanho da URL original (ex: 2048 chars)

Arquitetura final

Usuário → CDN (estáticos + UI)
        → Load Balancer
        → App Servers (stateless, autoscalável)
              ↓ redirect hot path (leitura)
              ├── Local Cache (Caffeine/ristretto, top-1000)
              ├── Redis Cluster (short_code → original_url, TTL)
              └── PostgreSQL Primary + 2 Read Replicas
                    (hash index em short_code)

              ↓ analytics (fire-and-forget, fora do hot path)
              └── Kafka (topic: url-clicks, retenção 7 dias)
                    └── Analytics Consumer
                          ├── GeoIP lookup (MaxMind, em memória)
                          ├── Bot detection
                          ├── PostgreSQL: url_clicks (raw, particionado por mês)
                          └── PostgreSQL: url_stats_daily (agregado)

KGS (Key Generation Service):
  → PostgreSQL: codes_available (pré-gerados, 100M códigos = ~700MB)
  → PostgreSQL: codes_used (short_code → url_id)
  → Buffer em memória: 10k códigos pré-carregados para baixa latência
o que o URL shortener ensina sobre systems design

O URL shortener é o "hello world" do system design porque cada componente da solução corresponde a um padrão que aparece em sistemas muito maiores. A geração de IDs únicos sem coordenação central é o mesmo problema que o Snowflake ID do Twitter ou o UUID v4 do Stripe. O trade-off 301 vs 302 é o mesmo trade-off de cache vs observabilidade que aparece em qualquer sistema com tracking. O pipeline assíncrono de analytics é o mesmo padrão event sourcing que aparece em sistemas de audit log, billing, e recommendation engines. Dominar o URL shortener é dominar os building blocks que vão aparecer em qualquer design mais complexo.

Decisões de engenharia

KGS vs Snowflake ID para geração de short codes

KGS garante imprevisibilidade completa (códigos são aleatórios, não derivados de timestamp) e elimina colisões via transação atômica. Snowflake é mais simples operacionalmente (sem serviço adicional) mas os códigos são previsíveis no tempo porque o timestamp compõe o ID.

Regra prática: se imprevisibilidade é requisito de segurança (não queremos que adversários consigam enumerar URLs), KGS é a escolha correta. Se o sistema é interno e enumeração não é uma ameaça, Snowflake + base62 é mais simples de operar.

301 vs 302 para o redirect

301 reduz carga no servidor mas destrói a precisão de analytics — browsers cacheiam o redirect e não voltam mais ao shortener. 302 garante que todo click passa pelo servidor, mas aumenta a carga. Com cache Redis, o "aumento de carga" de 302 é absorvido sem problema até dezenas de milhares de redirects por segundo.

Regra prática: 302 é o padrão correto para qualquer URL shortener com analytics. 301 só faz sentido como opção explícita do usuário ("modo privacidade") onde tracking não é desejado.

Verificação de URL maliciosa: síncrona vs assíncrona

Verificar sincronamente na criação (usuário espera o scan) garante que URLs maliciosas nunca entram no sistema, mas adiciona latência de criação (Google Safe Browsing tem latência de 100-300ms). Verificar assincronamente (URL é criada, scan roda em background) permite criação rápida mas há uma janela onde URLs maliciosas estão ativas.

Regra prática: verificação síncrona para domínios sem histórico ou com padrões suspeitos; verificação assíncrona para usuários autenticados com reputação estabelecida. Combinação: scan rápido de domínio (blocklist em memória) síncrono + scan completo assíncrono.

SQL vs NoSQL para o storage principal

O padrão de acesso principal é leitura por chave simples (short_code → URL), o que favorece NoSQL. Mas o volume de storage (566 GB em 5 anos) e o QPS de escrita (35/s no pico) estão bem dentro do que PostgreSQL suporta. NoSQL adicionaria complexidade sem benefício real nessa escala.

Regra prática: SQL até que um dos seguintes seja verdadeiro: (1) QPS de escrita ultrapassa 10k/s, (2) storage ultrapassa o que um único servidor aguenta (decenas de TB), (3) o schema precisa ser completamente livre de estrutura. Nenhum dos três se aplica ao URL shortener nessa escala.

Perguntas de entrevista

Como você garante unicidade de short codes num sistema distribuído sem um lock global?

O problema de geração de IDs únicos sem coordenação central é um dos problemas clássicos de sistemas distribuídos. Para o URL shortener, há três abordagens viáveis:

KGS (Key Generation Service): pré-gera códigos aleatórios e os armazena em banco. A atomicidade do SELECT + DELETE + INSERT em transação garante que nenhum código é atribuído duas vezes. O KGS pode ser replicado (múltiplos workers) cada um com seu próprio buffer de códigos retirados da tabela — sem coordenação entre workers. O único risco é crash de um worker com códigos no buffer não persistidos: esses códigos são "perdidos" (desperdiçados) mas não geram inconsistência.

Unique constraint no banco: gerar o código localmente (aleatório ou hash) e deixar o banco rejeitar colisões via UNIQUE constraint. Em caso de colisão, gerar um novo código e tentar novamente. Com 3,5 trilhões de possibilidades e 1M de URLs, a probabilidade de colisão na primeira tentativa é ~1 em 3.500.000 — praticamente zero. A taxa de retry é negligenciável.

Snowflake distribuído: cada worker tem um ID único configurado. O código é derivado de (timestamp + worker_id + sequence), garantindo unicidade sem coordenação. O custo é previsibilidade temporal.

Para o URL shortener com requisito de imprevisibilidade: KGS com buffer em memória é a solução de maior confiabilidade e menor latência.

O que acontece quando o Redis fica indisponível — o sistema continua funcionando?

Degradação graciosa é o comportamento correto — não falha total. O fluxo de fallback:

1. Local cache no servidor de aplicação: as URLs mais acessadas nos últimos 60 segundos estão em memória local. Para URLs populares, o sistema continua funcionando sem Redis, com latência levemente maior (sem Redis hop, mas sem Redis → local cache hit ainda é rápido).

2. Fallback ao banco: para URLs não no local cache, o servidor vai diretamente ao PostgreSQL. Latência aumenta de ~1ms (Redis) para ~5ms (banco), mas o sistema funciona. O risco real é o banco receber todos os reads (100% de miss no Redis) — com 3.500 reads/s, PostgreSQL com read replicas aguenta, mas com margem menor.

3. Circuit breaker: implementar circuit breaker no cliente Redis para que falhas rápidas não adicionem timeout de conexão ao caminho crítico. Se Redis não responde em 5ms, assumir indisponível e ir direto ao banco sem esperar o timeout padrão de 30s.

4. Alertas e recuperação: Redis indisponível deve alertar imediatamente. Redis Cluster com sentinel garante failover automático em segundos — a janela de indisponibilidade total é de milissegundos a alguns segundos, não minutos.

O ponto crítico: o sistema foi projetado para funcionar sem Redis, com degradação de performance. Isso é design correto — Redis é uma otimização de latência, não um requisito de corretude.

Como você implementaria analytics em tempo real (dashboard que mostra clicks nos últimos 5 minutos) sem impactar a latência de redirect?

Analytics em tempo real com janela de 5 minutos requer um pipeline diferente do analytics de histórico (daily aggregates). A arquitetura:

No caminho de redirect: o evento de click é publicado no Kafka com latência < 1ms (fire-and-forget). O redirect retorna antes mesmo do Kafka confirmar a publicação se necessário — usando publish async.

Consumer de streaming: um consumer Kafka com processamento de stream (Flink, Kafka Streams, ou até um consumer simples com janelas de tumbling window de 1 minuto) agrega clicks por (short_code, minuto). A cada minuto, escreve o agregado em Redis:

HINCRBY url:clicks:2024-01-01T14:30 "abc1234" 147
EXPIRE  url:clicks:2024-01-01T14:30 3600  # TTL de 1 hora

API de dashboard: para mostrar "últimos 5 minutos", lê as últimas 5 keys do formato acima e soma. Latência de leitura: < 5ms. Dados atrasados em relação ao tempo real: ~30 segundos (batch de 1 minuto do consumer).

O trade-off é explícito: "em tempo real" significa "atrasado em no máximo 60 segundos" — que é aceitável para um dashboard de analytics. Se o requisito fosse atualização a cada segundo, o consumer precisaria processar eventos individualmente (sem batching), o que aumenta o custo de operação do Redis e do consumer mas é tecnicamente viável.

Como o sistema escala de 1M para 1B redirects/dia sem redesign completo?

1B redirects/dia = 11.574 reads/s (média), ~35k reads/s no pico. Os pontos de pressão e como cada um é endereçado incrementalmente:

Servidores de aplicação: stateless → escala horizontal. De 2 para 20 servidores atrás do load balancer. Sem mudança de código.

Redis: 35k ops/s está dentro de um único Redis node (100k ops/s). Mas hot key problem aumenta — URLs virais podem saturar uma shard. Solução: Redis Cluster com 6 shards + local cache mais agressivo (top-5000 em vez de top-1000, TTL de 5 min em vez de 1 min).

PostgreSQL: com 95% de hit rate no cache, o banco recebe ~1.750 reads/s — ainda dentro de PostgreSQL com 3 read replicas. Se o hit rate cair (base de URLs cresceu, hot set diversificou), adicionar read replicas ou aumentar memória de cache Redis.

Analytics consumer: Kafka escala horizontalmente com partições. Com 35k clicks/s, um consumer group com 6 consumers (um por partição) processa sem dificuldade. O volume de dados de analytics é o maior desafio: 1B clicks/dia × 300B ≈ 300GB/dia de dados raw. Migrar para ClickHouse ou BigQuery para analytics — PostgreSQL não é adequado para analytics de alta cardinalidade nessa escala.

Storage principal: 1B clicks × (10M writes/dia, não 1M) × 310B ≈ 3,1GB/dia = ~5,7TB em 5 anos. PostgreSQL aguenta com particionamento por data. Sharding só seria necessário acima de ~100TB ou se o QPS de escrita ultrapassasse 10k/s.

Como você lidaria com custom domains (empresa quer usar seu próprio domínio para URLs curtas)?

Custom domains (empresa configura "links.acme.com" em vez de "shr.tt") é uma das features que mais complica a infraestrutura de um URL shortener — desproporcionalmente ao seu valor aparente.

DNS: o cliente configura um CNAME de "links.acme.com" apontando para o shortener. O servidor de aplicação precisa identificar qual domínio está servindo a requisição e mapear para a configuração correta do cliente (qual namespace de short codes pertence a qual cliente).

TLS: cada custom domain precisa de um certificado TLS válido. Usar o certificado do shortener para um domínio diferente gera erro de certificado no browser. Solução: cert-manager + Let's Encrypt com DNS-01 challenge (o cliente adiciona um registro DNS TXT para validar propriedade). Isso é automatizável mas requer que o shortener seja capaz de solicitar e renovar certificados para domínios de clientes — o que significa que o shortener precisa ter acesso a uma API de DNS ou que o cliente configure o registro DNS corretamente.

CDN: CDNs como Cloudflare suportam "SSL for SaaS" que permite criar certificados para domínios de clientes com uma API, mas o custo por domínio pode ser significativo em escala.

Modelo de dados: adicionar tabela custom_domains (domain VARCHAR, client_id BIGINT, verified_at TIMESTAMPTZ) e join no caminho de redirect para identificar o namespace correto. O join add latência → cachear o mapeamento domain → client_id no Redis.

Custom domains é uma feature que vale muito a pena para clientes enterprise mas que tem custo de implementação e operação que não deve ser subestimado no design inicial.

Exercícios práticos

Exercício 1 — Implementar a geração de short codes com três estratégias

Implemente as três estratégias de geração de short code num projeto de teste: (a) MD5 truncado com detecção de colisão e re-hash com salt, (b) base62 de auto-increment ID com shuffling de bits para reduzir previsibilidade, (c) geração aleatória com verificação de unicidade via constraint do banco. Para cada uma, meça: latência de geração de 1.000 URLs, taxa de colisão com 100k URLs existentes, e tempo para esgotar o espaço de combinações no ritmo de 1M URLs/dia.

Critério: você consegue articular o trade-off de cada estratégia com dados medidos, não só teóricos. A implementação (c) com constraint do banco demonstra retry transparente em caso de colisão sem expor o erro ao usuário.

Exercício 2 — Medir o impacto de 301 vs 302 em analytics

Configure dois endpoints num servidor local: um retornando 301, outro retornando 302. Acesse cada um 10 vezes com um browser real. Observe: quantas vezes o servidor de 302 recebe requisições? E o de 301? Depois limpe o cache do browser e repita. Documente o comportamento exato e por que isso invalida o 301 para analytics. Opcional: testar com curl vs browser para ver a diferença de comportamento de cache.

Critério: você verificou empiricamente que o browser não faz a segunda requisição após um 301, e consegue explicar por que isso torna o 301 incompatível com analytics de clicks por URL.

Exercício 3 — Implementar o pipeline assíncrono de analytics

Construa um pipeline simplificado: servidor web que recebe redirects e publica eventos em uma fila (pode ser RabbitMQ, SQS local, ou até um canal Go/goroutine para simplificar), e um consumer que lê da fila e escreve em banco. Meça: qual é a latência adicionada ao redirect pelo publish assíncrono vs síncrono? Qual é o atraso máximo de analytics (tempo entre o click e o dado estar no banco do consumer)? O que acontece se o consumer parar — os clicks são perdidos?

Critério: o redirect com publish assíncrono tem latência adicional inferior a 1ms. Os clicks não são perdidos quando o consumer para (fila persiste). O atraso de analytics é mensurável e dentro de um limite aceitável (menos de 60 segundos).

Exercício 4 — Simular o hot key problem e a solução com local cache

Configure Redis localmente. Gere carga de 10.000 requests/segundo para um único short_code usando k6 ou hey. Observe a CPU do Redis e a latência das operações. Implemente local cache em memória no servidor de aplicação (LRU de 1.000 entradas, TTL de 60s). Repita o teste com o local cache ativo. Compare: throughput máximo atingível, latência P99, e CPU do Redis. Documente o speedup obtido com o local cache para o cenário de URL viral.

Critério: com local cache, o Redis recebe no máximo 1 request por servidor por TTL (60s) para a URL popular — confirmado nos logs do Redis. A latência P99 do redirect caiu para menos de 1ms para URLs em local cache.

Exercício 5 — Design completo: URL shortener para empresa SaaS

Uma empresa SaaS quer oferecer URL shortener como feature de seu produto. Requisitos específicos: cada cliente tem seu próprio namespace de URLs (empresa A tem "go.empresa-a.com", empresa B tem "go.empresa-b.com"), analytics separados por cliente, rate limiting por cliente (não por IP), e URLs expiram automaticamente com o plano do cliente. Escreva o design completo: schema de banco adicionando multi-tenancy, como o redirect path identifica o tenant a partir do domínio, como o rate limiting por cliente é implementado, e as implicações de TLS com custom domains.

Critério: o design isola completamente dados entre tenants (uma URL de cliente A nunca é acessível via domínio de cliente B), o redirect path não faz mais de 2 trips ao banco/cache para resolver um redirect (mesmo com multi-tenancy), e o custo operacional de TLS com custom domains está explicitamente documentado.

Referências

  1. article Alex Xu — Design a URL Shortening Service bytebytego.com · o tratamento mais completo do URL shortener como problema de system design, com todos os componentes detalhados
  2. article Bitly Engineering — URL Shortening at Scale word.bitly.com · como o bit.ly real foi construído e evoluído — os problemas que aparecem em produção que não aparecem no design inicial
  3. article Twitter Engineering — Announcing Snowflake blog.twitter.com · a proposta original do Snowflake ID — geração de IDs únicos sem coordenação central em escala de bilhões
  4. article Instagram Engineering — Sharding IDs at Instagram instagram-engineering.com · como o Instagram gerou IDs únicos em escala usando PostgreSQL schemas como shards — alternativa ao Snowflake
  5. docs RFC 7231 — HTTP/1.1 Semantics: 301 vs 302 rfc-editor.org/rfc/rfc7231 · a semântica formal de 301 Moved Permanently vs 302 Found e suas implicações de cache
  6. docs Redis — Cluster Specification redis.io/docs/management/scaling · como Redis Cluster distribui chaves e trata hot keys — fundação para o design de cache do URL shortener
  7. article Cloudflare — SSL for SaaS developers.cloudflare.com/ssl/ssl-for-saas · como implementar TLS para custom domains de clientes SaaS — o problema real de certificados com custom domains
  8. article Google Safe Browsing — API Overview developers.google.com/safe-browsing · a API de scanning de URLs maliciosas usada por browsers e serviços de segurança
  9. book Martin Kleppmann — DDIA Chapter 11: Stream Processing O'Reilly · 2017 · a base teórica do pipeline de analytics assíncrono — event streams, consumers, e garantias de entrega
  10. article Ben Stopford — Designing Event-Driven Systems confluent.io/designing-event-driven-systems · o pattern de fire-and-forget para eventos no caminho crítico e as garantias de entrega necessárias
  11. article High Scalability — How TinyURL Works highscalability.com · análise da arquitetura do TinyURL — comparação com o design teórico e o que o sistema real prioriza diferente
  12. article Stripe Engineering — Idempotency Keys stripe.com/blog · como garantir que operações repetidas (retry de criação de URL) não criam duplicatas — o problema de idempotência na geração de IDs