← Blog
·12 blog.minutes

Fundamentos de Design de Sistemas Que Todo Desenvolvedor Deve Saber

Balanceamento de carga, cache, sharding, CDNs, filas de mensagens, teorema CAP, limitação de taxa — desmistificados com exemplos reais e configurações que você pode realmente usar.

Todo desenvolvedor backend eventualmente bate em uma parede. A aplicação funciona perfeitamente no seu laptop. Funciona perfeitamente no staging com três usuários simultâneos. Então você implanta em produção, mil pessoas aparecem de uma vez, e o banco de dados cai. A API começa a retornar erros 5xx, o frontend trava, e em algum lugar no Slack alguém posta o print que faz seu telefone vibrar às 2 da manhã.

Design de sistemas é o que vive do outro lado dessa parede. É o conjunto de padrões, tradeoffs e decisões de infraestrutura que separam uma aplicação de brinquedo de um sistema de produção que pode lidar com tráfego real sem colapsar. A boa notícia é que você não precisa ser um engenheiro de infraestrutura sênior para entender os fundamentos. Você precisa conhecer cerca de oito conceitos, como eles interagem e quando recorrer a cada um.

Este artigo cobre os padrões de design de sistemas que mais importam para desenvolvedores backend em atividade. Cada seção explica o conceito, mostra um exemplo prático e descreve os tradeoffs que você precisa avaliar antes de adotá-lo. Estes não são conceitos abstratos de livro texto — são ferramentas que você usará toda vez que construir algo que precisa escalar.

Balanceamento de Carga — Mantendo Cada Servidor Ocupado, Mas Não Sobrecarregado

O balanceamento de carga é o padrão de design de sistemas mais simples e impactante que você pode implementar. A ideia é direta: em vez de enviar todo o tráfego para um único servidor, você distribui as requisições recebidas por um conjunto de servidores. Isso lhe dá duas coisas ao mesmo tempo: maior disponibilidade (se um servidor morre, os outros continuam servindo) e maior throughput (múltiplos servidores compartilham o trabalho).

Os algoritmos de balanceamento de carga mais comuns são round-robin, mínimo de conexões e hash de IP. Round-robin percorre a lista de servidores em ordem — simples, previsível, mas não sabe o quão ocupado cada servidor realmente está. Mínimo de conexões envia cada requisição ao servidor com menos conexões ativas, o que lida melhor com cargas de requisição desiguais. Hash de IP usa o endereço IP do cliente para escolher deterministicamente um servidor, o que importa quando você precisa de sessões persistentes — garantindo que o mesmo cliente sempre atinja o mesmo servidor.

Na prática, a maioria das configurações de produção usa uma combinação. Um balanceador de carga de camada 4 (operando no nível TCP) distribui conexões brutas por um conjunto de proxies reversos ou gateways de API, que então realizam balanceamento de carga de camada 7 (operando no nível HTTP) para rotear requisições a instâncias específicas da aplicação. Esta abordagem em camadas mantém o plano de dados rápido e a lógica de roteamento flexível.

