Resiliência em sistemas distribuídos: o que é e por onde começar (retry, backoff e jitter)

Resiliência em sistemas distribuídos: o que é e por onde começar (retry, backoff e jitter)

Arquitetura

Todo sistema distribuído, mais cedo ou mais tarde, vai chamar algo que falha. A rede cai por um segundo, o serviço do outro lado reinicia, o banco fica sobrecarregado numa janela de pico. Isso não é exceção, é a regra: quanto mais peças um sistema tem, e quanto mais elas conversam entre si pela rede, maior a chance de alguma falhar num dado momento.

Resiliência é a capacidade de um sistema continuar funcionando, ou degradar de forma controlada, quando isso acontece. Não é o mesmo que evitar falha, evitar falha é impossível em qualquer sistema não trivial. É lidar com ela sem que um problema pequeno e temporário vire uma indisponibilidade grande e duradoura.

Neste post: o que é resiliência na prática, e o padrão mais simples e mais usado pra começar a aplicar isso, o retry, junto com duas variações dele que resolvem os problemas que o retry puro cria: backoff exponencial e jitter.

O que é resiliência

Resiliência responde uma pergunta específica: o que o sistema faz quando uma dependência falha?

Pensa numa chamada HTTP de um serviço A pro serviço B. Na maior parte do tempo ela funciona. Mas existe uma fração de chamadas que vai falhar por motivo transitório: um timeout de rede, um erro 503 porque B está sobrecarregado, uma conexão que caiu no meio do caminho. Esse tipo de falha tende a se resolver sozinho em segundos.

Um sistema sem nenhuma estratégia de resiliência trata essa falha transitória exatamente como trataria uma falha permanente: propaga o erro pra cima, devolve erro pro usuário, não tenta de novo. O problema é real, mas o sistema reage como se fosse maior do que é.

Existe uma família de padrões pra fechar essa distância entre “algo falhou” e “o sistema para de funcionar”, cada um resolvendo um tipo diferente de falha:

  • Retry, pra falha transitória que tem chance de se resolver sozinha
  • Circuit breaker, pra não insistir num serviço que já está claramente fora
  • Timeout, pra não esperar pra sempre por uma resposta que não vem
  • Fallback, pra ter uma resposta alternativa quando a principal falha
  • e outros…

O ponto de entrada mais comum, e o primeiro que a maioria dos sistemas precisa, é o retry. É nele que vou focar nesse post, junto com backoff e jitter, que não são padrões separados, são variações em cima do retry pra resolver problemas que o retry puro cria.

Retry: o ponto de partida

A ideia central é simples: se uma operação falhar por um motivo que tem chance real de se resolver sozinho, tenta de novo antes de propagar o erro.

$result = retry(3, function () {
    return Http::get('https://api.payments.com/status')->throw();
});

Só isso já resolve boa parte das falhas transitórias. Mas retry ingênuo, sem nenhum espaçamento entre tentativas, cria um problema novo: se o serviço B está sobrecarregado, um monte de clientes tentando de novo imediatamente, todos ao mesmo tempo, só aumenta a carga em cima de um serviço que já estava com dificuldade. É o tipo de coisa que transforma uma lentidão momentânea numa queda de verdade.

Backoff: a primeira variação do retry

Backoff exponencial é o retry de sempre, com uma regra a mais: o tempo de espera entre uma tentativa e a próxima cresce exponencialmente, em vez de tentar de novo na hora. O sistema espera 1 segundo, depois 2, depois 4, depois 8, dando tempo real pro serviço se recuperar antes da próxima tentativa. É o padrão descrito no post canônico da AWS sobre exponential backoff e jitter.

$attempt = 0;
$maxAttempts = 5;

while ($attempt < $maxAttempts) {
    try {
        return Http::get('https://api.payments.com/status')->throw();
    } catch (\Throwable $e) {
        $attempt++;
        if ($attempt >= $maxAttempts) {
            throw $e;
        }
        $delaySeconds = 2 ** $attempt; // 2, 4, 8, 16 seconds
        sleep($delaySeconds);
    }
}

