Arquitetura de software

Padrões de projeto para microsserviços: resiliência, descoberta, dados e observabilidade

Avançado11 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Sistemas distribuídos falham de maneiras que um processo único não falha, e o catálogo de padrões de microsserviços existe para que o sistema sobreviva a essas falhas. Este guia agrupa os padrões pelo problema que resolvem (roteamento, resiliência, consistência de dados, deploy e observabilidade), mostra configuração e código dos que você vai de fato implementar e deixa explícito o custo que cada um acrescenta.

O que você vai aprender

  • Escolher o padrão de roteamento e descoberta certo para uma determinada plataforma de deploy
  • Configurar timeouts, retries, circuit breakers e bulkheads para que as falhas fiquem contidas
  • Tornar uma operação idempotente para que retries e reentregas sejam seguros
  • Escolher entre CQRS, event sourcing e um simples modelo de leitura compartilhado diante de um problema de consulta
  • Decidir se um service mesh compensa a complexidade operacional na sua plataforma

O catálogo de padrões de microsserviços parece uma lista de compras, e tratá-lo assim é o caminho mais rápido para construir algo que ninguém consegue operar. Cada padrão aqui existe porque uma falha específica machucou alguém o bastante para ser documentada, e cada um acrescenta peças móveis. A pergunta útil nunca é "quais padrões devemos adotar", e sim "a que falha estou exposto agora, e qual é a forma mais barata de eliminá-la".

Este guia é organizado por problema, não por nome de padrão. Ele parte do princípio de que você já escolheu o estilo e traçou suas fronteiras (veja microsserviços para isso) e deixa o tratamento aprofundado de consistência para transações distribuídas e consistência, cobrindo saga e outbox aqui apenas na medida em que dão forma a um design.

Levando uma requisição até a instância certa

Gateway de borda. Os clientes não deveriam conhecer a topologia dos seus serviços. Um ponto de entrada único termina o TLS, autentica, aplica rate limits e roteia por path ou host para o serviço adequado. Ele mantém as preocupações transversais em um só lugar e permite mover uma rota para uma nova implementação sem mexer nos clientes, que é justamente o que torna possível uma migração strangler fig. O gateway precisa continuar enxuto: roteamento, políticas, tradução de protocolos e nada que codifique regras de negócio. O guia de gestão de APIs trata de produtos e políticas em profundidade.

Backend for frontend (BFF). Quando um app mobile e um app web precisam de agregações substancialmente diferentes, uma única API de gateway atendendo os dois vira um meio-termo que não serve a nenhum. Um BFF é um serviço pequeno por tipo de cliente, mantido pelo time desse cliente, que compõe chamadas aos serviços downstream e molda as respostas para um único consumidor. O risco é duplicar lógica entre BFFs; restrinja-os a composição e formatação.

Service discovery. Instâncias vêm e vão, então quem chama resolve um nome lógico para um endereço no momento da chamada. Há dois formatos. Na descoberta do lado do cliente, quem chama consulta um registro e escolhe a instância por conta própria, o que permite uma seleção sofisticada (a menos carregada, com afinidade de zona) ao preço de um cliente de descoberta em cada linguagem que você usa. Na descoberta do lado do servidor, quem chama fala com um endereço virtual estável e um proxy ou o load balancer da plataforma escolhe a instância. No Kubernetes isso é simplesmente um Service, e é por isso que a maioria dos sistemas novos não precisa de nenhum registro separado.

AbordagemOnde a escolha é feitaIndicada para
Registro no clienteNo processo de quem chamaPlataformas mistas, deploys em VM, roteamento refinado
Proxy no servidorEm um load balancer ou na plataformaOrquestração de contêineres, frotas poliglotas
Só DNSResolver DNS, com cache pelo TTLTopologias simples; cuidado com caches desatualizados no failover

Sobre load balancing, vale um alerta: round-robin distribui as requisições por igual, e esse é o objetivo errado quando elas têm custos diferentes. Uma política de "least outstanding requests" manda o trabalho para a instância com menos chamadas em andamento e se adapta sozinha a uma instância lenta; prefira-a sempre que seu proxy ou cliente oferecer.

Contendo as falhas

Toda chamada remota tem três desfechos ruins: falha rápido, falha devagar ou mente. A falha lenta é a perigosa, porque quem espera por uma dependência retém uma thread, uma conexão e memória, e alguns milhares de chamadas nessa situação esgotam a capacidade de quem chama. Todos os padrões a seguir atacam essa cadeia.