# nginx.conf — simple round-robin load balancing across three app servers
upstream app_cluster {
    round-robin;
    server app1.internal:3000 max_fails=3 fail_timeout=30s;
    server app2.internal:3000 max_fails=3 fail_timeout=30s;
    server app3.internal:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

O detalhe crítico que a maioria dos desenvolvedores ignora é a verificação de saúde. Um balanceador de carga só é útil se sabe quais servidores estão saudáveis. Se o app2 falha mas o balanceador de carga continua enviando tráfego para ele, sua taxa de erro dispara em aproximadamente um terço. Sempre configure verificações de saúde ativas — o balanceador de carga pinga periodicamente cada servidor e remove os que não respondem do pool automaticamente.

Cache — Velocidade, Mas a Que Custo?

Cache é a otimização de desempenho de maior alavancagem disponível para qualquer desenvolvedor. Um único acerto de cache pode transformar uma consulta de banco de dados de 200 milissegundos em uma busca em memória de 2 milissegundos. Duas ordens de magnitude. Aplicado a milhões de requisições, essa diferença é a diferença entre uma conta de banco de dados mensal de $10.000 e uma de $500.

A pilha de cache canônica tem três camadas, cada uma com características diferentes. O cache em nível de aplicação (caches em memória como Redis ou Memcached) armazena os resultados de computações caras ou consultas de banco de dados. O cache de CDN armazena ativos estáticos e semiestáticos em locais de borda próximos aos usuários. O cache HTTP usa cabeçalhos Cache-Control e ETag para permitir que navegadores e proxies armazenem respostas em cache sem envolver seus servidores.

A parte mais difícil do cache não é configurá-lo — é invalidar o cache quando os dados subjacentes mudam. A indústria se estabeleceu em alguns padrões confiáveis. Cache-aside (também chamado de lazy loading) significa que a aplicação verifica o cache primeiro, recorre ao banco de dados em caso de falta, e popula o cache com o resultado. Write-through cache significa que toda escrita vai tanto para o cache quanto para o banco de dados simultaneamente. Write-behind cache significa que as escritas vão para o cache primeiro e são liberadas assincronamente para o banco de dados.

Só existem duas coisas difíceis na ciência da computação: invalidação de cache e nomear coisas. Invalidação de cache é mais difícil porque você não pode simplesmente renomeá-la e esperar que desapareça.

Para a maioria das aplicações, cache-aside com um tempo de vida curto é o padrão correto. Defina um TTL que corresponda à sua tolerância para dados obsoletos — 60 segundos para perfis de usuário, 5 minutos para listagens de produtos, 24 horas para dados de referência. Quando você precisar de consistência mais forte, use write-through cache mas aceite a maior latência de escrita. Quando precisar de throughput máximo de leitura e puder tolerar consistência eventual, use cache-aside com um TTL generoso.

import redis.asyncio as aioredis
import json

cache = aioredis.Redis.from_url("redis://cache:6379")

CACHE_TTL = 300  # 5 minutes

async def get_user_profile(user_id: str) -> dict:
    key = f"profile:{user_id}"
    cached = await cache.get(key)
    if cached:
        return json.loads(cached)
    profile = await db.fetch_user(user_id)
    if profile:
        await cache.setex(key, CACHE_TTL, json.dumps(profile))
    return profile

Uma palavra de advertência: o cache pode mascarar problemas em vez de resolvê-los. Se suas consultas de banco de dados são lentas porque você está perdendo um índice, adicionar um cache esconde o sintoma mas a consulta subjacente ainda é lenta. O cache é despejado, a consulta lenta executa, o usuário espera. Sempre profile e otimize seus caminhos lentos primeiro, depois adicione cache em cima da versão otimizada.

Sharding de Banco de Dados — Dividindo o Trabalho para Nenhum Banco de Dados Afogar

Sharding (particionamento horizontal) é o que você usa quando uma única instância de banco de dados não consegue lidar com seu throughput de escrita ou tamanho do conjunto de dados. A ideia é dividir seus dados entre múltiplas instâncias de banco de dados, onde cada instância (shard) contém um subconjunto dos dados. A aplicação determina qual shard consultar com base em uma chave de shard — tipicamente um hash do ID do usuário, uma região geográfica ou um intervalo de tempo.

A chave de shard é a decisão mais importante em qualquer sistema sharded. Uma boa chave de shard distribui dados uniformemente entre os shards e se alinha aos seus padrões de consulta. Uma chave de shard ruim cria shards quentes — alguns shards que lidam com a maior parte do tráfego enquanto o resto fica ocioso. Por exemplo, sharding por timestamp de criação parece razoável até você perceber que o shard de hoje lida com todas as escritas enquanto os shards de anos anteriores lidam com nenhuma.

Hashing consistente resolve o problema de rebalanceamento que aflige o sharding ingênuo. Em um esquema de sharding simples baseado em módulo (shard = hash(chave) % N), adicionar um novo shard requer embaralhar quase todos os dados. Hashing consistente mapeia tanto chaves quanto shards em um anel de hash; quando você adiciona um shard, apenas as chaves na vizinhança imediata do novo shard precisam ser movidas. Isso torna escalar para cima e para baixo muito menos doloroso.

  • Sharding baseado em hash — distribui pelo hash da chave de shard; simples e uniforme, mas re-sharding é caro
  • Sharding baseado em intervalo — divide por faixas de valor (ex.: usuários com IDs 1–10000 no shard A, 10001–20000 no shard B); eficiente para consultas de intervalo mas propenso a pontos quentes
  • Sharding baseado em diretório — mantém uma tabela de consulta mapeando chaves para shards; flexível mas adiciona um salto de consulta e um ponto único de falha se o diretório cair
  • Sharding geográfico — divide por região do usuário; excelente para latência mas estranho se os usuários viajam ou se os dados precisam ser globais

O tradeoff que você aceita com sharding é que consultas entre shards se tornam caras ou impossíveis. Se você shardear sua tabela de usuários por user_id e sua tabela de pedidos por user_id, uma consulta por todos os pedidos dos últimos 30 dias precisa atingir todos os shards. Aplicações que precisam de análises globais ou joins entre shards frequentemente usam uma réplica de leitura secundária (ou um banco de dados de análise dedicado) que agrega dados de todos os shards assincronamente.

O Teorema CAP — Você Só Pode Escolher Dois

O teorema CAP afirma que um armazenamento de dados distribuído não pode fornecer simultaneamente mais de duas de três garantias: Consistência (toda leitura recebe a escrita mais recente), Disponibilidade (toda requisição recebe uma resposta, mesmo que não sejam os dados mais recentes) e Tolerância a Partição (o sistema continua operando apesar de falhas de rede entre nós).

Na prática, tolerância a partição não é opcional. Redes falham. Pacotes são perdidos, conexões expiram, data centers perdem energia. Então a escolha real é entre CP (consistência + tolerância a partição) e AP (disponibilidade + tolerância a partição). Um sistema CP como etcd ou Zookeeper se recusará a servir leituras se não puder garantir consistência entre os nós. Um sistema AP como Cassandra ou DynamoDB servirá leituras de qualquer nó acessível, mesmo que esse nó tenha dados desatualizados.

Esta não é uma distinção acadêmica. Quando você projeta um sistema que abrange múltiplos data centers, você deve decidir o que acontece quando o link de rede entre eles cai. Você continua servindo requisições com dados potencialmente desatualizados (AP)? Ou você para de servir até que a rede se recupere (CP)? A resposta depende da sua aplicação. Uma rede de entrega de conteúdo deve ser AP — conteúdo desatualizado é melhor que nenhum conteúdo. Um sistema de processamento de pagamentos deve ser CP — você nunca quer cobrar duas vezes um cliente porque dois nós aceitaram o mesmo pagamento enquanto particionados.

Filas de Mensagens — Transformando Dor Síncrona em Graça Assíncrona

Filas de mensagens são a espinha dorsal do processamento assíncrono em sistemas distribuídos. Elas permitem que um serviço (o produtor) envie uma mensagem para uma fila sem esperar que o consumidor a processe. O consumidor pega a mensagem quando está pronto, a processa e confirma a conclusão. Isso desacopla o produtor do consumidor tanto no tempo quanto no espaço — eles não precisam rodar na mesma velocidade, ou nem mesmo ao mesmo tempo.

Todo sistema backend não trivial deveria usar uma fila de mensagens em algum lugar. O exemplo canônico é o envio de emails. Quando um usuário se registra em sua plataforma, você não quer que a resposta HTTP espere o serviço de entrega de email renderizar um template, conectar-se ao SendGrid e entregar a mensagem. Em vez disso, sua API envia um evento send_email para uma fila e retorna uma resposta 201 Created imediatamente. Um worker separado pega o evento, envia o email e marca a tarefa como concluída.

Os dois modelos dominantes de fila de mensagens são publish-subscribe (pub/sub) e filas de trabalho. Em pub/sub, cada mensagem é transmitida para todos os assinantes. Isso é útil para arquiteturas orientadas a eventos onde múltiplos serviços precisam reagir ao mesmo evento — um novo registro de usuário pode disparar um email de boas-vindas, uma atualização de CRM e um evento de análise simultaneamente. Em uma fila de trabalho, cada mensagem é entregue a exatamente um consumidor. Isso é útil para distribuir trabalho entre um pool de workers — cada upload de imagem vai para exatamente um gerador de miniaturas.

# docker-compose.yml — minimal RabbitMQ setup for local development
version: "3.8"
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"   # AMQP port for producers/consumers
      - "15672:15672" # Management UI
    environment:
      RABBITMQ_DEFAULT_USER: app
      RABBITMQ_DEFAULT_PASS: dev-only-password
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  rabbitmq_data:

A parte complicada das filas de mensagens é lidar com falhas graciosamente. O que acontece se o consumidor falhar no meio do processamento de uma mensagem? RabbitMQ e Amazon SQS lidam com isso através de acknowledgments de entrega — o consumidor deve explicitamente confirmar que uma mensagem foi processada com sucesso. Se o consumidor desconectar sem confirmar, a mensagem é recolocada na fila e entregue a outro consumidor. Essa garantia de entrega pelo menos uma vez significa que seus consumidores devem ser idempotentes: processar a mesma mensagem duas vezes deve produzir o mesmo resultado que processá-la uma vez.

Filas de letras mortas são outro padrão essencial. Quando uma mensagem não pode ser processada após várias tentativas (o serviço downstream está fora, os dados estão malformados, a regra de negócio mudou), a mensagem é movida para uma fila de letras mortas em vez de ser repetida para sempre. Um operador monitora a fila de letras mortas, investiga a causa raiz, e ou corrige a mensagem e a recoloca na fila ou a descarta após confirmar que é seguro ignorá-la.

CDNs e Limitação de Taxa — As Defesas de Linha de Frente

Redes de entrega de conteúdo e limitadores de taxa servem propósitos diferentes mas compartilham uma característica comum: são a primeira linha de defesa entre seus usuários e seus servidores. Uma CDN mantém ativos estáticos e respostas em cache próximas ao usuário, reduzindo latência e desafogando o tráfego de seus servidores de origem. Um limitador de taxa impede que qualquer usuário ou cliente único sobrecarregue seu sistema com requisições.

CDNs funcionam distribuindo seu conteúdo por uma rede global de servidores de borda. Quando um usuário em Tóquio solicita um ativo, a CDN o serve da localização de borda mais próxima em vez de rotear a requisição até seu servidor de origem na Virgínia. Isso reduz a latência de 200 milissegundos para 10 milissegundos para ativos estáticos. CDNs modernas vão além — podem armazenar respostas de API em cache, terminar conexões TLS e até executar funções serverless na borda.

A limitação de taxa protege seu sistema em múltiplos níveis. A limitação de taxa global limita o total de requisições que todo seu sistema pode manipular por segundo, protegendo contra picos de tráfego e ataques DDoS. A limitação de taxa por usuário garante que um locatário abusivo não esgote recursos de outros usuários. A limitação de taxa por endpoint aplica limites diferentes a diferentes rotas — um endpoint de login pode permitir 5 requisições por minuto enquanto um endpoint de busca somente leitura permite 100 requisições por minuto.

O algoritmo de janela deslizante é o padrão da indústria para limitação de taxa porque é tanto preciso quanto eficiente. Em vez de redefinir contadores em intervalos fixos (o que permite rajadas no limite), uma janela deslizante considera requisições ao longo de uma janela de tempo contínua. Redis é a escolha natural para implementar isso — use um conjunto ordenado com timestamps como pontuação, corte entradas fora da janela, e conte as entradas que restam. O custo de memória é mínimo (alguns bytes por requisição) e a complexidade de tempo é logarítmica.

// Redis-backed sliding window rate limiter (TypeScript)
import { createClient } from "redis";

const redis = createClient({ url: "redis://ratelimit:6379" });

async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  const multi = redis.multi();
  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));

  const [, , count] = await multi.exec() as [any, any, number];
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