Jitter: a segunda variação do retry

Backoff sozinho ainda tem um problema quando existem muitos clientes fazendo a mesma coisa ao mesmo tempo. Imagina 1000 instâncias de um serviço A chamando o serviço B. B fica indisponível por 3 segundos. As 1000 instâncias, todas usando o mesmo backoff exponencial, vão calcular exatamente o mesmo tempo de espera, e vão tentar de novo todas juntas, no mesmo milissegundo. Esse pico sincronizado tem nome: thundering herd. O resultado é o mesmo problema que o backoff tentava evitar, só que adiado alguns segundos.

Jitter é mais uma regra em cima do retry: em vez de todo mundo esperar exatamente o mesmo tempo calculado pelo backoff, adiciona aleatoriedade a essa espera, pra que as tentativas se espalhem no tempo em vez de colidirem:

$attempt = 0;
$maxAttempts = 5;
$baseMs = 100;

while ($attempt < $maxAttempts) {
    try {
        return Http::get('https://api.payments.com/status')->throw();
    } catch (\Throwable $e) {
        $attempt++;
        if ($attempt >= $maxAttempts) {
            throw $e;
        }
        $maxDelayMs = min(30000, $baseMs * 2 ** $attempt);
        $delayMs = random_int(0, $maxDelayMs);
        usleep($delayMs * 1000);
    }
}

Cada instância agora espera um tempo aleatório dentro de uma janela que cresce a cada tentativa. Continua havendo mais espera nas tentativas seguintes, mas sem o efeito manada. Se quiser entender o thundering herd com mais profundidade, esse vídeo do Arpit Bhayani explica bem o problema e por que jitter resolve.

Um cuidado que anda junto: idempotência

Retry assume uma coisa que precisa ser verdade pra ser seguro: repetir a operação não pode causar efeito colateral duplicado. Se a chamada for um GET, tudo bem. Se for um POST que cria uma cobrança, e a primeira tentativa na verdade teve sucesso mas a resposta se perdeu no caminho, retry sem cuidado cria uma cobrança duplicada. Por isso operações que mudam estado normalmente usam uma idempotency key, um identificador único por operação que o serviço do outro lado usa pra reconhecer “essa operação já foi processada, não repete o efeito, só devolve o resultado de novo”. Esse artigo sobre idempotência em sistemas distribuídos detalha bem o conceito.

Quando usar e quando não usar

Usa retry quando:

  • A falha é transitória por natureza: timeout, conexão caindo, erro 5xx de sobrecarga
  • A operação é idempotente, ou tem uma idempotency key garantindo isso
  • Existe um limite claro de tentativas, retry sem limite vira espera infinita disfarçada

Não usa retry quando:

  • O erro é de negócio, não de infraestrutura: um 404, um 422 de validação. Tentar de novo não muda o resultado
  • A operação não é idempotente e não tem como ficar segura, aí o risco de duplicar é maior que o ganho
  • O chamador já está no limite de tempo que pode esperar, retry só adia uma resposta que já devia ter voltado

Resumo

Base O que muda em relação ao retry puro Risco se faltar
Retry É o ponto de partida: tenta de novo antes de desistir Erro real propagado por causa de um problema que se resolveria sozinho
+ Backoff exponencial Espaça as tentativas em vez de repetir na hora Retry imediato aumenta a carga num serviço já sobrecarregado
+ Jitter Randomiza a espera do backoff pra evitar tentativas sincronizadas Thundering herd: todos os clientes tentam de novo no mesmo instante

Fechando

O retry é a base: tenta de novo antes de desistir. Backoff é o retry com uma regra a mais, espaçar as tentativas pra dar tempo de recuperação. Jitter é mais uma regra em cima disso, randomizar esse espaçamento pra não gerar um pico sincronizado. Não são três padrões diferentes, é um único padrão que vai ganhando refinamento conforme os problemas do retry puro aparecem, e já resolve uma fatia grande das falhas do dia a dia de qualquer sistema distribuído.

Tags: resiliência backend arquitetura retry sistemas distribuídos