Timeouts. O controle de maior retorno, e o que mais vezes fica no valor padrão da biblioteca, medido em minutos. Toda chamada de saída precisa de um prazo derivado do que quem chama pode bancar, não do tempo que o serviço chamado costuma levar. Uma disciplina útil é o orçamento por requisição: uma requisição de entrada com 800 ms disponíveis reserva 300 ms para uma dependência e 200 ms para outra, e qualquer chamada que estouraria o orçamento restante nem chega a ser feita.

Retries com backoff e jitter. Repetir uma chamada após uma falha transitória vale a pena; repetir imediatamente, a partir de todos os clientes, é assim que um soluço de cinco segundos vira um estouro de manada (thundering herd). Use um número máximo de tentativas pequeno, backoff exponencial e jitter aleatório para que os clientes não se sincronizem. Nunca repita uma requisição que não seja idempotente, e nunca repita diante de uma resposta que indique erro do cliente.

Circuit breaker. Acompanhe a taxa de falhas recente de uma dependência; quando ela passar de um limite, pare de chamá-la por um período de resfriamento e falhe imediatamente com um fallback. Passada essa janela, deixe algumas chamadas de teste passarem e feche o breaker se elas tiverem sucesso. O ganho é duplo: quem chama para de queimar recursos com chamadas que vão falhar, e a dependência em apuros tem chance de se recuperar em vez de continuar sendo martelada.

Bulkhead. Particione os recursos de quem chama para que nenhuma dependência consiga consumir tudo. Na prática, isso significa um pool de threads limitado ou um semáforo restringindo as chamadas concorrentes por dependência, e pools de conexão separados em vez de um único pool compartilhado.

resilience4j:
  circuitbreaker:
    instances:
      pricing:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 50
        failureRateThreshold: 50          # abre com 50% de falhas na janela
        slowCallDurationThreshold: 400ms  # uma chamada lenta conta como falha
        slowCallRateThreshold: 60
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 5
  bulkhead:
    instances:
      pricing:
        maxConcurrentCalls: 20            # nunca mais de 20 em andamento
        maxWaitDuration: 0                # rejeitar na hora em vez de enfileirar
  retry:
    instances:
      pricing:
        maxAttempts: 3
        waitDuration: 100ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        retryExceptions:
          - java.io.IOException
@Service
class PricingClient {

    @CircuitBreaker(name = "pricing", fallbackMethod = "listPrice")
    @Bulkhead(name = "pricing")
    @Retry(name = "pricing")
    Money quote(String sku, String customerId) {
        return http.get("/quotes/{sku}?customer={c}", Money.class, sku, customerId);
    }

    // Degradar, não falhar: o preço de catálogo é pior que uma cotação, mas melhor que uma página de erro.
    private Money listPrice(String sku, String customerId, Throwable cause) {
        log.warn("pricing degraded for {}: {}", sku, cause.toString());
        return catalogue.listPrice(sku);
    }
}

Rate limiting e load shedding protegem o outro lado da mesma relação, o do serviço chamado. Um serviço que aceita todas as requisições até desabar não atende ninguém; um que rejeita rapidamente o excesso com 429 continua atendendo o tráfego que consegue suportar. Descartar por prioridade (rejeitar tráfego batch e de relatórios antes do interativo) é um refinamento barato e eficaz.

Tornando as operações seguras para repetir

Os padrões de resiliência garantem que algumas requisições serão entregues mais de uma vez. A entrega at-least-once de um broker garante o mesmo. A única defesa é a idempotência: processar duas vezes a mesma operação lógica tem o mesmo efeito que processá-la uma vez.

Leituras e exclusões são naturalmente idempotentes, e PUT substitui o recurso inteiro, então normalmente também é. O problema está na criação e na alteração. A resposta padrão é uma chave de idempotência enviada pelo cliente, armazenada pelo servidor junto com o resultado e usada para devolver esse mesmo resultado em uma repetição.

-- Quem garante a correção é a restrição de unicidade, não o código da aplicação.
CREATE TABLE processed_request (
    idempotency_key VARCHAR(64) PRIMARY KEY,
    request_hash    CHAR(64)     NOT NULL,
    response_body   JSONB        NOT NULL,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
);

O handler insere a chave na mesma transação da alteração de negócio. Se o insert violar a chave primária, a requisição é uma repetição: devolva a resposta armazenada. Se a mesma chave chegar com um hash de payload diferente, é bug do cliente: rejeite com 422 em vez de devolver silenciosamente a resposta antiga. Guarde as linhas por mais tempo que a janela de retry de qualquer cliente e depois faça-as expirar.

