Resiliência em sistemas distribuídos: o que é e por onde começar (retry, backoff e jitter)
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.