Tanto CDNs quanto limitadores de taxa compartilham um princípio operacional importante: falhar aberto ou falhar fechado? Se a borda da CDN não consegue alcançar a origem, deve servir uma resposta em cache desatualizada (fail open) ou retornar um erro (fail closed)? Se o cluster Redis do limitador de taxa cair, todas as requisições devem passar (fail open — risco de sobrecarga) ou todas as requisições devem ser rejeitadas (fail closed — downtime garantido)?

Não há uma resposta universal, mas um bom padrão para a maioria dos sistemas é: fail open para leituras, fail closed para escritas. Uma página de listagem de produto desatualizada é aceitável. Uma ordem de compra perdida não é. Documente esta decisão explicitamente em seu runbook para que o engenheiro de plantão saiba qual comportamento esperar quando a infraestrutura ceder.

Juntando Tudo — Pensando em Tradeoffs

Design de sistemas não é sobre memorizar padrões. É sobre entender tradeoffs e reconhecer qual padrão se encaixa em suas restrições. Toda decisão envolve um tradeoff entre consistência e disponibilidade, entre throughput de leitura e latência de escrita, entre complexidade operacional e desempenho bruto. Os melhores engenheiros não são os que conhecem mais padrões — são os que podem olhar para um problema e identificar quais restrições são fixas e quais são negociáveis.