Para consumidores de mensagens, a mesma ideia vale com o identificador da mensagem como chave. Quando o efeito é uma transição de estado, uma alternativa mais barata funciona bem: condicione o update ao estado atual, de modo que UPDATE orders SET status='PAID' WHERE id=? AND status='PENDING' simplesmente afete zero linhas em uma duplicata.

Padrões de dados entre serviços

Quando cada serviço é dono dos próprios dados, surgem três problemas recorrentes: gravar no seu banco e publicar um evento de forma atômica, manter consistente um processo de negócio que atravessa vários serviços e consultar dados que não são seus.

Transactional outbox. Um serviço não consegue gravar no banco e publicar em um broker na mesma transação, então grava o evento em uma tabela outbox dentro da transação de negócio, e um relay separado lê essa tabela e publica. Nada se perde se o processo morrer entre as duas etapas.

Saga. Um processo de negócio que abrange vários serviços vira uma sequência de transações locais, cada uma com uma ação de compensação caso uma etapa posterior falhe. A coreografia liga os serviços por eventos; a orquestração coloca um coordenador no comando da sequência. As duas são tratadas com o devido cuidado, incluindo análise de falhas, no guia de transações distribuídas.

CQRS. Separe o modelo em que você escreve do modelo em que você lê. O modelo de escrita garante as invariantes e continua normalizado; um ou mais modelos de leitura são moldados para consultas específicas e atualizados a partir de eventos. Use quando os requisitos de leitura e escrita divergem de verdade (busca de alto volume sobre dados com um modelo de escrita complexo) e não como padrão, porque ele introduz um atraso que todo consumidor precisa tolerar e em torno do qual toda interface precisa ser desenhada.

Event sourcing. Persista a sequência de eventos que alteram o estado em vez do estado atual, e reconstrua o estado reproduzindo esses eventos. Você ganha uma trilha de auditoria perfeita, consultas temporais e a capacidade de construir novas projeções a partir do histórico. Você paga com a evolução do schema de eventos que vivem para sempre, snapshots para streams longos e um modelo mental pouco familiar para a maioria dos times. Adote em um bounded context em que o histórico é o produto (livros-razão, trading, compliance), e não no sistema inteiro.

Composição de APIs. O caso simples: quem chama dispara consultas para vários serviços e junta os resultados. Perfeitamente razoável para conjuntos de resultados pequenos, inviável para qualquer coisa que exija filtrar ou ordenar entre serviços.

Padrões de deploy e de plataforma

Sidecar. Faça o deploy de um contêiner auxiliar ao lado do serviço, na mesma unidade, compartilhando seu ciclo de vida e seu namespace de rede, para fornecer uma capacidade que o serviço não deveria implementar sozinho: envio de logs, rotação de credenciais, um proxy. O serviço continua focado no comportamento de negócio; o sidecar é atualizado de forma independente.

Service mesh. Um mesh é sidecar por toda parte: cada instância ganha um proxy, e um plano de controle os configura para oferecer TLS mútuo, retries, timeouts, divisão de tráfego e telemetria uniforme sem código de aplicação. Isso tem valor real em uma frota grande e poliglota. Também é um segundo sistema distribuído, com seu próprio caminho de atualização, seus próprios modos de falha e seu custo de latência, e duplica capacidades que suas bibliotecas talvez já ofereçam. Decida contando: quantas linguagens, quantos serviços e quanto da lista de funcionalidades do mesh você teria de escrever por conta própria.

Configuração externalizada. A configuração vem do ambiente, não do artefato, de modo que uma mesma imagem é promovida sem alterações entre ambientes. Os segredos vêm de um gerenciador de segredos e podem ser rotacionados sem rebuild.

Endpoints de saúde. Diferencie liveness (o processo quebrou sem chance de recuperação? Reinicie) de readiness (ele consegue atender tráfego agora? Não mande nada até que consiga). Confundir os dois provoca loops de reinício quando uma dependência fica indisponível por um instante, o que transforma uma indisponibilidade parcial em total.

Releases blue/green e canary. Direcione uma pequena parcela do tráfego de produção para a nova versão, compare taxa de erros e latência com as da versão antiga e promova ou faça rollback automaticamente. É o padrão que torna o deploy independente seguro na prática, e depende de interfaces e mudanças de banco compatíveis com versões anteriores.

Enxergando o que está acontecendo

Em um monólito, um stack trace explica uma falha. Em um sistema distribuído, nada explica — a não ser que você tenha construído o sistema para isso.

Correlação e tracing distribuído. Toda requisição de entrada recebe um identificador de trace, propagado em toda chamada de saída pelos headers W3C traceparent, e cada salto registra um span. O trace resultante mostra exatamente para onde foram os 4 segundos do checkout. OpenTelemetry permite instrumentar isso sem se prender a um fornecedor; com a autoinstrumentação dos frameworks mais comuns, o básico custa pouco esforço.

Logs estruturados. Linhas JSON com o trace ID, o nome do serviço, a versão e o tenant em cada registro, agregadas de forma centralizada. Logs em texto livre espalhados por doze serviços são impossíveis de pesquisar.

Métricas que descrevem os usuários. Taxa de requisições, taxa de erros e percentis de latência por endpoint, mais a saturação dos pools que você isolou com bulkheads. Alerte com base em objetivos de nível de serviço ("99% dos checkouts concluídos em menos de 1 s ao longo de 30 dias"), e não em CPU, que não diz nada sobre se alguém está sofrendo. Interpretar esses percentis corretamente é o assunto de performance de aplicações.

Saúde agregada. Um dashboard por serviço mostrando os quatro sinais e o estado dos circuit breakers basta para a maioria dos incidentes; qualquer coisa mais elaborada acaba sem ser lida.

Principais pontos

  • Adote um padrão para eliminar uma falha à qual você está realmente exposto; todos eles acrescentam peças móveis e superfície operacional.
  • Gateways e service discovery mantêm os clientes alheios à topologia; em uma plataforma de contêineres, a abstração de serviço da própria plataforma geralmente substitui um registro separado.
  • Timeouts, retries limitados com jitter, circuit breakers e bulkheads funcionam em conjunto: configure retries em uma única camada e coloque-os dentro do breaker.
  • Entrega at-least-once e retries tornam a idempotência obrigatória; garanta-a com uma chave de idempotência armazenada ou com um update condicionado ao estado, não com uma lógica de aplicação otimista.
  • Outbox, saga, CQRS e event sourcing resolvem problemas de dados distintos; use o menor que resolva e não adote event sourcing por padrão.
  • Tracing distribuído, logs estruturados com um ID de correlação e alertas baseados em SLO não são extras opcionais: sem eles, uma falha em microsserviços fica sem explicação.

Fontes e leituras complementares

Perguntas frequentes

Os retries devem ficar no cliente, no gateway ou no service mesh?
Em um lugar só. Retries configurados em três camadas se multiplicam: três tentativas em cada uma viram vinte e sete requisições para um serviço que já está com problemas, e é assim que uma degradação vira uma indisponibilidade. Coloque-os onde quem chama sabe se a operação pode ser repetida com segurança, normalmente a biblioteca cliente, e desative-os nos demais lugares. Se o mesh faz os retries, a aplicação não deve fazer.
Qual é a diferença entre um circuit breaker e um bulkhead?
Um circuit breaker para de chamar uma dependência que já está falhando, para que quem chama falhe rápido em vez de ficar na fila atrás de timeouts. Um bulkhead limita quanto dos recursos do próprio chamador (threads, conexões, requisições em andamento) uma única dependência pode consumir, para que uma dependência lenta não esgote o resto da aplicação. Eles tratam metades diferentes do mesmo problema e costumam ser aplicados juntos.
Eu preciso de um service mesh?
Só quando você tem serviços suficientes, em linguagens suficientes, para que implementar mTLS, retries, divisão de tráfego e telemetria em cada biblioteca cliente tenha virado um fardo de verdade. Um mesh leva esse trabalho para proxies sidecar ao preço de mais um plano de controle distribuído para operar, atualizar e depurar, além de um pequeno custo de latência a cada salto. Abaixo de mais ou menos uma dúzia de serviços, bibliotecas costumam sair mais baratas.
Event sourcing é obrigatório para usar CQRS?
Não, e confundir os dois gera muita complexidade desnecessária. CQRS significa apenas que o modelo de escrita e o modelo de leitura são separados, o que dá para implementar com um banco de dados comum e uma tabela de projeção. Event sourcing significa que o log de eventos é a fonte da verdade. Cada um é útil sozinho; combiná-los faz sentido principalmente quando você precisa de um histórico de auditoria completo e de consultas temporais.