Aqui está um framework de decisão rápido para quando você enfrentar um novo problema de design de sistemas. Comece listando seus requisitos não-funcionais: volume de tráfego esperado, metas de latência, requisitos de consistência, orçamento para custos de infraestrutura e familiaridade da equipe com a tecnologia. Em seguida, percorra os padrões deste artigo e pergunte se cada um o aproxima ou afasta de seus requisitos.

Se latência é sua principal preocupação, comece com cache e uma CDN — eles dão a maior melhoria com a menor complexidade. Se disponibilidade é crítica, use balanceamento de carga com verificações de saúde, projete seus serviços para serem stateless e escolha AP em vez de CP onde o negócio permitir. Se você está lidando com cargas de trabalho intensivas em escrita, avalie sharding e filas de mensagens cedo — eles são muito mais fáceis de introduzir antes de você ter milhões de linhas de dados. Se você está lidando com uma API pública que desenvolvedores terceiros consumirão, implemente limitação de taxa no primeiro dia. Adicioná-la depois significa versionar sua API ou quebrar clientes existentes.

A habilidade mais importante em design de sistemas é saber o que você não precisa. A maioria das aplicações não precisa de sharding. A maioria das aplicações não precisa de uma fila de mensagens. Distribuição prematura é a raiz de todo mal em design de sistemas — todo sistema distribuído introduz modos de falha que um sistema de servidor único simplesmente não tem. Adicione complexidade apenas quando as métricas mandarem, não porque o padrão soa impressionante em uma entrevista de emprego.

Comece simples. Meça tudo. Adicione um padrão de cada vez. Verifique a melhoria antes de prosseguir. Os sistemas que sobrevivem não são os com a arquitetura mais sofisticada — são os que são fáceis de entender, fáceis de operar e fáceis de mudar quando o próximo gargalo aparecer.